refactor(web): move embed package to web/ so go:embed reaches build/

go:embed paths are relative to the source file and cannot reach parent
directories, so internal/web/embed.go could not see web/build/ without
duplicating the placeholder. Relocating to web/embed.go lets the
directive resolve to the real SvelteKit build output with no copy step.
This commit is contained in:
2026-04-22 10:29:31 -04:00
parent d5692a73ca
commit 8fe4a5f578
18 changed files with 0 additions and 31 deletions
+61
View File
@@ -0,0 +1,61 @@
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
// Requests for real files under build/ are served verbatim; anything else —
// deep links like /artists/abc, missing asset paths — returns build/index.html
// so the SPA can resolve the route client-side.
package web
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:build
var buildFS embed.FS
// Handler returns an http.Handler that serves the embedded SPA.
//
// Behavior:
// - GET / -> build/index.html
// - GET /<path that exists in build/> -> that file, via http.FileServer
// - GET /<anything else> -> build/index.html (SPA fallback)
//
// Callers are expected to register this as the router's NotFound/catch-all so
// explicitly-routed paths (like /api/* and /rest/*) take precedence.
func Handler() http.Handler {
sub, err := fs.Sub(buildFS, "build")
if err != nil {
// fs.Sub on a valid embed path can only fail if the embed directive
// is malformed, which the compile would have caught.
panic("web: fs.Sub(build) failed: " + err.Error())
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
panic("web: embedded build/index.html missing: " + err.Error())
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clean := path.Clean(r.URL.Path)
if clean == "/" || clean == "." {
serveIndex(w, index)
return
}
name := strings.TrimPrefix(clean, "/")
if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
serveIndex(w, index)
})
}
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)
}
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandler_ServesIndexAtRoot(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
body, _ := io.ReadAll(rec.Body)
if !strings.Contains(strings.ToLower(string(body)), "<html") {
t.Errorf("body missing <html tag; got: %q", string(body))
}
}
func TestHandler_UnknownPathFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/artists/some-uuid", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for SPA fallback", ct)
}
}
func TestHandler_MissingAssetFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/_app/immutable/chunks/does-not-exist.js", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (fallback to index.html)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for missing asset", ct)
}
}