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:
- 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.
- 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.
- 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.
- 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).
- 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
Summary
Add a way for
WebTransportto serve static assets (a directory of files, or embedded bundles) with SPAindex.htmlfallback, 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 readsPOST /commands/<name>for writesThe only missing piece to host a full app (API + UI) on one transport is static-asset serving.
Current behavior
Service.Transport.Webdispatches on a closed route set: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/htmlfrom 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
Applicationbuilder, e.g.:Behavior:
notFound "Not found"with "try to serve a static file, else 404". API routes always win over files.spaFallbackis set and the requested path doesn't resolve to a real file (and isn't an API route), serve the fallback document (typicallyindex.html) so client-side routing works on deep links./assets/*.[hash].jsetc.) should be servedimmutable, whileindex.htmland other non-hashed entry documents must beno-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.responseFile, and Warp streams response bodies natively, so this need not pull inwai-app-static(though that remains an option if preferred).root.Out of scope
🤖 Generated with Claude Code