feat(web): embed SvelteKit build output with SPA fallback handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 10:26:36 -04:00
parent 8d88a76dbf
commit d5692a73ca
18 changed files with 144 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export const env={}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{u as o,c as t,l as c,a as u}from"./Bczxu_ef.js";function l(n){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(n){t===null&&l(),c&&t.l!==null?a(t).m.push(n):o(()=>{const e=u(n);if(typeof e=="function")return e})}function a(n){var e=n.l;return e.u??(e.u={a:[],b:[],m:[]})}export{r as o};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{e}from"./Bczxu_ef.js";e();
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{i as p,j as u,k as c,m as l,n as E,T as w,o as g,h as i,q as s,R as y,b as N,v as x,w as M,x as b}from"./Bczxu_ef.js";var f;const d=((f=globalThis==null?void 0:globalThis.window)==null?void 0:f.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function A(t){return(d==null?void 0:d.createHTML(t))??t}function L(t){var r=p("template");return r.innerHTML=A(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=c;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function P(t,r){var e=(r&w)!==0,m=(r&g)!==0,a,v=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=L(v?t:"<!>"+t),e||(a=l(a)));var o=m||E?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=l(o),h=o.lastChild;n(T,h)}else n(o,o);return o}}function C(t=""){if(!i){var r=u(t+"");return n(r,r),r}var e=s;return e.nodeType!==x?(e.before(e=u()),M(e)):b(e),n(e,e),e}function I(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=u();return t.append(r,e),n(r,e),t}function D(t,r){if(i){var e=c;(!(e.f&y)||e.nodes.end===null)&&(e.nodes.end=s),N();return}t!==null&&t.before(r)}const R="5";var _;typeof window<"u"&&((_=window.__svelte??(window.__svelte={})).v??(_.v=new Set)).add(R);export{D as a,n as b,I as c,P as f,C as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/Dg9CzvF0.js";export{o as load_css,r as start};
@@ -0,0 +1 @@
import{c as o,a as s}from"../chunks/jjiUbCvc.js";import"../chunks/TKBjD5KV.js";import{h as l,b as d,f as m}from"../chunks/Bczxu_ef.js";function c(f,a,t,e,u){var i;l&&d();var n=(i=a.$$slots)==null?void 0:i[t],r=!1;n===!0&&(n=a.children,r=!0),n===void 0||n(f,r?()=>e:e)}function v(f,a){var t=o(),e=m(t);c(e,a,"default",{}),s(f,t)}export{v as component};
@@ -0,0 +1 @@
import{a as g,f as h}from"../chunks/jjiUbCvc.js";import{p as d,f as v,t as l,d as _,g as a,r as o,s as x}from"../chunks/Bczxu_ef.js";import{s as p}from"../chunks/VGLXWWN0.js";import{s as $,p as m}from"../chunks/Dg9CzvF0.js";const k={get error(){return m.error},get status(){return m.status}};$.updated.check;const f=k;var b=h("<h1> </h1> <p> </p>",1);function y(c,n){d(n,!0);var t=b(),r=v(t),u=a(r,!0);o(r);var e=x(r,2),i=a(e,!0);o(e),l(()=>{var s;p(u,f.status),p(i,(s=f.error)==null?void 0:s.message)}),g(c,t),_()}export{y as component};
@@ -0,0 +1 @@
import{a as s,f as a}from"../chunks/jjiUbCvc.js";import"../chunks/TKBjD5KV.js";var n=a('<main class="flex h-screen items-center justify-center"><div class="text-center"><h1 class="text-4xl font-semibold">Minstrel</h1> <p class="mt-2 text-text-secondary">Scaffold &mdash; UI features land in subsequent plans.</p></div></main>');function i(e){var t=n();s(e,t)}export{i as component};
+1
View File
@@ -0,0 +1 @@
{"version":"1776864372510"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Minstrel</title>
</head>
<body>
<p>
Minstrel SPA has not been built. Run
<code>cd web &amp;&amp; npm install &amp;&amp; npm run build</code> or
build the container image, which runs the web build during
<code>docker build</code>.
</p>
</body>
</html>
+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)
}
}