diff --git a/Makefile b/Makefile
index b9a17106..675e7cca 100644
--- a/Makefile
+++ b/Makefile
@@ -216,9 +216,26 @@ internal/server/openapi_embed.yaml: api/openapi.yaml
# - $(SPA_DIR)/index.html — a lightweight stub so go vet/lint/test compile
# the embed without a Node toolchain (created on demand, fast).
# - make spa — the real `vite build` output, for release binaries.
+#
+# The stub also stages assets/app-abc123.js — a content-hashed asset fixture the
+# static-delivery tests (system-http-server AC-15/AC-16) serve to assert gzip +
+# immutable caching. It must stay >=256 bytes and compressible so the handler's
+# gzip path engages, and contain `console.log` (the gzip test decodes and checks
+# for it). The directory is gitignored, so this stub is what CI tests against.
$(SPA_DIR)/index.html:
- @mkdir -p $(SPA_DIR)
+ @mkdir -p $(SPA_DIR)/assets
@printf '%s\n' '
OpenWatchOpenWatch SPA placeholder. Run `make spa` (or `make build`) to embed the real UI.' > $@
+ @printf '%s\n' \
+ '// OpenWatch SPA placeholder asset (test stub for static-delivery tests).' \
+ '// Run `make spa` (or `make build`) to embed the real Vite output instead.' \
+ 'console.log("OpenWatch SPA placeholder");' \
+ '/* padding so the stub exceeds the 256-byte gzip threshold and stays */' \
+ '/* compressible (repeated lines shrink well under gzip). ------------- */' \
+ '/* filler ------------------------------------------------------------ */' \
+ '/* filler ------------------------------------------------------------ */' \
+ '/* filler ------------------------------------------------------------ */' \
+ '/* filler ------------------------------------------------------------ */' \
+ > $(SPA_DIR)/assets/app-abc123.js
# Build the real frontend and stage it into the embed directory. Uses
# `vite build` directly rather than the frontend `build` script's `tsc -b`,
diff --git a/internal/server/spa.go b/internal/server/spa.go
index 411d8703..34aac8f1 100644
--- a/internal/server/spa.go
+++ b/internal/server/spa.go
@@ -1,11 +1,17 @@
package server
import (
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "embed"
+ "encoding/hex"
"io/fs"
+ "mime"
"net/http"
+ "path"
+ "strconv"
"strings"
-
- "embed"
)
// spaFiles holds the built single-page app. It is a build-time directory:
@@ -17,11 +23,31 @@ import (
//go:embed all:spa
var spaFiles embed.FS
-// newSPAHandler serves the embedded SPA. Real static assets (hashed JS/CSS,
-// favicon, etc.) are served directly from the embedded FS; every other
-// non-API path falls back to index.html so client-side routing survives deep
-// links and page reloads. Requests under /api/ never fall through to the SPA —
-// an unknown API route returns 404 so callers get a not-found, not an HTML page.
+// asset is one precomputed static file. Compression and the content hash
+// are computed ONCE at handler construction (the embedded FS is fixed at
+// build time), so request handling is a map lookup + a buffer write — the
+// gzip CPU cost is never paid per request. This is the optimization an
+// NGINX/Caddy front would otherwise provide; baking it into the binary
+// keeps the single-binary deployment self-contained.
+type asset struct {
+ raw []byte
+ gz []byte // nil when not compressible / gzip didn't help
+ contentType string
+ etag string
+ cacheControl string
+}
+
+type spaHandler struct {
+ assets map[string]*asset // keyed by clean path, e.g. "assets/index-abc.js"
+ index *asset // served for the SPA fallback (client routes)
+}
+
+// newSPAHandler serves the embedded SPA with production-grade static
+// delivery: gzip (when the client accepts it), immutable caching for the
+// content-hashed assets Vite emits under assets/, ETag revalidation for the
+// rest, and an index.html fallback so client-side routing survives deep
+// links and reloads. Requests under /api/ never fall through — an unknown
+// API route returns 404 so callers get a not-found, not an HTML page.
func newSPAHandler() http.Handler {
sub, err := fs.Sub(spaFiles, "spa")
if err != nil {
@@ -29,27 +55,166 @@ func newSPAHandler() http.Handler {
// the Makefile prevents. Fail loudly rather than serve nothing.
panic("server: embedded spa/ directory not found: " + err.Error())
}
- index, err := fs.ReadFile(sub, "index.html")
- if err != nil {
- panic("server: embedded spa/index.html not found: " + err.Error())
- }
- fileServer := http.FileServer(http.FS(sub))
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasPrefix(r.URL.Path, "/api/") {
- http.Error(w, "404 page not found", http.StatusNotFound)
- return
+ h := &spaHandler{assets: make(map[string]*asset)}
+ walkErr := fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ raw, rerr := fs.ReadFile(sub, p)
+ if rerr != nil {
+ return rerr
}
- clean := strings.TrimPrefix(r.URL.Path, "/")
- if clean != "" {
- if info, statErr := fs.Stat(sub, clean); statErr == nil && !info.IsDir() {
- fileServer.ServeHTTP(w, r)
- return
- }
+ a := buildAsset(p, raw)
+ h.assets[p] = a
+ if p == "index.html" {
+ h.index = a
}
- // SPA fallback: client-side route or a refreshed deep link.
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Header().Set("Cache-Control", "no-cache")
- _, _ = w.Write(index)
+ return nil
})
+ if walkErr != nil {
+ panic("server: reading embedded spa/: " + walkErr.Error())
+ }
+ if h.index == nil {
+ panic("server: embedded spa/index.html not found")
+ }
+ return h
+}
+
+// buildAsset precomputes the content type, ETag, cache policy, and (for
+// compressible types) the gzip-encoded body for one embedded file.
+func buildAsset(p string, raw []byte) *asset {
+ sum := sha256.Sum256(raw)
+ a := &asset{
+ raw: raw,
+ contentType: contentTypeFor(p),
+ etag: `"` + hex.EncodeToString(sum[:])[:16] + `"`,
+ cacheControl: cachePolicy(p),
+ }
+ // Compress once at startup. Skip tiny bodies (the gzip framing overhead
+ // isn't worth it) and only keep the result if it actually shrank.
+ if compressible(p) && len(raw) >= 256 {
+ var b bytes.Buffer
+ zw, _ := gzip.NewWriterLevel(&b, gzip.BestCompression)
+ if _, werr := zw.Write(raw); werr == nil && zw.Close() == nil && b.Len() < len(raw) {
+ a.gz = append([]byte(nil), b.Bytes()...)
+ }
+ }
+ return a
+}
+
+// cachePolicy returns the Cache-Control header for a given embedded path.
+// Vite emits content-hashed filenames under assets/ (e.g. index-abc123.js),
+// so those are immutable and cacheable for a year — the browser never
+// re-requests them. Everything else (index.html, favicon, manifest) is
+// revalidated so a new deploy is picked up immediately; the ETag makes that
+// revalidation a cheap 304.
+func cachePolicy(p string) string {
+ if strings.HasPrefix(p, "assets/") {
+ return "public, max-age=31536000, immutable"
+ }
+ return "no-cache"
+}
+
+// compressible reports whether a file's bytes are worth gzip-encoding.
+// Already-compressed media (png/jpg/woff2/…) is excluded — re-compressing
+// it wastes CPU and can grow the payload.
+func compressible(p string) bool {
+ switch strings.ToLower(path.Ext(p)) {
+ case ".html", ".css", ".js", ".mjs", ".json", ".map", ".svg", ".txt", ".xml", ".webmanifest", ".wasm":
+ return true
+ }
+ return false
+}
+
+// contentTypeFor maps an extension to a Content-Type, filling gaps the
+// stdlib mime table can leave (notably .js on some platforms).
+func contentTypeFor(p string) string {
+ if ct := mime.TypeByExtension(path.Ext(p)); ct != "" {
+ return ct
+ }
+ switch strings.ToLower(path.Ext(p)) {
+ case ".js", ".mjs":
+ return "text/javascript; charset=utf-8"
+ case ".map", ".json":
+ return "application/json"
+ case ".webmanifest":
+ return "application/manifest+json"
+ case ".wasm":
+ return "application/wasm"
+ case ".svg":
+ return "image/svg+xml"
+ }
+ return "application/octet-stream"
+}
+
+func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ http.Error(w, "404 page not found", http.StatusNotFound)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Look the path up in the precomputed table only — request handling
+ // never touches the filesystem, so there is no path-traversal surface.
+ clean := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+ a, ok := h.assets[clean]
+ if !ok || clean == "" {
+ a = h.index // SPA fallback: client-side route or a refreshed deep link
+ }
+ h.serveAsset(w, r, a)
+}
+
+func (h *spaHandler) serveAsset(w http.ResponseWriter, r *http.Request, a *asset) {
+ hdr := w.Header()
+ hdr.Set("Content-Type", a.contentType)
+ hdr.Set("Cache-Control", a.cacheControl)
+ hdr.Set("ETag", a.etag)
+ hdr.Set("Vary", "Accept-Encoding")
+
+ if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, a.etag) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ body := a.raw
+ if a.gz != nil && clientAcceptsGzip(r) {
+ hdr.Set("Content-Encoding", "gzip")
+ body = a.gz
+ }
+ hdr.Set("Content-Length", strconv.Itoa(len(body)))
+
+ w.WriteHeader(http.StatusOK)
+ if r.Method == http.MethodHead {
+ return
+ }
+ _, _ = w.Write(body)
+}
+
+// clientAcceptsGzip reports whether the request's Accept-Encoding lists gzip.
+func clientAcceptsGzip(r *http.Request) bool {
+ for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
+ // Strip any q-value (e.g. "gzip;q=0.8") before comparing.
+ if strings.EqualFold(strings.TrimSpace(strings.SplitN(part, ";", 2)[0]), "gzip") {
+ return true
+ }
+ }
+ return false
+}
+
+// etagMatches reports whether an If-None-Match header value covers etag,
+// tolerating the weak-validator prefix and the "*" wildcard.
+func etagMatches(headerVal, etag string) bool {
+ for _, tag := range strings.Split(headerVal, ",") {
+ tag = strings.TrimSpace(tag)
+ tag = strings.TrimPrefix(tag, "W/")
+ if tag == "*" || tag == etag {
+ return true
+ }
+ }
+ return false
}
diff --git a/internal/server/spa_test.go b/internal/server/spa_test.go
index 1c7b1486..04cefa1f 100644
--- a/internal/server/spa_test.go
+++ b/internal/server/spa_test.go
@@ -2,6 +2,9 @@
package server
import (
+ "bytes"
+ "compress/gzip"
+ "io"
"net/http"
"net/http/httptest"
"strings"
@@ -75,6 +78,86 @@ func TestSPA_UnmatchedAPIPathReturns404(t *testing.T) {
})
}
+// @ac AC-15
+// AC-15: a compressible static asset is gzip-encoded when (and only when)
+// the client sends Accept-Encoding: gzip, with a Vary: Accept-Encoding
+// header so caches key on it. The gzipped body decodes to the original.
+func TestSPA_GzipWhenAccepted(t *testing.T) {
+ t.Run("system-http-server/AC-15", func(t *testing.T) {
+ h := newSPAHandler()
+
+ req := httptest.NewRequest(http.MethodGet, "/assets/app-abc123.js", nil)
+ req.Header.Set("Accept-Encoding", "gzip, deflate, br")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ resp := rec.Result()
+ defer resp.Body.Close()
+
+ if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want gzip", got)
+ }
+ if v := resp.Header.Get("Vary"); !strings.Contains(v, "Accept-Encoding") {
+ t.Errorf("Vary = %q, want to contain Accept-Encoding", v)
+ }
+ gr, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ t.Fatalf("body is not valid gzip: %v", err)
+ }
+ dec, _ := io.ReadAll(gr)
+ if !bytes.Contains(dec, []byte("console.log")) {
+ t.Errorf("decoded gzip body missing original content")
+ }
+
+ // No Accept-Encoding → identity (never force-encode).
+ req2 := httptest.NewRequest(http.MethodGet, "/assets/app-abc123.js", nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req2)
+ if ce := rec2.Result().Header.Get("Content-Encoding"); ce != "" {
+ t.Errorf("no Accept-Encoding but Content-Encoding = %q, want identity", ce)
+ }
+ })
+}
+
+// @ac AC-16
+// AC-16: content-hashed assets under assets/ are served immutable + long
+// max-age (the browser never re-requests them); the SPA shell (index.html)
+// is no-cache so deploys are picked up; an ETag enables a 304 revalidation.
+func TestSPA_CacheHeaders(t *testing.T) {
+ t.Run("system-http-server/AC-16", func(t *testing.T) {
+ h := newSPAHandler()
+
+ req := httptest.NewRequest(http.MethodGet, "/assets/app-abc123.js", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ resp := rec.Result()
+ cc := resp.Header.Get("Cache-Control")
+ if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=31536000") {
+ t.Errorf("asset Cache-Control = %q, want immutable + max-age=31536000", cc)
+ }
+ etag := resp.Header.Get("ETag")
+ if etag == "" {
+ t.Fatal("asset has no ETag")
+ }
+
+ // Matching If-None-Match → 304 Not Modified.
+ req2 := httptest.NewRequest(http.MethodGet, "/assets/app-abc123.js", nil)
+ req2.Header.Set("If-None-Match", etag)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req2)
+ if rec2.Result().StatusCode != http.StatusNotModified {
+ t.Errorf("If-None-Match (matching) status = %d, want 304", rec2.Result().StatusCode)
+ }
+
+ // The SPA shell must revalidate so a new deploy is seen immediately.
+ req3 := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec3 := httptest.NewRecorder()
+ h.ServeHTTP(rec3, req3)
+ if got := rec3.Result().Header.Get("Cache-Control"); got != "no-cache" {
+ t.Errorf("index.html Cache-Control = %q, want no-cache", got)
+ }
+ })
+}
+
// @ac AC-17
// AC-17: every response carries the security headers (HSTS, CSP, nosniff,
// frame-deny, referrer-policy); /docs gets a Swagger-compatible CSP that
diff --git a/specs/system/http-server.spec.yaml b/specs/system/http-server.spec.yaml
index 8220729e..3ca25f44 100644
--- a/specs/system/http-server.spec.yaml
+++ b/specs/system/http-server.spec.yaml
@@ -1,7 +1,7 @@
spec:
id: system-http-server
title: HTTPS server with TLS hot-reload
- version: "1.3.0"
+ version: "1.4.0"
status: approved
tier: 2
@@ -68,6 +68,10 @@ spec:
description: The server MUST serve the embedded single-page app for non-API routes (static assets directly, index.html as the client-side-routing fallback) and MUST NOT serve the SPA for unmatched /api/ paths (those return 404).
type: technical
enforcement: error
+ - id: C-11
+ description: The embedded SPA MUST be served with production static-delivery semantics so the single binary needs no reverse proxy for performance — gzip-encode compressible assets when the client sends Accept-Encoding gzip (with Vary Accept-Encoding) and never otherwise; serve Vite's content-hashed assets/ files as immutable with a long max-age while keeping index.html no-cache so deploys are seen immediately; and set an ETag so non-immutable responses revalidate as a 304. Compression and hashing are computed once at startup, not per request.
+ type: technical
+ enforcement: error
- id: C-12
description: >-
Every response MUST carry security headers, since the single binary
@@ -77,8 +81,8 @@ spec:
restricts default-src to 'self', X-Content-Type-Options nosniff,
X-Frame-Options DENY, and Referrer-Policy no-referrer. The CSP for
the embedded Swagger UI under /docs may relax script/style to
- 'unsafe-inline' but MUST still deny framing. (C-11 is reserved for
- the static-delivery spec slice.)
+ 'unsafe-inline' but MUST still deny framing. (C-11 is the
+ static-delivery spec slice.)
type: security
enforcement: error
- id: C-13
@@ -158,6 +162,14 @@ spec:
description: GET an unmatched /api/ path returns 404 and does NOT serve the SPA index.html.
priority: critical
references_constraints: [C-10]
+ - id: AC-15
+ description: A compressible static asset is gzip-encoded (Content-Encoding gzip, Vary Accept-Encoding, body decodes to the original) when and only when the request sends Accept-Encoding gzip; without it the response is identity-encoded.
+ priority: high
+ references_constraints: [C-11]
+ - id: AC-16
+ description: A content-hashed asset under assets/ is served with Cache-Control immutable + max-age=31536000 and an ETag; a matching If-None-Match returns 304; index.html is served Cache-Control no-cache so a new deploy is picked up.
+ priority: high
+ references_constraints: [C-11]
- id: AC-17
description: Every response carries Strict-Transport-Security (max-age >= 1 year, includeSubDomains), Content-Security-Policy (with frame-ancestors 'none' and default-src 'self'), X-Content-Type-Options nosniff, X-Frame-Options DENY, and Referrer-Policy no-referrer. A request under /docs gets a CSP that relaxes script/style to 'unsafe-inline' (for Swagger UI) but still denies framing.
priority: high