Files
beug.k@beugsiud.deandClaude Opus 5.5 7819158009
ci / Go (lint, test, vuln) (push) Successful in 1m58s
ci / Development environment (compose) (push) Failing after 14s
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 32s
ci / Shell and licence headers (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 1m12s
fix(web): show build notice instead of 404 when index.html is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:35:37 +02:00

101 lines
3.0 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package web embeds the built frontend (web/dist, produced by `npm run build`)
// into the server binary and serves it as a single-page application.
package web
import (
"embed"
"errors"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:dist
var embedded embed.FS
// Handler serves the embedded frontend.
func Handler() http.Handler {
sub, err := fs.Sub(embedded, "dist")
if err != nil {
// fs.Sub only fails for invalid path names; "dist" is constant.
panic(err)
}
return NewHandler(sub)
}
// NewHandler serves a built frontend from fsys:
// - existing files are served directly; hashed files under assets/ are
// cacheable forever, everything else must be revalidated;
// - paths without a file extension fall back to index.html so that the
// client-side router can handle them;
// - if no index.html exists (Go-only development build), a notice page
// explains how to build the UI instead of failing.
func NewHandler(fsys fs.FS) http.Handler {
files := http.FileServerFS(fsys)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" {
name = "index.html"
}
if info, err := fs.Stat(fsys, name); err == nil && !info.IsDir() {
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
if name == "index.html" {
serveIndex(w, r, fsys)
return
}
files.ServeHTTP(w, r)
return
}
// index.html itself may be missing (UI not built): serveIndex then
// shows the notice page instead of a bare 404.
if name != "index.html" && path.Ext(name) != "" {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
serveIndex(w, r, fsys)
})
}
// serveIndex writes index.html directly; http.FileServer would redirect
// requests for /index.html to /.
func serveIndex(w http.ResponseWriter, r *http.Request, fsys fs.FS) {
data, err := fs.ReadFile(fsys, "index.html")
if errors.Is(err, fs.ErrNotExist) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(notBuiltPage))
return
}
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(data)
}
const notBuiltPage = `<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Weboberfläche nicht gebaut</title></head>
<body>
<h1>Weboberfläche nicht gebaut</h1>
<p>Dieses Server-Binary enthält keine Weboberfläche. Bauen Sie sie mit <code>make web</code>
und anschließend den Server mit <code>make build</code>.</p>
<p>Die Endpunkte <code>/healthz</code> und <code>/readyz</code> sind unabhängig davon verfügbar.</p>
</body>
</html>
`