diff --git a/README.md b/README.md
index 131ceeae..3d40ca54 100644
--- a/README.md
+++ b/README.md
@@ -25,10 +25,11 @@ If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./
## Requirements
- Python 3.10+
-- Node.js LTS or current (20, 22, 24, 25)
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore radio connected via USB serial, TCP, or BLE
+Node.js is not required to run the app when using the bundled prebuilt frontend. You only need Node>=20 + recent NPM if you want to develop the frontend or rebuild the web UI from source.
+
Finding your serial port
@@ -76,9 +77,6 @@ cd Remote-Terminal-for-MeshCore
# Install backend dependencies
uv sync
-# Build frontend
-cd frontend && npm install && npm run build && cd ..
-
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
@@ -101,16 +99,16 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
-Access at http://localhost:8000
+Access at http://localhost:8000.
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
+>
+> If you're running from a source checkout and want to rebuild the UI yourself, install Node.js and run `cd frontend && npm install && npm run build`. The backend prefers `frontend/dist` when present and otherwise falls back to the bundled prebuilt frontend.
## Docker Compose
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
-> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
-
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
```bash
@@ -177,7 +175,7 @@ uv run uvicorn app.main:app --reload
cd frontend
npm install
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
-npm run build # Production build to dist/
+npm run build # Production build to dist/ (used preferentially) and prebuilt/ (bundled to save low-memory friends from needing to build)
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
@@ -207,7 +205,7 @@ cd frontend
npm run lint:fix # esLint + auto-fix
npm run test:run # run tests
npm run format # prettier (always writes)
-npm run build # build the frontend
+npm run build # build frontend/dist and frontend/prebuilt
```
@@ -298,11 +296,6 @@ cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
-# Build frontend (required for the backend to serve the web UI)
-cd /opt/remoteterm/frontend
-sudo -u remoteterm npm install
-sudo -u remoteterm npm run build
-
# Install and start service
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
sudo systemctl daemon-reload
@@ -314,6 +307,8 @@ sudo journalctl -u remoteterm -f
```
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
+
+If you are deploying from a source checkout that does not include the bundled prebuilt frontend, install Node.js and run `cd /opt/remoteterm/frontend && sudo -u remoteterm npm install && sudo -u remoteterm npm run build` before starting the service.
diff --git a/app/frontend_static.py b/app/frontend_static.py
index ddb0109c..45bb6777 100644
--- a/app/frontend_static.py
+++ b/app/frontend_static.py
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
INDEX_CACHE_CONTROL = "no-store"
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
+FRONTEND_BUILD_INSTRUCTIONS = "Run 'cd frontend && npm install && npm run build'."
class CacheControlStaticFiles(StaticFiles):
@@ -48,40 +49,38 @@ def _resolve_request_origin(request: Request) -> str:
return str(request.base_url).rstrip("/")
-def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
- """Register frontend static file routes if a built frontend is available.
-
- Returns True when routes are registered, False when frontend files are
- missing/incomplete. Missing frontend files are logged but are not fatal.
- """
+def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
+ """Resolve and validate a built frontend directory."""
frontend_dir = frontend_dir.resolve()
index_file = frontend_dir / "index.html"
- assets_dir = frontend_dir / "assets"
if not frontend_dir.exists():
- logger.error(
- "Frontend build directory not found at %s. "
- "Run 'cd frontend && npm run build'. API will continue without frontend routes.",
- frontend_dir,
- )
- return False
+ if log_failures:
+ logger.error("Frontend build directory not found at %s.", frontend_dir)
+ return False, frontend_dir
if not frontend_dir.is_dir():
- logger.error(
- "Frontend build path is not a directory: %s. "
- "API will continue without frontend routes.",
- frontend_dir,
- )
- return False
+ if log_failures:
+ logger.error("Frontend build path is not a directory: %s.", frontend_dir)
+ return False, frontend_dir
if not index_file.exists():
- logger.error(
- "Frontend index file not found at %s. "
- "Run 'cd frontend && npm run build'. API will continue without frontend routes.",
- index_file,
- )
+ if log_failures:
+ logger.error("Frontend index file not found at %s.", index_file)
+ return False, frontend_dir
+
+ return True, frontend_dir
+
+
+def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
+ """Register frontend static file routes if a built frontend is available."""
+ valid, frontend_dir = _validate_frontend_dir(frontend_dir)
+ if not valid:
return False
+ index_file = frontend_dir / "index.html"
+ assets_dir = frontend_dir / "assets"
+
if assets_dir.exists() and assets_dir.is_dir():
app.mount(
"/assets",
@@ -157,6 +156,30 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
return True
+def register_first_available_frontend_static_routes(
+ app: FastAPI, frontend_dirs: list[Path]
+) -> Path | None:
+ """Register frontend routes from the first valid build directory."""
+ for i, candidate in enumerate(frontend_dirs):
+ valid, resolved_candidate = _validate_frontend_dir(candidate, log_failures=False)
+ if not valid:
+ continue
+
+ if register_frontend_static_routes(app, resolved_candidate):
+ logger.info("Selected frontend build directory %s", resolved_candidate)
+ return resolved_candidate
+
+ if i < len(frontend_dirs) - 1:
+ logger.warning("Frontend build at %s was unusable; trying fallback", resolved_candidate)
+
+ logger.error(
+ "No usable frontend build found. Searched: %s. %s API will continue without frontend routes.",
+ ", ".join(str(path.resolve()) for path in frontend_dirs),
+ FRONTEND_BUILD_INSTRUCTIONS,
+ )
+ return None
+
+
def register_frontend_missing_fallback(app: FastAPI) -> None:
"""Register a fallback route that tells the user to build the frontend."""
@@ -164,7 +187,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
async def frontend_not_built():
return JSONResponse(
status_code=404,
- content={
- "detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
- },
+ content={"detail": f"Frontend not built. {FRONTEND_BUILD_INSTRUCTIONS}"},
)
diff --git a/app/main.py b/app/main.py
index 35d499cc..2a03f5d7 100644
--- a/app/main.py
+++ b/app/main.py
@@ -11,7 +11,10 @@ from fastapi.responses import JSONResponse
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
-from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
+from app.frontend_static import (
+ register_first_available_frontend_static_routes,
+ register_frontend_missing_fallback,
+)
from app.radio import RadioDisconnectedError
from app.radio_sync import (
stop_message_polling,
@@ -151,6 +154,9 @@ app.include_router(statistics.router, prefix="/api")
app.include_router(ws.router, prefix="/api")
# Serve frontend static files in production
-FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
-if not register_frontend_static_routes(app, FRONTEND_DIR):
+FRONTEND_DIST_DIR = Path(__file__).parent.parent / "frontend" / "dist"
+FRONTEND_PREBUILT_DIR = Path(__file__).parent.parent / "frontend" / "prebuilt"
+if not register_first_available_frontend_static_routes(
+ app, [FRONTEND_DIST_DIR, FRONTEND_PREBUILT_DIR]
+):
register_frontend_missing_fallback(app)
diff --git a/frontend/package.json b/frontend/package.json
index 7794dad1..b7962e33 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc && vite build",
+ "build": "tsc && vite build && vite build --outDir prebuilt",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
diff --git a/frontend/prebuilt/apple-touch-icon.png b/frontend/prebuilt/apple-touch-icon.png
new file mode 100644
index 00000000..81c36f5a
Binary files /dev/null and b/frontend/prebuilt/apple-touch-icon.png differ
diff --git a/frontend/prebuilt/assets/BotCodeEditor-CBFX17Kn.js b/frontend/prebuilt/assets/BotCodeEditor-CBFX17Kn.js
new file mode 100644
index 00000000..344d57af
Binary files /dev/null and b/frontend/prebuilt/assets/BotCodeEditor-CBFX17Kn.js differ
diff --git a/frontend/prebuilt/assets/BotCodeEditor-CBFX17Kn.js.map b/frontend/prebuilt/assets/BotCodeEditor-CBFX17Kn.js.map
new file mode 100644
index 00000000..4810b403
Binary files /dev/null and b/frontend/prebuilt/assets/BotCodeEditor-CBFX17Kn.js.map differ
diff --git a/frontend/prebuilt/assets/CrackerPanel-RTfofPO9.js b/frontend/prebuilt/assets/CrackerPanel-RTfofPO9.js
new file mode 100644
index 00000000..24374ab6
Binary files /dev/null and b/frontend/prebuilt/assets/CrackerPanel-RTfofPO9.js differ
diff --git a/frontend/prebuilt/assets/CrackerPanel-RTfofPO9.js.map b/frontend/prebuilt/assets/CrackerPanel-RTfofPO9.js.map
new file mode 100644
index 00000000..5021e95c
Binary files /dev/null and b/frontend/prebuilt/assets/CrackerPanel-RTfofPO9.js.map differ
diff --git a/frontend/prebuilt/assets/MapView-DG71Do9A.js b/frontend/prebuilt/assets/MapView-DG71Do9A.js
new file mode 100644
index 00000000..7050c294
Binary files /dev/null and b/frontend/prebuilt/assets/MapView-DG71Do9A.js differ
diff --git a/frontend/prebuilt/assets/MapView-DG71Do9A.js.map b/frontend/prebuilt/assets/MapView-DG71Do9A.js.map
new file mode 100644
index 00000000..36364688
Binary files /dev/null and b/frontend/prebuilt/assets/MapView-DG71Do9A.js.map differ
diff --git a/frontend/prebuilt/assets/NeighborsMiniMap-D6bvxuvI.js b/frontend/prebuilt/assets/NeighborsMiniMap-D6bvxuvI.js
new file mode 100644
index 00000000..65aca506
Binary files /dev/null and b/frontend/prebuilt/assets/NeighborsMiniMap-D6bvxuvI.js differ
diff --git a/frontend/prebuilt/assets/NeighborsMiniMap-D6bvxuvI.js.map b/frontend/prebuilt/assets/NeighborsMiniMap-D6bvxuvI.js.map
new file mode 100644
index 00000000..8b1d046e
Binary files /dev/null and b/frontend/prebuilt/assets/NeighborsMiniMap-D6bvxuvI.js.map differ
diff --git a/frontend/prebuilt/assets/PathRouteMap-DiGw6Qka.js b/frontend/prebuilt/assets/PathRouteMap-DiGw6Qka.js
new file mode 100644
index 00000000..cb9632d8
Binary files /dev/null and b/frontend/prebuilt/assets/PathRouteMap-DiGw6Qka.js differ
diff --git a/frontend/prebuilt/assets/PathRouteMap-DiGw6Qka.js.map b/frontend/prebuilt/assets/PathRouteMap-DiGw6Qka.js.map
new file mode 100644
index 00000000..1a0c65e0
Binary files /dev/null and b/frontend/prebuilt/assets/PathRouteMap-DiGw6Qka.js.map differ
diff --git a/frontend/prebuilt/assets/Popup-JtfOLvZE.js b/frontend/prebuilt/assets/Popup-JtfOLvZE.js
new file mode 100644
index 00000000..460ebc75
Binary files /dev/null and b/frontend/prebuilt/assets/Popup-JtfOLvZE.js differ
diff --git a/frontend/prebuilt/assets/Popup-JtfOLvZE.js.map b/frontend/prebuilt/assets/Popup-JtfOLvZE.js.map
new file mode 100644
index 00000000..10a1dd64
Binary files /dev/null and b/frontend/prebuilt/assets/Popup-JtfOLvZE.js.map differ
diff --git a/frontend/prebuilt/assets/RepeaterDashboard-bua8zxo_.js b/frontend/prebuilt/assets/RepeaterDashboard-bua8zxo_.js
new file mode 100644
index 00000000..ff80783b
Binary files /dev/null and b/frontend/prebuilt/assets/RepeaterDashboard-bua8zxo_.js differ
diff --git a/frontend/prebuilt/assets/RepeaterDashboard-bua8zxo_.js.map b/frontend/prebuilt/assets/RepeaterDashboard-bua8zxo_.js.map
new file mode 100644
index 00000000..3a16d650
Binary files /dev/null and b/frontend/prebuilt/assets/RepeaterDashboard-bua8zxo_.js.map differ
diff --git a/frontend/prebuilt/assets/SearchView-Cs7v3m6K.js b/frontend/prebuilt/assets/SearchView-Cs7v3m6K.js
new file mode 100644
index 00000000..03fb6788
Binary files /dev/null and b/frontend/prebuilt/assets/SearchView-Cs7v3m6K.js differ
diff --git a/frontend/prebuilt/assets/SearchView-Cs7v3m6K.js.map b/frontend/prebuilt/assets/SearchView-Cs7v3m6K.js.map
new file mode 100644
index 00000000..43a11016
Binary files /dev/null and b/frontend/prebuilt/assets/SearchView-Cs7v3m6K.js.map differ
diff --git a/frontend/prebuilt/assets/SettingsModal-C-alEUMR.js b/frontend/prebuilt/assets/SettingsModal-C-alEUMR.js
new file mode 100644
index 00000000..c5ca5478
Binary files /dev/null and b/frontend/prebuilt/assets/SettingsModal-C-alEUMR.js differ
diff --git a/frontend/prebuilt/assets/SettingsModal-C-alEUMR.js.map b/frontend/prebuilt/assets/SettingsModal-C-alEUMR.js.map
new file mode 100644
index 00000000..77548428
Binary files /dev/null and b/frontend/prebuilt/assets/SettingsModal-C-alEUMR.js.map differ
diff --git a/frontend/prebuilt/assets/VisualizerView-BBRdi0S-.js b/frontend/prebuilt/assets/VisualizerView-BBRdi0S-.js
new file mode 100644
index 00000000..e187a6a1
Binary files /dev/null and b/frontend/prebuilt/assets/VisualizerView-BBRdi0S-.js differ
diff --git a/frontend/prebuilt/assets/VisualizerView-BBRdi0S-.js.map b/frontend/prebuilt/assets/VisualizerView-BBRdi0S-.js.map
new file mode 100644
index 00000000..a0697740
Binary files /dev/null and b/frontend/prebuilt/assets/VisualizerView-BBRdi0S-.js.map differ
diff --git a/frontend/prebuilt/assets/hooks-NBTgSuxX.js b/frontend/prebuilt/assets/hooks-NBTgSuxX.js
new file mode 100644
index 00000000..6720f03b
Binary files /dev/null and b/frontend/prebuilt/assets/hooks-NBTgSuxX.js differ
diff --git a/frontend/prebuilt/assets/hooks-NBTgSuxX.js.map b/frontend/prebuilt/assets/hooks-NBTgSuxX.js.map
new file mode 100644
index 00000000..edc92738
Binary files /dev/null and b/frontend/prebuilt/assets/hooks-NBTgSuxX.js.map differ
diff --git a/frontend/prebuilt/assets/index-BJ3L5G2s.js b/frontend/prebuilt/assets/index-BJ3L5G2s.js
new file mode 100644
index 00000000..d9be552f
Binary files /dev/null and b/frontend/prebuilt/assets/index-BJ3L5G2s.js differ
diff --git a/frontend/prebuilt/assets/index-BJ3L5G2s.js.map b/frontend/prebuilt/assets/index-BJ3L5G2s.js.map
new file mode 100644
index 00000000..cc0130d2
Binary files /dev/null and b/frontend/prebuilt/assets/index-BJ3L5G2s.js.map differ
diff --git a/frontend/prebuilt/assets/index-Kkb-Bio9.css b/frontend/prebuilt/assets/index-Kkb-Bio9.css
new file mode 100644
index 00000000..8421d558
Binary files /dev/null and b/frontend/prebuilt/assets/index-Kkb-Bio9.css differ
diff --git a/frontend/prebuilt/assets/leaflet-Dgihpmma.css b/frontend/prebuilt/assets/leaflet-Dgihpmma.css
new file mode 100644
index 00000000..62b00a01
Binary files /dev/null and b/frontend/prebuilt/assets/leaflet-Dgihpmma.css differ
diff --git a/frontend/prebuilt/assets/leaflet-TNk2YzRY.js b/frontend/prebuilt/assets/leaflet-TNk2YzRY.js
new file mode 100644
index 00000000..56bd547b
Binary files /dev/null and b/frontend/prebuilt/assets/leaflet-TNk2YzRY.js differ
diff --git a/frontend/prebuilt/assets/leaflet-TNk2YzRY.js.map b/frontend/prebuilt/assets/leaflet-TNk2YzRY.js.map
new file mode 100644
index 00000000..10f8bed4
Binary files /dev/null and b/frontend/prebuilt/assets/leaflet-TNk2YzRY.js.map differ
diff --git a/frontend/prebuilt/assets/separator-ChUIbJIJ.js b/frontend/prebuilt/assets/separator-ChUIbJIJ.js
new file mode 100644
index 00000000..6229c01a
Binary files /dev/null and b/frontend/prebuilt/assets/separator-ChUIbJIJ.js differ
diff --git a/frontend/prebuilt/assets/separator-ChUIbJIJ.js.map b/frontend/prebuilt/assets/separator-ChUIbJIJ.js.map
new file mode 100644
index 00000000..d6c89661
Binary files /dev/null and b/frontend/prebuilt/assets/separator-ChUIbJIJ.js.map differ
diff --git a/frontend/prebuilt/assets/wordlist-BtmChKSf.js b/frontend/prebuilt/assets/wordlist-BtmChKSf.js
new file mode 100644
index 00000000..142d5ed2
Binary files /dev/null and b/frontend/prebuilt/assets/wordlist-BtmChKSf.js differ
diff --git a/frontend/prebuilt/assets/wordlist-BtmChKSf.js.map b/frontend/prebuilt/assets/wordlist-BtmChKSf.js.map
new file mode 100644
index 00000000..309c844c
Binary files /dev/null and b/frontend/prebuilt/assets/wordlist-BtmChKSf.js.map differ
diff --git a/frontend/prebuilt/favicon-256x256.png b/frontend/prebuilt/favicon-256x256.png
new file mode 100644
index 00000000..c7ca86c6
Binary files /dev/null and b/frontend/prebuilt/favicon-256x256.png differ
diff --git a/frontend/prebuilt/favicon-96x96.png b/frontend/prebuilt/favicon-96x96.png
new file mode 100644
index 00000000..d8b794f7
Binary files /dev/null and b/frontend/prebuilt/favicon-96x96.png differ
diff --git a/frontend/prebuilt/favicon.ico b/frontend/prebuilt/favicon.ico
new file mode 100644
index 00000000..66ce0b3f
Binary files /dev/null and b/frontend/prebuilt/favicon.ico differ
diff --git a/frontend/prebuilt/favicon.svg b/frontend/prebuilt/favicon.svg
new file mode 100644
index 00000000..fdf37c39
Binary files /dev/null and b/frontend/prebuilt/favicon.svg differ
diff --git a/frontend/prebuilt/glyph.png b/frontend/prebuilt/glyph.png
new file mode 100644
index 00000000..99fa871a
Binary files /dev/null and b/frontend/prebuilt/glyph.png differ
diff --git a/frontend/prebuilt/glyph.svg b/frontend/prebuilt/glyph.svg
new file mode 100644
index 00000000..e66f46e5
Binary files /dev/null and b/frontend/prebuilt/glyph.svg differ
diff --git a/frontend/prebuilt/index.html b/frontend/prebuilt/index.html
new file mode 100644
index 00000000..3ba8dfae
Binary files /dev/null and b/frontend/prebuilt/index.html differ
diff --git a/frontend/prebuilt/robots.txt b/frontend/prebuilt/robots.txt
new file mode 100644
index 00000000..1f53798b
Binary files /dev/null and b/frontend/prebuilt/robots.txt differ
diff --git a/frontend/prebuilt/web-app-manifest-192x192.png b/frontend/prebuilt/web-app-manifest-192x192.png
new file mode 100644
index 00000000..a8820761
Binary files /dev/null and b/frontend/prebuilt/web-app-manifest-192x192.png differ
diff --git a/frontend/prebuilt/web-app-manifest-512x512.png b/frontend/prebuilt/web-app-manifest-512x512.png
new file mode 100644
index 00000000..c7fd26df
Binary files /dev/null and b/frontend/prebuilt/web-app-manifest-512x512.png differ
diff --git a/tests/test_frontend_static.py b/tests/test_frontend_static.py
index fbc21cec..212b85c1 100644
--- a/tests/test_frontend_static.py
+++ b/tests/test_frontend_static.py
@@ -5,8 +5,10 @@ from fastapi.testclient import TestClient
from app.frontend_static import (
ASSET_CACHE_CONTROL,
+ FRONTEND_BUILD_INSTRUCTIONS,
INDEX_CACHE_CONTROL,
STATIC_FILE_CACHE_CONTROL,
+ register_first_available_frontend_static_routes,
register_frontend_missing_fallback,
register_frontend_static_routes,
)
@@ -28,8 +30,7 @@ def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 404
- assert "npm install" in resp.json()["detail"]
- assert "npm run build" in resp.json()["detail"]
+ assert FRONTEND_BUILD_INSTRUCTIONS in resp.json()["detail"]
def test_missing_index_logs_error_and_skips_frontend_routes(tmp_path, caplog):
@@ -120,3 +121,41 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
assert data["start_url"] == "https://mesh.example.com:8443/"
assert data["scope"] == "https://mesh.example.com:8443/"
assert data["id"] == "https://mesh.example.com:8443/"
+
+
+def test_first_available_prefers_dist_over_prebuilt(tmp_path):
+ app = FastAPI()
+ frontend_dir = tmp_path / "frontend"
+ dist_dir = frontend_dir / "dist"
+ prebuilt_dir = frontend_dir / "prebuilt"
+ dist_dir.mkdir(parents=True)
+ prebuilt_dir.mkdir(parents=True)
+ (dist_dir / "index.html").write_text("dist")
+ (prebuilt_dir / "index.html").write_text("prebuilt")
+
+ selected = register_first_available_frontend_static_routes(app, [dist_dir, prebuilt_dir])
+
+ assert selected == dist_dir.resolve()
+
+ with TestClient(app) as client:
+ response = client.get("/")
+ assert response.status_code == 200
+ assert "dist" in response.text
+
+
+def test_first_available_uses_prebuilt_when_dist_missing(tmp_path):
+ app = FastAPI()
+ frontend_dir = tmp_path / "frontend"
+ dist_dir = frontend_dir / "dist"
+ prebuilt_dir = frontend_dir / "prebuilt"
+ prebuilt_dir.mkdir(parents=True)
+ (prebuilt_dir / "index.html").write_text("prebuilt")
+
+ selected = register_first_available_frontend_static_routes(app, [dist_dir, prebuilt_dir])
+
+ assert selected == prebuilt_dir.resolve()
+
+ with TestClient(app) as client:
+ response = client.get("/")
+ assert response.status_code == 200
+ assert "prebuilt" in response.text