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:
1 parent
155b84caea
commit
9905ffb4cb
7 files changed
+430
-36
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user