diff --git a/internal/services/middleware/404_service.go b/internal/services/middleware/404_service.go new file mode 100644 index 000000000..ed40d8f86 --- /dev/null +++ b/internal/services/middleware/404_service.go @@ -0,0 +1,61 @@ +package middleware + +// Copyright (C) 2023 by Posit Software, PBC. + +import "net/http" + +type notFoundWriter struct { + writer http.ResponseWriter + header http.Header + StatusCode int +} + +func newNotFoundWriter(w http.ResponseWriter) *notFoundWriter { + return ¬FoundWriter{ + writer: w, + header: http.Header{}, + } +} + +func (w *notFoundWriter) Header() http.Header { + // Capture the header sent by the underlying handler, + // and decide later whether to send it. + return w.header +} + +func (w *notFoundWriter) Write(data []byte) (int, error) { + if w.StatusCode != http.StatusNotFound { + // We have a valid response, send it. + return w.writer.Write(data) + } else { + // Eat this response and we'll serve up a better one + return len(data), nil + } +} + +func (w *notFoundWriter) WriteHeader(statusCode int) { + w.StatusCode = statusCode + if statusCode != http.StatusNotFound { + // Valid response from the underlying handler. + // Respond with the headers that handler provided. + realHeader := w.writer.Header() + for k, v := range w.header { + realHeader[k] = v + } + w.writer.WriteHeader(statusCode) + } +} + +func ServeIndexOn404(h http.Handler, location string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + writer := newNotFoundWriter(w) + h.ServeHTTP(writer, req) + + if writer.StatusCode == http.StatusNotFound { + // Serve the index.html page and let the frontend handle routing + req.URL.Path = "/" + req.URL.RawPath = "/" + h.ServeHTTP(w, req) + } + } +} diff --git a/internal/services/ui/ui_service.go b/internal/services/ui/ui_service.go index 5a0dc338e..ca41d83c5 100644 --- a/internal/services/ui/ui_service.go +++ b/internal/services/ui/ui_service.go @@ -103,13 +103,17 @@ func RouterHandlerFunc(base util.Path, lister accounts.AccountList, log logging. r.Handle(ToPath("deployments", "{name}"), api.DeleteDeploymentHandlerFunc(base, log)). Methods(http.MethodDelete) - // GET / + // Handle any frontend paths that leak out (for example, on a refresh) + // by redirecting to the SPA at "/". + + // GET / + // Serves static files from /web/dist. + fileHandler := middleware.InsertPrefix(web.Handler, web.Prefix) r.PathPrefix("/"). - Handler(middleware.InsertPrefix(web.Handler, web.Prefix)). + Handler(middleware.ServeIndexOn404(fileHandler, "/")). Methods("GET") c := cors.AllowAll().Handler(r) - return c.ServeHTTP } diff --git a/web/vite.config.ts b/web/vite.config.ts index 7b2ec6c1e..9a5cf2f83 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,7 +8,7 @@ import { quasar, transformAssetUrls } from '@quasar/vite-plugin'; // https://vitejs.dev/config/ // eslint-disable-next-line no-restricted-syntax export default defineConfig({ - base: './', + base: '/', build: { rollupOptions: { output: {