From 1c7d4d0510b6a8e337ca7b30a59b728b14562d14 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Sun, 8 Mar 2026 07:27:06 -0600 Subject: [PATCH] feat: add production deployment and macOS dev scripts Add dev.sh for running server + client in parallel on macOS, and prod.sh for building and deploying in production. The Rust server now serves static client files with SPA fallback, eliminating the need for Vite in production. Also adds missing BRAVE_API_KEY to .env.example. Co-Authored-By: Claude Opus 4.6 --- client/package-lock.json | 1 - dev.sh | 71 ++++++++++++++++++ prod.sh | 150 +++++++++++++++++++++++++++++++++++++++ server/.env.example | 6 ++ server/src/main.rs | 12 +++- 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100755 dev.sh create mode 100755 prod.sh diff --git a/client/package-lock.json b/client/package-lock.json index 8e00429..f80bab0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -901,7 +901,6 @@ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..16a2bdb --- /dev/null +++ b/dev.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# GroupChat2 Dev Script (macOS/Linux) +# Runs both server (Rust/Axum) and client (Vite) with unified console output. +# Ctrl+C stops both processes. + +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" + +# Colors +MAGENTA='\033[0;35m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +GRAY='\033[0;90m' +NC='\033[0m' + +# Install client deps if needed +if [ ! -d "$ROOT/client/node_modules" ]; then + echo -e "${CYAN}[dev] Installing client dependencies...${NC}" + (cd "$ROOT/client" && npm install) +fi + +echo "" +echo -e " ${MAGENTA}GroupChat2 Dev Server${NC}" +echo -e " ${YELLOW}Server: http://localhost:3001${NC}" +echo -e " ${CYAN}Client: http://localhost:3000${NC}" +echo -e " ${GRAY}Press Ctrl+C to stop both${NC}" +echo "" + +# Track child PIDs for cleanup +SERVER_PID="" +CLIENT_PID="" + +cleanup() { + echo "" + echo -e "${RED}[dev] Shutting down...${NC}" + + # Kill background jobs + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null + + # Kill any lingering processes on ports 3000/3001 + lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true + lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true + + # Kill groupchat-server if still running + pkill -f "groupchat-server" 2>/dev/null || true + + echo -e "${RED}[dev] Stopped.${NC}" + exit 0 +} + +trap cleanup INT TERM + +# Start server (Rust/Axum) +(cd "$ROOT/server" && cargo run 2>&1 | while IFS= read -r line; do + [ -n "$line" ] && echo -e "${YELLOW}[server]${NC} $line" +done) & +SERVER_PID=$! + +# Start client (Vite) +(cd "$ROOT/client" && npm run dev 2>&1 | while IFS= read -r line; do + [ -n "$line" ] && echo -e "${CYAN}[client]${NC} $line" +done) & +CLIENT_PID=$! + +# Wait for either to exit +wait -n "$SERVER_PID" "$CLIENT_PID" 2>/dev/null +echo -e "${RED}[dev] A process exited unexpectedly.${NC}" +cleanup diff --git a/prod.sh b/prod.sh new file mode 100755 index 0000000..6fd642c --- /dev/null +++ b/prod.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# GroupChat2 Production Build & Run Script +# Builds the client, compiles the server in release mode, and runs it. +# The Rust server serves the static client files directly — no Vite needed. +# +# Usage: +# ./prod.sh # Build + run +# ./prod.sh build # Build only (no run) +# ./prod.sh run # Run only (skip build, assumes already built) + +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" + +# Colors +MAGENTA='\033[0;35m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +GREEN='\033[0;32m' +GRAY='\033[0;90m' +NC='\033[0m' + +MODE="${1:-all}" # all | build | run + +# ─── Preflight checks ─────────────────────────────────────────────── + +check_env() { + if [ ! -f "$ROOT/server/.env" ]; then + echo -e "${RED}[prod] Missing server/.env file!${NC}" + echo -e "${GRAY} Copy server/.env.example and fill in your keys:${NC}" + echo -e "${GRAY} cp server/.env.example server/.env${NC}" + exit 1 + fi + + # Source .env to validate required vars + set -a + source "$ROOT/server/.env" + set +a + + if [ -z "$OPENROUTER_API_KEY" ] || [ "$OPENROUTER_API_KEY" = "your-openrouter-api-key-here" ]; then + echo -e "${RED}[prod] OPENROUTER_API_KEY not set in server/.env${NC}" + exit 1 + fi + if [ -z "$BRAVE_API_KEY" ] || [ "$BRAVE_API_KEY" = "your-brave-api-key-here" ]; then + echo -e "${RED}[prod] BRAVE_API_KEY not set in server/.env${NC}" + exit 1 + fi + if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "dev-secret-change-me" ]; then + echo -e "${YELLOW}[prod] WARNING: Using default JWT_SECRET — set a real secret for production!${NC}" + fi +} + +# ─── Build ─────────────────────────────────────────────────────────── + +build() { + echo -e "${MAGENTA}[prod] Building GroupChat2 for production...${NC}" + echo "" + + # 1. Install client deps if needed + if [ ! -d "$ROOT/client/node_modules" ]; then + echo -e "${CYAN}[prod] Installing client dependencies...${NC}" + (cd "$ROOT/client" && npm install) + fi + + # 2. Build client (Vite → dist/) + echo -e "${CYAN}[prod] Building client...${NC}" + (cd "$ROOT/client" && npm run build) + echo -e "${GREEN}[prod] Client built → client/dist/${NC}" + + # 3. Build server in release mode + echo -e "${YELLOW}[prod] Building server (release)...${NC}" + (cd "$ROOT/server" && cargo build --release) + echo -e "${GREEN}[prod] Server built → server/target/release/groupchat-server${NC}" + + echo "" + echo -e "${GREEN}[prod] Build complete!${NC}" +} + +# ─── Run ───────────────────────────────────────────────────────────── + +run() { + check_env + + # Verify build artifacts exist + if [ ! -f "$ROOT/server/target/release/groupchat-server" ]; then + echo -e "${RED}[prod] Server binary not found. Run './prod.sh build' first.${NC}" + exit 1 + fi + if [ ! -d "$ROOT/client/dist" ]; then + echo -e "${RED}[prod] Client dist not found. Run './prod.sh build' first.${NC}" + exit 1 + fi + + BIND_ADDR="${BIND_ADDR:-0.0.0.0:3001}" + + echo "" + echo -e " ${MAGENTA}GroupChat2 Production${NC}" + echo -e " ${GREEN}Domain: https://gchat.tud.ink${NC}" + echo -e " ${YELLOW}Bind: ${BIND_ADDR}${NC}" + echo -e " ${GRAY}Press Ctrl+C to stop${NC}" + echo "" + + # Source .env so the server binary picks up the vars + set -a + source "$ROOT/server/.env" + set +a + + export STATIC_DIR="$ROOT/client/dist" + export BIND_ADDR + export RUST_LOG="${RUST_LOG:-info}" + + cleanup() { + echo "" + echo -e "${RED}[prod] Shutting down...${NC}" + kill "$SERVER_PID" 2>/dev/null || true + lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true + echo -e "${RED}[prod] Stopped.${NC}" + exit 0 + } + trap cleanup INT TERM + + # Run the release binary + "$ROOT/server/target/release/groupchat-server" & + SERVER_PID=$! + + wait "$SERVER_PID" +} + +# ─── Main ──────────────────────────────────────────────────────────── + +case "$MODE" in + build) + build + ;; + run) + run + ;; + all|"") + build + run + ;; + *) + echo "Usage: $0 [build|run]" + echo " (no args) Build and run" + echo " build Build only" + echo " run Run only (must build first)" + exit 1 + ;; +esac diff --git a/server/.env.example b/server/.env.example index 34f615f..7da919b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -10,3 +10,9 @@ JWT_SECRET=change-me-to-a-random-secret # OpenRouter API OPENROUTER_API_KEY=sk-or-v1-your-key-here + +# Brave Search API +BRAVE_API_KEY=your-brave-api-key-here + +# Production: path to built client files (default: ../client/dist) +# STATIC_DIR=../client/dist diff --git a/server/src/main.rs b/server/src/main.rs index d9ac180..153bf8c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -11,6 +11,7 @@ use sqlx::sqlite::SqlitePoolOptions; use std::sync::Arc; use tokio::sync::broadcast; use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; pub struct AppState { @@ -97,7 +98,10 @@ async fn main() { .allow_methods(Any) .allow_headers(Any); - let app = Router::new() + // Serve static files from client dist in production + let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../client/dist".into()); + + let api_routes = Router::new() // Auth routes .route("/api/auth/register", post(handlers::auth::register)) .route("/api/auth/login", post(handlers::auth::login)) @@ -118,6 +122,12 @@ async fn main() { .layer(cors) .with_state(state); + // SPA fallback: serve static assets, fall back to index.html for client-side routing + let spa = ServeDir::new(&static_dir) + .not_found_service(ServeFile::new(format!("{}/index.html", static_dir))); + + let app = api_routes.fallback_service(spa); + let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3001".into()); tracing::info!("Server starting on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();