Skip to main content

routa_server/
lib.rs

1//! Routa Server — HTTP adapter for the Routa.js platform.
2//!
3//! This crate provides the HTTP/REST layer (via axum) on top of `routa-core`.
4//! It re-exports all core modules so downstream consumers that only need the
5//! server can depend on this single crate.
6//!
7//! # Architecture
8//!
9//! ```text
10//! routa-core    (domain: models, stores, state, protocols, RPC)
11//!      ↑
12//! routa-server  (adapter: HTTP/axum, this crate)
13//! ```
14
15// ── Re-export everything from routa-core ────────────────────────────────
16// This allows API handlers and external consumers to use `crate::models::*`,
17// `crate::error::ServerError`, etc. without knowing about the crate split.
18
19pub use routa_core::acp;
20pub use routa_core::db;
21pub use routa_core::error;
22pub use routa_core::events;
23pub use routa_core::git;
24pub use routa_core::mcp;
25pub use routa_core::models;
26pub use routa_core::orchestration;
27pub use routa_core::rpc;
28pub use routa_core::sandbox;
29pub use routa_core::shell_env;
30pub use routa_core::skills;
31pub use routa_core::state;
32pub use routa_core::store;
33pub use routa_core::tools;
34
35// Also re-export commonly used types at the top level
36pub use routa_core::{AppState, AppStateInner, Database, ServerError};
37
38// ── HTTP-specific modules ───────────────────────────────────────────────
39
40pub mod api;
41mod application;
42
43// ── Server bootstrap ────────────────────────────────────────────────────
44
45use std::net::SocketAddr;
46use std::sync::Arc;
47
48use axum::Router;
49use tower_http::cors::{Any, CorsLayer};
50use tower_http::trace::TraceLayer;
51
52/// Configuration for the Routa backend server.
53pub struct ServerConfig {
54    pub host: String,
55    pub port: u16,
56    pub db_path: String,
57    /// Optional path to static frontend files (Next.js export).
58    /// When set, the server serves these files for all non-API routes.
59    pub static_dir: Option<String>,
60}
61
62impl Default for ServerConfig {
63    fn default() -> Self {
64        Self {
65            host: "127.0.0.1".to_string(),
66            port: 3210,
67            db_path: "routa.db".to_string(),
68            static_dir: None,
69        }
70    }
71}
72
73/// Create a shared `AppState` from a database path.
74///
75/// This is useful when you need to share the state between the HTTP server
76/// and other consumers (e.g. Tauri IPC commands, JSON-RPC router).
77pub async fn create_app_state(db_path: &str) -> Result<state::AppState, String> {
78    let db = db::Database::open(db_path).map_err(|e| format!("Failed to open database: {}", e))?;
79
80    let state: state::AppState = Arc::new(state::AppStateInner::new(db));
81
82    // Ensure default workspace exists
83    state
84        .workspace_store
85        .ensure_default()
86        .await
87        .map_err(|e| format!("Failed to initialize default workspace: {}", e))?;
88
89    // Discover skills
90    let cwd = std::env::current_dir()
91        .map(|p| p.to_string_lossy().to_string())
92        .unwrap_or_else(|_| ".".to_string());
93    state.skill_registry.reload(&cwd);
94
95    // Start polling if enabled via environment variables
96    api::polling::start_polling_if_enabled();
97
98    Ok(state)
99}
100
101fn resolve_static_target(path: &str) -> (String, &'static str) {
102    let is_rsc_request = path.ends_with(".txt");
103
104    if path.starts_with("/workspace/") {
105        let clean_path = path.trim_end_matches(".txt");
106        let segments: Vec<&str> = clean_path
107            .trim_start_matches("/workspace/")
108            .split('/')
109            .filter(|s| !s.is_empty())
110            .collect();
111
112        let ext = if is_rsc_request { "txt" } else { "html" };
113        let content = if is_rsc_request {
114            "text/x-component; charset=utf-8"
115        } else {
116            "text/html; charset=utf-8"
117        };
118        let placeholder_with_suffix = |base: &str, suffix: &[&str]| {
119            if suffix.is_empty() {
120                format!("{}.{}", base, ext)
121            } else {
122                format!("{}/{}.{}", base, suffix.join("/"), ext)
123            }
124        };
125
126        if segments.len() >= 3 && segments[1] == "sessions" {
127            let suffix = if segments.len() > 3 {
128                &segments[3..]
129            } else {
130                &[][..]
131            };
132            (
133                placeholder_with_suffix(
134                    "workspace/__placeholder__/sessions/__placeholder__",
135                    suffix,
136                ),
137                content,
138            )
139        } else if segments.len() >= 3 && segments[1] == "team" {
140            let suffix = if segments.len() > 3 {
141                &segments[3..]
142            } else {
143                &[][..]
144            };
145            (
146                placeholder_with_suffix("workspace/__placeholder__/team/__placeholder__", suffix),
147                content,
148            )
149        } else if segments.len() >= 2 && segments[1] == "kanban" {
150            let suffix = if segments.len() > 2 {
151                &segments[2..]
152            } else {
153                &[][..]
154            };
155            (
156                placeholder_with_suffix("workspace/__placeholder__/kanban", suffix),
157                content,
158            )
159        } else if segments.len() >= 2 && segments[1] == "team" {
160            let suffix = if segments.len() > 2 {
161                &segments[2..]
162            } else {
163                &[][..]
164            };
165            (
166                placeholder_with_suffix("workspace/__placeholder__/team", suffix),
167                content,
168            )
169        } else if !segments.is_empty() {
170            let suffix = if segments.len() > 1 {
171                &segments[1..]
172            } else {
173                &[][..]
174            };
175            (
176                placeholder_with_suffix("workspace/__placeholder__", suffix),
177                content,
178            )
179        } else {
180            ("index.html".to_string(), "text/html; charset=utf-8")
181        }
182    } else {
183        let clean_path = path.trim_start_matches('/').trim_end_matches('/');
184        if is_rsc_request {
185            (
186                if clean_path.is_empty() {
187                    "index.txt".to_string()
188                } else {
189                    format!("{}.txt", clean_path)
190                },
191                "text/x-component; charset=utf-8",
192            )
193        } else if clean_path.is_empty() {
194            ("index.html".to_string(), "text/html; charset=utf-8")
195        } else {
196            (format!("{}.html", clean_path), "text/html; charset=utf-8")
197        }
198    }
199}
200
201/// Start the embedded Rust backend server.
202///
203/// Returns the actual address the server is listening on.
204pub async fn start_server(config: ServerConfig) -> Result<SocketAddr, String> {
205    // Initialize tracing (ignore if already initialized)
206    let _ = tracing_subscriber::fmt()
207        .with_env_filter(
208            tracing_subscriber::EnvFilter::try_from_default_env()
209                .unwrap_or_else(|_| "routa_core=info,routa_server=info,tower_http=info".into()),
210        )
211        .try_init();
212
213    // Resolve and set the full shell PATH early so all child processes
214    // (agent CLIs, git, etc.) can be found even when launched from Finder.
215    let full_path = shell_env::full_path();
216    std::env::set_var("PATH", full_path);
217
218    tracing::info!(
219        "Starting Routa backend server on {}:{}",
220        config.host,
221        config.port
222    );
223
224    std::env::set_var(
225        "ROUTA_SERVER_URL",
226        format!("http://{}:{}", config.host, config.port),
227    );
228
229    let state = create_app_state(&config.db_path).await?;
230
231    start_server_with_state(config, state).await
232}
233
234/// Start the HTTP server with a pre-built `AppState`.
235///
236/// This variant is useful when you want to share the state with other
237/// consumers (e.g. a Tauri IPC command that routes JSON-RPC calls directly).
238pub async fn start_server_with_state(
239    config: ServerConfig,
240    state: state::AppState,
241) -> Result<SocketAddr, String> {
242    std::env::set_var(
243        "ROUTA_SERVER_URL",
244        format!("http://{}:{}", config.host, config.port),
245    );
246
247    // Build router
248    let cors = CorsLayer::new()
249        .allow_origin(Any)
250        .allow_methods(Any)
251        .allow_headers(Any);
252
253    let mut app = Router::new()
254        .merge(api::api_router())
255        .route("/api/health", axum::routing::get(health_check))
256        .layer(cors.clone())
257        .layer(TraceLayer::new_for_http())
258        .with_state(state);
259
260    // Serve static frontend files if configured
261    if let Some(ref static_dir) = config.static_dir {
262        let static_path = std::path::Path::new(static_dir);
263        if static_path.exists() && static_path.is_dir() {
264            tracing::info!("Serving static frontend from: {}", static_dir);
265
266            // For Next.js static export with dynamic routes, we need custom fallback logic.
267            // Next.js generates placeholder files for dynamic routes:
268            // - workspace/__placeholder__.html (for /workspace/[workspaceId])
269            // - workspace/__placeholder__/kanban.html (for /workspace/[workspaceId]/kanban)
270            // - workspace/__placeholder__/sessions/__placeholder__.html
271            //   (for /workspace/[workspaceId]/sessions/[sessionId])
272            //
273            // Additionally, Next.js client navigation requests .txt RSC payload files:
274            // - workspace/default/kanban.txt → workspace/__placeholder__/kanban.txt
275            // - workspace/default/sessions/abc123.txt
276            //   → workspace/__placeholder__/sessions/__placeholder__.txt
277            //
278            // We match the URL pattern and serve the corresponding placeholder file.
279            let static_dir_clone = static_dir.clone();
280            let fallback_service =
281                tower::service_fn(move |req: axum::http::Request<axum::body::Body>| {
282                    let static_dir = static_dir_clone.clone();
283                    async move {
284                        let path = req.uri().path();
285                        let is_rsc_request = path.ends_with(".txt");
286                        let (target_file, content_type) = resolve_static_target(path);
287
288                        let file_path = std::path::Path::new(&static_dir).join(&target_file);
289                        tracing::debug!(
290                            "SPA fallback: {} -> {} (rsc={})",
291                            path,
292                            file_path.to_string_lossy(),
293                            is_rsc_request
294                        );
295
296                        let workspace_segments: Vec<&str> = path
297                            .trim_start_matches("/workspace/")
298                            .trim_end_matches(".txt")
299                            .split('/')
300                            .filter(|segment| !segment.is_empty())
301                            .collect();
302                        let should_rewrite_workspace_placeholder = path.starts_with("/workspace/")
303                            && !workspace_segments.is_empty()
304                            && workspace_segments
305                                .get(1)
306                                .map(|segment| *segment != "sessions")
307                                .unwrap_or(true);
308                        let actual_workspace_id = workspace_segments
309                            .first()
310                            .copied()
311                            .unwrap_or("__placeholder__");
312
313                        let response = match tokio::fs::read(&file_path).await {
314                            Ok(contents) => {
315                                let body = if should_rewrite_workspace_placeholder {
316                                    let rewritten = String::from_utf8_lossy(&contents)
317                                        .replace("__placeholder__", actual_workspace_id);
318                                    axum::body::Body::from(rewritten)
319                                } else {
320                                    axum::body::Body::from(contents)
321                                };
322
323                                axum::http::Response::builder()
324                                    .status(axum::http::StatusCode::OK)
325                                    .header("content-type", content_type)
326                                    .body(body)
327                                    .unwrap()
328                            }
329                            Err(_) => {
330                                // If the specific file doesn't exist, fall back to index.html
331                                let index_path =
332                                    std::path::Path::new(&static_dir).join("index.html");
333                                match tokio::fs::read(&index_path).await {
334                                    Ok(contents) => axum::http::Response::builder()
335                                        .status(axum::http::StatusCode::OK)
336                                        .header("content-type", "text/html; charset=utf-8")
337                                        .body(axum::body::Body::from(contents))
338                                        .unwrap(),
339                                    Err(_) => axum::http::Response::builder()
340                                        .status(axum::http::StatusCode::NOT_FOUND)
341                                        .body(axum::body::Body::from("Not found"))
342                                        .unwrap(),
343                                }
344                            }
345                        };
346                        Ok::<_, std::convert::Infallible>(response)
347                    }
348                });
349
350            let serve_dir =
351                tower_http::services::ServeDir::new(static_dir).fallback(fallback_service);
352            app = app.fallback_service(serve_dir);
353        } else {
354            tracing::warn!(
355                "Static directory not found: {}. Frontend won't be served.",
356                static_dir
357            );
358        }
359    }
360
361    // Bind and serve
362    let addr: SocketAddr = format!("{}:{}", config.host, config.port)
363        .parse()
364        .map_err(|e| format!("Invalid address: {}", e))?;
365
366    let listener = tokio::net::TcpListener::bind(addr)
367        .await
368        .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
369
370    let local_addr = listener
371        .local_addr()
372        .map_err(|e| format!("Failed to get local address: {}", e))?;
373
374    tracing::info!("Routa backend server listening on {}", local_addr);
375
376    // Spawn the server in a background task
377    tokio::spawn(async move {
378        if let Err(e) = axum::serve(listener, app).await {
379            tracing::error!("Server error: {}", e);
380        }
381    });
382
383    Ok(local_addr)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::resolve_static_target;
389
390    #[test]
391    fn resolves_workspace_overview_placeholder() {
392        let (target, content_type) = resolve_static_target("/workspace/default");
393        assert_eq!(target, "workspace/__placeholder__.html");
394        assert_eq!(content_type, "text/html; charset=utf-8");
395    }
396
397    #[test]
398    fn resolves_workspace_kanban_placeholder() {
399        let (target, content_type) = resolve_static_target("/workspace/default/kanban");
400        assert_eq!(target, "workspace/__placeholder__/kanban.html");
401        assert_eq!(content_type, "text/html; charset=utf-8");
402    }
403
404    #[test]
405    fn resolves_workspace_team_placeholder() {
406        let (target, content_type) = resolve_static_target("/workspace/default/team");
407        assert_eq!(target, "workspace/__placeholder__/team.html");
408        assert_eq!(content_type, "text/html; charset=utf-8");
409    }
410
411    #[test]
412    fn resolves_workspace_team_run_placeholder() {
413        let (target, content_type) = resolve_static_target("/workspace/default/team/session-123");
414        assert_eq!(
415            target,
416            "workspace/__placeholder__/team/__placeholder__.html"
417        );
418        assert_eq!(content_type, "text/html; charset=utf-8");
419    }
420
421    #[test]
422    fn resolves_workspace_session_placeholder() {
423        let (target, content_type) =
424            resolve_static_target("/workspace/default/sessions/session-123");
425        assert_eq!(
426            target,
427            "workspace/__placeholder__/sessions/__placeholder__.html"
428        );
429        assert_eq!(content_type, "text/html; charset=utf-8");
430    }
431
432    #[test]
433    fn resolves_workspace_team_rsc_placeholder() {
434        let (target, content_type) =
435            resolve_static_target("/workspace/default/team/session-123.txt");
436        assert_eq!(target, "workspace/__placeholder__/team/__placeholder__.txt");
437        assert_eq!(content_type, "text/x-component; charset=utf-8");
438    }
439}
440
441async fn health_check() -> axum::Json<serde_json::Value> {
442    axum::Json(serde_json::json!({
443        "status": "ok",
444        "timestamp": chrono::Utc::now().to_rfc3339(),
445        "server": "routa-server",
446        "version": env!("CARGO_PKG_VERSION"),
447    }))
448}