2026-06-02 18:49:05 +00:00
|
|
|
// Package spa provides the HTTP handler that serves a single-page application.
|
|
|
|
|
package spa
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
2026-08-18 17:55:56 -06:00
|
|
|
"path"
|
2026-06-02 18:49:05 +00:00
|
|
|
"path/filepath"
|
2026-08-18 17:55:56 -06:00
|
|
|
"strings"
|
2026-06-02 18:49:05 +00:00
|
|
|
|
|
|
|
|
"code.nochebuena.dev/einherjar/contracts/logging"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type handler struct {
|
|
|
|
|
logger logging.Logger
|
|
|
|
|
staticDir string
|
|
|
|
|
fs http.Handler
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewHandler returns an http.Handler that serves static files from staticDir.
|
2026-08-18 17:55:56 -06:00
|
|
|
//
|
|
|
|
|
// A path that resolves to a file on disk is served directly, with a Cache-Control
|
|
|
|
|
// header chosen from its name (see [cacheControl]). A path that does not resolve to
|
|
|
|
|
// a file follows the SPA routing contract: a browser navigation receives index.html
|
|
|
|
|
// so the client-side router can handle the route, while a request for a missing
|
|
|
|
|
// asset (a path with a file extension, or a non-HTML client) receives 404 — so a
|
|
|
|
|
// misconfigured deploy fails loudly at the first request instead of returning
|
|
|
|
|
// index.html as JavaScript ("Unexpected token '<'").
|
2026-06-02 18:49:05 +00:00
|
|
|
func NewHandler(logger logging.Logger, staticDir string) http.Handler {
|
|
|
|
|
return &handler{
|
|
|
|
|
logger: logger,
|
|
|
|
|
staticDir: staticDir,
|
|
|
|
|
fs: http.FileServer(http.Dir(staticDir)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// http.Dir.Open sanitises the path, preventing directory traversal.
|
|
|
|
|
f, err := http.Dir(h.staticDir).Open(r.URL.Path)
|
|
|
|
|
if err != nil {
|
2026-08-18 17:55:56 -06:00
|
|
|
h.serveFallback(w, r)
|
2026-06-02 18:49:05 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
stat, err := f.Stat()
|
|
|
|
|
if err != nil || stat.IsDir() {
|
|
|
|
|
// Directory listing is disabled; treat directories as SPA routes.
|
2026-08-18 17:55:56 -06:00
|
|
|
h.serveFallback(w, r)
|
2026-06-02 18:49:05 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 17:55:56 -06:00
|
|
|
// Existing file: set Cache-Control from its name, then delegate to FileServer,
|
|
|
|
|
// which fills Content-Type, Last-Modified, range support and 304s — but never
|
|
|
|
|
// Cache-Control, so ours stands.
|
|
|
|
|
w.Header().Set("Cache-Control", cacheControl(path.Base(r.URL.Path)))
|
2026-06-02 18:49:05 +00:00
|
|
|
h.fs.ServeHTTP(w, r)
|
|
|
|
|
}
|
2026-08-18 17:55:56 -06:00
|
|
|
|
|
|
|
|
// serveFallback applies the SPA routing contract for a path that is not a file.
|
|
|
|
|
func (h *handler) serveFallback(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if looksLikeFile(r.URL.Path) || !acceptsHTML(r) {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// index.html must always be revalidated so a new deploy is picked up.
|
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// looksLikeFile reports whether the last path segment carries a file extension —
|
|
|
|
|
// e.g. /main.4f8a2b1c.js is an asset request, /dashboard is a route.
|
|
|
|
|
func looksLikeFile(urlPath string) bool {
|
|
|
|
|
return path.Ext(path.Base(path.Clean(urlPath))) != ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// acceptsHTML reports whether the client will take an HTML document. A browser
|
|
|
|
|
// navigation sends "text/html"; "*/*" and an absent header are treated as willing
|
|
|
|
|
// (curl, health probes). A typed non-HTML Accept (e.g. application/json) is not —
|
|
|
|
|
// such a request for a missing route gets 404 rather than an HTML body.
|
|
|
|
|
func acceptsHTML(r *http.Request) bool {
|
|
|
|
|
accept := r.Header.Get("Accept")
|
|
|
|
|
return accept == "" ||
|
|
|
|
|
strings.Contains(accept, "text/html") ||
|
|
|
|
|
strings.Contains(accept, "*/*")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cacheControl chooses a Cache-Control value for a static file by name, correct for
|
|
|
|
|
// Vite, CRA and Angular output alike:
|
|
|
|
|
//
|
|
|
|
|
// - index.html and service-worker files → no-cache (revalidate every load, so a
|
|
|
|
|
// new release is never masked by a cached shell or a stale ngsw.json).
|
|
|
|
|
// - content-hashed filenames → one year, immutable (the hash changes when the
|
|
|
|
|
// content does, so the URL is safe to cache forever).
|
|
|
|
|
// - everything else → one hour (favicon.ico, icons/ and other verbatim assets are
|
|
|
|
|
// not hashed; a year would be a footgun when they change under the same name).
|
|
|
|
|
func cacheControl(name string) string {
|
|
|
|
|
if name == "index.html" || isServiceWorker(name) {
|
|
|
|
|
return "no-cache"
|
|
|
|
|
}
|
|
|
|
|
if isHashed(name) {
|
|
|
|
|
return "public, max-age=31536000, immutable"
|
|
|
|
|
}
|
|
|
|
|
return "public, max-age=3600"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isServiceWorker reports whether name is a service-worker or its manifest, across
|
|
|
|
|
// the common toolchains: Angular (ngsw.json, ngsw-worker.js, safety-worker.js),
|
|
|
|
|
// CRA (service-worker.js), hand-rolled (sw.js) and Vite-PWA/Workbox (workbox-*.js).
|
|
|
|
|
// These must never be cached, or a client can get pinned to a superseded release.
|
|
|
|
|
func isServiceWorker(name string) bool {
|
|
|
|
|
switch name {
|
|
|
|
|
case "ngsw.json", "ngsw-worker.js", "safety-worker.js", "service-worker.js", "sw.js":
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return strings.HasPrefix(name, "workbox-")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isHashed reports whether name ends in a content-hash token: a run of at least 8
|
|
|
|
|
// alphanumerics containing a digit, set off by '.', '-' or '_' right before the
|
|
|
|
|
// extension (main.4f8a2b1c.js, index-DkJf3x9a.js, styles-4NDEUD5S.css). It is a
|
|
|
|
|
// heuristic — a false negative only costs a revalidation, never staleness — so it
|
|
|
|
|
// leans conservative (the digit requirement keeps it off plain words like about.js).
|
|
|
|
|
func isHashed(name string) bool {
|
|
|
|
|
base := strings.TrimSuffix(name, path.Ext(name))
|
|
|
|
|
i := strings.LastIndexAny(base, ".-_")
|
|
|
|
|
if i < 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
token := base[i+1:]
|
|
|
|
|
if len(token) < 8 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
hasDigit := false
|
|
|
|
|
for _, c := range token {
|
|
|
|
|
switch {
|
|
|
|
|
case c >= '0' && c <= '9':
|
|
|
|
|
hasDigit = true
|
|
|
|
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return hasDigit
|
|
|
|
|
}
|