mirror of
https://github.com/Xevion/banner.git
synced 2025-12-15 00:11:06 -06:00
feat: add formatter CLI argument, setup asset embedding in release mode
This commit is contained in:
64
src/web/assets.rs
Normal file
64
src/web/assets.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Embedded assets for the web frontend
|
||||
//!
|
||||
//! This module handles serving static assets that are embedded into the binary
|
||||
//! at compile time using rust-embed.
|
||||
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
/// Embedded web assets from the dist directory
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "web/dist/"]
|
||||
#[include = "*"]
|
||||
#[exclude = "*.map"]
|
||||
pub struct WebAssets;
|
||||
|
||||
/// Serve embedded static assets
|
||||
pub async fn serve_asset(Path(path): Path<String>) -> Response {
|
||||
let path = path.trim_start_matches('/');
|
||||
|
||||
match WebAssets::get(path) {
|
||||
Some(content) => {
|
||||
let mime_type = mime_guess::from_path(path).first_or_text_plain();
|
||||
let data = content.data.to_vec();
|
||||
([(header::CONTENT_TYPE, mime_type.as_ref())], data).into_response()
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Asset not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the main SPA index.html for client-side routing
|
||||
pub async fn serve_spa_index() -> Response {
|
||||
match WebAssets::get("index.html") {
|
||||
Some(content) => {
|
||||
let data = content.data.to_vec();
|
||||
let html_content = String::from_utf8_lossy(&data).to_string();
|
||||
Html(html_content).into_response()
|
||||
}
|
||||
None => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load index.html",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
const ASSET_EXTENSIONS: &[&str] = &[
|
||||
"js", "css", "png", "jpg", "jpeg", "gif", "svg", "ico", "woff", "woff2", "ttf", "eot",
|
||||
];
|
||||
|
||||
/// Check if a path should be served as a static asset
|
||||
pub fn is_asset_path(path: &str) -> bool {
|
||||
if !path.starts_with("/assets/") {
|
||||
return path.eq("index.html");
|
||||
}
|
||||
|
||||
match path.split_once('.') {
|
||||
Some((_, extension)) => ASSET_EXTENSIONS.contains(&extension),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Web API module for the banner application.
|
||||
|
||||
pub mod assets;
|
||||
pub mod routes;
|
||||
|
||||
pub use routes::*;
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
//! Web API endpoints for Banner bot monitoring and metrics.
|
||||
|
||||
use axum::{Router, extract::State, response::Json, routing::get};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::{StatusCode, Uri},
|
||||
response::{IntoResponse, Json, Response},
|
||||
routing::{any, get},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::web::assets::{is_asset_path, serve_asset, serve_spa_index};
|
||||
|
||||
use crate::banner::BannerApi;
|
||||
|
||||
/// Shared application state for web server
|
||||
@@ -15,24 +27,72 @@ pub struct BannerState {
|
||||
|
||||
/// Creates the web server router
|
||||
pub fn create_router(state: BannerState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
let api_router = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/status", get(status))
|
||||
.route("/metrics", get(metrics))
|
||||
.with_state(state)
|
||||
.with_state(state);
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
// Development mode: API routes only, frontend served by Vite dev server
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.nest("/api", api_router)
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
} else {
|
||||
// Production mode: serve embedded assets and handle SPA routing
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.nest("/api", api_router)
|
||||
.route("/assets/{*path}", any(serve_asset))
|
||||
.fallback(handle_spa_fallback)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
}
|
||||
|
||||
async fn root() -> Json<Value> {
|
||||
Json(json!({
|
||||
"message": "Banner Discord Bot API",
|
||||
"version": "0.1.0",
|
||||
"endpoints": {
|
||||
"health": "/health",
|
||||
"status": "/status",
|
||||
"metrics": "/metrics"
|
||||
}
|
||||
}))
|
||||
async fn root() -> Response {
|
||||
if cfg!(debug_assertions) {
|
||||
// Development mode: return API info
|
||||
Json(json!({
|
||||
"message": "Banner Discord Bot API",
|
||||
"version": "0.2.1",
|
||||
"mode": "development",
|
||||
"frontend": "http://localhost:3000",
|
||||
"endpoints": {
|
||||
"health": "/api/health",
|
||||
"status": "/api/status",
|
||||
"metrics": "/api/metrics"
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
} else {
|
||||
// Production mode: serve the SPA index.html
|
||||
handle_spa_fallback(Uri::from_static("/")).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles SPA routing by serving index.html for non-API, non-asset requests
|
||||
async fn handle_spa_fallback(uri: Uri) -> Response {
|
||||
let path = uri.path();
|
||||
|
||||
// Don't serve index.html for API routes or asset requests
|
||||
if path.starts_with("/api/") || is_asset_path(path) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// In production, serve embedded index.html for SPA routing
|
||||
if cfg!(not(debug_assertions)) {
|
||||
return serve_spa_index().await;
|
||||
}
|
||||
|
||||
// Development fallback (shouldn't reach here in production)
|
||||
(StatusCode::NOT_FOUND, "Not Found").into_response()
|
||||
}
|
||||
|
||||
/// Health check endpoint
|
||||
|
||||
Reference in New Issue
Block a user