// Package spa provides the HTTP handler that serves a single-page application. package spa import ( "net/http" "path" "path/filepath" "strings" "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. // // 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 '<'"). 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 { h.serveFallback(w, r) return } defer f.Close() stat, err := f.Stat() if err != nil || stat.IsDir() { // Directory listing is disabled; treat directories as SPA routes. h.serveFallback(w, r) return } // 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))) h.fs.ServeHTTP(w, r) } // 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 }