Files
zeekayandHanzo Dev ed4603ffce Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 18:56:54 -07:00

529 lines
17 KiB
Go

// Package main is hanzoai/spa — zero-config SPA server.
//
// Single app mode (default):
//
// FROM ghcr.io/hanzoai/spa
// COPY dist /public
//
// Multi-app mode (MULTI_APP=true):
//
// FROM ghcr.io/hanzoai/spa
// COPY --from=build /app/apps/default/dist /public/default
// COPY --from=build /app/apps/admin/dist /public/admin
// ENV MULTI_APP=true
//
// In multi-app mode, the hostname prefix selects the app:
//
// admin.example.com → /public/admin/
// app.example.com → /public/app/
// Default → /public/default/
//
// Runtime config:
//
// SPA_API_HOST=https://api.test.example.com
// SPA_IAM_HOST=https://iam.test.example.com
// SPA_CHAIN_ID=1
// SPA_ENV=test
//
// ...at startup is templated into /public/config.json (or
// /public/<app>/config.json per app in multi-app mode). SPA reads
// /config.json at boot. No SPA_* vars = the placeholder file shipped in
// the image is left alone, and the SPA falls back to its own defaults.
//
// Features:
// - SPA mode: index.html served for all routes (client-side routing)
// - Aggressive caching: hashed assets get 1 year, index.html gets no-cache
// - Security headers: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy
// - Gzip/Brotli: pre-compressed .gz/.br files served automatically
// - Health check: GET /health → 200
// - Runtime config: /config.json templated from SPA_* env vars at startup
// - Port: 3000 (override with PORT env var)
// - Root: /public (override with ROOT env var)
// - Scratch-based: ~5MB total image size
package main
import (
"encoding/json"
"log"
"mime"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
root := os.Getenv("ROOT")
if root == "" {
root = "/public"
}
multiApp := os.Getenv("MULTI_APP") == "true"
defaultApp := os.Getenv("DEFAULT_APP")
if defaultApp == "" {
defaultApp = "default"
}
if err := writeRuntimeConfig(root, multiApp); err != nil {
log.Fatalf("spa: runtime config: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Write([]byte(`{"status":"ok"}`))
})
// 301 redirect routes. REDIRECTS=/from1=/to1,/from2=/to2 installs an
// exact-match handler for each pair that returns 301 → target.
// Useful when consolidating multi-page sites into a single landing
// and you want old URLs to redirect rather than 404 / fall through
// to index.html.
if redirects := os.Getenv("REDIRECTS"); redirects != "" {
for pair := range strings.SplitSeq(redirects, ",") {
pair = strings.TrimSpace(pair)
from, to, ok := strings.Cut(pair, "=")
if !ok || from == "" || to == "" {
log.Printf("spa: ignoring malformed REDIRECTS pair %q", pair)
continue
}
fromPath, toPath := from, to
mux.HandleFunc(fromPath, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, toPath, http.StatusMovedPermanently)
})
log.Printf("spa: redirect %s → %s (301)", from, to)
}
}
// Reverse-proxy routes. Each PROXY_<PREFIX>=<upstream-url> env var
// installs a prefix handler that forwards matching requests to the
// upstream. Example:
// PROXY_V1=http://api.tenant:8080 → /v1/* → api.tenant:8080
// PROXY_API=https://api.example.com → /api/* → api.example.com
// The exchange SPA needs this so relative `/v1/*` fetches don't fall
// through the SPA 404 handler and come back as `index.html`.
for _, e := range os.Environ() {
k, v, ok := strings.Cut(e, "=")
if !ok || !strings.HasPrefix(k, "PROXY_") || v == "" {
continue
}
prefix := "/" + strings.ToLower(strings.TrimPrefix(k, "PROXY_")) + "/"
upstream, err := url.Parse(v)
if err != nil {
log.Fatalf("spa: PROXY_%s parse: %v", strings.TrimPrefix(k, "PROXY_"), err)
}
mux.Handle(prefix, httputil.NewSingleHostReverseProxy(upstream))
log.Printf("spa: proxy %s → %s", prefix, upstream)
}
if multiApp {
log.Printf("spa: multi-app mode, root=%s, default=%s, port=%s", root, defaultApp, port)
mux.Handle("/", multiAppHandler(root, defaultApp))
} else {
log.Printf("spa: serving %s on :%s", root, port)
mux.Handle("/", spaHandler(root))
}
// Optional native auth gate. When SPA_PROTECT is unset, loadProtect returns
// nil and wrap is a no-op — the mux is served byte-for-byte as before.
handler := loadProtect().wrap(mux)
log.Fatal(http.ListenAndServe(":"+port, handler))
}
// writeRuntimeConfig templates /public/config.json (or each app's config.json
// in multi-app mode) from SPA_* env vars. SPA_API_HOST → apiHost,
// SPA_CHAIN_ID → chainId (int if all digits), SPA_FEATURE_X → featureX. No
// SPA_* vars set = the placeholder file shipped in the image is left alone.
func writeRuntimeConfig(root string, multiApp bool) error {
vars := map[string]any{}
for _, e := range os.Environ() {
k, v, ok := strings.Cut(e, "=")
if !ok || !strings.HasPrefix(k, "SPA_") || v == "" {
continue
}
// config.json is served to browsers, so never template a secret into
// it. Server-side secrets already drop the SPA_ prefix (PROTECT_*), but
// this is defense-in-depth: any SPA_* key that looks like a secret is
// excluded. NOT *TOKEN — SPA_COMMERCE_TOKEN is an intentionally
// browser-safe Published token that must appear in config.json.
if isSensitiveKey(k) {
log.Printf("spa: refusing to template %s into browser config.json (looks secret)", k)
continue
}
vars[toCamel(strings.TrimPrefix(k, "SPA_"))] = parseValue(v)
}
if len(vars) == 0 {
log.Print("spa: no SPA_* env vars set — leaving existing /config.json in place")
return nil
}
vars["v"] = 1
targets := []string{root}
if multiApp {
entries, err := os.ReadDir(root)
if err != nil {
return err
}
targets = targets[:0]
for _, e := range entries {
if e.IsDir() {
targets = append(targets, filepath.Join(root, e.Name()))
}
}
}
body := marshalSorted(vars)
for _, dir := range targets {
dst := filepath.Join(dir, "config.json")
tmp := dst + ".tmp"
if err := os.WriteFile(tmp, body, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
return err
}
log.Printf("spa: wrote runtime %s", dst)
}
return nil
}
// marshalSorted produces deterministic JSON output — keys sorted, no HTML
// escaping. Two builds with the same env produce byte-identical config.json.
func marshalSorted(m map[string]any) []byte {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]byte, 0, 256)
out = append(out, '{')
for i, k := range keys {
if i > 0 {
out = append(out, ',')
}
kb, _ := json.Marshal(k)
vb, _ := json.Marshal(m[k])
out = append(out, kb...)
out = append(out, ':')
out = append(out, vb...)
}
out = append(out, '}', '\n')
return out
}
// isSensitiveKey reports whether an env var name looks like a secret that must
// never be templated into the browser-served config.json. SECRET/PASSWORD/
// PRIVATE only — deliberately NOT TOKEN, because SPA_COMMERCE_TOKEN is a
// browser-safe Published token the SPA needs at runtime.
func isSensitiveKey(k string) bool {
u := strings.ToUpper(k)
return strings.Contains(u, "SECRET") ||
strings.Contains(u, "PASSWORD") ||
strings.Contains(u, "PRIVATE")
}
// toCamel turns SPA_API_HOST → apiHost, SPA_CHAIN_ID → chainId.
func toCamel(s string) string {
parts := strings.Split(strings.ToLower(s), "_")
for i, p := range parts {
if i == 0 || p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, "")
}
// parseValue coerces env-var strings: "true"/"false" → bool, all-digits →
// int64, else → string. Needed so chainId serializes as a JSON number.
func parseValue(v string) any {
switch strings.ToLower(v) {
case "true":
return true
case "false":
return false
}
allDigits := len(v) > 0
for _, c := range v {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if allDigits {
var n int64
for _, c := range v {
n = n*10 + int64(c-'0')
}
return n
}
return v
}
// resolveApp picks the app subdirectory from the hostname prefix.
// The first label of the hostname maps to a directory under root;
// if that directory exists, the prefix wins, otherwise defaultApp is used.
func resolveApp(host, defaultApp string) string {
return resolveAppWithRoot(host, defaultApp, "")
}
func resolveAppWithRoot(host, defaultApp, root string) string {
// Strip port
if idx := strings.IndexByte(host, ':'); idx >= 0 {
host = host[:idx]
}
// First label of hostname
prefix := host
if idx := strings.IndexByte(host, '.'); idx >= 0 {
prefix = host[:idx]
}
if prefix == "" || prefix == host {
return defaultApp
}
// Only accept simple identifiers so a hostile Host header can't escape root.
for _, c := range prefix {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return defaultApp
}
}
if root != "" {
if stat, err := os.Stat(filepath.Join(root, prefix)); err == nil && stat.IsDir() {
return prefix
}
return defaultApp
}
return prefix
}
func multiAppHandler(root, defaultApp string) http.Handler {
allowFraming := os.Getenv("ALLOW_FRAMING") == "true"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app := resolveAppWithRoot(r.Host, defaultApp, root)
appRoot := filepath.Join(root, app)
setSecurityHeaders(w, allowFraming)
resolveAndServe(w, r, appRoot)
})
}
func spaHandler(root string) http.Handler {
allowFraming := os.Getenv("ALLOW_FRAMING") == "true"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w, allowFraming)
resolveAndServe(w, r, root)
})
}
// resolveAndServe handles both Vite-style SPAs (one index.html) and Next.js
// static exports (per-route .html or per-route /index.html). Resolution order:
// 1. <root>/<path> — exact file (e.g. /favicon.ico)
// 2. <root>/<path>.html — Next.js export with trailingSlash:false (/foo → /foo.html)
// 3. <root>/<path>/index.html — Next.js export with trailingSlash:true OR a directory hit
// 4. <root>/index.html — SPA fallback for client-side routing
func resolveAndServe(w http.ResponseWriter, r *http.Request, root string) {
clean := filepath.Clean(r.URL.Path)
path := filepath.Join(root, clean)
if fi, err := os.Stat(path); err == nil && !fi.IsDir() {
serveFile(w, r, path, false)
return
}
if fi, err := os.Stat(path + ".html"); err == nil && !fi.IsDir() {
serveFile(w, r, path+".html", false)
return
}
idx := filepath.Join(path, "index.html")
if fi, err := os.Stat(idx); err == nil && !fi.IsDir() {
serveFile(w, r, idx, false)
return
}
// A request that names a file — it ends in an asset's extension, or targets
// a known asset root — but reached the fallback simply doesn't exist: 404 it
// rather than serving the index.html shell under an asset's URL. Client
// routes fall through to index.html for client-side routing.
//
// The test is a named set rather than "has a dot", because a route can carry
// one: /users/[email protected] ends in ".ai", and answering that with a 404 logs
// every customer out of a page that exists.
if assetExtensions[strings.ToLower(filepath.Ext(clean))] || strings.HasPrefix(clean, "/img/") || strings.HasPrefix(clean, "/fonts/") {
// This miss is transient by construction: during a rolling deploy the
// old replica is asked for the new build's hashed assets. An edge cache
// that stores the 404 (Cloudflare's default TTL rules do) stretches
// that seconds-long window into hours of blank pages, so the refusal
// must be uncacheable.
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, r)
return
}
serveFile(w, r, filepath.Join(root, "index.html"), true)
}
// What a browser fetches as a subresource rather than navigates to. A build
// emits these; a router never routes to one.
var assetExtensions = map[string]bool{
".js": true, ".mjs": true, ".cjs": true, ".map": true,
".css": true,
".json": true, ".xml": true, ".txt": true, ".webmanifest": true,
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true,
".webp": true, ".avif": true, ".ico": true, ".bmp": true,
".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true,
".mp4": true, ".webm": true, ".mp3": true, ".wav": true, ".ogg": true,
".wasm": true, ".pdf": true, ".zip": true, ".gz": true, ".br": true,
".html": true, ".htm": true,
}
// permissionsPolicyHeader and cspFrameAncestorsHeader are evaluated once
// at startup. SPAs that need camera/mic (biometric KYC) or inline-script
// CSP (dev HMR, analytics) set the env vars; defaults stay locked-down.
var (
permissionsPolicyHeader = envOr("SPA_PERMISSIONS_POLICY",
"camera=(), microphone=(), geolocation=()")
cspFrameAncestorsHeader = envOr("SPA_CSP",
"frame-ancestors 'none'")
)
func envOr(k, fallback string) string {
if v := os.Getenv(k); v != "" {
return v
}
return fallback
}
func setSecurityHeaders(w http.ResponseWriter, allowFraming bool) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("X-Content-Type-Options", "nosniff")
if !allowFraming {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", cspFrameAncestorsHeader)
}
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", permissionsPolicyHeader)
}
func serveFile(w http.ResponseWriter, r *http.Request, path string, isFallback bool) {
f, err := os.Open(path)
if err != nil {
http.Error(w, "not found", 404)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
http.Error(w, "error", 500)
return
}
ext := filepath.Ext(path)
ct := mime.TypeByExtension(ext)
if ct != "" {
w.Header().Set("Content-Type", ct)
}
name := filepath.Base(path)
// config.json must never be cached — it's templated at startup and the
// SPA fetches it on boot.
if name == "config.json" {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
} else if isFallback || name == "index.html" {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
} else if isHashedAsset(name) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Everything else keeps its name across builds — logo.svg, brand.json,
// the favicons. Its content changes while its URL does not, so it has to
// be revalidated rather than held: a day-long TTL meant a new brand mark
// shipped, went live at the origin, and kept showing the old one to every
// returning visitor and every warm edge until the next day.
//
// A revalidation is a conditional GET answered by ETag or Last-Modified,
// so the common case is a 304 with no body — cheap enough to be the rule.
w.Header().Set("Cache-Control", "public, no-cache")
}
// Vary: Accept-Encoding is required regardless of which branch fires —
// the response varies by the request's Accept-Encoding even when we
// fall through to the identity body (CDN representation cache must
// not serve the identity bytes to a client that asked for br/gzip).
w.Header().Set("Vary", "Accept-Encoding")
// The pre-compressed variants go through ServeContent too, on the original
// file's modification time. That is what answers a conditional request with
// a 304 — copying the bytes straight out skipped the check and re-sent the
// whole body every time, which is precisely the cost a revalidating
// Cache-Control is counting on not paying.
accept := r.Header.Get("Accept-Encoding")
if acceptsEncoding(accept, "br") {
if br, err := os.Open(path + ".br"); err == nil {
defer br.Close()
w.Header().Set("Content-Encoding", "br")
http.ServeContent(w, r, fi.Name(), fi.ModTime(), br)
return
}
}
if acceptsEncoding(accept, "gzip") {
if gz, err := os.Open(path + ".gz"); err == nil {
defer gz.Close()
w.Header().Set("Content-Encoding", "gzip")
http.ServeContent(w, r, fi.Name(), fi.ModTime(), gz)
return
}
}
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
}
// acceptsEncoding reports whether the Accept-Encoding header allows the given
// coding. A token is allowed unless the client explicitly disables it with
// `;q=0`. Whitespace and case are ignored. Falls back to a substring check
// for the most common single-token forms (br, gzip).
func acceptsEncoding(header, coding string) bool {
for part := range strings.SplitSeq(header, ",") {
token := strings.TrimSpace(part)
if token == "" {
continue
}
name := token
q := "1"
if before, after, ok := strings.Cut(token, ";"); ok {
name = strings.TrimSpace(before)
rest := strings.TrimSpace(after)
if eq := strings.IndexByte(rest, '='); eq > 0 && strings.EqualFold(strings.TrimSpace(rest[:eq]), "q") {
q = strings.TrimSpace(rest[eq+1:])
}
}
if !strings.EqualFold(name, coding) && name != "*" {
continue
}
// q=0 is "not acceptable" per RFC 9110 §12.5.3.
if q == "0" || strings.HasPrefix(q, "0.") && strings.TrimLeft(q[2:], "0") == "" {
return false
}
return true
}
return false
}
func isHashedAsset(name string) bool {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
if idx := strings.LastIndex(base, "-"); idx > 0 {
hash := base[idx+1:]
if len(hash) >= 6 {
for _, c := range hash {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
return false
}
}
return true
}
}
return false
}