feat(project): containerize as a single image serving SPA + API
Add a multi-stage Dockerfile that builds the SvelteKit SPA (adapter-static, no Node runtime in the final image) and the Go server, then ships an Alpine runtime that serves both the static frontend and the API on one port. - Stage 1 (node): npm ci + build → static SPA (index.html, _app, fonts, sw) - Stage 2 (golang): CGO_ENABLED=0 static binary (image processing is pure Go) - Stage 3 (alpine): + ffmpeg for video thumbnails, non-root user, /data volume, healthcheck on /health; secrets passed at runtime, not baked in To serve the SPA on the API port, the Go server now optionally hosts static files behind a new STATIC_DIR env var: a request maps to a real file when one exists, otherwise falls back to index.html for client-side routes; unknown /api/ paths still return JSON 404. Empty STATIC_DIR (local dev) keeps the API standalone while Vite serves the UI. Cache-Control is tuned to adapter-static output (immutable hashed assets, no-cache service worker) and .webmanifest is registered so nosniff doesn't reject the PWA manifest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ func NewRouter(
|
||||
userHandler *UserHandler,
|
||||
aclHandler *ACLHandler,
|
||||
auditHandler *AuditHandler,
|
||||
staticDir string,
|
||||
) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
|
||||
@@ -179,5 +180,12 @@ func NewRouter(
|
||||
// -------------------------------------------------------------------------
|
||||
v1.GET("/audit", auth.Handle(), auditHandler.List)
|
||||
|
||||
// Serve the built single-page app on the same port as the API. When
|
||||
// staticDir is empty (local development) the Vite dev server serves the UI
|
||||
// instead, so the API runs standalone and unknown routes 404 normally.
|
||||
if staticDir != "" {
|
||||
r.NoRoute(spaHandler(staticDir))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Go's mime table doesn't know .webmanifest; register it so the PWA manifest
|
||||
// is served as JSON and isn't rejected by the X-Content-Type-Options header.
|
||||
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||||
}
|
||||
|
||||
// spaHandler serves the built single-page app from dir. It is wired as the
|
||||
// router's NoRoute handler, so it only sees requests that matched no API route.
|
||||
//
|
||||
// A request whose path maps to a real file on disk is served directly (with
|
||||
// cache headers tuned to SvelteKit's adapter-static output). Anything else
|
||||
// falls back to index.html so the client-side router can resolve deep links
|
||||
// like /pools/123. Unknown /api/ paths return a JSON 404 instead of the HTML
|
||||
// shell, keeping API error responses machine-readable.
|
||||
func spaHandler(dir string) gin.HandlerFunc {
|
||||
indexPath := filepath.Join(dir, "index.html")
|
||||
|
||||
return func(c *gin.Context) {
|
||||
reqPath := c.Request.URL.Path
|
||||
|
||||
if strings.HasPrefix(reqPath, "/api/") {
|
||||
c.JSON(http.StatusNotFound, errorBody{
|
||||
Code: "not_found",
|
||||
Message: "resource not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the request to a path inside dir. Cleaning an absolute path
|
||||
// collapses any "../" segments before the join, so the result can never
|
||||
// escape dir — this is the traversal guard.
|
||||
clean := path.Clean("/" + reqPath)
|
||||
target := filepath.Join(dir, filepath.FromSlash(clean))
|
||||
|
||||
if info, err := os.Stat(target); err == nil && !info.IsDir() {
|
||||
c.Header("Cache-Control", cacheControl(clean))
|
||||
c.File(target)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback: serve the shell, never cached so a new deploy is picked
|
||||
// up immediately on the next navigation.
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.File(indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
// cacheControl returns the Cache-Control value for a served static asset.
|
||||
// SvelteKit emits content-hashed files under /_app/immutable — those are safe
|
||||
// to cache forever. The service worker must never be cached, or clients pin to
|
||||
// a stale shell. Everything else gets a short, revalidated TTL.
|
||||
func cacheControl(p string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(p, "/_app/immutable/"):
|
||||
return "public, max-age=31536000, immutable"
|
||||
case p == "/service-worker.js":
|
||||
return "no-cache"
|
||||
default:
|
||||
return "public, max-age=3600"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user