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
@@ -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