feat(web,server): embed статики SPA + fallback, монтирование в cmd/server
This commit is contained in:
@@ -7,3 +7,4 @@
|
|||||||
# web (Vite frontend)
|
# web (Vite frontend)
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
|
internal/web/dist/
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user