From fecb74679148a70501373efebd8bb06c1d53198e Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Tue, 16 Jun 2026 18:07:40 -0400 Subject: [PATCH 1/2] perf(server): gzip + immutable caching for the embedded SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single binary serves the embedded SPA directly (no NGINX), but the handler shipped raw bytes with no compression and no cache headers, so in production the JS/CSS bundles downloaded uncompressed and the browser re-fetched every hashed asset on each navigation — the reported slowness. TLS and HTTP/2 are already handled by the binary (ListenAndServeTLS, h2 auto-enabled over TLS), so the only gap vs an NGINX/Caddy front was static delivery. Close it in-process so the deployment stays a single binary: - gzip-encode compressible assets when the client sends Accept-Encoding: gzip (Vary: Accept-Encoding), identity otherwise. Compression is computed ONCE at startup (the embedded FS is fixed at build time) and cached, so it is never paid per request. - Vite's content-hashed assets/ files -> Cache-Control: public, max-age=31536000, immutable (browser never re-requests them). - index.html (the SPA shell) -> no-cache, so a new deploy is picked up immediately. An ETag on every response makes revalidation a cheap 304. - Serving is now a precomputed-table lookup (no per-request FS access, so no path-traversal surface). Scoped to the SPA handler only — /api and the SSE stream are never gzip-wrapped. Spec system-http-server -> v1.1.0: C-11, AC-15 (gzip), AC-16 (caching). Does NOT change the auth/redirect behaviour. The 'no redirect on session expiry' symptom is tracked separately (top suspect: Secure cookies require HTTPS end-to-end; the 401->refresh-cookie->/login flow already exists in the frontend client). --- internal/server/spa.go | 217 +++++++++++++++++++++++++---- internal/server/spa_test.go | 83 +++++++++++ specs/system/http-server.spec.yaml | 14 +- 3 files changed, 287 insertions(+), 27 deletions(-) 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 235dba5a..2271082a 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" @@ -74,3 +77,83 @@ 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) + } + }) +} diff --git a/specs/system/http-server.spec.yaml b/specs/system/http-server.spec.yaml index 981467ee..26e0fc6d 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.0.0" + version: "1.1.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 acceptance_criteria: - id: AC-01 @@ -120,3 +124,11 @@ 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] From e161dba5e86950aedbaecf248afbc1826539e2f9 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Tue, 16 Jun 2026 23:34:32 -0400 Subject: [PATCH 2/2] build(spa): stage a stub asset for the static-delivery tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/server/spa/ is gitignored, so the assets/app-abc123.js fixture only existed locally — CI's Makefile stub created index.html alone, leaving the static-delivery tests (system-http-server AC-15 gzip, AC-16 immutable caching) with no asset to serve. They passed locally and failed in CI. Extend the stub target to also stage assets/app-abc123.js: >=256 bytes and compressible so the handler's gzip path engages, containing console.log (the gzip test decodes and checks for it). Verified by wiping spa/ and regenerating via the target, then running the suite — AC-15/16/17 all pass. --- Makefile | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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`,