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        let is_next_metadata_segment = |segment: &str| segment.starts_with("__next.");
126
127        if segments.len() >= 3 && segments[1] == "sessions" {
128            let suffix = if segments.len() > 3 {
129                &segments[3..]
130            } else {
131                &[][..]
132            };
133            (
134                placeholder_with_suffix(
135                    "workspace/__placeholder__/sessions/__placeholder__",
136                    suffix,
137                ),
138                content,
139            )
140        } else if segments.len() >= 3
141            && segments[1] == "team"
142            && !is_next_metadata_segment(segments[2])
143        {
144            let suffix = if segments.len() > 3 {
145                &segments[3..]
146            } else {
147                &[][..]
148            };
149            (
150                placeholder_with_suffix("workspace/__placeholder__/team/__placeholder__", suffix),
151                content,
152            )
153        } else if segments.len() >= 2 && segments[1] == "kanban" {
154            let suffix = if segments.len() > 2 {
155                &segments[2..]
156            } else {
157                &[][..]
158            };
159            (
160                placeholder_with_suffix("workspace/__placeholder__/kanban", suffix),
161                content,
162            )
163        } else if segments.len() >= 2 && segments[1] == "team" {
164            let suffix = if segments.len() > 2 {
165                &segments[2..]
166            } else {
167                &[][..]
168            };
169            (
170                placeholder_with_suffix("workspace/__placeholder__/team", suffix),
171                content,
172            )
173        } else if segments.len() >= 4 && segments[1] == "codebases" && segments[3] == "reposlide" {
174            let suffix = if segments.len() > 4 {
175                &segments[4..]
176            } else {
177                &[][..]
178            };
179            (
180                placeholder_with_suffix(
181                    "workspace/__placeholder__/codebases/__placeholder__/reposlide",
182                    suffix,
183                ),
184                content,
185            )
186        } else if !segments.is_empty() {
187            let suffix = if segments.len() > 1 {
188                &segments[1..]
189            } else {
190                &[][..]
191            };
192            (
193                placeholder_with_suffix("workspace/__placeholder__", suffix),
194                content,
195            )
196        } else {
197            ("index.html".to_string(), "text/html; charset=utf-8")
198        }
199    } else {
200        let clean_path = path.trim_start_matches('/').trim_end_matches('/');
201        if is_rsc_request {
202            (
203                if clean_path.is_empty() {
204                    "index.txt".to_string()
205                } else {
206                    format!("{}.txt", clean_path)
207                },
208                "text/x-component; charset=utf-8",
209            )
210        } else if clean_path.is_empty() {
211            ("index.html".to_string(), "text/html; charset=utf-8")
212        } else {
213            (format!("{}.html", clean_path), "text/html; charset=utf-8")
214        }
215    }
216}
217
218/// Start the embedded Rust backend server.
219///
220/// Returns the actual address the server is listening on.
221pub async fn start_server(config: ServerConfig) -> Result<SocketAddr, String> {
222    // Initialize tracing (ignore if already initialized)
223    let _ = tracing_subscriber::fmt()
224        .with_env_filter(
225            tracing_subscriber::EnvFilter::try_from_default_env()
226                .unwrap_or_else(|_| "routa_core=info,routa_server=info,tower_http=info".into()),
227        )
228        .try_init();
229
230    // Resolve and set the full shell PATH early so all child processes
231    // (agent CLIs, git, etc.) can be found even when launched from Finder.
232    let full_path = shell_env::full_path();
233    std::env::set_var("PATH", full_path);
234
235    tracing::info!(
236        "Starting Routa backend server on {}:{}",
237        config.host,
238        config.port
239    );
240
241    std::env::set_var(
242        "ROUTA_SERVER_URL",
243        format!("http://{}:{}", config.host, config.port),
244    );
245
246    let state = create_app_state(&config.db_path).await?;
247
248    start_server_with_state(config, state).await
249}
250
251/// Start the HTTP server with a pre-built `AppState`.
252///
253/// This variant is useful when you want to share the state with other
254/// consumers (e.g. a Tauri IPC command that routes JSON-RPC calls directly).
255pub async fn start_server_with_state(
256    config: ServerConfig,
257    state: state::AppState,
258) -> Result<SocketAddr, String> {
259    std::env::set_var(
260        "ROUTA_SERVER_URL",
261        format!("http://{}:{}", config.host, config.port),
262    );
263
264    // Build router
265    let cors = CorsLayer::new()
266        .allow_origin(Any)
267        .allow_methods(Any)
268        .allow_headers(Any);
269
270    let mut app = Router::new()
271        .merge(api::api_router())
272        .route("/api/health", axum::routing::get(health_check))
273        .layer(cors.clone())
274        .layer(TraceLayer::new_for_http())
275        .with_state(state);
276
277    // Serve static frontend files if configured
278    if let Some(ref static_dir) = config.static_dir {
279        let static_path = std::path::Path::new(static_dir);
280        if static_path.exists() && static_path.is_dir() {
281            tracing::info!("Serving static frontend from: {}", static_dir);
282
283            // For Next.js static export with dynamic routes, we need custom fallback logic.
284            // Next.js generates placeholder files for dynamic routes:
285            // - workspace/__placeholder__.html (for /workspace/[workspaceId])
286            // - workspace/__placeholder__/kanban.html (for /workspace/[workspaceId]/kanban)
287            // - workspace/__placeholder__/sessions/__placeholder__.html
288            //   (for /workspace/[workspaceId]/sessions/[sessionId])
289            //
290            // Additionally, Next.js client navigation requests .txt RSC payload files:
291            // - workspace/default/kanban.txt → workspace/__placeholder__/kanban.txt
292            // - workspace/default/sessions/abc123.txt
293            //   → workspace/__placeholder__/sessions/__placeholder__.txt
294            //
295            // We match the URL pattern and serve the corresponding placeholder file.
296            let static_dir_clone = static_dir.clone();
297            let fallback_service =
298                tower::service_fn(move |req: axum::http::Request<axum::body::Body>| {
299                    let static_dir = static_dir_clone.clone();
300                    async move {
301                        let path = req.uri().path();
302                        let is_rsc_request = path.ends_with(".txt");
303                        let (target_file, content_type) = resolve_static_target(path);
304
305                        let file_path = std::path::Path::new(&static_dir).join(&target_file);
306                        tracing::debug!(
307                            "SPA fallback: {} -> {} (rsc={})",
308                            path,
309                            file_path.to_string_lossy(),
310                            is_rsc_request
311                        );
312
313                        let workspace_segments: Vec<&str> = path
314                            .trim_start_matches("/workspace/")
315                            .trim_end_matches(".txt")
316                            .split('/')
317                            .filter(|segment| !segment.is_empty())
318                            .collect();
319                        let should_rewrite_workspace_placeholder = path.starts_with("/workspace/")
320                            && !workspace_segments.is_empty()
321                            && workspace_segments
322                                .get(1)
323                                .map(|segment| *segment != "sessions")
324                                .unwrap_or(true);
325                        let actual_workspace_id = workspace_segments
326                            .first()
327                            .copied()
328                            .unwrap_or("__placeholder__");
329
330                        let response = match tokio::fs::read(&file_path).await {
331                            Ok(contents) => {
332                                let body = if should_rewrite_workspace_placeholder {
333                                    let rewritten = String::from_utf8_lossy(&contents)
334                                        .replace("__placeholder__", actual_workspace_id);
335                                    axum::body::Body::from(rewritten)
336                                } else {
337                                    axum::body::Body::from(contents)
338                                };
339
340                                axum::http::Response::builder()
341                                    .status(axum::http::StatusCode::OK)
342                                    .header("content-type", content_type)
343                                    .body(body)
344                                    .unwrap()
345                            }
346                            Err(_) => {
347                                // If the specific file doesn't exist, fall back to index.html
348                                let index_path =
349                                    std::path::Path::new(&static_dir).join("index.html");
350                                match tokio::fs::read(&index_path).await {
351                                    Ok(contents) => axum::http::Response::builder()
352                                        .status(axum::http::StatusCode::OK)
353                                        .header("content-type", "text/html; charset=utf-8")
354                                        .body(axum::body::Body::from(contents))
355                                        .unwrap(),
356                                    Err(_) => axum::http::Response::builder()
357                                        .status(axum::http::StatusCode::NOT_FOUND)
358                                        .body(axum::body::Body::from("Not found"))
359                                        .unwrap(),
360                                }
361                            }
362                        };
363                        Ok::<_, std::convert::Infallible>(response)
364                    }
365                });
366
367            let serve_dir =
368                tower_http::services::ServeDir::new(static_dir).fallback(fallback_service);
369            app = app.fallback_service(serve_dir);
370        } else {
371            tracing::warn!(
372                "Static directory not found: {}. Frontend won't be served.",
373                static_dir
374            );
375        }
376    }
377
378    // Bind and serve
379    let addr: SocketAddr = format!("{}:{}", config.host, config.port)
380        .parse()
381        .map_err(|e| format!("Invalid address: {}", e))?;
382
383    let listener = tokio::net::TcpListener::bind(addr)
384        .await
385        .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
386
387    let local_addr = listener
388        .local_addr()
389        .map_err(|e| format!("Failed to get local address: {}", e))?;
390
391    tracing::info!("Routa backend server listening on {}", local_addr);
392
393    // Spawn the server in a background task
394    tokio::spawn(async move {
395        if let Err(e) = axum::serve(listener, app).await {
396            tracing::error!("Server error: {}", e);
397        }
398    });
399
400    Ok(local_addr)
401}
402
403async fn health_check() -> axum::Json<serde_json::Value> {
404    axum::Json(serde_json::json!({
405        "status": "ok",
406        "timestamp": chrono::Utc::now().to_rfc3339(),
407        "server": "routa-server",
408        "version": env!("CARGO_PKG_VERSION"),
409    }))
410}
411
412#[cfg(test)]
413mod tests {
414    use super::resolve_static_target;
415
416    #[test]
417    fn resolves_workspace_overview_placeholder() {
418        let (target, content_type) = resolve_static_target("/workspace/default");
419        assert_eq!(target, "workspace/__placeholder__.html");
420        assert_eq!(content_type, "text/html; charset=utf-8");
421    }
422
423    #[test]
424    fn resolves_workspace_kanban_placeholder() {
425        let (target, content_type) = resolve_static_target("/workspace/default/kanban");
426        assert_eq!(target, "workspace/__placeholder__/kanban.html");
427        assert_eq!(content_type, "text/html; charset=utf-8");
428    }
429
430    #[test]
431    fn resolves_workspace_team_placeholder() {
432        let (target, content_type) = resolve_static_target("/workspace/default/team");
433        assert_eq!(target, "workspace/__placeholder__/team.html");
434        assert_eq!(content_type, "text/html; charset=utf-8");
435    }
436
437    #[test]
438    fn resolves_workspace_team_root_tree_placeholder() {
439        let (target, content_type) =
440            resolve_static_target("/workspace/default/team/__next._tree.txt");
441        assert_eq!(target, "workspace/__placeholder__/team/__next._tree.txt");
442        assert_eq!(content_type, "text/x-component; charset=utf-8");
443    }
444
445    #[test]
446    fn resolves_workspace_team_run_placeholder() {
447        let (target, content_type) = resolve_static_target("/workspace/default/team/session-123");
448        assert_eq!(
449            target,
450            "workspace/__placeholder__/team/__placeholder__.html"
451        );
452        assert_eq!(content_type, "text/html; charset=utf-8");
453    }
454
455    #[test]
456    fn resolves_workspace_team_run_tree_placeholder() {
457        let (target, content_type) =
458            resolve_static_target("/workspace/default/team/session-123/__next._tree.txt");
459        assert_eq!(
460            target,
461            "workspace/__placeholder__/team/__placeholder__/__next._tree.txt"
462        );
463        assert_eq!(content_type, "text/x-component; charset=utf-8");
464    }
465
466    #[test]
467    fn resolves_workspace_session_placeholder() {
468        let (target, content_type) =
469            resolve_static_target("/workspace/default/sessions/session-123");
470        assert_eq!(
471            target,
472            "workspace/__placeholder__/sessions/__placeholder__.html"
473        );
474        assert_eq!(content_type, "text/html; charset=utf-8");
475    }
476
477    #[test]
478    fn resolves_workspace_team_rsc_placeholder() {
479        let (target, content_type) =
480            resolve_static_target("/workspace/default/team/session-123.txt");
481        assert_eq!(target, "workspace/__placeholder__/team/__placeholder__.txt");
482        assert_eq!(content_type, "text/x-component; charset=utf-8");
483    }
484
485    #[test]
486    fn resolves_workspace_reposlide_placeholder() {
487        let (target, content_type) =
488            resolve_static_target("/workspace/ws-1/codebases/cb-1/reposlide");
489        assert_eq!(
490            target,
491            "workspace/__placeholder__/codebases/__placeholder__/reposlide.html"
492        );
493        assert_eq!(content_type, "text/html; charset=utf-8");
494    }
495}