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.
251 lines
6.7 KiB
Go
251 lines
6.7 KiB
Go
// 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
|
|
}
|