mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -06:00
feat: embed SvelteKit client assets in Rust binary
- Add include_dir for serving /_app static bundles from binary - Add console-logger.js for structured JSON logs from Bun - Fix API routing edge cases and add method restrictions
This commit is contained in:
Generated
+719
-10
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -6,8 +6,10 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.8"
|
axum = "0.8.8"
|
||||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||||
|
include_dir = "0.7.4"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
nu-ansi-term = "0.50.3"
|
nu-ansi-term = "0.50.3"
|
||||||
reqwest = { version = "0.13", features = ["charset", "json"], default-features = false }
|
reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "charset", "json", "stream"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.148"
|
serde_json = "1.0.148"
|
||||||
time = { version = "0.3.44", features = ["formatting", "macros"] }
|
time = { version = "0.3.44", features = ["formatting", "macros"] }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use axum::{
|
||||||
|
http::{header, StatusCode, Uri},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use include_dir::{include_dir, Dir};
|
||||||
|
|
||||||
|
/// Embedded client assets from the SvelteKit build
|
||||||
|
/// These are the static JS/CSS bundles that get served to browsers
|
||||||
|
static CLIENT_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/client");
|
||||||
|
|
||||||
|
/// Serves embedded client assets from the /_app path
|
||||||
|
/// Returns 404 if the asset doesn't exist
|
||||||
|
pub async fn serve_embedded_asset(uri: Uri) -> Response {
|
||||||
|
let path = uri.path();
|
||||||
|
|
||||||
|
// Strip leading slash for lookup
|
||||||
|
let asset_path = path.strip_prefix('/').unwrap_or(path);
|
||||||
|
|
||||||
|
match CLIENT_ASSETS.get_file(asset_path) {
|
||||||
|
Some(file) => {
|
||||||
|
let mime_type = mime_guess::from_path(asset_path)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.as_ref()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
mime_type.parse().unwrap_or_else(|_| {
|
||||||
|
header::HeaderValue::from_static("application/octet-stream")
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Immutable assets can be cached forever (they're content-hashed)
|
||||||
|
if path.contains("/immutable/") {
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
header::HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Version file and other assets get short cache
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
header::HeaderValue::from_static("public, max-age=3600"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, headers, file.contents()).into_response()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::debug!(path, "Embedded asset not found");
|
||||||
|
(StatusCode::NOT_FOUND, "Asset not found").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-13
@@ -3,7 +3,7 @@ use axum::{
|
|||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::{any, get},
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -12,9 +12,11 @@ use std::sync::Arc;
|
|||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod assets;
|
||||||
mod config;
|
mod config;
|
||||||
mod formatter;
|
mod formatter;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
|
use assets::serve_embedded_asset;
|
||||||
use config::{Args, ListenAddr};
|
use config::{Args, ListenAddr};
|
||||||
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
|
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
|
||||||
use middleware::RequestIdLayer;
|
use middleware::RequestIdLayer;
|
||||||
@@ -81,8 +83,12 @@ async fn main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build router with shared state
|
// Build router with shared state
|
||||||
|
// Note: Axum's nest() handles /api/* but not /api or /api/ at the parent level
|
||||||
|
// So we explicitly add those routes before nesting
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", api_routes().fallback(api_404_handler))
|
.nest("/api", api_routes())
|
||||||
|
.route("/api/", any(api_root_404_handler))
|
||||||
|
.route("/_app/{*path}", get(serve_embedded_asset))
|
||||||
.fallback(isr_handler)
|
.fallback(isr_handler)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(RequestIdLayer::new(args.trust_request_id.clone()))
|
.layer(RequestIdLayer::new(args.trust_request_id.clone()))
|
||||||
@@ -192,8 +198,15 @@ fn is_page_route(path: &str) -> bool {
|
|||||||
// API routes for data endpoints
|
// API routes for data endpoints
|
||||||
fn api_routes() -> Router<Arc<AppState>> {
|
fn api_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/", any(api_root_404_handler))
|
||||||
.route("/health", get(health_handler))
|
.route("/health", get(health_handler))
|
||||||
.route("/projects", get(projects_handler))
|
.route("/projects", get(projects_handler))
|
||||||
|
.fallback(api_404_handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API root 404 handler - explicit 404 for /api and /api/ requests
|
||||||
|
async fn api_root_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
|
||||||
|
api_404_handler(uri).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
@@ -268,12 +281,23 @@ async fn projects_handler() -> impl IntoResponse {
|
|||||||
|
|
||||||
// ISR handler - proxies to Bun SSR server
|
// ISR handler - proxies to Bun SSR server
|
||||||
// This is the fallback for all routes not matched by /api/*
|
// This is the fallback for all routes not matched by /api/*
|
||||||
#[tracing::instrument(skip(state, req), fields(path = %req.uri().path()))]
|
#[tracing::instrument(skip(state, req), fields(path = %req.uri().path(), method = %req.method()))]
|
||||||
async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Response {
|
async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Response {
|
||||||
|
let method = req.method();
|
||||||
let uri = req.uri();
|
let uri = req.uri();
|
||||||
let path = uri.path();
|
let path = uri.path();
|
||||||
let query = uri.query().unwrap_or("");
|
let query = uri.query().unwrap_or("");
|
||||||
|
|
||||||
|
// Only allow GET requests outside of /api routes
|
||||||
|
if method != axum::http::Method::GET {
|
||||||
|
tracing::warn!(method = %method, path = %path, "Non-GET request to non-API route");
|
||||||
|
return (
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
"Method not allowed",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if API route somehow reached ISR handler (shouldn't happen)
|
// Check if API route somehow reached ISR handler (shouldn't happen)
|
||||||
if path.starts_with("/api/") {
|
if path.starts_with("/api/") {
|
||||||
tracing::error!("API request reached ISR handler - routing bug!");
|
tracing::error!("API request reached ISR handler - routing bug!");
|
||||||
@@ -285,10 +309,22 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build URL for Bun server
|
// Build URL for Bun server
|
||||||
let bun_url = if query.is_empty() {
|
// For unix sockets, use http://localhost + path (socket is configured in client)
|
||||||
format!("{}{}", state.downstream_url, path)
|
// For TCP, use the actual downstream URL
|
||||||
|
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./") {
|
||||||
|
// Unix socket - host is ignored, just need the path
|
||||||
|
if query.is_empty() {
|
||||||
|
format!("http://localhost{}", path)
|
||||||
|
} else {
|
||||||
|
format!("http://localhost{}?{}", path, query)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}?{}", state.downstream_url, path, query)
|
// TCP - use the actual downstream URL
|
||||||
|
if query.is_empty() {
|
||||||
|
format!("{}{}", state.downstream_url, path)
|
||||||
|
} else {
|
||||||
|
format!("{}{}?{}", state.downstream_url, path, query)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track request timing
|
// Track request timing
|
||||||
@@ -297,7 +333,7 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
// TODO: Add ISR caching layer here (moka, singleflight, stale-while-revalidate)
|
// TODO: Add ISR caching layer here (moka, singleflight, stale-while-revalidate)
|
||||||
// For now, just proxy directly to Bun
|
// For now, just proxy directly to Bun
|
||||||
|
|
||||||
match proxy_to_bun(&bun_url, &state.downstream_url).await {
|
match proxy_to_bun(&bun_url, state.clone()).await {
|
||||||
Ok((status, headers, body)) => {
|
Ok((status, headers, body)) => {
|
||||||
let duration_ms = start.elapsed().as_millis() as u64;
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
let cache = "miss"; // Hardcoded for now, will change when caching is implemented
|
let cache = "miss"; // Hardcoded for now, will change when caching is implemented
|
||||||
@@ -373,18 +409,18 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
// Proxy a request to the Bun SSR server, returning status, headers and body
|
// Proxy a request to the Bun SSR server, returning status, headers and body
|
||||||
async fn proxy_to_bun(
|
async fn proxy_to_bun(
|
||||||
url: &str,
|
url: &str,
|
||||||
downstream_url: &str,
|
state: Arc<AppState>,
|
||||||
) -> Result<(StatusCode, HeaderMap, String), ProxyError> {
|
) -> Result<(StatusCode, HeaderMap, String), ProxyError> {
|
||||||
// Check if downstream is a Unix socket path
|
// Build client - if downstream_url is a path, use unix socket, otherwise TCP
|
||||||
let client = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
|
let client = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./") {
|
||||||
// Unix socket
|
// Unix socket - the host in the URL (localhost) is ignored
|
||||||
let path = PathBuf::from(downstream_url);
|
let path = PathBuf::from(&state.downstream_url);
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.unix_socket(path)
|
.unix_socket(path)
|
||||||
.build()
|
.build()
|
||||||
.map_err(ProxyError::Network)?
|
.map_err(ProxyError::Network)?
|
||||||
} else {
|
} else {
|
||||||
// Regular HTTP
|
// Regular TCP connection
|
||||||
reqwest::Client::new()
|
reqwest::Client::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Patch console methods to output structured JSON logs
|
||||||
|
// This runs before the Bun server starts to ensure all console output is formatted
|
||||||
|
|
||||||
|
const originalConsole = {
|
||||||
|
log: console.log,
|
||||||
|
error: console.error,
|
||||||
|
warn: console.warn,
|
||||||
|
info: console.info,
|
||||||
|
debug: console.debug,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLog(level, args) {
|
||||||
|
const message = args.map(arg =>
|
||||||
|
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
const logEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: level,
|
||||||
|
message: message,
|
||||||
|
target: 'bun',
|
||||||
|
};
|
||||||
|
|
||||||
|
originalConsole.log(JSON.stringify(logEntry));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log = (...args) => formatLog('info', args);
|
||||||
|
console.info = (...args) => formatLog('info', args);
|
||||||
|
console.warn = (...args) => formatLog('warn', args);
|
||||||
|
console.error = (...args) => formatLog('error', args);
|
||||||
|
console.debug = (...args) => formatLog('debug', args);
|
||||||
Reference in New Issue
Block a user