// Package web serves the head-office platform at platform.loyaly.ai. // // Embedded into the server binary rather than deployed as static files beside // it. One artefact, for the same reason `provision` is a subcommand and not a // second image: a second thing to deploy is a second thing to forget to deploy, // and a UI that is one version behind its API fails in ways nobody can // reproduce. package web import ( "embed" "io/fs" "net/http" "path" "strings" ) // dist is written by `npm run build` in ../../../web. // // The directory must exist for the package to compile at all, which is a real // constraint on a fresh checkout: `go build` fails with "pattern all:dist: no // matching files" until the frontend has been built once. That is why a // placeholder index.html is kept in the tree - the alternative is a Go build // that cannot run without npm. // //go:embed all:dist var dist embed.FS // cacheFor decides how long a response may be reused. // // Vite fingerprints everything under assets/, so its name changes whenever its // content does and a year is safe. Everything else - index.html above all - // must never be cached: a browser holding last week's entry document runs last // week's bundle against this week's API, and the resulting failure depends on // one machine's cache, so nobody else can reproduce it. func cacheFor(path string) string { if strings.HasPrefix(path, "assets/") { return "public, max-age=31536000, immutable" } return "no-store" } // Handler serves the single-page app, with the routing a SPA needs. // // Two behaviours that are not the default and both matter: // // - Any path that is not a real file returns index.html, so a deep link or a // browser reload lands on the app rather than a 404. It does NOT do this // for /api, which is mounted separately - swallowing an unmatched API path // into an HTML page turns a typo'd endpoint into a JSON parse error three // layers away from the cause. // - Hashed build assets are cached hard, index.html never. Caching the entry // document is how a browser keeps running last week's bundle against this // week's API. func Handler() (http.Handler, error) { sub, err := fs.Sub(dist, "dist") if err != nil { return nil, err } files := http.FileServer(http.FS(sub)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { clean := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") if clean == "" { clean = "index.html" } if f, err := sub.Open(clean); err == nil { f.Close() //nolint:errcheck // Set on BOTH branches, because "/" resolves to a real file and // would otherwise take the file-server path with no cache header at // all - the entry document cached by default, which is precisely // the skew this is here to prevent. w.Header().Set("Cache-Control", cacheFor(clean)) files.ServeHTTP(w, r) return } // Not a file: hand back the app and let the router decide. w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") index, err := fs.ReadFile(sub, "index.html") if err != nil { http.Error(w, "the web application was not built into this server", http.StatusInternalServerError) return } w.Write(index) //nolint:errcheck }), nil }