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) {