use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::Request;
use hyper_util::rt::TokioIo;
use rust_dix::{ServiceCollection, ServiceProvider};
use webx_core::app::IHost;
use webx_core::config::{self, AppOptions};
use webx_core::error::Result;
use webx_core::handler::IHostedService;
use webx_core::http::IHttpContext;
use webx_core::localization::{IStringLocalizer, LocalizationOptions, StringLocalizer};
use webx_core::middleware::IMiddleware;
use webx_core::mode::AppMode;
use webx_core::route::catalog::{EndpointCatalog, IEndpointCatalog};
use webx_core::routing::{HttpMethod, IEndpoint, IRouter};
use crate::auth_jwt::{init_jwt_secret, jwt_middleware, JwtAuth};
use crate::authz::collect_authorizers;
use crate::context::{BodyLimits, HttpContext, RespBody};
use crate::cors::{CorsConfig, CorsMiddleware};
use crate::endpoint::{StaticHtmlEndpoint, StaticJsonEndpoint, StubEndpoint};
use crate::health::{HealthCheckEndpoint, HealthCheckRegistry, HealthStatus};
use crate::memory_cache::MemoryCache;
use crate::metrics::{HttpMetrics, MetricsEndpoint, MetricsMiddleware};
use crate::pipeline::{HandlerFn, MiddlewarePipeline};
use crate::problem_response::{build_problem, write_problem_response, write_unauthorized};
use crate::rate_limit::RateLimitMiddleware;
use crate::router::Router;
use jsonwebtoken::{DecodingKey, Validation};
use webx_core::route::scan::RouteEntry;
use webx_core::DispatchRuntime;
use webx_openapi::{generate_openapi_spec, APIUI_HTML};
use webx_spa::{EmbeddedAssets, SpaMiddleware, SpaSource};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::ServerConfig;
use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
use std::sync::Arc;
use std::time::Instant;
use tokio::task::JoinSet;
use tokio_rustls::TlsAcceptor;
fn is_weak_jwt_secret(secret: &str) -> bool {
secret.is_empty()
|| secret.len() < 32
|| secret.contains("change-in-production")
|| secret.contains("please-change")
|| secret.contains("demo-secret")
|| secret.contains("dev-secret")
}
fn log_spa_source(spa: &SpaMiddleware) {
tracing::info!("[Host] SPA root: {}", spa.resolved_root().display());
let Some(assets) = spa.embedded() else {
return;
};
tracing::info!(
"[Host] Embedded assets: {} files, {:.1} MiB embedded ({:.1} MiB uncompressed), built from `{}`",
assets.len(),
assets.total_bytes() as f64 / (1024.0 * 1024.0),
assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
assets.root(),
);
let shadowed = assets.shadowed_by(spa.resolved_root());
if !shadowed.is_empty() {
tracing::warn!(
"[Host] {} embedded file(s) are overridden on disk: {}. \
An override older than the current build stays in effect until it is \
updated or deleted.",
shadowed.len(),
shadowed.join(", "),
);
}
}
fn with_embedded_assets(
source: Option<SpaSource>,
assets: Option<EmbeddedAssets>,
) -> Option<SpaSource> {
let Some(assets) = assets else {
return source;
};
let overlay_root = match source.as_ref() {
Some(existing) => existing.disk_root().to_string(),
None => assets.root().to_string(),
};
Some(SpaSource::Embedded {
assets,
overlay_root,
})
}
type ShutdownSignal = Arc<tokio::sync::watch::Sender<bool>>;
pub struct Host {
#[allow(dead_code)]
provider: Arc<ServiceProvider>,
dispatch_runtime: Arc<DispatchRuntime>,
pub options: AppOptions,
endpoints: Arc<dyn IEndpointCatalog>,
localizer: Arc<dyn IStringLocalizer>,
body_limits: BodyLimits,
pipeline: Arc<MiddlewarePipeline>,
#[allow(dead_code)]
router: Arc<Router>,
router_handler: HandlerFn,
mode: AppMode,
#[allow(dead_code)]
spa_source: Option<SpaSource>,
shutdown: ShutdownSignal,
hosted_services: Vec<Arc<dyn IHostedService>>,
}
type MiddlewareFactory = Box<dyn Fn() -> Arc<dyn IMiddleware> + Send + Sync>;
struct MiddlewareSpec {
slot: webx_core::middleware::MiddlewareSlot,
factory: MiddlewareFactory,
}
#[allow(clippy::type_complexity)]
pub struct HostBuilder {
service_configs: Vec<Box<dyn FnOnce(ServiceCollection) -> ServiceCollection + Send>>,
mode: AppMode,
spa_source: Option<SpaSource>,
spa_disabled: bool,
options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
cors_config: Option<CorsConfig>,
auth_enabled: bool,
localization: Option<LocalizationOptions>,
health_registry: Arc<HealthCheckRegistry>,
middleware_specs: Vec<MiddlewareSpec>,
}
#[allow(clippy::type_complexity)]
pub struct HostAppBuilder {
options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
}
impl HostAppBuilder {
fn new() -> Self {
Self {
options_modifiers: Vec::new(),
}
}
#[allow(non_snake_case)]
pub fn useOptions<F>(&mut self, f: F)
where
F: FnOnce(&mut AppOptions) + Send + 'static,
{
self.options_modifiers.push(Box::new(f));
}
}
impl HostBuilder {
pub fn new() -> Self {
Self {
service_configs: Vec::new(),
mode: AppMode::from_env(),
spa_source: None,
spa_disabled: false,
options_modifiers: Vec::new(),
cors_config: None,
auth_enabled: false,
localization: None,
health_registry: HealthCheckRegistry::new(),
middleware_specs: Vec::new(),
}
}
pub fn register<F>(mut self, f: F) -> Self
where
F: FnOnce(ServiceCollection) -> ServiceCollection + Send + 'static,
{
self.service_configs.push(Box::new(f));
self
}
pub fn configure<F>(mut self, f: F) -> Self
where
F: FnOnce(&mut HostAppBuilder) + Send + 'static,
{
let mut builder = HostAppBuilder::new();
f(&mut builder);
self.options_modifiers
.append(&mut builder.options_modifiers);
self
}
pub fn mode(mut self, mode: AppMode) -> Self {
self.mode = mode;
self
}
pub fn add_options<T>(self, section: &str) -> Self
where
T: for<'de> serde::Deserialize<'de> + Default + Send + Sync + 'static,
{
let mode = self.mode;
let section = section.to_string();
self.register(move |svc| {
let appsettings = config::load_appsettings(mode).unwrap_or_default();
let bound: T = config::bind_config(&appsettings, §ion);
tracing::info!(
"[Host] add_options<{}>(\"{}\") registered",
std::any::type_name::<T>(),
section
);
svc.instance(Arc::new(bound))
})
}
pub fn use_spa(mut self, source: impl Into<SpaSource>) -> Self {
self.spa_source = Some(match (self.spa_source.take(), source.into()) {
(Some(SpaSource::Embedded { assets, .. }), SpaSource::Disk(root)) => {
SpaSource::Embedded {
assets,
overlay_root: root,
}
}
(_, incoming) => incoming,
});
self
}
pub fn no_spa(mut self) -> Self {
self.spa_disabled = true;
self
}
pub fn use_cors(mut self, config: CorsConfig) -> Self {
self.cors_config = Some(config);
self
}
pub fn add_authentication(mut self) -> Self {
self.auth_enabled = true;
self
}
pub fn add_localization(mut self) -> Self {
self.localization = Some(LocalizationOptions::new());
self
}
pub fn add_localization_with(mut self, options: LocalizationOptions) -> Self {
self.localization = Some(options);
self
}
#[deprecated(
since = "0.6.0",
note = "Route authorization is enforced from endpoint metadata; remove this call"
)]
pub fn use_resource_authorization(self) -> Self {
self
}
pub fn add_memory_cache(mut self) -> Self {
let cache = Arc::new(MemoryCache::new());
self.service_configs
.push(Box::new(move |svc| svc.instance(cache)));
self
}
pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
self.service_configs
.push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
self
}
pub fn use_middleware<T: IMiddleware + Default + 'static>(self) -> Self {
self.use_middleware_at::<T>(webx_core::middleware::MiddlewareSlot::AfterAuth)
}
pub fn use_middleware_at<T: IMiddleware + Default + 'static>(
mut self,
slot: webx_core::middleware::MiddlewareSlot,
) -> Self {
self.middleware_specs.push(MiddlewareSpec {
slot,
factory: Box::new(|| Arc::new(T::default()) as Arc<dyn IMiddleware>),
});
self
}
pub fn use_middleware_with<F>(self, factory: F) -> Self
where
F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
{
self.use_middleware_with_at(webx_core::middleware::MiddlewareSlot::AfterAuth, factory)
}
pub fn use_middleware_with_at<F>(
mut self,
slot: webx_core::middleware::MiddlewareSlot,
factory: F,
) -> Self
where
F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
{
self.middleware_specs.push(MiddlewareSpec {
slot,
factory: Box::new(factory),
});
self
}
pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
where
F: Fn() -> HealthStatus + Send + Sync + 'static,
{
self.health_registry.register(name, Arc::new(check));
self
}
pub fn build(self) -> Host {
let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
if self.mode == AppMode::Development {
let _ = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.without_time()
.with_target(false)
.try_init();
} else {
let _ = tracing_subscriber::fmt()
.json()
.with_env_filter(env_filter)
.try_init();
}
let appsettings =
config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
let mut options: AppOptions = config::bind_root(&appsettings);
for modifier in self.options_modifiers {
modifier(&mut options);
}
let default_culture = if options.culture.default.trim().is_empty() {
"en".to_string()
} else {
options.culture.default.clone()
};
let localization = self.localization.unwrap_or_else(LocalizationOptions::none);
let localizer: Arc<dyn IStringLocalizer> = Arc::new(
StringLocalizer::new(default_culture, &localization)
.unwrap_or_else(|error| panic!("Invalid localization resources: {error}")),
);
let endpoints: Arc<dyn IEndpointCatalog> = Arc::new(EndpointCatalog::from_inventory());
let mut svc = ServiceCollection::from_injected();
for cfg in self.service_configs {
svc = cfg(svc);
}
let health_registry = Arc::clone(&self.health_registry);
svc = svc.instance(health_registry);
let endpoints_for_di = Arc::clone(&endpoints);
svc = svc.singleton::<dyn IEndpointCatalog>(move |_| Arc::clone(&endpoints_for_di));
let localizer_for_di = Arc::clone(&localizer);
svc = svc.singleton::<dyn IStringLocalizer>(move |_| Arc::clone(&localizer_for_di));
let provider = svc.build().unwrap_or_else(|e| {
panic!(
"Failed to build ServiceProvider: {}. Check your DI registrations.",
e
)
});
let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
let body_limits = BodyLimits::from_options(&options);
body_limits.apply_spool_root();
tracing::info!(
"[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
body_limits.multipart_body,
body_limits.file,
body_limits.memory_threshold,
body_limits.spool_dir.display()
);
let http_metrics = HttpMetrics::new();
let mut pipeline = MiddlewarePipeline::new();
pipeline.add_middleware(Arc::new(
crate::security_headers::SecurityHeadersMiddleware::new(),
));
pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
for spec in self
.middleware_specs
.iter()
.filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::BeforeCors)
{
pipeline.add_middleware((spec.factory)());
}
let cors = self.cors_config.unwrap_or_else(|| {
let cs = &options.cors;
CorsConfig {
origins: cs.origins.clone(),
methods: cs.methods.clone(),
headers: cs.headers.clone(),
expose_headers: cs.expose_headers.clone(),
allow_credentials: cs.allow_credentials,
max_age: cs.max_age,
}
});
pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
if options.metrics.enabled {
tracing::info!("[Host] Metrics enabled at GET /metrics");
pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
}
if options.rate_limit.enabled {
tracing::info!(
"[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
options.rate_limit.requests_per_second,
options.rate_limit.burst_size,
options.rate_limit.max_tracked_ips
);
pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
&options.rate_limit,
)));
}
if self.auth_enabled {
let secret = options.jwt.secret.clone();
if !secret.is_empty() {
let jwt_auth = Arc::new(JwtAuth::new(
DecodingKey::from_secret(secret.as_bytes()),
Validation::default(),
));
pipeline.add_middleware(Arc::new(jwt_middleware(jwt_auth)));
init_jwt_secret(&secret);
} else {
tracing::warn!(
"add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
);
}
}
for spec in self
.middleware_specs
.iter()
.filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterAuth)
{
pipeline.add_middleware((spec.factory)());
}
for mw in provider.get_all::<dyn IMiddleware>() {
pipeline.add_middleware(mw);
}
let spa_source = if self.spa_disabled {
None
} else {
let base = self.spa_source.clone().or_else(|| {
let candidate = webx_core::paths::app_base().join("wwwroot");
if candidate.is_dir() {
tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
} else {
None
}
});
with_embedded_assets(base, EmbeddedAssets::registered().copied())
};
let mut spa_fallback: Option<Arc<SpaMiddleware>> = None;
if let Some(ref source) = spa_source {
let spa = Arc::new(SpaMiddleware::from_source(source.clone()));
log_spa_source(&spa);
pipeline.add_middleware(Arc::clone(&spa) as Arc<dyn IMiddleware>);
spa_fallback = Some(spa);
}
for spec in self
.middleware_specs
.iter()
.filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterSpa)
{
pipeline.add_middleware((spec.factory)());
}
let pipeline = Arc::new(pipeline);
if self.mode == AppMode::Production
&& self.auth_enabled
&& is_weak_jwt_secret(&options.jwt.secret)
{
panic!(
"Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
);
}
if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
panic!(
"Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
);
}
let mut router = Router::new();
let mut route_count = 0usize;
crate::diagnostics::assert_route_configuration_valid();
let mut dispatch_map: std::collections::HashMap<
&'static str,
webx_core::route::scan::RouteDispatchFn,
> = std::collections::HashMap::new();
for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
}
for entry in inventory::iter::<RouteEntry> {
route_count += 1;
let stub = Arc::new(StubEndpoint {
method: entry.method.as_str(),
path: entry.path,
handler_type: entry.handler_type,
dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
authorization: entry.authorization,
authorizers: collect_authorizers(provider.as_ref()),
});
router.register(entry.method, entry.path, stub);
}
let openapi_spec = generate_openapi_spec(
"Rust WebX API",
env!("CARGO_PKG_VERSION"),
localizer.as_ref(),
);
if self.mode == AppMode::Development {
let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
router.register(
HttpMethod::Get,
"/api/openapi.json",
Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
);
router.register(
HttpMethod::Get,
"/api/openapi.html",
Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
);
}
let health_registry = Arc::clone(&self.health_registry);
let health_endpoint: Arc<dyn IEndpoint> =
Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
router.register(HttpMethod::Get, "/healthz", health_endpoint);
let live_body =
serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
router.register(
HttpMethod::Get,
"/health/live",
Arc::new(StaticJsonEndpoint {
body: live_body,
content_type: "application/health+json",
}),
);
router.register(
HttpMethod::Get,
"/health/ready",
Arc::new(HealthCheckEndpoint::new(health_registry)),
);
if options.metrics.enabled {
router.register(
HttpMethod::Get,
"/metrics",
Arc::new(MetricsEndpoint::new(http_metrics)),
);
}
if self.mode == AppMode::Development {
let version = env!("CARGO_PKG_VERSION");
tracing::info!("");
tracing::info!(" ----------------------------------------------------------------");
tracing::info!(" Rust WebX Framework v{}", version);
tracing::info!(" ----------------------------------------------------------------");
tracing::info!(" App: {}", options.app.name);
tracing::info!(" CORS: enabled");
if let Some(source) = spa_source.as_ref() {
tracing::info!(" SPA Root: {}", source.disk_root());
if let Some(assets) = source.embedded() {
tracing::info!(
" Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
assets.len(),
assets.total_bytes() as f64 / (1024.0 * 1024.0),
assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
assets.root(),
);
}
}
if route_count > 0 {
tracing::info!(" Routes: {} registered", route_count);
}
crate::diagnostics::log_startup_diagnostics();
let banner_urls = if options.app.urls.is_empty() {
vec!["http://localhost:5000".to_string()]
} else {
options
.app
.urls
.iter()
.map(|u| u.replace("0.0.0.0", "localhost"))
.collect::<Vec<_>>()
};
for url in &banner_urls {
tracing::info!(" OpenAPI: {}/api/openapi.html", url);
tracing::info!(" OpenAPI: {}/api/openapi.json", url);
}
tracing::info!(" ----------------------------------------------------------------");
tracing::info!("");
} else if route_count > 0 {
tracing::info!("{} route(s) registered", route_count);
}
let router = Arc::new(router);
let router_handler = make_router_handler(Arc::clone(&router), spa_fallback);
let hosted_services: Vec<Arc<dyn IHostedService>> =
provider.get_all::<dyn IHostedService>();
Host {
provider,
dispatch_runtime,
options,
endpoints,
localizer,
body_limits,
pipeline,
router,
router_handler,
mode: self.mode,
spa_source,
shutdown: Arc::new(tokio::sync::watch::channel(false).0),
hosted_services,
}
}
}
pub struct ServerHandle {
shutdown: ShutdownSignal,
}
impl ServerHandle {
pub fn shutdown(self) {
trigger_shutdown(&self.shutdown);
}
}
pub struct Server {
host: Host,
}
impl Server {
pub async fn run(self) -> Result<()> {
self.host.run().await
}
pub async fn run_at(self, addr: &str) -> Result<()> {
self.host.run_at(addr).await
}
pub fn handle(&self) -> ServerHandle {
self.host.server_handle()
}
}
impl Default for HostBuilder {
fn default() -> Self {
Self::new()
}
}
impl Host {
pub fn builder() -> HostBuilder {
HostBuilder::new()
}
pub fn options(&self) -> &AppOptions {
&self.options
}
pub fn provider(&self) -> &Arc<ServiceProvider> {
&self.provider
}
pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
&self.dispatch_runtime
}
pub fn endpoints(&self) -> &dyn IEndpointCatalog {
&*self.endpoints
}
pub fn localizer(&self) -> &dyn IStringLocalizer {
&*self.localizer
}
pub async fn run(&self) -> Result<()> {
let urls = if self.options.app.urls.is_empty() {
vec!["http://0.0.0.0:5000".to_string()]
} else {
self.options.app.urls.clone()
};
let mut http_addrs: Vec<String> = Vec::new();
let mut https_addrs: Vec<String> = Vec::new();
for url in &urls {
let (scheme, addr) = parse_url(url)?;
match scheme {
"http" => http_addrs.push(addr),
"https" => https_addrs.push(addr),
other => {
return Err(webx_core::error::Error::Http(format!(
"Unsupported URL scheme '{}' in '{}'",
other, url
)))
}
}
}
let acceptor = if !https_addrs.is_empty() {
let tls = &self.options.tls;
if tls.cert_path.is_empty() || tls.key_path.is_empty() {
return Err(webx_core::error::Error::Http(
"HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
));
}
Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
} else {
None
};
let mut http_listeners = Vec::with_capacity(http_addrs.len());
for addr in &http_addrs {
http_listeners.push(bind_listener(addr, "HTTP").await?);
}
let mut https_listeners = Vec::with_capacity(https_addrs.len());
for addr in &https_addrs {
https_listeners.push(bind_listener(addr, "HTTPS").await?);
}
start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
if self.mode == AppMode::Development {
tracing::info!("");
for url in &urls {
let display_url = url.replace("0.0.0.0", "localhost");
tracing::info!(" Listening on {}", display_url);
}
} else {
tracing::info!("Listening on {} url(s)", urls.len());
}
install_shutdown_handler(Arc::clone(&self.shutdown));
spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
let mut handles = Vec::new();
let pipeline = Arc::clone(&self.pipeline);
let router_handler = self.router_handler.clone();
let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
let mode = self.mode;
let body_limits = self.body_limits.clone();
let max_connections = self.options.app.max_connections;
for (addr, listener) in http_addrs.into_iter().zip(http_listeners) {
handles.push(tokio::spawn(serve_http(
addr,
listener,
self.shutdown.subscribe(),
Arc::clone(&pipeline),
router_handler.clone(),
Arc::clone(&dispatch_runtime),
mode,
body_limits.clone(),
max_connections,
)));
}
if let Some(ref tls_acceptor) = acceptor {
for (addr, listener) in https_addrs.into_iter().zip(https_listeners) {
handles.push(tokio::spawn(serve_https(
addr,
tls_acceptor.clone(),
listener,
self.shutdown.subscribe(),
Arc::clone(&pipeline),
router_handler.clone(),
Arc::clone(&dispatch_runtime),
mode,
body_limits.clone(),
max_connections,
)));
}
}
let mut run_error: Option<webx_core::error::Error> = None;
for h in handles {
if let Err(join_err) = h.await {
if run_error.is_none() {
run_error = Some(webx_core::error::Error::Internal(format!(
"a listener task failed: {join_err}"
)));
}
}
}
stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
match run_error {
Some(err) => Err(err),
None => Ok(()),
}
}
pub async fn run_at(&self, addr: &str) -> Result<()> {
let listener = bind_listener(addr, "HTTP").await?;
start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
install_shutdown_handler(Arc::clone(&self.shutdown));
spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
serve_http(
addr.to_string(),
listener,
self.shutdown.subscribe(),
Arc::clone(&self.pipeline),
self.router_handler.clone(),
Arc::clone(&self.dispatch_runtime),
self.mode,
self.body_limits.clone(),
self.options.app.max_connections,
)
.await;
stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
Ok(())
}
pub fn server_handle(&self) -> ServerHandle {
ServerHandle {
shutdown: Arc::clone(&self.shutdown),
}
}
pub fn into_server(self) -> Server {
Server { host: self }
}
}
#[async_trait::async_trait]
impl IHost for Host {
async fn run(&self, addr: &str) -> Result<()> {
self.run_at(addr).await
}
async fn stop(&self) -> Result<()> {
tracing::info!("Stop requested.");
trigger_shutdown(&self.shutdown);
Ok(())
}
}
fn make_router_handler(router: Arc<Router>, spa_fallback: Option<Arc<SpaMiddleware>>) -> HandlerFn {
Arc::new(move |ctx: &mut dyn IHttpContext| {
let router = Arc::clone(&router);
let spa_fallback = spa_fallback.clone();
Box::pin(async move {
if let Some(err) = ctx.take_early_error() {
return Err(err);
}
match router.match_route(ctx).await? {
Some((endpoint, params, pattern)) => {
drop(router);
for (key, value) in params {
ctx.request_mut().route_params_mut().insert(key, value);
}
*ctx.request_mut().route_pattern_mut() = Some(pattern);
endpoint.handle(ctx).await
}
None => {
let path_exists = router.path_exists(ctx.request().path());
let allowed = if path_exists {
router.allowed_methods(ctx.request().path())
} else {
Vec::new()
};
drop(router);
if !ctx.response().has_body() {
if let Some(spa) = spa_fallback {
if spa.try_index_fallback(ctx).await? {
return Ok(());
}
}
if path_exists {
if ctx.request().method().eq_ignore_ascii_case("OPTIONS") {
ctx.response_mut().set_status(204);
ctx.response_mut().set_header("allow", &allowed.join(", "));
} else {
write_method_not_allowed(ctx, &allowed).await;
}
} else {
write_plain_error(ctx, 404, "Not Found").await;
}
}
Ok(())
}
}
})
})
}
async fn handle_request(
req: Request<Incoming>,
pipeline: Arc<MiddlewarePipeline>,
router_handler: HandlerFn,
dispatch_runtime: Arc<DispatchRuntime>,
body_limits: BodyLimits,
remote_addr: Option<std::net::SocketAddr>,
) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
let mut ctx = HttpContext::new(req, body_limits, remote_addr);
let result = dispatch_runtime
.run(async { pipeline.execute(&mut ctx, router_handler).await })
.await;
if let Err(e) = result {
match e {
webx_core::error::Error::Problem(problem) => {
write_problem_response(&mut ctx, *problem).await
}
other => {
write_framework_error(&mut ctx, &other).await;
}
}
}
Ok(ctx.into_response().await)
}
async fn write_framework_error(ctx: &mut dyn IHttpContext, error: &webx_core::error::Error) {
let status = error.status_code();
if status == 401 {
write_unauthorized(
ctx,
error.public_detail().unwrap_or("authentication required"),
)
.await;
} else if status >= 500 {
tracing::error!(status, error = %error, "unhandled server error");
write_problem_response(ctx, build_problem(status, "an unexpected error occurred")).await;
} else {
write_problem_response(
ctx,
build_problem(status, error.public_detail().unwrap_or("request failed")),
)
.await;
}
}
async fn write_plain_error(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
write_problem_response(ctx, build_problem(status, message)).await;
}
async fn write_method_not_allowed(ctx: &mut dyn IHttpContext, allowed: &[&str]) {
let allow = allowed.join(", ");
ctx.response_mut().set_header("allow", &allow);
write_plain_error(ctx, 405, "Method Not Allowed").await;
}
fn trigger_shutdown(shutdown: &ShutdownSignal) {
shutdown.send_replace(true);
}
async fn wait_for_shutdown(shutdown: &mut tokio::sync::watch::Receiver<bool>) {
if *shutdown.borrow() {
return;
}
loop {
if shutdown.changed().await.is_err() {
return;
}
if *shutdown.borrow() {
return;
}
}
}
fn install_shutdown_handler(shutdown: ShutdownSignal) {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock, Weak};
static HANDLERS: OnceLock<Mutex<Vec<Weak<tokio::sync::watch::Sender<bool>>>>> = OnceLock::new();
static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
static INSTALLED: AtomicBool = AtomicBool::new(false);
let handlers = HANDLERS.get_or_init(|| Mutex::new(Vec::new()));
{
let mut registered = handlers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
registered.retain(|weak| weak.strong_count() > 0);
registered.push(Arc::downgrade(&shutdown));
}
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
if let Err(e) = ctrlc::set_handler(move || {
let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
if n == 0 {
tracing::info!("Shutdown signal received, draining connections...");
let registered = handlers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for weak in registered.iter() {
if let Some(shutdown) = weak.upgrade() {
trigger_shutdown(&shutdown);
}
}
} else {
tracing::warn!("Second interrupt — force exiting.");
std::process::exit(130);
}
}) {
tracing::warn!("Could not install Ctrl+C handler: {}", e);
}
}
fn spawn_async_shutdown_listener(shutdown: ShutdownSignal) {
tokio::spawn(async move {
wait_for_shutdown_signal().await;
tracing::info!("Async shutdown signal received, draining connections...");
trigger_shutdown(&shutdown);
});
}
async fn wait_for_shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! {
res = tokio::signal::ctrl_c() => {
if let Err(e) = res {
tracing::warn!("Ctrl+C listener error: {}", e);
}
}
_ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
{
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!("Ctrl+C listener error: {}", e);
}
}
}
const HOSTED_START_TIMEOUT_SECS: u64 = 30;
const HOSTED_STOP_TIMEOUT_SECS: u64 = 30;
async fn start_hosted_services(
services: &[Arc<dyn IHostedService>],
dispatch_runtime: &Arc<DispatchRuntime>,
) -> Result<()> {
if services.is_empty() {
return Ok(());
}
tracing::info!("Starting {} hosted service(s)...", services.len());
let mut started = 0usize;
let mut failure: Option<webx_core::error::Error> = None;
for svc in services {
let attempt = tokio::time::timeout(
std::time::Duration::from_secs(HOSTED_START_TIMEOUT_SECS),
dispatch_runtime.run_in_scope(async { svc.start().await }),
)
.await;
match attempt {
Ok(Ok(())) => started += 1,
Ok(Err(err)) => {
failure = Some(err);
break;
}
Err(_) => {
failure = Some(webx_core::error::Error::Internal(format!(
"hosted service start timed out after {HOSTED_START_TIMEOUT_SECS}s"
)));
break;
}
}
}
match failure {
None => {
tracing::info!("All {started} hosted service(s) started.");
Ok(())
}
Some(err) => {
for (index, svc) in services[..started].iter().enumerate().rev() {
stop_hosted_service(dispatch_runtime, index, svc).await;
}
Err(err)
}
}
}
async fn stop_hosted_services(
services: &[Arc<dyn IHostedService>],
dispatch_runtime: &Arc<DispatchRuntime>,
) {
if services.is_empty() {
return;
}
tracing::info!("Stopping {} hosted service(s)...", services.len());
for (index, svc) in services.iter().enumerate().rev() {
stop_hosted_service(dispatch_runtime, index, svc).await;
}
tracing::info!("All hosted services stopped.");
}
async fn stop_hosted_service(
dispatch_runtime: &Arc<DispatchRuntime>,
index: usize,
svc: &Arc<dyn IHostedService>,
) {
match tokio::time::timeout(
std::time::Duration::from_secs(HOSTED_STOP_TIMEOUT_SECS),
dispatch_runtime.run_in_scope(async { svc.stop().await }),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
tracing::warn!(service_index = index, error = %err, "hosted service stop error")
}
Err(_) => tracing::error!(
service_index = index,
timeout_secs = HOSTED_STOP_TIMEOUT_SECS,
"hosted service stop timed out"
),
}
}
async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
let drain_future = async {
while let Some(result) = join_set.join_next().await {
if let Err(e) = result {
tracing::error!("Connection task panicked: {:?}", e);
}
}
};
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
Ok(_) => tracing::info!("{}: drained.", label),
Err(_) => {
tracing::warn!("{}: drain timeout, aborting connections.", label);
join_set.abort_all();
while join_set.join_next().await.is_some() {}
}
}
}
async fn bind_listener(addr: &str, label: &str) -> Result<tokio::net::TcpListener> {
tokio::net::TcpListener::bind(addr).await.map_err(|err| {
webx_core::error::Error::Http(format!("failed to bind {label} on {addr}: {err}"))
})
}
fn parse_url(url: &str) -> Result<(&str, String)> {
if let Some(rest) = url.strip_prefix("https://") {
Ok(("https", rest.to_string()))
} else if let Some(rest) = url.strip_prefix("http://") {
Ok(("http", rest.to_string()))
} else {
Err(webx_core::error::Error::Http(format!(
"Invalid URL '{}'. Use http://host:port or https://host:port",
url
)))
}
}
#[allow(clippy::too_many_arguments)]
async fn serve_http(
addr: String,
listener: tokio::net::TcpListener,
mut shutdown: tokio::sync::watch::Receiver<bool>,
pipeline: Arc<MiddlewarePipeline>,
router_handler: HandlerFn,
dispatch_runtime: Arc<DispatchRuntime>,
mode: AppMode,
body_limits: BodyLimits,
max_connections: usize,
) {
let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
let mut join_set = JoinSet::new();
loop {
if *shutdown.borrow() {
tracing::info!("HTTP {}: stop accepting, draining...", addr);
break;
}
tokio::select! {
_ = shutdown.changed() => {
tracing::info!("HTTP {}: stop accepting, draining...", addr);
break;
}
accept_result = listener.accept() => {
let (stream, peer_addr) = match accept_result {
Ok(v) => v,
Err(e) => {
tracing::error!("Accept error (will retry): {}", e);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
};
let remote_addr = Some(peer_addr);
while join_set.try_join_next().is_some() {}
let permit = match sem.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => continue,
};
let io = TokioIo::new(stream);
let pipeline = Arc::clone(&pipeline);
let router_handler = router_handler.clone();
let dispatch_runtime = Arc::clone(&dispatch_runtime);
let body_limits = body_limits.clone();
let conn_shutdown = shutdown.clone();
join_set.spawn(async move {
let _permit = permit;
let svc_fn = service_fn(move |req: Request<Incoming>| {
let pipeline = Arc::clone(&pipeline);
let router_handler = router_handler.clone();
let dispatch_runtime = Arc::clone(&dispatch_runtime);
let body_limits = body_limits.clone();
let mode = mode;
let remote_addr = remote_addr;
async move {
let start = Instant::now();
let method = req.method().to_string();
let path = req.uri().path().to_string();
let result = handle_request(
req,
pipeline,
router_handler,
dispatch_runtime,
body_limits,
remote_addr,
)
.await;
let elapsed = start.elapsed();
if mode == AppMode::Development {
let status =
result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
tracing::info!(
"[{}] {} -> {} ({:.0}ms)",
method,
path,
status,
elapsed.as_secs_f64() * 1000.0
);
}
result
}
});
let conn = hyper::server::conn::http1::Builder::new()
.serve_connection(io, svc_fn);
let mut conn = std::pin::pin!(conn);
let mut conn_shutdown = conn_shutdown;
tokio::select! {
result = conn.as_mut() => {
if let Err(err) = result {
tracing::error!("Connection error: {}", err);
}
}
_ = wait_for_shutdown(&mut conn_shutdown) => {
conn.as_mut().graceful_shutdown();
if let Err(err) = conn.await {
tracing::debug!(error = %err, "connection closed during shutdown");
}
}
}
});
}
}
}
drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
}
#[allow(clippy::too_many_arguments)]
async fn serve_https(
addr: String,
acceptor: TlsAcceptor,
listener: tokio::net::TcpListener,
mut shutdown: tokio::sync::watch::Receiver<bool>,
pipeline: Arc<MiddlewarePipeline>,
router_handler: HandlerFn,
dispatch_runtime: Arc<DispatchRuntime>,
mode: AppMode,
body_limits: BodyLimits,
max_connections: usize,
) {
let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
let mut join_set = JoinSet::new();
loop {
if *shutdown.borrow() {
tracing::info!("HTTPS {}: stop accepting, draining...", addr);
break;
}
tokio::select! {
_ = shutdown.changed() => {
tracing::info!("HTTPS {}: stop accepting, draining...", addr);
break;
}
accept_result = listener.accept() => {
let (stream, peer_addr) = match accept_result {
Ok(v) => v,
Err(e) => {
tracing::error!("Accept error (will retry): {}", e);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
};
let remote_addr = Some(peer_addr);
while join_set.try_join_next().is_some() {}
let permit = match sem.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => continue,
};
let acceptor = acceptor.clone();
let pipeline = Arc::clone(&pipeline);
let router_handler = router_handler.clone();
let dispatch_runtime = Arc::clone(&dispatch_runtime);
let body_limits = body_limits.clone();
let conn_shutdown = shutdown.clone();
join_set.spawn(async move {
let _permit = permit;
match acceptor.accept(stream).await {
Ok(tls_stream) => {
let io = TokioIo::new(tls_stream);
let svc_fn = service_fn(move |req: Request<Incoming>| {
let pipeline = Arc::clone(&pipeline);
let router_handler = router_handler.clone();
let dispatch_runtime = Arc::clone(&dispatch_runtime);
let body_limits = body_limits.clone();
let mode = mode;
let remote_addr = remote_addr;
async move {
let start = Instant::now();
let method = req.method().to_string();
let path = req.uri().path().to_string();
let result = handle_request(
req,
pipeline,
router_handler,
dispatch_runtime,
body_limits,
remote_addr,
)
.await;
let elapsed = start.elapsed();
if mode == AppMode::Development {
let status =
result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
tracing::info!(
"[{}] {} -> {} ({:.0}ms)",
method,
path,
status,
elapsed.as_secs_f64() * 1000.0
);
}
result
}
});
let conn = hyper::server::conn::http1::Builder::new()
.serve_connection(io, svc_fn);
let mut conn = std::pin::pin!(conn);
let mut conn_shutdown = conn_shutdown;
tokio::select! {
result = conn.as_mut() => {
if let Err(err) = result {
tracing::error!("TLS connection error: {}", err);
}
}
_ = wait_for_shutdown(&mut conn_shutdown) => {
conn.as_mut().graceful_shutdown();
if let Err(err) = conn.await {
tracing::debug!(error = %err, "TLS connection closed during shutdown");
}
}
}
}
Err(e) => {
tracing::error!("TLS handshake error: {}", e);
}
}
});
}
}
}
drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
}
fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
use std::fs::File;
use std::io::BufReader;
if cert_path.is_empty() || key_path.is_empty() {
return Err(webx_core::error::Error::Http(
"TLS certificate or key path not configured.".into(),
));
}
let cert_file = File::open(cert_path).map_err(|e| {
webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
})?;
let mut cert_reader = BufReader::new(cert_file);
let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
if certs.is_empty() {
return Err(webx_core::error::Error::Http(format!(
"No valid certs in '{}'",
cert_path
)));
}
let key_file = File::open(key_path).map_err(|e| {
webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
})?;
let mut key_reader = BufReader::new(key_file);
let key = pkcs8_private_keys(&mut key_reader)
.filter_map(|r| r.ok())
.map(PrivateKeyDer::from)
.next()
.or_else(|| {
let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
let mut kr2 = key_file2;
let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
.filter_map(|r| r.ok())
.map(PrivateKeyDer::from)
.collect();
rsa_keys.into_iter().next()
})
.ok_or_else(|| {
webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
})?;
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
}
#[cfg(test)]
mod embedded_attach_tests {
use super::with_embedded_assets;
use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
path: "index.html",
bytes: b"hi",
raw_len: 2,
encoding: ContentEncoding::Identity,
content_type: "text/html",
etag: "\"x\"",
}];
#[test]
fn no_table_leaves_disk_source() {
let source = Some(SpaSource::Disk("wwwroot".into()));
let merged = with_embedded_assets(source, None);
assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
}
#[test]
fn table_layers_under_use_spa_overlay() {
let assets = EmbeddedAssets::new("../wwwroot", FILES);
let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
match merged {
Some(SpaSource::Embedded {
overlay_root,
assets,
}) => {
assert_eq!(overlay_root, "wwwroot");
assert_eq!(assets.root(), "../wwwroot");
}
other => panic!("expected embedded, got {other:?}"),
}
}
#[test]
fn table_alone_uses_build_root_as_overlay() {
let assets = EmbeddedAssets::new("../wwwroot", FILES);
let merged = with_embedded_assets(None, Some(assets));
match merged {
Some(SpaSource::Embedded { overlay_root, .. }) => {
assert_eq!(overlay_root, "../wwwroot");
}
other => panic!("expected embedded, got {other:?}"),
}
}
#[test]
fn host_crate_registers_no_asset_table() {
assert!(EmbeddedAssets::registered().is_none());
}
}