Skip to main content

ito_web/
server.rs

1//! HTTP server bootstrap and route assembly.
2//!
3//! [`serve`] is the single entry point: it wires up frontend routes, the REST
4//! API, the WebSocket terminal, authentication middleware, and CORS, then binds
5//! to the configured address. All business logic lives in `ito-core`; this
6//! module only handles transport-level concerns.
7
8use axum::{Router, middleware, routing::get};
9use std::net::SocketAddr;
10use std::path::PathBuf;
11use std::sync::Arc;
12use tower_http::cors::CorsLayer;
13
14use crate::api;
15use crate::auth::{self, AuthState};
16use crate::frontend;
17use crate::terminal::{self, TerminalState};
18
19/// Configuration passed to [`serve`] to start the web server.
20#[derive(Debug, Clone)]
21pub struct ServeConfig {
22    /// Root directory to serve (typically the project root).
23    pub root: PathBuf,
24    /// Address to bind to.
25    pub bind: String,
26    /// Port to listen on.
27    pub port: u16,
28}
29
30impl Default for ServeConfig {
31    fn default() -> Self {
32        Self {
33            root: PathBuf::from("."),
34            bind: "127.0.0.1".to_string(),
35            port: 9009,
36        }
37    }
38}
39
40/// Start the web server and block until it shuts down.
41///
42/// Binds to `config.bind:config.port`, prints the access URL (with a token for
43/// non-loopback addresses), and serves until the process is terminated.
44pub async fn serve(config: ServeConfig) -> miette::Result<()> {
45    let root = config.root.canonicalize().unwrap_or(config.root.clone());
46
47    // Generate token for non-loopback addresses
48    let token = if auth::is_loopback(&config.bind) {
49        None
50    } else {
51        Some(auth::generate_token(&root))
52    };
53
54    let auth_state = Arc::new(AuthState {
55        token: token.clone(),
56    });
57    let terminal_state = Arc::new(TerminalState { root: root.clone() });
58
59    let app = Router::new()
60        // Frontend routes
61        .route("/", get(frontend::index))
62        .route("/app.js", get(frontend::app_js))
63        // Terminal WebSocket
64        .route("/ws/terminal", get(terminal::ws_handler))
65        .with_state(terminal_state)
66        // API routes
67        .nest("/api", api::router(root.clone()))
68        // Auth middleware (checks token for non-loopback)
69        .layer(middleware::from_fn_with_state(
70            auth_state,
71            auth::auth_middleware,
72        ))
73        // CORS for development
74        .layer(CorsLayer::permissive());
75
76    let addr: SocketAddr = format!("{}:{}", config.bind, config.port)
77        .parse()
78        .map_err(|e| miette::miette!("Invalid address: {e}"))?;
79
80    let listener = tokio::net::TcpListener::bind(addr)
81        .await
82        .map_err(|e| miette::miette!("Failed to bind to {addr}: {e}"))?;
83
84    // Print URL with token if required
85    let url = if let Some(t) = &token {
86        format!("http://{addr}/?token={t}")
87    } else {
88        format!("http://{addr}/")
89    };
90    println!("Serving {} at {url}", root.display());
91
92    axum::serve(listener, app)
93        .await
94        .map_err(|e| miette::miette!("Server error: {e}"))?;
95
96    Ok(())
97}