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
+9 -4
View File
@@ -5,6 +5,7 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { dev } from "$app/environment";
import { appBase, withBase } from "$lib/paths";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { eventStream } from "../api/events.svelte";
@@ -26,7 +27,9 @@
// in svelte.config.js) so `vite dev` and the dev-server e2e suite
// run without the worker intercepting requests.
if (!dev && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("/service-worker.js");
void navigator.serviceWorker.register(withBase("/service-worker.js"), {
scope: withBase("/"),
});
}
return () => {
eventStream.stop();
@@ -75,7 +78,9 @@
streamSessionId = null;
}
const pathname = page.url.pathname;
// page.url.pathname includes the configured base path; strip it so
// the route comparisons below stay base-agnostic.
const pathname = page.url.pathname.slice(appBase.length);
// Debug-only routes under /__debug/* run their own bootstrap
// path against the storage primitives and must bypass the
// auth guard so Phase 6's Playwright spec can drive the
@@ -84,9 +89,9 @@
return;
}
if (session.status === "anonymous" && pathname !== "/login") {
void goto("/login", { replaceState: true });
void goto(withBase("/login"), { replaceState: true });
} else if (session.status === "authenticated" && pathname === "/login") {
void goto("/lobby", { replaceState: true });
void goto(withBase("/lobby"), { replaceState: true });
}
});
</script>
@@ -43,6 +43,7 @@ the next game's snapshot — and the next game's selection — start
fresh.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -86,9 +87,9 @@ fresh.
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
import { createEdgeGatewayClient } from "../../../api/connect";
import { createGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
getSyntheticReport,
isSyntheticGameId,
@@ -373,7 +374,7 @@ fresh.
if (isSyntheticGameId(gameId)) {
const report = getSyntheticReport(gameId);
if (report === undefined) {
await goto("/lobby");
await goto(withBase("/lobby"));
return;
}
try {
@@ -420,7 +421,7 @@ fresh.
coreHolder.set(core);
const client = new GalaxyClient({
core,
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId,
@@ -472,7 +473,7 @@ fresh.
messageParams: { from: parsed.from },
actionLabelKey: "game.events.mail_new.action",
onAction: () => {
void goto(`/games/${gameId}/mail`);
void goto(withBase(`/games/${gameId}/mail`));
},
durationMs: 8000,
});
+7 -6
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { createEdgeGatewayClient } from "../../api/connect";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
LobbyError,
@@ -19,7 +20,7 @@
} from "../../api/lobby";
import { ByteBuffer } from "flatbuffers";
import { AccountResponse } from "../../proto/galaxy/fbs/user";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
@@ -184,11 +185,11 @@
}
function gotoCreate(): void {
goto("/lobby/create");
goto(withBase("/lobby/create"));
}
function gotoGame(gameId: string): void {
goto(`/games/${gameId}/map`);
goto(withBase(`/games/${gameId}/map`));
}
async function onSyntheticFileChange(
@@ -207,7 +208,7 @@
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
await goto(`/games/${gameId}/map`);
await goto(withBase(`/games/${gameId}/map`));
} catch (err) {
if (err instanceof SyntheticReportError) {
syntheticError = err.message;
@@ -250,7 +251,7 @@
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
@@ -1,11 +1,12 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { createEdgeGatewayClient } from "../../../api/connect";
import { createGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { LobbyError, createGame } from "../../../api/lobby";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../../platform/core/index";
import { session } from "$lib/session-store.svelte";
@@ -51,7 +52,7 @@
}
function cancel(): void {
goto("/lobby");
goto(withBase("/lobby"));
}
async function submit(): Promise<void> {
@@ -93,7 +94,7 @@
turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
});
goto("/lobby");
goto(withBase("/lobby"));
} catch (err) {
formError = describeLobbyError(err);
} finally {
@@ -116,7 +117,7 @@
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import {
AuthError,
@@ -88,7 +89,7 @@
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
await session.signIn(result.deviceSessionId);
void goto("/lobby", { replaceState: true });
void goto(withBase("/lobby"), { replaceState: true });
} catch (err) {
if (err instanceof AuthError && err.code === "invalid_request") {
challengeId = null;