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
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:
@@ -20,10 +20,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
||||
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
||||
ARG VITE_TELEGRAM_BOT_ID=
|
||||
ARG VITE_TELEGRAM_LINK=
|
||||
ARG VITE_TELEGRAM_LINK_EN=
|
||||
ARG VITE_TELEGRAM_LINK_RU=
|
||||
ARG VITE_GATEWAY_URL=
|
||||
ARG VITE_APP_VERSION=
|
||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
|
||||
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
|
||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||
|
||||
|
||||
+5
-4
@@ -6,8 +6,9 @@ cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
||||
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves the one SPA at `/` (web) and
|
||||
`/telegram/` (the Mini App) — the single-origin model. It can also serve the
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
[`../deploy`](../deploy)). See
|
||||
@@ -28,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
|
||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||
internal/webui/ # embedded SPA build (go:embed dist) served at / and /telegram/
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
||||
```
|
||||
|
||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||
@@ -77,7 +78,7 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
|
||||
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
|
||||
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
||||
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
|
||||
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
|
||||
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
|
||||
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
||||
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
|
||||
|
||||
@@ -73,7 +73,7 @@ const (
|
||||
defaultBackendTimeout = 5 * time.Second
|
||||
defaultSessionTTL = 10 * time.Minute
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 15 * time.Second
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
|
||||
defaultServiceName = "scrabble-gateway"
|
||||
)
|
||||
|
||||
|
||||
@@ -99,12 +99,14 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// does not serve the app shell at the operator path.
|
||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||
}
|
||||
// The embedded single-page UI is served at the site root and, for the Telegram
|
||||
// Mini App, under /telegram/ — the single-origin model (docs/ARCHITECTURE.md
|
||||
// §13). Both mounts sit below the h2c wrap so the Connect edge (a more specific
|
||||
// prefix) keeps priority; "/" is the catch-all SPA fallback for the hash router.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/"))
|
||||
mux.Handle("/", webui.Handler(""))
|
||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram Mini
|
||||
// App), with a separate landing page at the catch-all "/" — the single-origin model
|
||||
// (docs/ARCHITECTURE.md §13). All sit below the h2c wrap so the Connect edge (a more
|
||||
// specific prefix) keeps priority. Each SPA mount falls back to the app shell
|
||||
// (index.html) for the hash router; "/" falls back to the landing (landing.html).
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||
mux.Handle("/", webui.Handler("", "landing.html"))
|
||||
return h2c.NewHandler(mux, &http2.Server{})
|
||||
}
|
||||
|
||||
@@ -184,6 +186,14 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
||||
events, cancel := s.hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
// Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
|
||||
// right away and resets edge/client idle timers, instead of the connection sitting silent
|
||||
// until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
|
||||
// every interval (Stage 17).
|
||||
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(s.heartbeat)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
+4
-3
@@ -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
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user