package httpapi import ( "bufio" "compress/gzip" "io" "io/fs" "net" "net/http" "path" "strconv" "strings" "github.com/mithral/vellum/web" ) // mountWeb serves the embedded single-page app at the root path. // // Unknown non-API paths fall back to index.html so client-side routes work on // reload. API paths never fall back: a typo in an endpoint returns JSON, not // an HTML page. func (s *Server) mountWeb() { sub, err := fs.Sub(web.Dist, "dist") if err != nil { s.mux.HandleFunc("/", s.handleNotFound) return } index, err := fs.ReadFile(sub, "index.html") if err != nil { // Frontend not built into this binary. Keep JSON 404s rather than // pretending the app exists. s.log.Warn("web UI not embedded; run the frontend build to include it") s.mux.HandleFunc("/", s.handleNotFound) return } files := http.FileServer(http.FS(sub)) // Only the embedded static assets are wrapped for compression. API // responses and media are served by other handlers and are never touched. serveFiles := compressStatic(files.ServeHTTP) serveShell := compressStatic(func(w http.ResponseWriter, r *http.Request) { serveIndex(w, index) }) s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if isAPIPath(r.URL.Path) { s.handleNotFound(w, r) return } if r.URL.Path == "/" || r.URL.Path == "" { serveShell(w, r) return } name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") if f, err := sub.Open(name); err == nil { _ = f.Close() serveFiles(w, r) return } // SPA route: hand back the shell and let the client router decide. serveShell(w, r) }) } func serveIndex(w http.ResponseWriter, index []byte) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(index) } func isAPIPath(p string) bool { return p == "/healthz" || strings.HasPrefix(p, "/api/") } // ── Static-asset gzip ────────────────────────────────────────────────── // // The built SPA ships uncompressed: the JS bundle is ~456 KB and the CSS // ~31 KB on every cold load. compressStatic wraps only the embedded static // handlers with gzip negotiation; API responses and media are never wrapped. // // The narrow scope is deliberate. (a) Media is already compressed. (b) A Range // response is used for seeking, and re-encoding it would invalidate the byte // offsets. (c) server.go's statusRecorder was just fixed to forward // http.Flusher and io.ReaderFrom for streaming; wrapping every response in a // gzip writer would force all bodies through a compressor and undo that. // Restricting the wrapper to the static file handler avoids all three. // compressStatic gzips a static handler's response when the client accepts // gzip and the response is a compressible text type. Close is called after the // handler returns so the gzip trailer is always written. func compressStatic(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gz := newGzipResponseWriter(w, r) next(gz, r) _ = gz.Close() } } // compressibleType reports whether a Content-Type should be gzip-encoded. Only // text-like types are listed; images, fonts, wasm and other media are already // compressed, so re-encoding them wastes CPU for no gain. func compressibleType(contentType string) bool { if i := strings.IndexByte(contentType, ';'); i >= 0 { contentType = contentType[:i] // drop "charset=utf-8" and friends } switch strings.ToLower(strings.TrimSpace(contentType)) { case "text/html", "text/css", "application/javascript", "text/javascript", "application/json", "image/svg+xml", "application/manifest+json": return true } return false } // acceptsGzip reports whether the client's Accept-Encoding allows gzip. An // explicit "gzip;q=0" is a refusal, so it does not count. func acceptsGzip(header string) bool { for _, part := range strings.Split(header, ",") { token, params, _ := strings.Cut(part, ";") if !strings.EqualFold(strings.TrimSpace(token), "gzip") { continue } for _, p := range strings.Split(params, ";") { if v, ok := strings.CutPrefix(strings.TrimSpace(p), "q="); ok { if q, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && q == 0 { return false } } } return true } return false } // gzipResponseWriter wraps a static response so its body can be gzip-encoded. // // Like statusRecorder it forwards Flush, Hijack and ReadFrom. While the body is // being compressed those forwards must route through the compressor (or flush // it first), or raw bytes would be written under a gzip Content-Encoding. type gzipResponseWriter struct { http.ResponseWriter eligible bool compress bool wroteHeader bool gz *gzip.Writer } func newGzipResponseWriter(w http.ResponseWriter, r *http.Request) *gzipResponseWriter { return &gzipResponseWriter{ ResponseWriter: w, // A Range request must stay identity so its byte offsets remain valid. eligible: acceptsGzip(r.Header.Get("Accept-Encoding")) && r.Header.Get("Range") == "", } } // WriteHeader decides compression once the handler has set Content-Type. The // decision has to happen before the status line is committed. func (g *gzipResponseWriter) WriteHeader(code int) { if g.wroteHeader { return } g.wroteHeader = true h := g.Header() if g.eligible && code == http.StatusOK && h.Get("Content-Encoding") == "" && h.Get("Content-Length") != "0" && compressibleType(h.Get("Content-Type")) { g.compress = true h.Set("Content-Encoding", "gzip") h.Add("Vary", "Accept-Encoding") // The compressed length is not known until the body is written, and // the handler's original Content-Length is now wrong. h.Del("Content-Length") } g.ResponseWriter.WriteHeader(code) } // writer lazily creates the gzip stream on the first body byte. A HEAD request // (or a bodyless response) never writes, so no empty gzip stream is emitted and // net/http has nothing from which to fabricate a misleading Content-Length. func (g *gzipResponseWriter) writer() *gzip.Writer { if g.gz == nil { g.gz = gzip.NewWriter(g.ResponseWriter) } return g.gz } func (g *gzipResponseWriter) Write(b []byte) (int, error) { if !g.wroteHeader { g.WriteHeader(http.StatusOK) } if g.compress { return g.writer().Write(b) } return g.ResponseWriter.Write(b) } // Flush forwards to the underlying http.Flusher, after flushing any buffered // compressed bytes so the client sees a complete gzip block. func (g *gzipResponseWriter) Flush() { if !g.wroteHeader { g.WriteHeader(http.StatusOK) } if g.compress && g.gz != nil { _ = g.gz.Flush() } if f, ok := g.ResponseWriter.(http.Flusher); ok { f.Flush() } } // Hijack forwards to the underlying http.Hijacker for protocols that take over // the connection. func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { if hj, ok := g.ResponseWriter.(http.Hijacker); ok { return hj.Hijack() } return nil, nil, http.ErrNotSupported } // ReadFrom forwards to the underlying io.ReaderFrom so io.Copy keeps the // sendfile fast path. When compressing, the bytes must pass through the gzip // writer instead; the returned count is the source byte count. func (g *gzipResponseWriter) ReadFrom(src io.Reader) (int64, error) { if !g.wroteHeader { g.WriteHeader(http.StatusOK) } if g.compress { return io.Copy(g.writer(), src) } if rf, ok := g.ResponseWriter.(io.ReaderFrom); ok { return rf.ReadFrom(src) } return io.Copy(g.ResponseWriter, src) } // Close flushes the gzip trailer. It must run after the static handler returns, // or the body will be missing its checksum and the client will reject it. func (g *gzipResponseWriter) Close() error { if g.compress && g.gz != nil { return g.gz.Close() } return nil }