Skip to content

Commit 2161ffe

Browse files
authored
fix(web): make serve loads the browser app (#101)
1 parent 9b0b489 commit 2161ffe

17 files changed

Lines changed: 412 additions & 35 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ make setup
1414
make serve
1515
```
1616

17-
In another terminal, run `make dev` and open the URL printed by Vite. The API
18-
defaults to `http://127.0.0.1:8080`; its interactive reference is at `/docs/`.
17+
Open `http://127.0.0.1:8080/` for the browser app; the API reference is at
18+
`/docs/`. `make serve` installs the locked browser dependencies and builds the
19+
production app before starting the server. `make serve-postgres` does the same
20+
with PostgreSQL.
21+
22+
For hot reload, use `make serve-api` in one terminal and `make dev` in another.
23+
Open Vite’s URL (normally `http://127.0.0.1:5173/`). API-only mode needs no browser
24+
build and redirects its root URL to the API reference.
1925

2026
```sh
21-
make serve PORT=9090 DB_PATH=./development.sqlite
27+
make serve-api PORT=9090 DB_PATH=./development.sqlite
2228
GFA_API_TARGET=http://127.0.0.1:9090 make dev
2329
make serve CLI_ARGS='--stockfish /usr/games/stockfish'
2430
make mcp
@@ -38,7 +44,7 @@ environment variable. The database URL is never a Make command-line argument.
3844
| --- | --- |
3945
| `make build` | Rust workspace and production web assets |
4046
| `make check` | Rust format, Clippy, dependency boundaries, all-feature tests; web types, lint, boundaries and format |
41-
| `make test-browser` | Build the server and run the actual browser integration suite |
47+
| `make test-browser` | Build the app and server, then run production and development browser tests |
4248
| `make format` | Format Rust and browser sources |
4349
| `make audit` | Run the existing cargo-deny policy (install cargo-deny first) |
4450
| `make docs` | Build Rust API documentation |

‎Cargo.lock‎

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ tempfile = "3"
6262

6363
axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio", "ws"] }
6464
tower = { version = "0.5", features = ["util"] }
65+
tower-http = { version = "0.6", default-features = false, features = ["fs"] }
6566

6667
uuid = { version = "1", features = ["v4"] }
6768

‎Makefile‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ CLI_ARGS ?=
1111
SQLX_OFFLINE ?= true
1212
export SQLX_OFFLINE
1313

14-
.PHONY: help setup setup-rust setup-web setup-python serve serve-postgres mcp dev \
14+
.PHONY: help setup setup-rust setup-web setup-python serve serve-api serve-postgres mcp dev \
1515
build build-rust build-server build-web build-python check check-rust check-web \
1616
test test-rust test-browser test-python lint-rust check-deps audit format \
1717
format-rust format-web format-check format-check-rust format-check-web \
@@ -34,16 +34,19 @@ setup-web: ## Install the pinned browser dependencies.
3434
setup-python: ## Install the native SDK and test/dataset/tournament extras (use a virtualenv).
3535
$(PYTHON) -m pip install '.[test,datasets,tournaments]' 'maturin>=1.9,<2'
3636

37-
serve: ## Run the local SQLite API; open a second terminal for make dev.
37+
serve: build-web ## Build and serve the browser app + SQLite API at http://127.0.0.1:8080/.
38+
$(CARGO) run -p gfa-cli --locked -- serve --web-dir web/apps/site/dist --sqlite "$(DB_PATH)" --port "$(PORT)" $(CLI_ARGS)
39+
40+
serve-api: ## Run only the SQLite API (pair with make dev for hot reload).
3841
$(CARGO) run -p gfa-cli --locked -- serve --sqlite "$(DB_PATH)" --port "$(PORT)" $(CLI_ARGS)
3942

40-
serve-postgres: ## Run the API using a PostgreSQL URL from POSTGRES_ENV.
41-
$(CARGO) run -p gfa-cli --features postgres --locked -- serve --postgres-env "$(POSTGRES_ENV)" --port "$(PORT)" $(CLI_ARGS)
43+
serve-postgres: build-web ## Serve the app + API using a PostgreSQL URL from POSTGRES_ENV.
44+
$(CARGO) run -p gfa-cli --features postgres --locked -- serve --web-dir web/apps/site/dist --postgres-env "$(POSTGRES_ENV)" --port "$(PORT)" $(CLI_ARGS)
4245

4346
mcp: ## Run the local MCP server over stdin/stdout.
4447
@$(CARGO) run -p gfa-cli --locked -- mcp --sqlite "$(DB_PATH)" $(CLI_ARGS)
4548

46-
dev: ## Run the browser development server (API must be running separately).
49+
dev: ## Run the browser with hot reload (run make serve-api in another terminal).
4750
$(PNPM) dev
4851

4952
build: build-rust build-web ## Build the Rust workspace and production browser assets.
@@ -54,7 +57,7 @@ build-rust: ## Build all Rust workspace members.
5457
build-server: ## Build the local gfa CLI/server.
5558
$(CARGO) build -p gfa-cli --locked
5659

57-
build-web: ## Build the browser application.
60+
build-web: setup-web ## Install locked dependencies and build the browser application.
5861
$(PNPM) build
5962

6063
build-python: ## Build a release Python wheel into dist/.
@@ -71,7 +74,7 @@ test: test-rust ## Run workspace unit/integration tests (alias for test-rust).
7174
test-rust: ## Test the Rust workspace with all features and the locked dependency graph.
7275
$(CARGO) test --workspace --all-features --locked
7376

74-
test-browser: build-server ## Test the browser against an isolated real backend.
77+
test-browser: build-server build-web ## Test the production app and dev browser against a real backend.
7578
$(PNPM) test:browser
7679

7780
test-python: ## Test the installed native Python SDK (run setup-python after Rust changes).

‎README.md‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,29 @@ See the [Product Requirements Document](docs/PRD.md).
66

77
## Run locally
88

9-
Use `make help` for setup, run, build, and verification shortcuts. The
10-
[contributor guide](CONTRIBUTING.md) documents prerequisites and optional Python
11-
setup. `make setup`, `make serve`, and `make dev` (in a second terminal) start the
12-
local development environment.
9+
With Rust, Node.js 22 and pnpm 10.6.5 installed, run:
1310

11+
```sh
12+
make serve
13+
```
14+
15+
This installs the locked browser dependencies, builds the app, and serves both
16+
it and the API at **http://127.0.0.1:8080/**. The interactive API reference is at
17+
`/docs/`. Stop and rerun the command after changing the browser source.
18+
Use `PORT=9090` or `DB_PATH=./development.sqlite` to override the local defaults.
19+
20+
For browser development with hot reload, run `make serve-api` in one terminal
21+
and `make dev` in another; open the URL printed by Vite (normally port 5173).
22+
For API-only use without Node.js:
1423

1524
```sh
1625
cargo run -p gfa-cli --locked -- serve --sqlite ./gfa.sqlite --port 8080
1726
```
1827

19-
Open `http://127.0.0.1:8080/docs/` for the interactive API reference. Local mode
20-
binds to loopback and treats the local caller as the match owner.
28+
API-only mode redirects `/` to `/docs/`. To serve a previously built frontend,
29+
add `--web-dir web/apps/site/dist`. Local mode binds to loopback and treats the
30+
local caller as the match owner. See `make help` and the
31+
[contributor guide](CONTRIBUTING.md) for other workflows.
2132

2233
For PostgreSQL, enable the backend and put the connection URL in an environment
2334
variable so credentials do not appear in the command arguments:

‎crates/adapters/gfa-http/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ gfa-core = { workspace = true, features = ["openapi"] }
99
gfa-api-types = { workspace = true, features = ["openapi"] }
1010
gfa-service.workspace = true
1111
axum.workspace = true
12+
tower-http.workspace = true
1213
serde.workspace = true
1314
serde_json.workspace = true
1415
tokio.workspace = true

‎crates/adapters/gfa-http/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ mod opponents;
1212
pub use openapi::openapi_document;
1313
mod stream;
1414
mod training;
15+
mod web;
16+
pub use web::browser_router;
1517

1618
pub use stream::LiveUpdates;
1719

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//! Host-selected browser assets. The app uses hash routes, so unknown API paths
2+
//! must keep their normal JSON errors instead of falling back to index.html.
3+
use axum::{response::Redirect, routing::get, Router};
4+
use std::{io, path::Path};
5+
use tower_http::services::{ServeDir, ServeFile};
6+
7+
/// Serve a trusted Vite production build, or direct API-only visitors to the docs.
8+
///
9+
/// The host must wrap these routes in `protect_local_routes`. Only index.html and
10+
/// assets/ are exposed; the build directory's other files are never served.
11+
/// Files remain on disk and must be kept available for the server's lifetime.
12+
pub fn browser_router(directory: Option<&Path>) -> io::Result<Router> {
13+
let Some(directory) = directory else {
14+
return Ok(Router::new().route("/", get(|| async { Redirect::temporary("/docs/") })));
15+
};
16+
let directory = directory.canonicalize().map_err(|error| {
17+
io::Error::new(
18+
error.kind(),
19+
format!(
20+
"Browser build unavailable at {}. Run make build-web, then pass --web-dir web/apps/site/dist.",
21+
directory.display()
22+
),
23+
)
24+
})?;
25+
let index = directory.join("index.html");
26+
let assets = directory.join("assets");
27+
if !index.is_file() || !assets.is_dir() {
28+
return Err(io::Error::new(
29+
io::ErrorKind::NotFound,
30+
"Browser build requires index.html and assets/. Run make build-web before serving it.",
31+
));
32+
}
33+
Ok(Router::new()
34+
.route_service("/", ServeFile::new(index.clone()))
35+
.route_service("/index.html", ServeFile::new(index))
36+
.nest_service(
37+
"/assets",
38+
ServeDir::new(assets).append_index_html_on_directories(false),
39+
))
40+
}

‎crates/apps/gfa-cli/src/main.rs‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
use gfa_server::{Config, Database, ServerError};
33
use std::path::PathBuf;
44

5-
const USAGE: &str = "Usage: gfa mcp [--sqlite PATH | --postgres-env VARIABLE] [--seat SEAT | --spectator] [--stockfish PATH]\n gfa serve [--sqlite PATH | --postgres-env VARIABLE] [--port PORT] [--stockfish PATH] [--mcp-seat SEAT | --mcp-spectator]\n gfa tournament --game GAME --agents FILES [--opponents LADDER] [--games COUNT] [--report PATH]\n\nmcp serves a trusted local client over stdin/stdout; seat defaults to 0. No TCP port is opened.\nserve starts a local API on 127.0.0.1 (default port 8080).\nHTTP /mcp defaults to seat 0; use --mcp-seat or --mcp-spectator to change it.\ntournament uses gamesforai[tournaments] in GFA_PYTHON (default python3; python on Windows).\nSQLite defaults to ./gfa.sqlite. Port 0 selects an available port.\nPostgreSQL requires the postgres build feature and reads its URL from VARIABLE.\nStockfish requires an absolute binary path, Linux, bubblewrap and working user namespaces.";
5+
const USAGE: &str = "Usage: gfa mcp [--sqlite PATH | --postgres-env VARIABLE] [--seat SEAT | --spectator] [--stockfish PATH]\n gfa serve [--sqlite PATH | --postgres-env VARIABLE] [--port PORT] [--web-dir DIRECTORY] [--stockfish PATH] [--mcp-seat SEAT | --mcp-spectator]\n gfa tournament --game GAME --agents FILES [--opponents LADDER] [--games COUNT] [--report PATH]\n\nmcp serves a trusted local client over stdin/stdout; seat defaults to 0. No TCP port is opened.\nserve starts a local API on 127.0.0.1 (default port 8080). Add --web-dir to serve a browser build.\nWithout --web-dir, / redirects to /docs/. make serve builds and serves the browser app.\nHTTP /mcp defaults to seat 0; use --mcp-seat or --mcp-spectator to change it.\ntournament uses gamesforai[tournaments] in GFA_PYTHON (default python3; python on Windows).\nSQLite defaults to ./gfa.sqlite. Port 0 selects an available port.\nPostgreSQL requires the postgres build feature and reads its URL from VARIABLE.\nStockfish requires an absolute binary path, Linux, bubblewrap and working user namespaces.";
66

77
fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Config>, String> {
88
parse_with_env(args, |name| std::env::var(name).ok())
@@ -23,6 +23,7 @@ fn parse_with_env(
2323
let mut config = Config::default();
2424
let mut database_seen = false;
2525
let mut port_seen = false;
26+
let mut web_seen = false;
2627
let mut stockfish_seen = false;
2728
let mut mcp_viewer_seen = false;
2829
while let Some(option) = args.next() {
@@ -83,6 +84,14 @@ fn parse_with_env(
8384
config.stockfish = Some(gfa_server::StockfishConfig::linux(path));
8485
stockfish_seen = true;
8586
}
87+
"--web-dir" if !web_seen => {
88+
let path = args.next().ok_or("--web-dir requires a build directory")?;
89+
if path.is_empty() || path.starts_with("--") {
90+
return Err("--web-dir requires a build directory".into());
91+
}
92+
config.web_dir = Some(PathBuf::from(path));
93+
web_seen = true;
94+
}
8695
"--port" if !port_seen => {
8796
config.port = args
8897
.next()
@@ -128,6 +137,7 @@ fn command(args: impl IntoIterator<Item = String>) -> Result<Option<Command>, St
128137
viewer_seen = true;
129138
}
130139
"--seat" | "--spectator" => return Err("Choose --seat or --spectator once".into()),
140+
"--web-dir" => return Err("--web-dir is only supported by gfa serve".into()),
131141
"--mcp-seat" | "--mcp-spectator" => {
132142
return Err("Use --seat or --spectator for mcp stdio".into())
133143
}
@@ -236,6 +246,7 @@ mod tests {
236246
port: 0,
237247
stockfish: None,
238248
mcp_seat: Some(0),
249+
web_dir: None,
239250
})
240251
);
241252
let configured =
@@ -309,6 +320,22 @@ mod tests {
309320
.is_some_and(|error| error.contains("--features postgres")));
310321
}
311322

323+
#[test]
324+
fn browser_directory_is_explicit_and_only_supported_for_http() -> Result<(), String> {
325+
let configured = options(&["serve", "--web-dir", "web build"])?.ok_or("missing config")?;
326+
assert_eq!(configured.web_dir, Some(PathBuf::from("web build")));
327+
for args in [
328+
vec!["serve", "--web-dir"],
329+
vec!["serve", "--web-dir", ""],
330+
vec!["serve", "--web-dir", "--port"],
331+
vec!["serve", "--web-dir", "one", "--web-dir", "two"],
332+
] {
333+
assert!(options(&args).is_err(), "{args:?}");
334+
}
335+
assert!(command(["mcp", "--web-dir", "web"].map(str::to_owned)).is_err());
336+
Ok(())
337+
}
338+
312339
#[test]
313340
fn rejects_invalid_or_ambiguous_options() {
314341
for args in [

‎crates/apps/gfa-server/src/lib.rs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub struct Config {
2727
pub database: Database,
2828
/// Loopback port. Zero lets the OS select an available port.
2929
pub port: u16,
30+
/// Optional trusted browser build directory containing index.html and assets/.
31+
/// None serves the API and redirects browser visits at / to /docs/.
32+
pub web_dir: Option<PathBuf>,
3033
/// Optional installed Stockfish engine and sandbox policy.
3134
pub stockfish: Option<StockfishConfig>,
3235
/// MCP viewer selected by the trusted local launcher. None selects spectator.
@@ -38,6 +41,7 @@ impl Default for Config {
3841
Self {
3942
database: Database::Sqlite("gfa.sqlite".into()),
4043
port: 8080,
44+
web_dir: None,
4145
stockfish: None,
4246
mcp_seat: Some(0),
4347
}
@@ -153,14 +157,19 @@ async fn components(config: &Config) -> Result<Components, ServerError> {
153157
}
154158

155159
async fn application(config: &Config, address: SocketAddr) -> Result<Application, ServerError> {
160+
// Validate the requested build before opening or migrating the database.
161+
let browser = gfa_http::protect_local_routes(
162+
gfa_http::browser_router(config.web_dir.as_deref())?,
163+
address,
164+
)?;
156165
let Components {
157166
store,
158167
updates,
159168
service,
160169
} = components(config).await?;
161170
let router = gfa_http::local_router_with_updates(service.clone(), address, updates.clone())?;
162171
let (mcp_router, mcp) = mcp_http::router(service.clone(), address, config.mcp_seat)?;
163-
let router = router.merge(mcp_router);
172+
let router = router.merge(mcp_router).merge(browser);
164173
Ok(Application {
165174
mcp,
166175
router,
@@ -182,6 +191,12 @@ pub async fn serve(
182191
let address = listener.local_addr()?;
183192
let app = application(&config, address).await?;
184193
println!("GamesForAI listening on http://{address} (local mode)");
194+
if config.web_dir.is_some() {
195+
println!("Browser app: http://{address}/");
196+
} else {
197+
println!("API-only mode. Run make serve to include the browser app.");
198+
}
199+
println!("API reference: http://{address}/docs/");
185200
serve_application(listener, app, shutdown).await
186201
}
187202

@@ -292,3 +307,6 @@ mod uci_cap_tests;
292307

293308
#[cfg(test)]
294309
mod training_tests;
310+
311+
#[cfg(test)]
312+
mod web_tests;

0 commit comments

Comments
 (0)