From 2db7b589fc477a7ca74ac87bb022d196e9a5b1fb Mon Sep 17 00:00:00 2001 From: Maurice Yap Date: Tue, 25 Feb 2025 16:17:09 +0000 Subject: [PATCH] Ensure cache-control headers are always served for index.html response In the Lookout UI web server, the HTTP response headers preventing index.html from being served are only served when the document is accessed directly i.e. when navigating to "/" or "/index.html". They are not served when the document is returned in response to a request path which doesn't exist. (This behaviour is needed for single-page applications because the handling of 404s is deferred to the front end's client side routing.) This commit makes sure these headers are served for the latter as well as the former. --- internal/common/serve/spa.go | 41 +++++++++++++++++++ internal/common/serve/static.go | 22 ---------- .../lookout/gen/restapi/configure_lookout.go | 15 +------ 3 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 internal/common/serve/spa.go diff --git a/internal/common/serve/spa.go b/internal/common/serve/spa.go new file mode 100644 index 00000000000..30d9c654db6 --- /dev/null +++ b/internal/common/serve/spa.go @@ -0,0 +1,41 @@ +package serve + +import ( + "errors" + "io/fs" + "net/http" + "strings" +) + +const indexHTMLPage = "index.html" + +// SinglePageApplicationHandler handles requests for a single-page application front end. It prevents caching of +// index.html responses and defers handling of file paths which cannot be found to the front end by serving index.html +// in such cases. +func SinglePageApplicationHandler(fsys http.FileSystem) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if shouldServeIndexHTML(fsys, r.URL.Path) { + r.URL.Path = "/" + // Prevent caching when serving index.html. Its content determines the version of the JS/CSS + // bundle, and we want to prevent the user from accessing a stale bundle. + w.Header().Set("Cache-Control", "no-store, must-revalidate") + } + + http.FileServer(fsys).ServeHTTP(w, r) + }) +} + +func shouldServeIndexHTML(fsys http.FileSystem, urlPath string) bool { + trimmedURLPath := strings.TrimPrefix(urlPath, "/") + + // Serve index.html when the file cannot be found - client-side routing in the SPA handles such cases. + if _, err := fsys.Open(trimmedURLPath); errors.Is(err, fs.ErrNotExist) { + return true + } + + if trimmedURLPath == "" || trimmedURLPath == indexHTMLPage { + return true + } + + return false +} diff --git a/internal/common/serve/static.go b/internal/common/serve/static.go index 355ae05d8db..3c5a12bb3a5 100644 --- a/internal/common/serve/static.go +++ b/internal/common/serve/static.go @@ -1,7 +1,6 @@ package serve import ( - "io/fs" "net/http" "github.com/pkg/errors" @@ -9,27 +8,6 @@ import ( "github.com/armadaproject/armada/internal/common/armadacontext" ) -// dirWithIndexFallback is a http.FileSystem that serves the index.html file at -// the root of dir if the requested file is not found. This behavior differs -// from http.Dir, which only forwards requests for /a/ to /a/index.html; we need -// to serve the index.html file at the root of dir if the requested file is not -// found, so that the frontend can handle routing in those cases. -type dirWithIndexFallback struct { - dir http.Dir -} - -func CreateDirWithIndexFallback(path string) http.FileSystem { - return dirWithIndexFallback{http.Dir(path)} -} - -func (d dirWithIndexFallback) Open(name string) (http.File, error) { - file, err := d.dir.Open(name) - if errors.Is(err, fs.ErrNotExist) { - return d.dir.Open("index.html") - } - return file, err -} - // ListenAndServe calls server.ListenAndServe(). // Additionally, it calls server.Shutdown() if ctx is cancelled. func ListenAndServe(ctx *armadacontext.Context, server *http.Server) error { diff --git a/internal/lookout/gen/restapi/configure_lookout.go b/internal/lookout/gen/restapi/configure_lookout.go index fd6300bf7d5..38307e4234e 100644 --- a/internal/lookout/gen/restapi/configure_lookout.go +++ b/internal/lookout/gen/restapi/configure_lookout.go @@ -97,7 +97,7 @@ func setupGlobalMiddleware(apiHandler http.Handler) http.Handler { func uiHandler(apiHandler http.Handler) http.Handler { mux := http.NewServeMux() - mux.Handle("/", setCacheControl(http.FileServer(serve.CreateDirWithIndexFallback("./internal/lookoutui/build")))) + mux.Handle("/", serve.SinglePageApplicationHandler(http.Dir("./internal/lookoutui/build"))) mux.HandleFunc("/config", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -112,19 +112,6 @@ func uiHandler(apiHandler http.Handler) http.Handler { return mux } -func setCacheControl(fileHandler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" || r.URL.Path == "/index.html" { - // Because the version of index.html determines the version of the - // JavaScript bundle, caching index.html would prevent users from - // ever picking up new versions of the JavaScript bundle without - // manually invalidating the cache. - w.Header().Set("Cache-Control", "no-store") - } - fileHandler.ServeHTTP(w, r) - }) -} - func allowCORS(handler http.Handler, corsAllowedOrigins []string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if origin := r.Header.Get("Origin"); origin != "" && slices.Contains(corsAllowedOrigins, origin) {