feat(web,server): embed статики SPA + fallback, монтирование в cmd/server

This commit is contained in:
2026-07-03 18:14:18 +07:00
parent 388bf4aeb6
commit bba72cc70f
5 changed files with 100 additions and 1 deletions
+1
View File
@@ -7,3 +7,4 @@
# web (Vite frontend) # web (Vite frontend)
web/node_modules/ web/node_modules/
web/dist/ web/dist/
internal/web/dist/
+9
View File
@@ -5,3 +5,12 @@ test:
.PHONY: build .PHONY: build
build: build:
go build ./... go build ./...
.PHONY: web
web:
cd web && npm ci && npm run build
rm -rf internal/web/dist
cp -r web/dist internal/web/dist
.PHONY: build-all
build-all: web build
+21 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -14,6 +15,7 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/provider/selectel" "github.com/vasyakrg/dns-autoresolver/internal/provider/selectel"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/web"
) )
func main() { func main() {
@@ -42,9 +44,27 @@ func main() {
svc := service.New(st, st, reg, cipher) svc := service.New(st, st, reg, cipher)
a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg} a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg}
apiRouter := api.NewRouter(a)
webHandler, err := web.Handler()
if err != nil {
log.Printf("web: static UI unavailable: %v", err)
}
mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
apiRouter.ServeHTTP(w, r)
return
}
if webHandler != nil {
webHandler.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
log.Printf("listening on %s", cfg.ListenAddr) log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil { if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
+43
View File
@@ -0,0 +1,43 @@
// Package web embeds the built React SPA (web/dist, copied to
// internal/web/dist by `make web` before go build/test) and serves it
// with SPA-fallback: any non-file path resolves to index.html so
// client-side routing works.
package web
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// Handler serves the embedded SPA with fallback to index.html for
// client-side routes (any non-file path that isn't an API route).
func Handler() (http.Handler, error) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil, err
}
fileServer := http.FileServer(http.FS(sub))
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// существующий файл (ассет) — отдать как есть
p := strings.TrimPrefix(r.URL.Path, "/")
if p != "" {
if f, err := sub.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
}
// иначе — SPA index
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(index)
}), nil
}
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerServesIndexAndSPAFallback(t *testing.T) {
h, err := Handler()
if err != nil {
t.Fatalf("handler: %v", err)
}
// корень → 200 и HTML
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("root status %d", rec.Code)
}
// неизвестный клиентский путь → SPA fallback (index.html), не 404
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/domains/xyz", nil))
if rec2.Code != http.StatusOK {
t.Fatalf("SPA fallback status %d", rec2.Code)
}
}