backend/server/server_frontend_routes.go
Rama main · réplica de lectura del repositorio original.
package server
import (
"io/fs"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
// registerFrontendRoutes wires up static file serving for the embedded frontend
// against the given filesystem (typically the embedded dist tree). It is extracted
// from embedFrontend so the behavior can be exercised in tests with an in-memory fs.FS.
//
// Layout:
// - Global static middleware serves the SPA shell (index.html) with HTML5 fallback
// for client-side routes, but skips /assets/* so the dedicated route below wins.
// - /assets/* is served by a real route that returns a true 404 for missing files
// (no HTML5 fallback) and attaches long-cache + gzip middleware.
func registerFrontendRoutes(e *echo.Echo, distFS fs.FS) {
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: func(c *echo.Context) bool {
return defaultAPIRequestSkipper(c) || strings.HasPrefix(c.Request().URL.Path, "/assets/")
},
HTML5: true,
Filesystem: distFS,
}))
assetsFS, err := fs.Sub(distFS, "assets")
if err != nil {
panic(err)
}
cacheImmutable := func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Mark an asset immutable only once the response is known to be a
// success. In an HA rolling deploy an older replica can 404 a new
// hashed asset briefly, and the static handler rejects a path with a
// dot or empty segment outright, so a year of cached "does not exist"
// has to be impossible. Reading the status rather than re-deriving the
// file from the path also leaves echo's path rules to echo: deciding
// from a cleaned path is what put the header on those 404s.
//
// 304 keeps the directive so a revalidating CDN does not shorten its
// TTL; WriteHeader sets Status before it runs these hooks.
res, err := echo.UnwrapResponse(c.Response())
if err != nil {
// Without echo's response the status is not observable, and guessing
// from the path is exactly what put immutable on those 404s. Serve
// the asset uncached instead.
return next(c)
}
res.Before(func() {
if res.Status == http.StatusOK || res.Status == http.StatusNotModified {
res.Header().Set(echo.HeaderCacheControl, "public, max-age=31536000, immutable")
}
})
return next(c)
}
}
// Register GET + HEAD so the dedicated asset route matches HEAD requests too
// (curl -I, CDN health checks, cache revalidation). Before this fix, the global
// StaticWithConfig middleware served any method; a GET-only route would 405 on HEAD.
e.Match([]string{http.MethodGet, http.MethodHead}, "/assets/*",
echo.StaticDirectoryHandler(assetsFS, false),
middleware.GzipWithConfig(middleware.GzipConfig{Level: 5}),
cacheImmutable,
)
}
// defaultAPIRequestSkipper is echo skipper for api requests. /bytebase.v1 covers
// Connect-go gRPC handlers so unknown gRPC paths return a structured 404 instead
// of falling through to the SPA's HTML5 fallback (which would return index.html).
func defaultAPIRequestSkipper(c *echo.Context) bool {
path := c.Request().URL.Path
return hasPrefixes(path, "/api", "/v1", "/bytebase.v1", "/.well-known", webhookAPIPrefix)
}
func hasPrefixes(src string, prefixes ...string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
}