Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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' '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>OpenWatch</title></head><body>OpenWatch SPA placeholder. Run `make spa` (or `make build`) to embed the real UI.</body></html>' > $@
@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`,
Expand Down
217 changes: 191 additions & 26 deletions internal/server/spa.go
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -17,39 +23,198 @@ 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 {
// Only happens if the embed directory is missing at build time, which
// 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
}
83 changes: 83 additions & 0 deletions internal/server/spa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
package server

import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading