Vellum/internal/httpapi/server.go
mithral 5283e073f5 Web UI: Svelte app, embedded into the binary
The frontend the app needs to be usable at all.

- web/: Svelte 5 + Vite + TypeScript SPA. Login, app shell with per-type nav,
  library list/create/scan/delete, item grid with covers, admin user
  management, and an epub.js reader that syncs progress.
- Reader resumes by spine index — an integer — which is why the polymorphic
  progress table needs no text column.
- Session state uses classic stores in a plain .ts module rather than runes in
  a .svelte.ts module, avoiding special module resolution.
- Embedding: web/embed.go embeds web/dist. A committed .gitkeep keeps the
  directive valid before the frontend is built, so `go build ./...` never
  fails. Unknown non-API paths serve index.html for client routes; /api/* and
  /healthz always return JSON and never fall back to HTML.
- Dockerfile builds the UI in a node stage and overlays dist into the Go stage.
- pnpm 12 needs allowBuilds in pnpm-workspace.yaml; esbuild approved, the two
  unused transitive scripts explicitly denied so install exits 0.

svelte-check: 0 errors, 0 warnings. Verified served: SPA shell at /, route
fallback, hashed asset, and JSON 404 on /api/*.
2026-10-09 16:40:58 -04:00

167 lines
4.6 KiB
Go

// Package httpapi is the HTTP layer: routing, middleware, and handlers.
//
// In Phase 0 it serves only health and version. Feature modules (auth, library,
// ebooks, comics, ...) mount their own routes onto the same mux via
// Server.Handle, so this package never imports a feature module.
package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/mithral/vellum/internal/auth"
)
// Server holds the dependencies every handler needs.
type Server struct {
db *sql.DB
log *slog.Logger
version string
started time.Time
mux *http.ServeMux
auth *auth.Authenticator
}
// New builds a Server and registers the core routes.
func New(db *sql.DB, log *slog.Logger, version string) *Server {
s := &Server{
db: db,
log: log,
version: version,
started: time.Now(),
mux: http.NewServeMux(),
}
s.routes()
s.mountWeb()
return s
}
// Mux exposes the root mux so feature modules can mount their own routes.
func (s *Server) Mux() *http.ServeMux { return s.mux }
// SetAuth installs the session middleware. Until it is set, no request carries
// a current user.
func (s *Server) SetAuth(a *auth.Authenticator) { s.auth = a }
// Handle lets a module mount routes on the shared mux.
func (s *Server) Handle(pattern string, h http.Handler) {
s.mux.Handle(pattern, h)
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
}
// Handler returns the fully wrapped root handler.
//
// Order matters: the request ID must be attached before the logging
// middleware runs, so the log line can include it. Session loading sits
// innermost so it runs after logging has started timing.
func (s *Server) Handler() http.Handler {
var h http.Handler = s.mux
if s.auth != nil {
h = s.auth.Middleware(h)
}
return s.withRecovery(s.withRequestID(s.withRequestLogging(h)))
}
// ── Middleware ─────────────────────────────────────────────────────────
type ctxKey string
const ctxRequestID ctxKey = "request_id"
func withValue(ctx context.Context, k ctxKey, v string) context.Context {
return context.WithValue(ctx, k, v)
}
func valueString(ctx context.Context, k ctxKey) string {
if v, ok := ctx.Value(k).(string); ok {
return v
}
return ""
}
func (s *Server) withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
var b [8]byte
_, _ = rand.Read(b[:])
id = hex.EncodeToString(b[:])
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(withValue(r.Context(), ctxRequestID, id)))
})
}
func (s *Server) withRequestLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", valueString(r.Context(), ctxRequestID),
)
})
}
func (s *Server) withRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic",
"path", r.URL.Path,
"recover", rec,
"request_id", valueString(r.Context(), ctxRequestID),
)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}
// ── Helpers ────────────────────────────────────────────────────────────
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "not found",
"path": r.URL.Path,
})
}