// 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 }