Skip to content

WebTransport: serve static assets (SPA) with index.html fallback #707

Description

@NickSeagull

Summary

Add a way for WebTransport to serve static assets (a directory of files, or embedded bundles) with SPA index.html fallback, so an application can serve a single-page frontend from the same transport that already serves its command/query API — without standing up a second WAI/Warp server.

Motivation

Tooling built on NeoHaskell increasingly wants to ship a web UI alongside its HTTP API (concretely: an event-modeling IDE whose React SPA talks to the app's commands/queries). Today that requires running a separate static server next to WebTransport, duplicating port/config/lifecycle management, when the transport is already a Warp server that could serve the bundle directly.

The command/query surface already maps cleanly onto WebTransport:

  • GET /queries/<name> for reads
  • POST /commands/<name> for writes

The only missing piece to host a full app (API + UI) on one transport is static-asset serving.

Current behavior

Service.Transport.Web dispatches on a closed route set:

-- core/service/Service/Transport/Web.hs:555
case Wai.pathInfo request of
  ["commands", commandNameKebab] -> ...
  ["queries",  queryNameKebab]   -> ...
  -- health / ready / oauth2 / files / swagger-ui ...
  _ -> notFound "Not found"   -- Web.hs:1026

There is no static-file branch and no user-extensible raw-route hook — an unmatched path always 404s.

Notably, the framework already has a precedent for serving static content from a WebTransport branch: the Swagger/Scalar UI is served as text/html from a dedicated branch (SwaggerUI.scalarHtml, ~Web.hs:1000). This proposal generalizes that pattern into first-class static-asset serving.

Proposal

Add opt-in static serving, configured through the Application builder, e.g.:

Application.new
  |> Application.withStaticAssets StaticAssets
       { root      = "dist/"          -- directory on disk, or embedded via file-embed
       , spaFallback = Just "index.html"
       }
  |> ...

Behavior:

  1. Precedence — static serving is the fallback, evaluated only after the existing API/health/oauth2/files/swagger branches, i.e. it replaces the terminal notFound "Not found" with "try to serve a static file, else 404". API routes always win over files.
  2. SPA fallback — when spaFallback is set and the requested path doesn't resolve to a real file (and isn't an API route), serve the fallback document (typically index.html) so client-side routing works on deep links.
  3. Cache-Control (load-bearing — please don't omit): hashed, content-addressed bundles (/assets/*.[hash].js etc.) should be served immutable, while index.html and other non-hashed entry documents must be no-cache, must-revalidate. Getting this wrong makes browsers show a stale UI after every rebuild; getting it right is what makes hot-rebuild-during-dev usable.
  4. No new dependencies required — WAI can serve files via responseFile, and Warp streams response bodies natively, so this need not pull in wai-app-static (though that remains an option if preferred).
  5. Path traversal safety — normalize/'..'-guard requested paths against root.

Out of scope

  • Server→client push (SSE / websockets) — tracked separately.
  • Authn/authz for static assets (initial version can serve them unauthenticated; revisit if a use case needs gating).

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions