feat(deploy): single-origin path-based deployment + project site
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m22s
Tests · UI / test (push) Failing after 2m42s

Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.

- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
  edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
  manifest, service-worker scope, WASM loader, and all navigation
  base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
  allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
  math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
  site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
  local-dev READMEs, CLAUDE.md, ui/PLAN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-23 18:19:07 +02:00
parent fa0df5183a
commit 8565942392
104 changed files with 2967 additions and 787 deletions
@@ -12,6 +12,7 @@ header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -126,10 +127,10 @@ viewer keeps its prop-driven contract.
});
function backToReport() {
goto(`/games/${gameId}/report`);
goto(withBase(`/games/${gameId}/report`));
}
function backToMap() {
goto(`/games/${gameId}/map`);
goto(withBase(`/games/${gameId}/map`));
}
</script>
@@ -25,6 +25,7 @@ fractions is a Phase 21 decision documented in
`ui/docs/science-designer-ux.md`.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, tick } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -125,7 +126,7 @@ fractions is a Phase 21 decision documented in
}
function backToTable(): void {
void goto(`/games/${gameId}/table/sciences`);
void goto(withBase(`/games/${gameId}/table/sciences`));
}
async function save(): Promise<void> {
+3 -2
View File
@@ -20,6 +20,7 @@ Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
preference the store already manages.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -636,14 +637,14 @@ preference the store already manages.
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`),
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
withBase(`/games/${gameId}/report#report-bombings`),
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
@@ -20,6 +20,7 @@ The active section is computed by the orchestrator
`activeSlug` prop. The TOC itself owns no observers.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -62,7 +63,7 @@ The active section is computed by the orchestrator
}
async function backToMap(): Promise<void> {
await goto(`/games/${gameId}/map`);
await goto(withBase(`/games/${gameId}/map`));
}
</script>
@@ -7,6 +7,7 @@ monospace `<span>`; the rewire here is the one-liner the Phase 23
decision log called out.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte";
import { page } from "$app/state";
@@ -47,7 +48,7 @@ decision log called out.
</span>
<a
class="uuid"
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)}
data-testid="report-battle-row"
data-id={b.id}
>{b.id}</a>
@@ -17,6 +17,7 @@ The component sits inside the active-view slot owned by
data fetching is performed here — the layout is responsible.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -117,11 +118,11 @@ data fetching is performed here — the layout is responsible.
}
function openDesigner(name: string): void {
void goto(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`);
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`));
}
function newScience(): void {
void goto(`/games/${gameId}/designer/science`);
void goto(withBase(`/games/${gameId}/designer/science`));
}
async function deleteScience(name: string): Promise<void> {
+23 -3
View File
@@ -3,9 +3,14 @@
// at the first import.
//
// `VITE_GATEWAY_BASE_URL` is the base URL of the gateway public REST
// surface and the Connect-Web authenticated edge (same host, same
// port; the gateway listener serves both). It defaults to the local
// dev address used by `tools/local-ci` and the integration suite.
// surface. An empty value means "same origin": the single-origin
// deployment serves the UI, the REST surface (`/api/...`), and the
// authenticated Connect-Web edge (`/rpc/...`) behind one host, so the
// browser issues same-origin requests and needs no absolute base. A
// non-empty value (the Vite dev proxy, `tools/local-ci`, the
// integration suite) points REST at that absolute host instead. The
// Connect base is derived from this value by `gatewayRpcBaseUrl`,
// which appends the `/rpc` routing prefix.
//
// `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` is the gateway's response-signing
// Ed25519 public key, encoded as standard (non-URL-safe) base64 of
@@ -26,6 +31,21 @@ const RAW_RESPONSE_PUBLIC_KEY: string =
export const GATEWAY_BASE_URL: string = stripTrailingSlash(RAW_BASE_URL);
/**
* gatewayRpcBaseUrl is the base URL for the authenticated Connect-Web
* surface. The edge Caddy and the Vite dev proxy route the `/rpc`
* prefix to the gateway's Connect listener, stripping it before the
* request reaches the proto-derived `edge.v1.Gateway` service path.
* When `GATEWAY_BASE_URL` is empty the gateway shares the document
* origin, so the origin is resolved at call time from `window`.
*/
export function gatewayRpcBaseUrl(): string {
const origin =
GATEWAY_BASE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return `${origin}/rpc`;
}
export const GATEWAY_RESPONSE_PUBLIC_KEY: Uint8Array = decodeBase64(
RAW_RESPONSE_PUBLIC_KEY,
);
+2 -1
View File
@@ -12,6 +12,7 @@ navigation. Phase 26 introduces the history-mode entry; Phase 35
polishes microcopy.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -41,7 +42,7 @@ polishes microcopy.
function go(path: string): void {
open = false;
void goto(path);
void goto(withBase(path));
}
function onKeyDown(event: KeyboardEvent): void {
+25
View File
@@ -0,0 +1,25 @@
// Base-path helpers for the single-origin deployment. The game UI is
// served under `kit.paths.base` — empty at the root for local dev,
// vitest, and Playwright, and `/game` in the deployed single-origin
// build. SvelteKit does not auto-prefix `goto`, `<a href>`, raw asset
// fetches, or the service-worker scope, so every app-internal absolute
// path is routed through `withBase`.
//
// `base` from `$app/paths` is the low-level primitive that `resolve()`
// builds on. We use it directly here (rather than `resolve()`) because
// the client navigates to and fetches dynamic, runtime-built paths
// (command routes, `core.wasm`, the service worker) that `resolve()`'s
// statically-typed route-id surface cannot express.
import { base } from "$app/paths";
/** appBase is the configured base path (empty string at the root). */
export const appBase = base;
/**
* withBase prefixes an app-internal absolute path (leading slash) with
* the configured base path. At the root it returns the path unchanged;
* under the single-origin deployment it yields e.g. `/game/lobby`.
*/
export function withBase(path: string): string {
return `${base}${path}`;
}
@@ -13,6 +13,7 @@ exists; until then the convenience of one source of truth for
destinations beats the duplication.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -47,13 +48,13 @@ destinations beats the duplication.
async function selectTool(tool: MobileTool): Promise<void> {
moreOpen = false;
onSelectTool(tool);
await goto(`/games/${gameId}/map`);
await goto(withBase(`/games/${gameId}/map`));
}
async function go(path: string): Promise<void> {
moreOpen = false;
onSelectTool("map");
await goto(path);
await goto(withBase(path));
}
function toggleMore(): void {