feat(deploy): M11 单二进制装配(cmd/cyrene 全链路 wire + go:embed 前端 + 外置 dist 覆盖 + 优雅关闭 + SPA 静态服务);前端 build 拷到 internal/webui/dist
This commit is contained in:
1 parent
f43eb17030
commit
a7feaeb2cb
7 files changed
+745
-1
No files matched your search
@@ -20,6 +20,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -116,6 +117,10 @@ type Deps struct {
|
||||
Memories MemoryStore
|
||||
// IoT lists IoT devices. Optional.
|
||||
IoT IoTStore
|
||||
// Frontend is the SPA filesystem served for all non-API, non-WS routes.
|
||||
// When nil the gateway serves no static assets (API and WS only). The
|
||||
// embedded dist (internal/webui) or an external dist directory is used.
|
||||
Frontend fs.FS
|
||||
}
|
||||
|
||||
// Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a
|
||||
@@ -130,6 +135,7 @@ type Server struct {
|
||||
personas PersonaStore
|
||||
memories MemoryStore
|
||||
iot IoTStore
|
||||
frontend fs.FS
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
}
|
||||
@@ -154,6 +160,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
personas: deps.Personas,
|
||||
memories: deps.Memories,
|
||||
iot: deps.IoT,
|
||||
frontend: deps.Frontend,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
s.hub = newHub(s)
|
||||
@@ -191,6 +198,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
||||
|
||||
var h http.Handler = mux
|
||||
if s.frontend != nil {
|
||||
h = s.spa(h)
|
||||
}
|
||||
h = s.recover(h)
|
||||
h = s.cors(h)
|
||||
h = s.logging(h)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// spa wraps the API mux with the SPA file server. Requests under /api/ and /ws/
|
||||
// are served by the API handler; every other path is served from the configured
|
||||
// frontend filesystem with a fallback to index.html so client-side routes (e.g.
|
||||
// /chat) resolve in a single-page app.
|
||||
func (s *Server) spa(api http.Handler) http.Handler {
|
||||
fileServer := s.spaFileServer()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isAPIRoute(r.URL.Path) {
|
||||
api.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// spaFileServer serves static assets, falling back to index.html for paths that
|
||||
// are not real files in the embedded/external dist.
|
||||
func (s *Server) spaFileServer() http.Handler {
|
||||
fsrv := http.FileServer(http.FS(s.frontend))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if rel != "" {
|
||||
if f, err := s.frontend.Open(rel); err == nil {
|
||||
_ = f.Close()
|
||||
fsrv.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/"
|
||||
fsrv.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
|
||||
// isAPIRoute reports whether path belongs to the REST/WS API surface the
|
||||
// gateway owns, which the SPA handler must leave untouched.
|
||||
func isAPIRoute(p string) bool {
|
||||
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/ws/")
|
||||
}
|
||||
Reference in new issue
Block a user