diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c5e381d..940953a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -267,6 +267,8 @@ jobs: TELEGRAM_TEST_ENV: "true" VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} + VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }} + VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }} # Unset vars render empty -> the compose ":-" defaults apply. diff --git a/PLAN.md b/PLAN.md index 4c0c937..4bfa5ef 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1319,23 +1319,35 @@ provided cert) at the contour caddy; prod VPN; rollback. `game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play overlapped — 5 integration tests. - - **Stage 17 round 6 — REMAINING (next pass), designs ready:** - 1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) + - a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes - `{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS - (`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as - `json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`). - *UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load - (next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn` - gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft - is position-only — never scored/submitted). - 2. **Landing + `/app/` move (#16–20).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite - `base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and - the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into - `ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru - `Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the - `aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point - to `/app/`. + - **Stage 17 round 6 — final pass (#4/#5/#6 + #16–20), shipped:** + 1. **Draft persistence — gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}` + (save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes + `{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage` + both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`) + is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6) + and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be + arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only, + so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no + caption, no new style (owner's call). The backend draft endpoint is sub-ms. + 2. **Landing + `/app/` move (#16–20, this PR).** One Vite build with **two HTML entries** — the game + SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the + theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the + **landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix, + indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the + planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the + Telegram **Mini App stays at `/telegram/`** — only the plain web app moved off `/` to `/app/`, so + BotFather is untouched. The landing's "Play in Telegram" link is **per-language** via two new build + vars `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` (test/prod bots differ → no hardcoding; the + button hides when unset). Logo copied `.claude/telegram-logo.svg` → `ui/public/` (source stays + untracked). + - **Edge robustness (folded into the landing PR).** (a) **Static cache headers** — the embedded + `http.FileServer` over `go:embed` has a zero modtime, so it emitted no validators → the client + re-downloaded the whole bundle every launch; now hash-named `/assets/*` are `immutable` (a relaunch + is a cache hit) and the HTML shells are `no-cache`. (b) **Live-stream 15 s abort** — the `Subscribe` + heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle + timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s** + default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually + the owner's **external network** (the server is sub-ms end-to-end) — not a regression. ## Deferred TODOs (cross-stage) diff --git a/deploy/.env.example b/deploy/.env.example index 7edeb9f..8b98c68 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -25,6 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt # --- UI build args (baked into the gateway image) --------------------------- VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_LINK= +VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot +VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot VITE_GATEWAY_URL= # --- Gateway ---------------------------------------------------------------- diff --git a/deploy/README.md b/deploy/README.md index b5778f4..e30f417 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -12,7 +12,7 @@ operational reference for **every environment variable**. | Service | Image | Role | | --- | --- | --- | | `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; everything else → gateway. TLS per `CADDY_SITE_ADDRESS`. | -| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded SPA at `/` and `/telegram/`; Connect-RPC edge. | +| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded landing at `/` and the game SPA at `/app/` + `/telegram/`; Connect-RPC edge. | | `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. | | `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). | | `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. | @@ -84,9 +84,11 @@ connector **fails at boot** if both are empty. | `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me//`). | +| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). | +| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | -The three `VITE_*` are **build-args** baked into the gateway image at build time, so +The five `VITE_*` are **build-args** baked into the gateway image at build time, so changing them requires a rebuild (`--build`), not just a restart. ## Fixed internal wiring (not operator-set) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7836d94..10585ce 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -78,6 +78,8 @@ services: args: 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: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46045bb..404d898 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -573,13 +573,16 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app ## 13. Deployment (informational) Single public origin, path-routed. The gateway **embeds** the static UI build -(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves the one -SPA at both `/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that -path redirects to the root — the client-side guard). An in-compose **caddy** is the +(`go:embed`, baked in by a node stage in `gateway/Dockerfile`). The Vite build has two +entries: a lightweight **landing page** served at `/`, and the game **SPA** served at +`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path +redirects to the root — the client-side guard). Hash-named `/assets/*` are served +`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are +`no-cache` so a new deploy is picked up. An in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**; -everything else (`/`, `/telegram/`, the Connect edge) goes to the gateway. The +everything else (`/`, `/app/`, `/telegram/`, the Connect edge) goes to the gateway. The **Telegram connector** runs as a separate container with **no public ingress** — it long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 771ae57..0320e7c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -21,6 +21,9 @@ email, the statistics screen, and the in-game history viewer with GCG export. Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. +A public **landing page** at the site root introduces the game, switches language and +theme, and links into the web app or the matching Telegram bot; the game itself runs at +`/app/` (web) and `/telegram/` (the Telegram Mini App). ### Identity & sessions *(Stage 1 / 6 / 9 / 15)* A player arrives from a platform (Telegram first), via email login, or as an @@ -83,7 +86,11 @@ the other player and the leaver keeps their score. In a game with three or four players the leaver's seat is dropped and the others play on, the game ending when a single active player remains; the disposition of the leaver's tiles (returned to the bag or removed from play) is chosen when the game is created, and the leaver's -rack is never shown to the others. +rack is never shown to the others. A player's **board composition is kept per game**: +the rack arrangement and the tiles laid but not yet submitted are saved as they compose +and restored on return (including on another device); a player may **arrange tiles during +the opponent's turn**, but that draft is position-only — the score preview and submission +stay available only on the player's own turn. ### Robot opponent *(Stage 5)* When auto-match finds no human within ten seconds, a robot opponent takes the empty diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index cbbd2aa..7edfee6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -22,6 +22,9 @@ top-1 подсказку, безлимитную проверку слова с доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. +Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и +тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по +адресам `/app/` (веб) и `/telegram/` (Telegram Mini App). ### Личность и сессии *(Stage 1 / 6 / 9 / 15)* Игрок приходит с платформы (сначала Telegram), через email-вход или как @@ -85,7 +88,11 @@ Mini App** авторизует по подписанным `initData` плат место вышедшего убирается, остальные играют дальше, и партия завершается, когда остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или убрать из игры) выбирается при создании партии, а его стойка никогда не -показывается остальным. +показывается остальным. **Композиция на доске сохраняется по партии**: расположение +фишек на стойке и выложенные, но не отправленные фишки сохраняются по мере составления +хода и восстанавливаются при возврате (в том числе на другом устройстве); игрок может +**раскладывать фишки и в ход соперника**, но такой черновик только позиционный — +предпросмотр счёта и отправка доступны лишь в собственный ход. ### Робот-соперник *(Stage 5)* Если авто-подбор не находит человека за десять секунд, свободное место занимает diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 4a22bad..ab6cca1 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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 diff --git a/gateway/README.md b/gateway/README.md index 43d1334..af4201f 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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` | diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index cd89309..e035687 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -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" ) diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 57bf31a..455ad3d 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -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() diff --git a/gateway/internal/webui/dist/index.html b/gateway/internal/webui/dist/index.html index b6598b4..6abfc51 100644 --- a/gateway/internal/webui/dist/index.html +++ b/gateway/internal/webui/dist/index.html @@ -6,10 +6,11 @@ Scrabble +

