Embed the PWA build and serve it with SPA fallback
Independent review Not signed off — 0 high, 1 medium, 1 low (#4)

Add internal/frontend, which embeds internal/frontend/dist and serves it
with Cache-Control "no-cache" for index.html, sw.js and
manifest.webmanifest and "public, max-age=31536000, immutable" for every
other asset. Any other GET or HEAD falls back to index.html, except paths
under the reserved /api, /auth, /media and /health prefixes, which get
the JSON not_found error instead.

NewRouter now takes the asset fs.FS so tests use a fstest.MapFS fixture,
mounts an /api subrouter with JSON 404/405 handlers, and fails fast if
the assets lack index.html. A placeholder dist/index.html is tracked so a
fresh clone compiles; built assets remain git-ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:29:41 -04:00
co-authored by Claude Opus 5.5
parent f0b2696059
commit ee4670754a
7 changed files with 417 additions and 21 deletions
+29 -5
View File
@@ -3,20 +3,44 @@ package server
import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
)
// reservedPrefixes are owned by the API router and are never answered with the
// SPA's index.html.
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health.
func NewRouter(logger *slog.Logger, version string) http.Handler {
// GET /health; assets is the web build served for every other GET.
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
if err != nil {
return nil, err
}
r := chi.NewRouter()
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Get("/health", health(logger, version))
r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed"))
r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found"))
return r
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
})
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
// handler passes them to notFound rather than the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil
}
type errorBody struct {