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
+423
-29
No files matched your search
@@ -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 != "" {
|
|
||||||
var (
|
|
||||||
accessLog *log.Logger
|
|
||||||
closeAccessLog func() error
|
|
||||||
)
|
|
||||||
accessLog, closeAccessLog, err = logging.OpenAccessLog(
|
|
||||||
conf.Server.AccessLog,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err = errors.Join(err, closeAccessLog())
|
err = errors.Join(err, closeAccessLog())
|
||||||
}()
|
}()
|
||||||
options = append(options, server.WithAccessLog(accessLog))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +132,8 @@ 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
|
||||||
|
// frontend is the frontend's build output, or nil for none.
|
||||||
|
frontend fs.FS
|
||||||
hooks map[Group][]Middleware
|
hooks map[Group][]Middleware
|
||||||
limiters map[Group]limiterHook
|
limiters map[Group]limiterHook
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user