diff --git a/.gitignore b/.gitignore
index 7523873..60709ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,11 @@ deploy/secrets/
# Build artifacts and dependencies
node_modules
dist
+bin/
+# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored.
+!internal/frontend/dist/
+internal/frontend/dist/*
+!internal/frontend/dist/index.html
# Local databases
*.db
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3917323
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,24 @@
+# Build entry point: build the PWA, embed it, then build the server binary.
+
+FRONTEND_DIST := internal/frontend/dist
+
+.PHONY: build web embed check-embed-clean
+
+build: embed
+ go build -o bin/mealquest ./cmd/server
+
+web:
+ cd web && npm ci && npm run build
+
+# Replace the committed placeholder with the built PWA so go:embed picks it up.
+# Recreate the whole directory: a glob would skip dotfiles, which all:dist embeds.
+embed: web
+ rm -rf $(FRONTEND_DIST)
+ mkdir -p $(FRONTEND_DIST)
+ cp -R web/dist/. $(FRONTEND_DIST)/
+
+# Regression check: a stale hidden file must not survive an embed.
+check-embed-clean:
+ touch $(FRONTEND_DIST)/.stale-embed-check
+ $(MAKE) embed
+ test ! -e $(FRONTEND_DIST)/.stale-embed-check
diff --git a/cmd/server/main.go b/cmd/server/main.go
index e0d649b..bbf388b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -13,6 +13,7 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
+ "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
)
@@ -37,9 +38,13 @@ func run(cfg config.Config, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
+ router, err := server.NewRouter(logger, version, frontend.Assets())
+ if err != nil {
+ return err
+ }
srv := &http.Server{
Addr: cfg.HTTPAddr,
- Handler: server.NewRouter(logger, version),
+ Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
diff --git a/internal/frontend/dist/index.html b/internal/frontend/dist/index.html
new file mode 100644
index 0000000..9f2b2eb
--- /dev/null
+++ b/internal/frontend/dist/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ MealQuest
+
+
+
+ MealQuest web assets have not been built.
+
+
diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go
new file mode 100644
index 0000000..1a678fe
--- /dev/null
+++ b/internal/frontend/frontend.go
@@ -0,0 +1,127 @@
+// Package frontend serves the embedded PWA build with an SPA fallback.
+package frontend
+
+import (
+ "embed"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+)
+
+// embedded holds the web build. The build copies web/dist over the committed
+// placeholder index.html.
+//
+//go:embed all:dist
+var embedded embed.FS
+
+const (
+ indexFile = "index.html"
+
+ cacheImmutable = "public, max-age=31536000, immutable"
+ cacheNoCache = "no-cache"
+)
+
+// noCacheFiles must be revalidated on every load: they name the hashed assets
+// (or, for the service worker, control their caching), so a stale copy pins an
+// old release.
+var noCacheFiles = map[string]bool{
+ indexFile: true,
+ "sw.js": true,
+ "manifest.webmanifest": true,
+}
+
+// Assets returns the embedded web build rooted at its dist directory.
+func Assets() fs.FS {
+ sub, err := fs.Sub(embedded, "dist")
+ if err != nil {
+ panic(err) // "dist" is a valid path, so Sub cannot fail
+ }
+ return sub
+}
+
+// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a
+// path that is not a file in fsys is answered with index.html so client-side
+// routes load the app. Paths equal to or under a reserved prefix (for example
+// "/api") are never answered with index.html, whether before or after the path
+// is cleaned; they are passed to notFound.
+// fsys must contain index.html.
+func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) {
+ if _, err := fs.Stat(fsys, indexFile); err != nil {
+ return nil, fmt.Errorf("frontend assets: %w", err)
+ }
+ return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil
+}
+
+type handler struct {
+ fsys fs.FS
+ logger *slog.Logger
+ notFound http.Handler
+ reserved []string
+}
+
+func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ urlPath := path.Clean("/" + r.URL.Path)
+ // Check the uncleaned path too, so dot segments cannot escape a reserved
+ // prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42").
+ if h.isReserved(r.URL.Path) || h.isReserved(urlPath) {
+ h.notFound.ServeHTTP(w, r)
+ return
+ }
+
+ name := strings.TrimPrefix(urlPath, "/")
+ if name == "" || !h.isFile(name) {
+ name = indexFile
+ }
+ h.serveFile(w, r, name)
+}
+
+func (h *handler) isReserved(urlPath string) bool {
+ for _, prefix := range h.reserved {
+ if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *handler) isFile(name string) bool {
+ info, err := fs.Stat(h.fsys, name)
+ return err == nil && info.Mode().IsRegular()
+}
+
+func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
+ f, err := h.fsys.Open(name)
+ if err != nil {
+ h.fail(w, r, name, err)
+ return
+ }
+ defer f.Close()
+ content, ok := f.(io.ReadSeeker)
+ if !ok {
+ h.fail(w, r, name, errors.New("file does not implement io.Seeker"))
+ return
+ }
+
+ if noCacheFiles[name] {
+ w.Header().Set("Cache-Control", cacheNoCache)
+ } else {
+ w.Header().Set("Cache-Control", cacheImmutable)
+ }
+ if path.Ext(name) == ".webmanifest" {
+ w.Header().Set("Content-Type", "application/manifest+json")
+ }
+ // Embedded files carry no modification time; ServeContent omits
+ // Last-Modified for the zero time.
+ http.ServeContent(w, r, name, time.Time{}, content)
+}
+
+func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) {
+ h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+}
diff --git a/internal/frontend/frontend_test.go b/internal/frontend/frontend_test.go
new file mode 100644
index 0000000..b6aa8c0
--- /dev/null
+++ b/internal/frontend/frontend_test.go
@@ -0,0 +1,137 @@
+package frontend
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+)
+
+const (
+ indexBody = "fixture index"
+ assetBody = "console.log('fixture asset')"
+ robotsBody = "User-agent: *"
+ swBody = "self.addEventListener('fetch', () => {})"
+ manifestBody = `{"name":"MealQuest"}`
+ notFoundBody = "reserved not found"
+)
+
+func fixture() fstest.MapFS {
+ return fstest.MapFS{
+ "index.html": {Data: []byte(indexBody)},
+ "sw.js": {Data: []byte(swBody)},
+ "manifest.webmanifest": {Data: []byte(manifestBody)},
+ "assets/index-AbC123.js": {Data: []byte(assetBody)},
+ "robots.txt": {Data: []byte(robotsBody)},
+ }
+}
+
+func newTestHandler(t *testing.T) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, notFoundBody, http.StatusNotFound)
+ })
+ h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return h
+}
+
+func TestServe(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ method string
+ path string
+ wantBody string
+ wantCache string
+ }{
+ {"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable},
+ {"unhashed asset", http.MethodGet, "/robots.txt", robotsBody, cacheImmutable},
+ {"root", http.MethodGet, "/", indexBody, cacheNoCache},
+ {"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache},
+ {"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache},
+ {"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache},
+ {"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache},
+ {"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache},
+ {"directory", http.MethodGet, "/assets", indexBody, cacheNoCache},
+ {"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache},
+ {"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache},
+ {"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable},
+ }
+ h := newTestHandler(t)
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rec.Code)
+ }
+ if got := rec.Body.String(); got != tt.wantBody {
+ t.Errorf("body = %q, want %q", got, tt.wantBody)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
+ t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
+ }
+ })
+ }
+}
+
+func TestManifestContentType(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil))
+
+ if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" {
+ t.Errorf("Content-Type = %q, want application/manifest+json", got)
+ }
+}
+
+func TestReservedPrefixesNotServed(t *testing.T) {
+ t.Parallel()
+
+ h := newTestHandler(t)
+ for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown", "/auth/../quests/42", "/api%2F..%2Fquests/42"} {
+ t.Run(path, func(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rec.Code)
+ }
+ if got := rec.Body.String(); got != notFoundBody+"\n" {
+ t.Errorf("body = %q, want the notFound handler's body", got)
+ }
+ })
+ }
+}
+
+func TestNewHandlerRequiresIndex(t *testing.T) {
+ t.Parallel()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}}
+ if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil {
+ t.Fatal("NewHandler without index.html: want error, got nil")
+ }
+}
+
+func TestAssetsContainsIndex(t *testing.T) {
+ t.Parallel()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil {
+ t.Fatalf("embedded assets: %v", err)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 33b0e38..929c846 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -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 {
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index f8e3cc5..f1adc7e 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -7,14 +7,35 @@ import (
"maps"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
+ "testing/fstest"
)
+const (
+ fixtureIndex = "fixture index"
+ fixtureAsset = "console.log('fixture asset')"
+ notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
+)
+
+func newTestRouter(t *testing.T, version string) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ assets := fstest.MapFS{
+ "index.html": {Data: []byte(fixtureIndex)},
+ "assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
+ }
+ r, err := NewRouter(logger, version, assets)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
func TestHealth(t *testing.T) {
t.Parallel()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
+ srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/health")
@@ -42,9 +63,8 @@ func TestHealth(t *testing.T) {
func TestHealthRejectsOtherMethods(t *testing.T) {
t.Parallel()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
rec := httptest.NewRecorder()
- NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
+ newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
@@ -68,21 +88,98 @@ func TestHealthRejectsOtherMethods(t *testing.T) {
}
}
-func TestUnknownPathNotFound(t *testing.T) {
+func TestReservedPathsNotFound(t *testing.T) {
t.Parallel()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- rec := httptest.NewRecorder()
- NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil))
+ router := newTestRouter(t, "dev")
+ tests := []struct {
+ method string
+ path string
+ }{
+ {http.MethodGet, "/api"},
+ {http.MethodGet, "/api/unknown"},
+ {http.MethodPost, "/api/unknown"},
+ {http.MethodGet, "/auth/callback"},
+ {http.MethodGet, "/media/photo.jpg"},
+ {http.MethodGet, "/health/deep"},
+ {http.MethodGet, "/auth/../quests/42"},
+ {http.MethodGet, "/api%2F..%2Fquests/42"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.method+" "+tt.path, func(t *testing.T) {
+ t.Parallel()
- if rec.Code != http.StatusNotFound {
- t.Errorf("status = %d, want 404", rec.Code)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
+
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
+ t.Errorf("Content-Type = %q, want application/json", ct)
+ }
+ if got := rec.Body.String(); got != notFoundJSON {
+ t.Errorf("body = %s, want %s", got, notFoundJSON)
+ }
+ })
}
- if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", ct)
+}
+
+func TestFrontend(t *testing.T) {
+ t.Parallel()
+
+ router := newTestRouter(t, "dev")
+ tests := []struct {
+ path string
+ wantBody string
+ wantCache string
+ }{
+ {"/", fixtureIndex, "no-cache"},
+ {"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"},
+ {"/quests/42", fixtureIndex, "no-cache"},
+ {"/quests/42/edit", fixtureIndex, "no-cache"},
}
- const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
- if got := rec.Body.String(); got != want {
- t.Errorf("body = %s, want %s", got, want)
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rec.Code)
+ }
+ if got := rec.Body.String(); got != tt.wantBody {
+ t.Errorf("body = %q, want %q", got, tt.wantBody)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
+ t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
+ }
+ })
+ }
+}
+
+func TestHealthNotShadowed(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
+
+ if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) {
+ t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body)
+ }
+}
+
+func TestFrontendRejectsOtherMethods(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil))
+
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Errorf("status = %d, want 405", rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), fixtureIndex) {
+ t.Errorf("body = %q, want no index.html", rec.Body.String())
}
}