Served the frontend's build output from server.frontend.dir

internal/frontend serves a Vite build from an fs.FS: hashed files under
/assets/ are cached for a year, HTML and other files get no-cache, ETags are
computed (and cached for a build that can't change), precompressed variants
are served when the client accepts them, and a path without an extension
falls back to index.html for client-side routes. Only GET and HEAD are
allowed.

server.WithFrontend serves it for every path outside /api and /ws, behind
compression and the frontend group's hooks. job-search serve passes the
directory in server.frontend.dir, checked for an index.html at start; empty
means no frontend. Its option building moved into serverOptions, with the
access log.
This commit is contained in:
bwilliams committed 2026-10-16 16:00:34 +01:00
1 parent 155b84caea
commit 9905ffb4cb
7 files changed
+430 -36

No files matched your search

+16
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/frontend"
"git.avsdev.uk/Go/job-search/internal/httpx"
"git.avsdev.uk/Go/job-search/internal/middleware"
)
@@ -18,6 +19,7 @@ import (
// /api : NoStore -> Compress -> [api rate limit + hooks] -> CSRF
// -> RequireJSON
// /ws : [ws rate limit + hooks] -> Origin check (hub)
// /* : Compress -> [frontend hooks] -> static files (if WithFrontend)
func (s *Server) routes() (http.Handler, error) {
conf := s.conf
router := chi.NewRouter()
@@ -67,6 +69,20 @@ func (s *Server) routes() (http.Handler, error) {
webSocketStack := s.groupStack(GroupWebSocket, conf.WebSocket.ConnectionLimit)
router.With(webSocketStack...).Get("/ws", s.hub.ServeHTTP)
// Frontend
if s.frontend != nil {
frontendHandler := frontend.New(s.frontend, frontend.Options{
ImmutablePrefix: conf.Frontend.ImmutablePrefix,
IndexCacheControl: conf.Frontend.IndexCacheControl,
// An embedded build never changes; a directory on disk may.
CacheETags: conf.Frontend.Dir == "",
})
frontendStack := append(
[]Middleware{compress}, s.hooks[GroupFrontend]...,
)
router.With(frontendStack...).Handle("/*", frontendHandler)
}
return router, nil
}
+17 -3
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"errors"
"fmt"
"io/fs"
"log"
"log/slog"
"net"
@@ -38,6 +39,8 @@ const (
GroupAPI
// GroupWebSocket is /ws upgrade requests.
GroupWebSocket
// GroupFrontend is the static frontend (see WithFrontend).
GroupFrontend
)
// Middleware is the standard net/http middleware shape.
@@ -83,6 +86,15 @@ func WithEvents(bus *events.Bus) Option {
}
}
// WithFrontend makes the server serve the frontend's build output, files,
// for every path outside /api and /ws. Without it there is no frontend: such
// paths get 404.
func WithFrontend(files fs.FS) Option {
return func(s *Server) {
s.frontend = files
}
}
// WithMiddleware appends middleware to a group. This is the generic hook
// (auth, metrics, tracing, custom header rules, ...).
func WithMiddleware(group Group, middlewares ...Middleware) Option {
@@ -120,8 +132,10 @@ type Server struct {
log *slog.Logger
// accessLog is where the access log is written, or nil for none.
accessLog *log.Logger
hooks map[Group][]Middleware
limiters map[Group]limiterHook
// frontend is the frontend's build output, or nil for none.
frontend fs.FS
hooks map[Group][]Middleware
limiters map[Group]limiterHook
// trusted are the proxies whose forwarding headers are believed.
trusted []netip.Prefix
@@ -145,7 +159,7 @@ type Server struct {
}
// New builds the server and its router. It logs to slog.Default() unless
// given WithLogger.
// given WithLogger, and serves no frontend unless given WithFrontend.
func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
server := &Server{
conf: conf,
+88 -1
View File
@@ -13,6 +13,7 @@ import (
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/coder/websocket"
@@ -47,7 +48,21 @@ func newTestServer(
if mutate != nil {
mutate(cfg)
}
opts = append([]Option{WithLogger(slog.New(slog.DiscardHandler))}, opts...)
frontendFiles := fstest.MapFS{
"index.html": {
Data: []byte("<!doctype html><title>t</title>"),
},
"assets/app-1.js": {
Data: []byte(strings.Repeat("console.log(1);", 200)),
},
}
opts = append(
[]Option{
WithLogger(slog.New(slog.DiscardHandler)),
WithFrontend(frontendFiles),
},
opts...,
)
s, err := New(cfg, opts...)
if err != nil {
t.Fatal(err)
@@ -154,6 +169,7 @@ func TestRealIPCoversAllGroups(t *testing.T) {
GroupGlobal: "/",
GroupAPI: "/api/health",
GroupWebSocket: "/ws",
GroupFrontend: "/index.html",
}
for g, path := range groups {
var ran, viaProxy bool
@@ -180,6 +196,77 @@ func TestRealIPCoversAllGroups(t *testing.T) {
}
}
// The frontend: a client-side route gets index.html, uncached; an asset is
// compressed, cached as immutable and revalidated by its ETag; a missing
// asset is 404.
func TestFrontend(t *testing.T) {
ts := newTestServer(t, func(conf *config.JSCServer) {
conf.Headers.ContentSecurityPolicy = "default-src 'self'"
})
res, err := http.Get(ts.URL + "/some/client/route")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
cacheControl := res.Header.Get("Cache-Control")
if res.StatusCode != 200 || cacheControl != "no-cache" {
t.Fatalf("spa fallback: %d %q", res.StatusCode, cacheControl)
}
if res.Header.Get("Content-Security-Policy") == "" ||
res.Header.Get("X-Request-Id") == "" {
t.Fatal("security headers missing")
}
req, _ := http.NewRequest("GET", ts.URL+"/assets/app-1.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
res, err = http.DefaultTransport.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
encoding := res.Header.Get("Content-Encoding")
cacheControl = res.Header.Get("Cache-Control")
if encoding != "gzip" || !strings.Contains(cacheControl, "immutable") {
t.Fatalf("asset: encoding=%q cache=%q", encoding, cacheControl)
}
req.Header.Set("If-None-Match", res.Header.Get("ETag"))
res, _ = http.DefaultTransport.RoundTrip(req)
res.Body.Close()
if res.StatusCode != http.StatusNotModified {
t.Fatalf("etag revalidation: %d", res.StatusCode)
}
res, _ = http.Get(ts.URL + "/assets/missing.js")
res.Body.Close()
if res.StatusCode != 404 {
t.Fatalf("missing asset: %d", res.StatusCode)
}
}
// Without WithFrontend, paths outside /api and /ws are 404.
func TestNoFrontend(t *testing.T) {
s, err := New(defaultConf(t), WithLogger(slog.New(slog.DiscardHandler)))
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() {
ts.Close()
s.backgroundCancel()
})
res, err := http.Get(ts.URL + "/index.html")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Fatalf("no frontend: %d; want 404", res.StatusCode)
}
}
// A WebSocket upgrade is refused from a foreign origin, accepted from an
// origin in csrf.cross_origin_protection.trusted_origins, and the client then
// gets replies.