diff --git a/frontend-s2s/.env.example b/frontend-s2s/.env.example index dedf168..6acf1c4 100644 --- a/frontend-s2s/.env.example +++ b/frontend-s2s/.env.example @@ -1,4 +1,6 @@ -# Existing s2s-client FastAPI server. Used only by Vite's server-side proxy. -S2S_BACKEND_URL=http://127.0.0.1:8420 -# Comma-separated hostnames, only needed when accessing Vite through a remote host. +# FastAPI target when running npm run dev independently. start_ui.sh sets this +# to its own backend using S2S_BACKEND_PORT (default 8421). +S2S_BACKEND_URL=http://127.0.0.1:8421 +# Comma-separated hostnames for remote access, without scheme or port. +# With start_ui.sh this can also be set in the repository root .env. S2S_DEV_ALLOWED_HOSTS= diff --git a/frontend-s2s/src/test/backend-proxy.test.ts b/frontend-s2s/src/test/backend-proxy.test.ts index 4ab4f36..29ed1b0 100644 --- a/frontend-s2s/src/test/backend-proxy.test.ts +++ b/frontend-s2s/src/test/backend-proxy.test.ts @@ -4,9 +4,9 @@ import { createBackendProxy } from "../../config/backend-proxy"; describe("development backend proxy", () => { it("keeps the public host and original paths for the FastAPI runtime URL", () => { - const proxy = createBackendProxy("http://127.0.0.1:8420"); - expect(proxy["/api"]).toEqual({ target: "http://127.0.0.1:8420", changeOrigin: false }); - expect(proxy["/v1/realtime"]).toEqual({ target: "http://127.0.0.1:8420", changeOrigin: false, ws: true }); + const proxy = createBackendProxy("http://127.0.0.1:8421"); + expect(proxy["/api"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false }); + expect(proxy["/v1/realtime"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false, ws: true }); expect(proxy["/openapi.json"]).toEqual(proxy["/api"]); expect(proxy["/oauth"]).toEqual(proxy["/api"]); }); diff --git a/frontend-s2s/vite.config.ts b/frontend-s2s/vite.config.ts index bb92ec4..73ab647 100644 --- a/frontend-s2s/vite.config.ts +++ b/frontend-s2s/vite.config.ts @@ -14,13 +14,14 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, server: { host: "127.0.0.1", - port: 5173, + port: 8420, strictPort: true, + ws: { path: "/__vite_hmr" }, allowedHosts: (env.S2S_DEV_ALLOWED_HOSTS ?? "") .split(",") .map((host) => host.trim()) .filter(Boolean), - proxy: createBackendProxy(env.S2S_BACKEND_URL ?? "http://127.0.0.1:8420"), + proxy: createBackendProxy(env.S2S_BACKEND_URL ?? "http://127.0.0.1:8421"), }, build: { target: "es2022" }, }; diff --git a/start_ui.sh b/start_ui.sh index 12a57f0..3e18dbd 100755 --- a/start_ui.sh +++ b/start_ui.sh @@ -4,16 +4,45 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="$ROOT_DIR/.venv" REQ_FILE="$ROOT_DIR/s2s-client/requirements.txt" -UI_PORT="${UI_PORT:-8420}" +FRONTEND_DIR="$ROOT_DIR/frontend-s2s" if [ -f "$ROOT_DIR/.env" ]; then set -a source "$ROOT_DIR/.env" set +a fi +UI_PORT="${UI_PORT:-8420}" +UI_HOST="${UI_HOST:-0.0.0.0}" +S2S_BACKEND_PORT="${S2S_BACKEND_PORT:-8421}" S2S_PROXY_TARGET="${S2S_PROXY_TARGET:-ws://ai-server:12345/v1/realtime}" -if ! command -v python3 >/dev/null 2>&1; then - echo "Error: python3 was not found." +if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )); then + echo "Error: start_ui.sh requires Bash 4.4 or newer." + exit 1 +fi + +for required_command in python3 node npm setsid curl; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo "Error: $required_command was not found." + exit 1 + fi +done + +if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit((major === 22 && minor >= 18) || major >= 24 ? 0 : 1)'; then + echo "Error: frontend-s2s requires Node.js 22.18+ on the 22.x line, or Node.js 24+." + exit 1 +fi + +for port_name in UI_PORT S2S_BACKEND_PORT; do + port_value="${!port_name}" + if [[ ! "$port_value" =~ ^[0-9]{1,5}$ ]] || (( 10#$port_value < 1 || 10#$port_value > 65535 )); then + echo "Error: $port_name must be a port between 1 and 65535." + exit 1 + fi + # Normalize leading zeroes before passing ports to Node and Uvicorn. + printf -v "$port_name" '%d' "$((10#$port_value))" +done +if [ "$UI_PORT" = "$S2S_BACKEND_PORT" ]; then + echo "Error: UI_PORT and S2S_BACKEND_PORT must be different." exit 1 fi @@ -29,17 +58,105 @@ if [ ! -f "$REQ_FILE" ]; then exit 1 fi -if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv" >/dev/null 2>&1; then +if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv, websockets" >/dev/null 2>&1; then echo "Installing dependencies from s2s-client/requirements.txt..." python -m pip install -r "$REQ_FILE" fi +# Keep restarts offline when the installed frontend manifests have not changed. +if [ ! -f "$FRONTEND_DIR/node_modules/vite/bin/vite.js" ] || \ + ! cmp -s "$FRONTEND_DIR/package-lock.json" "$FRONTEND_DIR/node_modules/.s2s-package-lock.json" || \ + ! cmp -s "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/node_modules/.s2s-package.json"; then + echo "Installing frontend dependencies from frontend-s2s/package-lock.json..." + npm --prefix "$FRONTEND_DIR" ci --include=dev + cp "$FRONTEND_DIR/package-lock.json" "$FRONTEND_DIR/node_modules/.s2s-package-lock.json" + cp "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/node_modules/.s2s-package.json" +fi + +# The combined launcher always proxies to the backend it starts. An alternate +# S2S_BACKEND_URL is only used when running Vite independently. +export S2S_BACKEND_URL="http://127.0.0.1:$S2S_BACKEND_PORT" export S2S_PROXY_TARGET -echo "💻 Starting the S2S client UI (port $UI_PORT)..." +child_pids=() +cleanup() { + local exit_status=$? + trap - EXIT + trap '' INT TERM HUP + for child_pid in "${child_pids[@]}"; do + kill -TERM -- "-$child_pid" 2>/dev/null || true + done + # Stop whole process groups, including Uvicorn's reload worker. Bound the + # graceful shutdown so a stuck connection cannot leave ports occupied. + for ((attempt = 0; attempt < 20; attempt++)); do + local alive=false + for child_pid in "${child_pids[@]}"; do + if kill -0 -- "-$child_pid" 2>/dev/null; then + alive=true + fi + done + if [ "$alive" = false ]; then + break + fi + sleep 0.05 + done + for child_pid in "${child_pids[@]}"; do + kill -KILL -- "-$child_pid" 2>/dev/null || true + wait "$child_pid" 2>/dev/null || true + done + exit "$exit_status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +echo "💻 Starting the React UI at http://$UI_HOST:$UI_PORT (Vite hot reload)..." +echo "🔧 FastAPI backend: $S2S_BACKEND_URL (Python reload enabled)" echo "🔌 Realtime proxy target: $S2S_PROXY_TARGET" -echo " Press Ctrl+C in this terminal to stop it." +echo " Press Ctrl+C in this terminal to stop both servers." echo "----------------------------------------------------" cd "$ROOT_DIR" -PYTHONPATH=s2s-client python -m uvicorn server:app --host 0.0.0.0 --port "$UI_PORT" --reload +setsid "$VENV_DIR/bin/python" -m uvicorn server:app \ + --app-dir "$ROOT_DIR/s2s-client" \ + --host 127.0.0.1 --port "$S2S_BACKEND_PORT" \ + --reload --reload-dir "$ROOT_DIR/s2s-client" & +backend_pid=$! +child_pids+=("$backend_pid") + +backend_ready=false +startup_deadline=$((SECONDS + 30)) +while (( SECONDS < startup_deadline )); do + if ! kill -0 "$backend_pid" 2>/dev/null; then + echo "Error: FastAPI exited before it was ready." + exit 1 + fi + if curl --noproxy '*' --silent --fail --max-time 1 \ + --output /dev/null "$S2S_BACKEND_URL/api/config"; then + backend_ready=true + break + fi + sleep 0.2 +done +if [ "$backend_ready" = false ]; then + echo "Error: FastAPI did not become ready within 30 seconds." + exit 1 +fi + +( + cd "$FRONTEND_DIR" + exec setsid node node_modules/vite/bin/vite.js --host "$UI_HOST" --port "$UI_PORT" --strictPort +) & +frontend_pid=$! +child_pids+=("$frontend_pid") + +# PM2 supervises this script. If either server exits, stop its sibling and fail +# the launcher so PM2 can restart the complete pair instead of a partial UI. +exit_status=0 +wait -n "$backend_pid" "$frontend_pid" || exit_status=$? +echo "Error: a UI server exited; stopping both servers." +if [ "$exit_status" -eq 0 ]; then + exit_status=1 +fi +exit "$exit_status"