- 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.

diff --git a/gateway/internal/webui/dist/landing.html b/gateway/internal/webui/dist/landing.html new file mode 100644 index 0000000..f06ae67 --- /dev/null +++ b/gateway/internal/webui/dist/landing.html @@ -0,0 +1,16 @@ + + + + + + Scrabble + + + +

+ 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. +

+ + diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go index 8bccfc3..5bde63e 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -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) diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go index 4528507..c7a4d4a 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -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), " { + await page.goto('/landing.html'); + + // The primary call to action opens the web app mount. + await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/'); + + // The language switch flips the copy to Russian (reusing the app i18n). + await page.getByRole('button', { name: 'Русский' }).click(); + await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible(); +}); diff --git a/ui/landing.html b/ui/landing.html new file mode 100644 index 0000000..b92f035 --- /dev/null +++ b/ui/landing.html @@ -0,0 +1,13 @@ + + + + + + + Scrabble + + +
+ + + diff --git a/ui/public/telegram-logo.svg b/ui/public/telegram-logo.svg new file mode 100644 index 0000000..c67526f --- /dev/null +++ b/ui/public/telegram-logo.svg @@ -0,0 +1,16 @@ + + + + Artboard + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte new file mode 100644 index 0000000..930fd49 --- /dev/null +++ b/ui/src/Landing.svelte @@ -0,0 +1,236 @@ + + +
+
+
+ {#each locales as lc (lc)} + + {/each} +
+
+ {#each themes as th (th)} + + {/each} +
+
+ +
+

{about.title}

+

{t('landing.tagline')}

+ +
+ +
+

+ {about.rulesPrefix}{about.rulesLink} +

+
+
+

{about.randomTitle}

+

❗️ {about.randomRespect}

+
    + {#each about.random as r (r)}
  • {r}
  • {/each} +
+
+
+

{about.friendsTitle}

+
    + {#each about.friends as f (f)}
  • {f}
  • {/each} +
+
+
+
+ +
{t('about.version', { v: __APP_VERSION__ })}
+
+ + diff --git a/ui/src/landing.ts b/ui/src/landing.ts new file mode 100644 index 0000000..2b0c25e --- /dev/null +++ b/ui/src/landing.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte'; +import './app.css'; +import Landing from './Landing.svelte'; + +// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at +// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts. +export default mount(Landing, { target: document.getElementById('app')! }); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3a17859..362daca 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -152,6 +152,10 @@ export const en = { 'about.description': 'A multiplatform Scrabble game.', 'about.version': 'Version {v}', + 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', + 'landing.playWeb': 'Play in browser', + 'landing.playTelegram': 'Play in Telegram', + 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b8a2614..592461e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -153,6 +153,10 @@ export const ru: Record = { 'about.description': 'Мультиплатформенная игра в скрабл.', 'about.version': 'Версия {v}', + 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', + 'landing.playWeb': 'Играть в браузере', + 'landing.playTelegram': 'Играть в Telegram', + 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts new file mode 100644 index 0000000..fb050d2 --- /dev/null +++ b/ui/src/lib/landing.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { telegramBotLink } from './landing'; + +describe('telegramBotLink', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns the per-language bot link when configured', () => { + vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game'); + vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game'); + expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game'); + expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game'); + }); + + it('returns null when the locale link is unset or blank', () => { + vi.stubEnv('VITE_TELEGRAM_LINK_EN', ''); + vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' '); + expect(telegramBotLink('en')).toBeNull(); + expect(telegramBotLink('ru')).toBeNull(); + }); +}); diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts new file mode 100644 index 0000000..d3915b8 --- /dev/null +++ b/ui/src/lib/landing.ts @@ -0,0 +1,16 @@ +// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so +// the per-language Telegram-bot link selection is unit-testable. + +import type { Locale } from './i18n/index.svelte'; + +/** + * telegramBotLink returns the t.me link for the locale's game bot, or null when it is not + * configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU) + * because the test and prod contours run different bots (different usernames), so the link + * cannot be hardcoded. + */ +export function telegramBotLink(locale: Locale): string | null { + const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN; + const link = (raw as string | undefined)?.trim(); + return link ? link : null; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index b15496d..0b927ed 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; @@ -34,5 +35,14 @@ export default defineConfig(({ mode }) => ({ build: { target: 'es2022', sourcemap: true, + // Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the + // public landing page (landing.html, served at /). Assets are shared in dist/assets/, and + // the relative base lets one build serve under any path. + rollupOptions: { + input: { + main: resolve(import.meta.dirname, 'index.html'), + landing: resolve(import.meta.dirname, 'landing.html'), + }, + }, }, }));