feat(deploy): M11 单二进制装配(cmd/cyrene 全链路 wire + go:embed 前端 + 外置 dist 覆盖 + 优雅关闭 + SPA 静态服务);前端 build 拷到 internal/webui/dist

This commit is contained in:
Aska committed 2026-09-06 08:10:35 +08:00
1 parent f43eb17030
commit a7feaeb2cb
7 files changed
+745 -1

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
// Command cyrene is the single Cyrene v2 binary. It assembles every subsystem
// (config, logging, database, bus, personas, model adapter, tool dispatcher,
// memory stack, agent loop, orchestration, execution, IoT, channel sessions and
// adapters) into one process and serves the gateway + embedded frontend on a
// single port (:8080), shutting down gracefully on a termination signal.
//
// Build with `go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`.
// The frontend must be built first so the SPA can be embedded:
// `cd frontend/web && npm run build`.
package main
import (
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"time"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/webui"
)
// envPath is the .env file merged at boot (development only; production uses
// systemd/env_file). A missing .env is not an error.
const envPath = ".env"
// shutdownTimeout bounds how long the graceful teardown may take.
const shutdownTimeout = 15 * time.Second
// externalDistCandidates are the on-disk directories checked (in order) for a
// frontend override before falling back to the embedded dist. The first that
// exists wins. They are relative to the process working directory.
func externalDistCandidates() []string {
return []string{
filepath.Join("frontend", "web", "dist"),
filepath.Join("frontend", "dist"),
}
}
// resolveFrontendFS returns the filesystem serving the SPA: an external dist
// directory when present (deploy-time hot override), otherwise the embedded dist.
func resolveFrontendFS(log *slog.Logger) fs.FS {
for _, dir := range externalDistCandidates() {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
log.Info("serving external frontend dist", "dir", dir)
return os.DirFS(dir)
}
}
log.Info("serving embedded frontend dist")
return webui.Assets()
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "cyrene:", err)
os.Exit(1)
}
}
// run boots the application, starts every service and the gateway, then blocks
// until a termination signal arrives or the gateway fails.
func run() error {
cfg := config.MustLoad(envPath)
format := logger.FormatText
if cfg.Env == "production" {
format = logger.FormatJSON
}
log := logger.New(logger.Options{Level: cfg.LogLevel, Format: string(format)})
log = logger.WithSource(log, "main")
ctx, stop := platform.SignalContext()
defer stop()
a, err := newApp(ctx, cfg, log)
if err != nil {
return err
}
if err := a.automation.Start(ctx); err != nil {
return fmt.Errorf("start automations: %w", err)
}
a.backthink.Start(ctx)
a.log.Info("cyrene subsystems started",
"personas", a.personas.CountPersonas(),
"tools", len(a.caps.List(capreg.KindTool)),
"channels", len(a.caps.List(capreg.KindChannel)))
a.log.Info("cyrene ready", "data_dir", resolveDataDir(cfg))
errCh, err := a.gw.Start(ctx)
if err != nil {
return fmt.Errorf("start gateway: %w", err)
}
select {
case <-ctx.Done():
a.log.Info("cyrene signal received, shutting down")
case gwErr := <-errCh:
if gwErr != nil {
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
a.close(shutCtx)
return fmt.Errorf("gateway failed: %w", gwErr)
}
}
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
a.close(shutCtx)
return nil
}
+510
View File
@@ -0,0 +1,510 @@
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
)
// defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner
// definitions shipped with the repo; they are relative to the working directory.
const (
defaultOwnerPath = "configs/users/owner.yaml"
defaultPersonasDir = "configs/personas"
defaultSQLitePath = "./data/cyrene.db"
fallbackInstruction = "You are Cyrene, a warm and attentive companion."
// Bound/knob constants. Compile-time so no bare magic number appears in the
// runtime path (project rule §10).
defaultMaxRounds = 8
defaultMaxParallel = 4
defaultAgentID = "orchestrator"
defaultToolTimeout = 30 * time.Second
)
// app holds every subsystem the single Cyrene binary assembles. It is built by
// newApp and torn down by close on shutdown.
type app struct {
cfg *config.Config
log *slog.Logger
db *sql.DB
bus bus.Bus
personas *personareg.Registry
caps *registry.Registry
dispatcher *tool.Dispatcher
memStore *memory.Store
retriever *memory.Retriever
loop *loop.Loop
scheduler *orchestration.Scheduler
tasks *task.Manager
iot *iot.Manager
sessions *session.SessionManager
automation *automations.Service
backthink *backthink.Backthink
vindex vector.Index
owner *user.OwnerProfile
gw *gateway.Server
}
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
// database, migrates the schema, wires the persona registry, model adapter, tool
// dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel
// sessions/adapters and the gateway, and returns the ready app.
//
// Fatal wiring failures (database open/migrate, required auth, gateway) are
// returned as errors; optional subsystems degrade gracefully so the binary can
// boot even when a model key or external channel is not configured.
func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) {
dataDir := resolveDataDir(cfg)
databasePath := resolveSQLitePath(cfg, dataDir)
dbCfg := cfg.DB
dbCfg.SQLitePath = databasePath
sqlDB, err := db.Open(ctx, databasePath, log)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.MigrateUp(ctx, sqlDB, log); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("migrate database: %w", err)
}
eventBus := bus.New()
a := &app{
cfg: cfg,
log: log,
db: sqlDB,
bus: eventBus,
caps: registry.New(),
}
// Release already-opened resources if any later wiring step fails, without
// the full-service shutdown log (a partial boot is not a clean shutdown).
completed := false
defer func() {
if completed {
return
}
if a.vindex != nil {
_ = a.vindex.Close()
}
if a.tasks != nil {
_ = a.tasks.Close()
}
if a.bus != nil {
_ = a.bus.Close()
}
if a.db != nil {
_ = a.db.Close()
}
}()
if err := a.buildPersonas(); err != nil {
return a, err
}
if err := a.wireMemory(ctx); err != nil {
return a, err
}
if err := a.wireRuntime(); err != nil {
return a, err
}
if err := a.wireAuxiliary(); err != nil {
return a, err
}
if err := a.wireChannels(); err != nil {
return a, err
}
if err := a.wireFrontend(); err != nil {
return a, err
}
completed = true
return a, nil
}
// buildPersonas loads the persona definitions and registers them, plus the
// default character and the owner profile used by channel identity resolution.
func (a *app) buildPersonas() error {
a.personas = personareg.New()
dirPersonas, err := loader.LoadDir(defaultPersonasDir)
if err != nil {
a.log.Warn("persona dir load skipped", "error", err)
} else {
for _, p := range dirPersonas {
if rerr := a.personas.RegisterPersona(p); rerr != nil {
a.log.Warn("persona register skipped", "persona", p.ID, "error", rerr)
}
}
}
if owner, oerr := user.LoadOwnerProfile(defaultOwnerPath); oerr == nil {
a.owner = owner
} else {
a.log.Warn("owner profile load skipped", "error", oerr)
}
// Load the default persona explicitly if it was not under the personas dir.
defaultID := "cyrene"
if a.owner != nil && a.owner.DefaultPersona != "" {
defaultID = a.owner.DefaultPersona
}
if _, ok := a.personas.Persona(defaultID); !ok {
if p, lerr := loader.Load(filepath.Join(defaultPersonasDir, defaultID+".yaml")); lerr == nil {
_ = a.personas.RegisterPersona(p)
} else {
a.log.Warn("default persona load skipped", "persona", defaultID, "error", lerr)
}
}
if p, ok := a.personas.Persona(defaultID); ok {
char := &personareg.Character{ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID}
if cerr := a.personas.RegisterCharacter(char); cerr != nil {
a.log.Warn("default character register skipped", "error", cerr)
}
}
a.log.Info("personas ready", "count", a.personas.CountPersonas(), "default", defaultID)
return nil
}
// wireMemory builds the long-term memory stack: store, embedding manager, vector
// index, project index and the retriever wired into the io knowledge tool.
func (a *app) wireMemory(ctx context.Context) error {
store, err := memory.NewStore(ctx, a.db, a.log)
if err != nil {
return fmt.Errorf("memory store: %w", err)
}
a.memStore = store
embedManager := embedding.New(a.cfg.Embedding)
embedder := embedManager.Embedder()
index, err := vector.New(a.dbCfgForEmbed(), embedder, a.log)
if err != nil {
a.log.Warn("vector index unavailable, falling back to in-memory semantic", "error", err)
} else {
a.vindex = index
}
projStore, err := projectindex.New(ctx, a.db, a.log)
if err != nil {
a.log.Warn("project index unavailable", "error", err)
projStore = nil
}
opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)}
if a.vindex != nil {
opts = append(opts, memory.WithVectorIndex(a.vindex))
}
if projStore != nil {
opts = append(opts, memory.WithProjectIndex(projStore))
}
a.retriever = memory.NewRetriever(store, opts...)
return nil
}
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
// and its loop, tracing, persistence and the orchestration scheduler.
func (a *app) wireRuntime() error {
adapter, aerr := model.NewOpenAIChatChain(a.cfg.LLM)
if aerr != nil {
a.log.Warn("llm adapter unavailable", "error", aerr)
adapter = nil
}
router := model.NewRouter(a.cfg.LLM.Model)
dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout))
if derr != nil {
return fmt.Errorf("tool dispatcher: %w", derr)
}
a.dispatcher = dispatcher
if err := basic.Register(a.caps); err != nil {
return fmt.Errorf("register basic tools: %w", err)
}
ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log))
if ioErr != nil {
return fmt.Errorf("build io tools: %w", ioErr)
}
for _, t := range ioTools {
if rerr := a.dispatcher.Register(t); rerr != nil {
a.log.Warn("io tool register skipped", "tool", t.Name(), "error", rerr)
}
}
guards := guardrail.New()
instructions := a.defaultInstructions()
ag, aerr := agent.New(instructions, a.dispatcher, guards,
agent.WithModel(adapter, router),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if aerr != nil {
return fmt.Errorf("agent: %w", aerr)
}
personaID := a.defaultPersonaID()
store := a.memStore
tracer := trace.New(trace.NewJSONRecorder(os.Stderr))
checkpoint, perr := persist.New(context.Background(), a.db, a.log)
if perr != nil {
return fmt.Errorf("checkpoint store: %w", perr)
}
a.loop = loop.New(loop.Options{
Agent: ag,
Tools: a.dispatcher,
Memory: store,
Retriever: a.retriever,
Extractor: memory.NewManualExtractor(personaID, "owner"),
Checkpoint: checkpoint,
Tracer: tracer,
Guards: guards,
PersonaID: personaID,
MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0),
Log: logger.WithSource(a.log, "runtime/loop"),
})
a.scheduler = orchestration.New(orchestration.Options{
MaxParallel: defaultMaxParallel,
Loop: a.loop,
Bus: a.bus,
Memory: store,
Tracer: tracer,
Approver: audit.NewApprover(a.log),
Logger: logger.WithSource(a.log, "orchestration"),
AgentID: defaultAgentID,
})
return nil
}
// wireAuxiliary builds the execution provider, task manager, IoT manager,
// channel session manager, automations and background thinking services.
func (a *app) wireAuxiliary() error {
provider, err := exec.New(a.cfg.ExecSandbox, a.log)
if err != nil {
return fmt.Errorf("exec provider: %w", err)
}
tmgr, err := task.New(a.cfg, provider, a.log, task.WithBus(a.bus))
if err != nil {
return fmt.Errorf("task manager: %w", err)
}
a.tasks = tmgr
iotMgr, err := iot.New(a.cfg.IoT, a.log)
if err != nil {
return fmt.Errorf("iot: %w", err)
}
a.iot = iotMgr
sess, err := session.New(context.Background(), a.db, a.log)
if err != nil {
return fmt.Errorf("session manager: %w", err)
}
a.sessions = sess
automation, err := automations.NewService(automations.Config{}, automations.Deps{
Bus: a.bus,
Loop: a.loop,
Log: logger.WithSource(a.log, "services/automations"),
})
if err != nil {
return fmt.Errorf("automations: %w", err)
}
a.automation = automation
bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{
Bus: a.bus,
Thinker: backthink.NewLoopThinker(a.loop),
State: backthink.NewIoTStateProvider(iotMgr, a.log),
DB: a.db,
Log: logger.WithSource(a.log, "services/backthink"),
})
if err != nil {
return fmt.Errorf("backthink: %w", err)
}
a.backthink = bt
return nil
}
// wireChannels constructs and registers the four IM channel adapters so the
// admin API can enumerate them. They are registered but not connected: real
// platform credentials are supplied per-deployment, and connecting with empty
// config would dial external networks.
func (a *app) wireChannels() error {
channels := []struct {
name string
value any
policy registry.ToolPolicy
}{
{"qq-obv11", qq_obv11.NewAdapter(qq_obv11.Config{Logger: a.log}), registry.ToolPolicy{}},
{"weixin-ilink", mustWeixin(a.log), registry.ToolPolicy{}},
{"telegram", telegram.New(telegram.Config{}, telegram.WithLogger(a.log)), registry.ToolPolicy{}},
{"webhook", webhook.New(webhook.WithLogger(a.log)), registry.ToolPolicy{}},
}
for _, ch := range channels {
capp := registry.Capability{
Name: ch.name,
Kind: registry.KindChannel,
Description: ch.name,
Policy: ch.policy,
Value: ch.value,
}
if err := a.caps.Register(capp); err != nil {
a.log.Warn("channel register skipped", "channel", ch.name, "error", err)
}
}
a.log.Info("channel adapters registered", "count", len(channels))
return nil
}
// wireFrontend builds the gateway with the embedded/external SPA filesystem and
// the JWT/RBAC authenticator.
func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth)
gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"),
Auth: jwt,
Bus: a.bus,
Registry: a.caps,
Loop: a.loop,
IoT: a.iot,
Frontend: resolveFrontendFS(a.log),
})
if err != nil {
return fmt.Errorf("gateway: %w", err)
}
a.gw = gw
return nil
}
// dbCfgForEmbed returns the DB config used by the vector index, which opens the
// same SQLite file as the primary pool.
func (a *app) dbCfgForEmbed() config.DBConfig {
cfg := a.cfg.DB
cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg))
return cfg
}
// defaultPersonaID returns the id of the default persona, falling back to
// "cyrene".
func (a *app) defaultPersonaID() string {
if c, ok := a.personas.DefaultCharacter(); ok {
return c.PersonaID
}
return "cyrene"
}
// defaultInstructions returns the default persona's system prompt, falling back
// to its role text or a minimal instruction when neither is set.
func (a *app) defaultInstructions() string {
if c, ok := a.personas.DefaultCharacter(); ok {
if p, ok := a.personas.Persona(c.PersonaID); ok {
if p.Identity.SystemPrompt != "" {
return p.Identity.SystemPrompt
}
if p.Identity.Role != "" {
return p.Identity.Role
}
}
}
return fallbackInstruction
}
// close shuts down every subsystem in dependency order and releases resources.
func (a *app) close(ctx context.Context) {
if a.backthink != nil {
_ = a.backthink.Stop(ctx)
}
if a.automation != nil {
_ = a.automation.Stop(ctx)
}
if a.scheduler != nil {
_ = a.scheduler.CancelAll(ctx)
}
if a.gw != nil {
a.gw.Close()
}
if a.tasks != nil {
_ = a.tasks.Close()
}
if a.vindex != nil {
_ = a.vindex.Close()
}
if a.bus != nil {
_ = a.bus.Close()
}
if a.db != nil {
_ = a.db.Close()
}
a.log.Info("cyrene shutdown complete")
}
// resolveDataDir returns the configured data directory, or the OS-appropriate
// default when DATA_DIR is not exported to the process environment.
func resolveDataDir(cfg *config.Config) string {
if _, ok := os.LookupEnv("DATA_DIR"); ok {
return cfg.DataDir
}
return platform.DefaultDataDir()
}
// resolveSQLitePath returns the SQLite path to use, deriving it under the data
// directory when the configured default is left untouched.
func resolveSQLitePath(cfg *config.Config, dataDir string) string {
p := cfg.DB.SQLitePath
if strings.TrimSpace(p) == "" || p == defaultSQLitePath {
return filepath.Join(dataDir, "cyrene.db")
}
return p
}
// mustWeixin builds the weixin-ilink adapter, panicking on an invalid config
// (which should never happen with defaults).
func mustWeixin(log *slog.Logger) *weixin_ilink.Adapter {
adapter, err := weixin_ilink.New(weixin_ilink.Config{}, nil, log)
if err != nil {
panic(fmt.Sprintf("weixin-ilink adapter: %v", err))
}
return adapter
}
+15
View File
@@ -0,0 +1,15 @@
// Copies the freshly built SPA (frontend/web/dist) into internal/webui/dist so
// the Go package can //go:embed it into the single Cyrene binary. It runs as the
// last step of `npm run build`. The source dist is the canonical build output,
// so it also serves as the external deploy-override directory.
import { cp, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, "dist");
const dest = resolve(here, "..", "..", "internal", "webui", "dist");
await rm(dest, { recursive: true, force: true });
await cp(src, dest, { recursive: true });
console.log(`webui: copied ${src} -> ${dest}`);
+1 -1
View File
@@ -6,7 +6,7 @@
"description": "Cyrene v2 user frontend (M9 modal UI) — Miuix design language",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && node copy-dist.mjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
+10
View File
@@ -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)
+47
View File
@@ -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/")
}
+47
View File
@@ -0,0 +1,47 @@
// Package webui embeds the built frontend SPA (frontend/web/dist) into the
// single Cyrene binary and exposes it as a filesystem. It is the default asset
// source for the gateway's static server; a deployer may still drop an external
// dist directory next to the process to override it (see internal/services/
// gateway and cmd/cyrene).
//
// The embedded tree is rooted at the dist directory, so index.html and
// assets/* appear at the filesystem root. Build the frontend with
// `cd frontend/web && npm run build` (which also syncs dist into this package)
// before running `go build ./...`; the //go:embed directive fails to compile
// when the dist directory is absent.
package webui
import (
"embed"
"io/fs"
)
// dist holds the built SPA. It is generated by the frontend build and must
// exist for the package to compile.
//
//go:embed all:dist
var dist embed.FS
// Assets returns the embedded dist as an fs.FS rooted at the dist directory.
// The returned FS is read-only and safe for concurrent use.
func Assets() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
// The embed directive guarantees dist exists, so this cannot fail; a
// panic here is a programming error, not a runtime condition.
panic("webui: dist embed missing: " + err.Error())
}
return sub
}
// Open opens a file under the dist root by its slash-separated (or
// filesystem) name. It is a convenience over Assets().Open.
func Open(name string) (fs.File, error) {
return Assets().Open(name)
}
// Exists reports whether a file or directory exists within the embedded dist.
func Exists(path string) bool {
_, err := fs.Stat(Assets(), path)
return err == nil
}