Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s

Close out Stage 17 round 6:

- Landing page at / — one Vite build with two entries (index.html = game
  SPA, landing.html = a lightweight landing reusing the theme/i18n/
  aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
  (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
  + /telegram/). Per-language "Play in Telegram" link via new
  VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
  go:embed zero modtime emitted no validators, so the client re-downloaded
  the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
  interval (the first tick at 15s raced the edge idle timeout -> reconnect
  storm).

PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
This commit is contained in:
Ilia Denisov
2026-06-08 13:33:05 +02:00
parent b8787a4123
commit e16076c89e
27 changed files with 519 additions and 82 deletions
+4 -3
View File
@@ -6,10 +6,11 @@
<title>Scrabble</title>
</head>
<body>
<!-- scrabble-app-shell -->
<p>
UI build placeholder. The production gateway image embeds the real Vite
build (see gateway/Dockerfile); seeing this page means the binary was
built without a UI build.
App shell build placeholder. The production gateway image embeds the real Vite
build (see gateway/Dockerfile); seeing this page means the binary was built
without a UI build.
</p>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scrabble</title>
</head>
<body>
<!-- scrabble-landing -->
<p>
Landing build placeholder. The production gateway image embeds the real Vite
build (see gateway/Dockerfile); seeing this page means the binary was built
without a UI build.
</p>
</body>
</html>
+30 -19
View File
@@ -1,12 +1,16 @@
// Package webui serves the embedded single-page UI build over the public edge.
// Package webui serves the embedded static UI build over the public edge.
//
// The committed dist/ holds only a placeholder index.html so the gateway module
// compiles with a plain `go build` (and in CI) without a UI build. The production
// The committed dist/ holds only placeholder index.html / landing.html so the gateway
// module compiles with a plain `go build` (and in CI) without a UI build. The production
// gateway image replaces dist/ with the real Vite build before compiling (see
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built
// with a relative asset base, one build serves under any path: Handler is mounted
// both at "/" (web) and at "/telegram/" (the Telegram Mini App), matching the
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built with a
// relative asset base, one build serves under any path: the game SPA is mounted at /app/
// (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the
// single-origin model in docs/ARCHITECTURE.md §13.
//
// Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and
// cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry
// no-cache so a new deploy is picked up immediately.
package webui
import (
@@ -30,25 +34,30 @@ func distFS() fs.FS {
return sub
}
// Handler serves the embedded SPA. An existing file is served directly (with the
// standard content-type and caching headers); every other path falls back to
// index.html so the client-side hash router can take over a deep link. When
// stripPrefix is non-empty it is removed from the request path before lookup, so
// the same build serves under a sub-path (e.g. "/telegram/").
func Handler(stripPrefix string) http.Handler {
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell or the landing
// page) so a client-side deep link still loads. When stripPrefix is non-empty it is removed
// from the request path before lookup, so the same build serves under a sub-path (e.g.
// "/app/" or "/telegram/").
func Handler(stripPrefix, indexName string) http.Handler {
content := distFS()
files := http.FileServer(http.FS(content))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" {
serveIndex(w, content)
serveIndex(w, content, indexName)
return
}
if info, err := fs.Stat(content, name); err != nil || info.IsDir() {
// Unknown path or a directory: serve the SPA shell, never a listing.
serveIndex(w, content)
// Unknown path or a directory: serve the shell, never a listing.
serveIndex(w, content, indexName)
return
}
// Hash-named build assets are immutable — cache them for a year so reopening the
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
})
if p := strings.TrimSuffix(stripPrefix, "/"); p != "" {
@@ -57,14 +66,16 @@ func Handler(stripPrefix string) http.Handler {
return h
}
// serveIndex writes the SPA shell with a 200 status, so a client-routed deep link
// still loads the app rather than a 404.
func serveIndex(w http.ResponseWriter, content fs.FS) {
data, err := fs.ReadFile(content, "index.html")
// serveIndex writes the named HTML shell with a 200 status, so a client-routed deep link
// still loads the app rather than a 404. The shell is marked no-cache so a new deploy's
// shell (and the asset URLs it references) is fetched fresh.
func serveIndex(w http.ResponseWriter, content fs.FS, indexName string) {
data, err := fs.ReadFile(content, indexName)
if err != nil {
http.Error(w, "ui not built", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
+36 -23
View File
@@ -16,37 +16,50 @@ func get(t *testing.T, h http.Handler, target string) *http.Response {
return rec.Result()
}
func TestHandlerServesIndexAndFallsBack(t *testing.T) {
h := Handler("")
func body(t *testing.T, resp *http.Response) string {
t.Helper()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// The embedded placeholder index is served at the root.
if resp := get(t, h, "/"); resp.StatusCode != http.StatusOK {
t.Fatalf("GET / status = %d, want 200", resp.StatusCode)
}
// TestLandingMountServesLandingAndFallsBack: "/" serves the landing shell (no-cache) and
// any unknown path falls back to it.
func TestLandingMountServesLandingAndFallsBack(t *testing.T) {
h := Handler("", "landing.html")
// An existing (non-index) file is served directly by the file server.
if resp := get(t, h, "/assets/.gitkeep"); resp.StatusCode != http.StatusOK {
t.Fatalf("GET /assets/.gitkeep status = %d, want 200 (served file)", resp.StatusCode)
resp := get(t, h, "/")
if resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-landing") {
t.Fatalf("GET / did not serve the landing shell (status %d)", resp.StatusCode)
}
// An unknown deep link falls back to the SPA shell (200, not 404) so the
// client-side hash router can take over.
resp := get(t, h, "/game/abc/deep")
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /game/abc/deep status = %d, want 200 (SPA fallback)", resp.StatusCode)
if cc := get(t, h, "/").Header.Get("Cache-Control"); cc != "no-cache" {
t.Errorf("landing Cache-Control = %q, want no-cache", cc)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "<html") {
t.Fatalf("fallback body is not the index HTML: %q", body)
if resp := get(t, h, "/whatever"); resp.StatusCode != http.StatusOK {
t.Fatalf("GET /whatever status = %d, want 200 (fallback)", resp.StatusCode)
}
}
func TestHandlerStripsPrefix(t *testing.T) {
h := Handler("/telegram/")
// TestAppMountServesShellStripsPrefixAndCachesAssets: "/app/" and "/telegram/" serve the app
// shell (index.html), strip their prefix, fall back for deep links, and mark hash-named
// assets immutable.
func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
for _, prefix := range []string{"/app/", "/telegram/"} {
h := Handler(prefix, "index.html")
for _, target := range []string{"/telegram/", "/telegram/assets/.gitkeep", "/telegram/lobby/x"} {
if resp := get(t, h, target); resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", target, resp.StatusCode)
if resp := get(t, h, prefix); resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-app-shell") {
t.Fatalf("GET %s did not serve the app shell (status %d)", prefix, resp.StatusCode)
}
// A deep link falls back to the shell so the hash router can take over.
if resp := get(t, h, prefix+"game/abc"); resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-app-shell") {
t.Fatalf("GET %sgame/abc did not fall back to the app shell (status %d)", prefix, resp.StatusCode)
}
// A hash-named asset is served directly and marked immutable.
resp := get(t, h, prefix+"assets/.gitkeep")
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %sassets/.gitkeep status = %d, want 200", prefix, resp.StatusCode)
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Errorf("asset Cache-Control = %q, want immutable", cc)
}
}
}