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"
"errors"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
@@ -16,19 +15,23 @@ import (
"github.com/spf13/cobra"
"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/fetcher"
"git.avsdev.uk/Go/job-search/internal/frontend"
"git.avsdev.uk/Go/job-search/internal/logging"
"git.avsdev.uk/Go/job-search/internal/server"
)
// NewCommand returns the serve command, which serves the REST API and the
// WebSocket endpoint, and runs a worker for every enabled source.
// NewCommand returns the serve command, which serves the REST API, the
// WebSocket endpoint and the frontend, and runs a worker for every enabled
// source.
func NewCommand(opts *cli.Options) *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Short: "Serve the REST API (/api) and WebSocket (/ws), and fetch from sources",
Long: `Serve the REST API under /api and the WebSocket endpoint under /ws.
Short: "Serve the REST API, WebSocket and frontend, and fetch from sources",
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,
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
}
var options []server.Option
if conf.Server.AccessLog != "" {
var (
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))
options, closeAccessLog, err := serverOptions(&conf.Server)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, closeAccessLog())
}()
// ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM.
ctx, stop := signal.NotifyContext(
@@ -201,3 +194,38 @@ set, the API serves the stored opportunities under /api/opportunities.`,
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
}