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

+49 -21
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
@@ -16,19 +15,23 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.avsdev.uk/Go/job-search/internal/cli" "git.avsdev.uk/Go/job-search/internal/cli"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/events" "git.avsdev.uk/Go/job-search/internal/events"
"git.avsdev.uk/Go/job-search/internal/fetcher" "git.avsdev.uk/Go/job-search/internal/fetcher"
"git.avsdev.uk/Go/job-search/internal/frontend"
"git.avsdev.uk/Go/job-search/internal/logging" "git.avsdev.uk/Go/job-search/internal/logging"
"git.avsdev.uk/Go/job-search/internal/server" "git.avsdev.uk/Go/job-search/internal/server"
) )
// NewCommand returns the serve command, which serves the REST API and the // NewCommand returns the serve command, which serves the REST API, the
// WebSocket endpoint, and runs a worker for every enabled source. // WebSocket endpoint and the frontend, and runs a worker for every enabled
// source.
func NewCommand(opts *cli.Options) *cobra.Command { func NewCommand(opts *cli.Options) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "serve", Use: "serve",
Short: "Serve the REST API (/api) and WebSocket (/ws), and fetch from sources", Short: "Serve the REST API, WebSocket and frontend, and fetch from sources",
Long: `Serve the REST API under /api and the WebSocket endpoint under /ws. Long: `Serve the REST API under /api, the WebSocket endpoint under /ws, and the
frontend's files from server.frontend.dir under / if it is set.
It also runs a worker for every enabled source, as fetcher service does, It also runs a worker for every enabled source, as fetcher service does,
saving what they fetch to database.file if it is set, and pushing an saving what they fetch to database.file if it is set, and pushing an
@@ -82,23 +85,13 @@ set, the API serves the stored opportunities under /api/opportunities.`,
conf.Server.AccessLog = accessLogOverride conf.Server.AccessLog = accessLogOverride
} }
var options []server.Option options, closeAccessLog, err := serverOptions(&conf.Server)
if conf.Server.AccessLog != "" { if err != nil {
var ( return err
accessLog *log.Logger
closeAccessLog func() error
)
accessLog, closeAccessLog, err = logging.OpenAccessLog(
conf.Server.AccessLog,
)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, closeAccessLog())
}()
options = append(options, server.WithAccessLog(accessLog))
} }
defer func() {
err = errors.Join(err, closeAccessLog())
}()
// ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM. // ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM.
ctx, stop := signal.NotifyContext( ctx, stop := signal.NotifyContext(
@@ -201,3 +194,38 @@ set, the API serves the stored opportunities under /api/opportunities.`,
return cmd return cmd
} }
// serverOptions returns the server's options for conf: the access log, if
// server.access_log is set, and the frontend's files, if server.frontend.dir
// is (checked first). closeAccessLog closes the access log; call it once the
// server has stopped.
func serverOptions(
conf *config.JSCServer,
) ([]server.Option, func() error, error) {
var options []server.Option
closeAccessLog := func() error {
return nil
}
if conf.AccessLog != "" {
accessLog, closeFile, err := logging.OpenAccessLog(conf.AccessLog)
if err != nil {
return nil, nil, err
}
closeAccessLog = closeFile
options = append(options, server.WithAccessLog(accessLog))
}
if dir := conf.Frontend.Dir; dir != "" {
files := os.DirFS(dir)
err := frontend.CheckFS(files)
if err != nil {
return nil, nil, errors.Join(
fmt.Errorf("server.frontend.dir %s: %w", dir, err),
closeAccessLog(),
)
}
options = append(options, server.WithFrontend(files))
}
return options, closeAccessLog, nil
}
+4 -6
View File
@@ -90,7 +90,7 @@ The `server` section is split into a number of sub-sections:
| `server.csrf` | CSRF and cross-origin protection settings | | `server.csrf` | CSRF and cross-origin protection settings |
| `server.tls` | TLS certificate, minimum version and HTTP redirect | | `server.tls` | TLS certificate, minimum version and HTTP redirect |
| `server.headers` | HTTP header settings including policy headers | | `server.headers` | HTTP header settings including policy headers |
| `server.frontend` | How the frontend is served (not used yet) | | `server.frontend` | Where the frontend's build output is and how it is cached |
| `server.websocket` | Websocket configuration | | `server.websocket` | Websocket configuration |
#### Server #### Server
@@ -177,13 +177,11 @@ The `server` section is split into a number of sub-sections:
#### Frontend #### Frontend
Not used yet.
| Key | Type | Default | Notes | | Key | Type | Default | Notes |
|---|---|---|---| |---|---|---|---|
| `server.frontend.dir` | string | | Serve the frontend from this directory; empty: the embedded build | | `server.frontend.dir` | string | | Directory of the frontend's build output, holding `index.html`; empty: no frontend is served (there is no embedded build yet) |
| `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files | | `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files, cached for a year; empty: `/assets/` |
| `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: none | | `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: `no-cache` |
#### Websockets #### Websockets
+6 -5
View File
@@ -7,11 +7,12 @@ import (
// JSCKServerFrontend is the frontend section's configuration key. // JSCKServerFrontend is the frontend section's configuration key.
const JSCKServerFrontend string = "frontend" const JSCKServerFrontend string = "frontend"
// JSCServerFrontend configures how the server serves the frontend. Nothing // JSCServerFrontend configures how the server serves the frontend. Dir is
// reads it yet. Dir serves the frontend from disk instead of the embedded // the directory of the frontend's build output (holding index.html); empty
// build; empty means the embedded build. ImmutablePrefix is where Vite emits // means no frontend is served, as there is no embedded build yet.
// content-hashed files, and IndexCacheControl the Cache-Control of HTML and // ImmutablePrefix is where Vite emits content-hashed files, cached for a
// other files without a hash; empty means none. // year; empty means "/assets/". IndexCacheControl is the Cache-Control of
// HTML and other files without a hash; empty means "no-cache".
type JSCServerFrontend struct { type JSCServerFrontend struct {
Dir string `cfg:"dir"` Dir string `cfg:"dir"`
ImmutablePrefix string `cfg:"immutable_prefix"` ImmutablePrefix string `cfg:"immutable_prefix"`
+250
View File
@@ -0,0 +1,250 @@
// Package frontend serves the Vite build output (embedded or from disk).
package frontend
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"io/fs"
"mime"
"net/http"
"path"
"strconv"
"strings"
"sync"
"time"
)
// ErrNoIndex is returned by CheckFS when index.html is missing.
var ErrNoIndex = errors.New("frontend: index.html not found")
// Options configures the handler. ImmutablePrefix is where Vite emits
// content-hashed files, cached for a year; empty means "/assets/".
// IndexCacheControl is the Cache-Control of HTML and other files without a
// hash; empty means "no-cache". CacheETags caches computed ETags: enable it
// for an embedded build, which never changes, and not for a directory on
// disk, which may change while the server runs.
type Options struct {
ImmutablePrefix string
IndexCacheControl string
CacheETags bool
}
// Handler serves files from an fs.FS.
type Handler struct {
fsys fs.FS
opts Options
// etags caches ETags by "name|modification time|size" (see etag).
etags sync.Map
}
// New returns a frontend handler.
func New(fsys fs.FS, opts Options) *Handler {
if opts.ImmutablePrefix == "" {
opts.ImmutablePrefix = "/assets/"
}
if opts.IndexCacheControl == "" {
opts.IndexCacheControl = "no-cache"
}
return &Handler{
fsys: fsys,
opts: opts,
}
}
// encodings are the precompressed variants of a file that can be served, in
// order of preference: the Accept-Encoding name and the file extension.
var encodings = []struct {
name, extension string
}{
{name: "br", extension: ".br"},
{name: "zstd", extension: ".zst"},
{name: "gzip", extension: ".gz"},
}
// ServeHTTP serves the file named by the request path, or index.html for a
// directory. A missing path without an extension (a client-side route) gets
// index.html; a missing asset gets 404. Only GET and HEAD are allowed.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(
w,
http.StatusText(http.StatusMethodNotAllowed),
http.StatusMethodNotAllowed,
)
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" {
name = "index.html"
}
if !fs.ValidPath(name) {
http.NotFound(w, r)
return
}
info, err := fs.Stat(h.fsys, name)
if err == nil && info.IsDir() {
name = path.Join(name, "index.html")
info, err = fs.Stat(h.fsys, name)
}
if err != nil {
// Client-side routes (/settings, /items/42) have no extension: serve
// the SPA shell. Missing assets (/assets/x.js) stay 404 so broken
// deploys are visible instead of returning HTML as JavaScript.
if path.Ext(name) == "" {
name = "index.html"
info, err = fs.Stat(h.fsys, name)
}
if err != nil {
http.NotFound(w, r)
return
}
}
h.serveFile(w, r, name, info)
}
// serveFile serves the file name, with info its file info: with its cache
// headers, content type and ETag, and a precompressed variant if the client
// accepts one (see encodings).
func (h *Handler) serveFile(
w http.ResponseWriter, r *http.Request, name string, info fs.FileInfo,
) {
header := w.Header()
switch {
case strings.HasPrefix("/"+name, h.opts.ImmutablePrefix):
header.Set("Cache-Control", "public, max-age=31536000, immutable")
default:
header.Set("Cache-Control", h.opts.IndexCacheControl)
}
contentType := mime.TypeByExtension(path.Ext(name))
if contentType == "" {
contentType = "application/octet-stream"
}
header.Set("Content-Type", contentType)
// Prefer a precompressed sibling (e.g. from vite-plugin-compression).
served, servedInfo := name, info
acceptEncoding := r.Header.Get("Accept-Encoding")
for _, encoding := range encodings {
if !acceptsEncoding(acceptEncoding, encoding.name) {
continue
}
compressedInfo, err := fs.Stat(h.fsys, name+encoding.extension)
if err == nil && !compressedInfo.IsDir() {
served, servedInfo = name+encoding.extension, compressedInfo
header.Set("Content-Encoding", encoding.name)
break
}
}
if !headerHasToken(header, "Vary", "Accept-Encoding") {
header.Add("Vary", "Accept-Encoding")
}
file, err := h.fsys.Open(served)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close() //nolint:errcheck // read only
var content io.ReadSeeker
if seeker, ok := file.(io.ReadSeeker); ok {
content = seeker
} else {
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
content = bytes.NewReader(data)
}
if tag, err := h.etag(served, servedInfo, content); err == nil {
header.Set("ETag", tag)
}
// Embedded files have a zero ModTime; ServeContent then omits Last-Modified.
http.ServeContent(w, r, name, servedInfo.ModTime(), content)
}
// etag returns a strong ETag for content, the file name with info: a hash of
// its content, cached by name, modification time and size if
// Options.CacheETags is set. content is left at its start.
func (h *Handler) etag(
name string, info fs.FileInfo, content io.ReadSeeker,
) (string, error) {
key := name + "|" + info.ModTime().Format(time.RFC3339Nano) +
"|" + strconv.FormatInt(info.Size(), 10)
if h.opts.CacheETags {
if cached, ok := h.etags.Load(key); ok {
return cached.(string), nil
}
}
sum := sha256.New()
if _, err := io.Copy(sum, content); err != nil {
return "", err
}
if _, err := content.Seek(0, io.SeekStart); err != nil {
return "", err
}
tag := `"` + base64.RawURLEncoding.EncodeToString(sum.Sum(nil)[:12]) + `"`
if h.opts.CacheETags {
h.etags.Store(key, tag)
}
return tag, nil
}
// acceptsEncoding reports whether encoding is acceptable in the
// Accept-Encoding header (present with q > 0).
func acceptsEncoding(header, encoding string) bool {
for _, part := range strings.Split(header, ",") {
token, params, _ := strings.Cut(strings.TrimSpace(part), ";")
if !strings.EqualFold(strings.TrimSpace(token), encoding) {
continue
}
for _, param := range strings.Split(params, ";") {
name, value, ok := strings.Cut(strings.TrimSpace(param), "=")
if ok && strings.EqualFold(name, "q") {
quality, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return err == nil && quality > 0
}
}
return true
}
return false
}
// headerHasToken reports whether the comma-separated header key holds token
// (case insensitively).
func headerHasToken(header http.Header, key, token string) bool {
for _, value := range header.Values(key) {
for _, item := range strings.Split(value, ",") {
if strings.EqualFold(strings.TrimSpace(item), token) {
return true
}
}
}
return false
}
// CheckFS verifies the filesystem looks like a Vite build.
func CheckFS(fsys fs.FS) error {
if _, err := fs.Stat(fsys, "index.html"); err != nil {
return ErrNoIndex
}
return nil
}
+16
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.avsdev.uk/Go/job-search/internal/config" "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/httpx"
"git.avsdev.uk/Go/job-search/internal/middleware" "git.avsdev.uk/Go/job-search/internal/middleware"
) )
@@ -18,6 +19,7 @@ import (
// /api : NoStore -> Compress -> [api rate limit + hooks] -> CSRF // /api : NoStore -> Compress -> [api rate limit + hooks] -> CSRF
// -> RequireJSON // -> RequireJSON
// /ws : [ws rate limit + hooks] -> Origin check (hub) // /ws : [ws rate limit + hooks] -> Origin check (hub)
// /* : Compress -> [frontend hooks] -> static files (if WithFrontend)
func (s *Server) routes() (http.Handler, error) { func (s *Server) routes() (http.Handler, error) {
conf := s.conf conf := s.conf
router := chi.NewRouter() router := chi.NewRouter()
@@ -67,6 +69,20 @@ func (s *Server) routes() (http.Handler, error) {
webSocketStack := s.groupStack(GroupWebSocket, conf.WebSocket.ConnectionLimit) webSocketStack := s.groupStack(GroupWebSocket, conf.WebSocket.ConnectionLimit)
router.With(webSocketStack...).Get("/ws", s.hub.ServeHTTP) 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 return router, nil
} }
+17 -3
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"log/slog" "log/slog"
"net" "net"
@@ -38,6 +39,8 @@ const (
GroupAPI GroupAPI
// GroupWebSocket is /ws upgrade requests. // GroupWebSocket is /ws upgrade requests.
GroupWebSocket GroupWebSocket
// GroupFrontend is the static frontend (see WithFrontend).
GroupFrontend
) )
// Middleware is the standard net/http middleware shape. // 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 // WithMiddleware appends middleware to a group. This is the generic hook
// (auth, metrics, tracing, custom header rules, ...). // (auth, metrics, tracing, custom header rules, ...).
func WithMiddleware(group Group, middlewares ...Middleware) Option { func WithMiddleware(group Group, middlewares ...Middleware) Option {
@@ -120,8 +132,10 @@ type Server struct {
log *slog.Logger log *slog.Logger
// accessLog is where the access log is written, or nil for none. // accessLog is where the access log is written, or nil for none.
accessLog *log.Logger accessLog *log.Logger
hooks map[Group][]Middleware // frontend is the frontend's build output, or nil for none.
limiters map[Group]limiterHook frontend fs.FS
hooks map[Group][]Middleware
limiters map[Group]limiterHook
// trusted are the proxies whose forwarding headers are believed. // trusted are the proxies whose forwarding headers are believed.
trusted []netip.Prefix trusted []netip.Prefix
@@ -145,7 +159,7 @@ type Server struct {
} }
// New builds the server and its router. It logs to slog.Default() unless // 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) { func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
server := &Server{ server := &Server{
conf: conf, conf: conf,
+88 -1
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"testing/fstest"
"time" "time"
"github.com/coder/websocket" "github.com/coder/websocket"
@@ -47,7 +48,21 @@ func newTestServer(
if mutate != nil { if mutate != nil {
mutate(cfg) 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...) s, err := New(cfg, opts...)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -154,6 +169,7 @@ func TestRealIPCoversAllGroups(t *testing.T) {
GroupGlobal: "/", GroupGlobal: "/",
GroupAPI: "/api/health", GroupAPI: "/api/health",
GroupWebSocket: "/ws", GroupWebSocket: "/ws",
GroupFrontend: "/index.html",
} }
for g, path := range groups { for g, path := range groups {
var ran, viaProxy bool 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 // A WebSocket upgrade is refused from a foreign origin, accepted from an
// origin in csrf.cross_origin_protection.trusted_origins, and the client then // origin in csrf.cross_origin_protection.trusted_origins, and the client then
// gets replies. // gets replies.