use anyhow::Result;
use async_trait::async_trait;
use axum::{
body::Body,
extract::Path,
response::{IntoResponse, Response},
routing::get,
Router,
};
use rust_embed::Embed;
use std::path::PathBuf;
use std::sync::Arc;
use oxios_gateway::plugin::{ChannelBundle, ChannelContext, ChannelPlugin};
use oxios_markdown::KnowledgeBase;
use crate::api_docs;
use crate::channel::{WebChannel, WebChannelHandle};
use crate::middleware::RateLimiter;
use crate::routes;
use crate::server::AppState;
#[derive(Embed)]
#[folder = "web/dist/"]
struct Assets;
struct SpaFallback;
impl IntoResponse for SpaFallback {
fn into_response(self) -> Response {
match Assets::get("index.html") {
Some(content) => Response::builder()
.status(200)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(content.data.to_vec()))
.unwrap(),
None => Response::builder()
.status(404)
.body(Body::from(
"index.html not found — run `cd web && npm run build`",
))
.unwrap(),
}
}
}
fn serve_file(path: &str) -> Response {
let clean_path = path.trim_start_matches('/');
let asset = Assets::get(clean_path).or_else(|| Assets::get(&format!("assets/{}", clean_path)));
match asset {
Some(content) => {
let lookup = if clean_path.starts_with("assets/") {
clean_path
} else {
&format!("assets/{}", clean_path)
};
let mime = mime_guess::from_path(lookup)
.first_or_octet_stream()
.to_string();
let body = Body::from(content.data.to_vec());
Response::builder()
.status(200)
.header("Content-Type", mime)
.body(body)
.unwrap()
}
None => Response::builder().status(404).body(Body::empty()).unwrap(),
}
}
async fn static_handler(Path(path): axum::extract::Path<String>) -> Response {
serve_file(&path)
}
async fn spa_handler() -> impl IntoResponse {
SpaFallback
}
pub struct WebPlugin;
impl WebPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for WebPlugin {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ChannelPlugin for WebPlugin {
fn name(&self) -> &str {
"web"
}
async fn setup(&self, ctx: ChannelContext) -> Result<ChannelBundle> {
let config = ctx.config.read().clone();
let host = config.gateway.host.clone();
let port = config.gateway.port;
let rate_limit = config.security.rate_limit_per_minute;
let web_channel = WebChannel::new(256);
let channel_handle = WebChannelHandle::from_channel(&web_channel);
let knowledge_root = PathBuf::from(&config.kernel.workspace).join("knowledge");
let knowledge = match KnowledgeBase::new(knowledge_root) {
Ok(kb) => Arc::new(kb),
Err(e) => {
tracing::warn!(error = %e, "Failed to create KnowledgeBase at workspace, using temp dir");
let fallback_dir = std::env::temp_dir().join("oxios-web-knowledge");
Arc::new(
KnowledgeBase::new(fallback_dir)
.expect("Failed to create fallback KnowledgeBase"),
)
}
};
let state = Arc::new(AppState {
base_url: format!("http://{}:{}", host, port),
knowledge,
kernel: ctx.kernel.clone(),
channel: channel_handle,
config: ctx.config.clone(),
config_path: ctx.config_path.clone(),
start_time: ctx.kernel.start_time(),
rate_limiter: RateLimiter::new(rate_limit),
});
let api_routes = routes::build_routes(state.clone());
let cors_origins: Vec<_> = config
.security
.cors_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect();
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(cors_origins)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
let openapi = api_docs::build_openapi();
let swagger: Router<()> = utoipa_swagger_ui::SwaggerUi::new("/api-docs")
.url("/openapi.json", openapi)
.into();
let spa_routes: Router<Arc<AppState>> = Router::new()
.route("/assets/{*path}", get(static_handler))
.route("/favicon.svg", get(static_handler))
.route("/icons.svg", get(static_handler))
.route("/{*path}", get(spa_handler))
.route("/", get(spa_handler));
let app = Router::new()
.merge(api_routes)
.merge(spa_routes)
.layer(cors)
.nest_service("/api-docs", swagger)
.with_state(state);
let addr = format!("{}:{}", host, port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "Web server listening");
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
tracing::info!("Web server shutting down");
})
.await
{
tracing::error!(error = %e, "Web server error");
}
});
Ok(ChannelBundle {
channel: Box::new(web_channel),
tasks: vec![handle],
})
}
}