Merge pull request 'feat: web UI scaffold with embedded SPA and multi-stage Docker build' (#16) from dev into main
This commit was merged in pull request #16.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
name: test
|
||||
|
||||
# Lint + short unit tests only. Integration tests needing Postgres/ffmpeg
|
||||
# run locally via docker-compose; they should guard with testing.Short().
|
||||
# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run
|
||||
# locally via docker-compose; they should guard with testing.Short().
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,6 +17,25 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install web deps
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Web build (populates web/build/ for go:embed)
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Web test (vitest)
|
||||
working-directory: web
|
||||
run: npm test
|
||||
|
||||
- name: Detect Go project
|
||||
id: gomod
|
||||
shell: bash
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.23-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
|
||||
|
||||
|
||||
@@ -25,3 +25,20 @@ Forgejo Actions handles:
|
||||
- Container image build + push to the Forgejo registry on merge to `main` (`:main`) and on `v*` tags (`:<version>`).
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
## Development
|
||||
|
||||
Minstrel has two concurrent dev processes:
|
||||
|
||||
1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`.
|
||||
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR.
|
||||
|
||||
The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`,
|
||||
so session cookies work correctly when the SPA is loaded from the Vite origin.
|
||||
|
||||
### Production build
|
||||
|
||||
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage,
|
||||
copies the output into the `golang` stage, and `//go:embed`s it into the
|
||||
final binary. The resulting container serves the SPA from `/` alongside the
|
||||
API surfaces. No separate static-file server is required.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||
)
|
||||
|
||||
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
|
||||
@@ -50,6 +52,18 @@ func (s *Server) Router() http.Handler {
|
||||
})
|
||||
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
|
||||
}
|
||||
|
||||
spa := web.Handler()
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := req.URL.Path
|
||||
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
|
||||
return
|
||||
}
|
||||
spa.ServeHTTP(w, req)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
@@ -33,3 +34,79 @@ func TestHealthz(t *testing.T) {
|
||||
t.Errorf("body = %v, want status=ok", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html*", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001")
|
||||
if err != nil {
|
||||
t.Fatalf("GET deep link: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html*", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/does-not-exist: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q; /api/* must never return text/html", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/rest/does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /rest/does-not-exist: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/*
|
||||
!build/index.html
|
||||
@@ -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 && npm install && npm run build</code> or
|
||||
build the container image, which runs the web build during
|
||||
<code>docker build</code>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
+3798
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "minstrel-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "svelte-kit sync && vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.1.9",
|
||||
"svelte-check": "^4.0.5",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Minstrel</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="bg-surface-900 text-text-primary">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('scaffold smoke', () => {
|
||||
it('runs vitest', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
@@ -0,0 +1,8 @@
|
||||
<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 — UI features land in subsequent plans.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -0,0 +1,18 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: false
|
||||
}),
|
||||
files: {
|
||||
assets: 'static'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,ts,svelte}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
900: '#14161a',
|
||||
800: '#1a1d22',
|
||||
700: '#2a2f36'
|
||||
},
|
||||
text: {
|
||||
primary: '#e8ecf2',
|
||||
secondary: '#cdd3db'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4533',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/rest': {
|
||||
target: 'http://localhost:4533',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user