From bba72cc70f4c65744f6d720e39c0a23df4deb404 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 18:14:18 +0700 Subject: [PATCH] =?UTF-8?q?feat(web,server):=20embed=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B8=20SPA=20+=20fallback,=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B2=20cmd/server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Makefile | 9 +++++++++ cmd/server/main.go | 22 +++++++++++++++++++- internal/web/web.go | 43 ++++++++++++++++++++++++++++++++++++++++ internal/web/web_test.go | 26 ++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go diff --git a/.gitignore b/.gitignore index 290d95f..4bed46b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ # web (Vite frontend) web/node_modules/ web/dist/ +internal/web/dist/ diff --git a/Makefile b/Makefile index 0d1fe41..2bf9af6 100644 --- a/Makefile +++ b/Makefile @@ -5,3 +5,12 @@ test: .PHONY: build 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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 9ba3d00..67be8b4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,6 +4,7 @@ import ( "context" "log" "net/http" + "strings" "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/service" "github.com/vasyakrg/dns-autoresolver/internal/store" + "github.com/vasyakrg/dns-autoresolver/internal/web" ) func main() { @@ -42,9 +44,27 @@ func main() { svc := service.New(st, st, reg, cipher) 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) - if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil { + if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { log.Fatal(err) } } diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..472fcda --- /dev/null +++ b/internal/web/web.go @@ -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 +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..976ecc4 --- /dev/null +++ b/internal/web/web_test.go @@ -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) + } +}