#!/bin/bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="$ROOT_DIR/.venv" REQ_FILE="$ROOT_DIR/s2s-client/requirements.txt" FRONTEND_DIR="$ROOT_DIR/s2s-frontend" 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 (( 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: s2s-frontend 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 if [ ! -d "$VENV_DIR" ]; then echo "Creating the virtual environment in .venv..." python3 -m venv "$VENV_DIR" fi source "$VENV_DIR/bin/activate" if [ ! -f "$REQ_FILE" ]; then echo "Error: requirements file was not found: $REQ_FILE" exit 1 fi if ! python -c "import fastapi, uvicorn, httpx, 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 s2s-frontend/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 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 both servers." echo "----------------------------------------------------" cd "$ROOT_DIR" 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" # Keep React Fast Refresh enabled even if PM2 exports NODE_ENV=production. export NODE_ENV=development 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"