Embed the PWA build and serve it with SPA fallback
Independent review Not signed off — 0 high, 1 medium, 1 low (#4)
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:
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Placeholder so the Go embed compiles. The build replaces this directory with web/dist. -->
|
||||
<p>MealQuest web assets have not been built.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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; 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)
|
||||
if 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)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const (
|
||||
indexBody = "<!doctype html><title>fixture index</title>"
|
||||
assetBody = "console.log('fixture asset')"
|
||||
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)},
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
{"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"} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user