Skip to main content

webx_host/
server.rs

1//! Host builder and hyper server integration.
2//!
3//! Includes built-in exception middleware: errors produced by endpoints
4//! are caught and converted to well-formed HTTP error responses using
5//! `Error::status_code()`.
6
7use hyper::body::Incoming;
8use hyper::service::service_fn;
9use hyper::Request;
10use hyper_util::rt::TokioIo;
11use rust_dix::{ServiceCollection, ServiceProvider};
12use webx_core::app::IHost;
13use webx_core::config::{self, AppOptions};
14use webx_core::error::{Error, Result};
15use webx_core::handler::IHostedService;
16use webx_core::http::IHttpContext;
17use webx_core::localization::{IStringLocalizer, LocalizationOptions, StringLocalizer};
18use webx_core::middleware::IMiddleware;
19use webx_core::mode::AppMode;
20use webx_core::route::catalog::{EndpointCatalog, IEndpointCatalog};
21use webx_core::routing::{HttpMethod, IEndpoint, IRouter};
22
23use crate::auth_jwt::{init_jwt_secret, jwt_middleware, JwtAuth};
24use crate::authz::collect_authorizers;
25use crate::context::{BodyLimits, HttpContext, RespBody};
26use crate::cors::{CorsConfig, CorsMiddleware};
27use crate::endpoint::{StaticHtmlEndpoint, StaticJsonEndpoint, StubEndpoint};
28use crate::health::{HealthCheckEndpoint, HealthCheckRegistry, HealthStatus};
29use crate::memory_cache::MemoryCache;
30use crate::metrics::{HttpMetrics, MetricsEndpoint, MetricsMiddleware};
31use crate::pipeline::{HandlerFn, MiddlewarePipeline};
32use crate::problem_response::{build_problem, write_problem_response, write_unauthorized};
33use crate::rate_limit::RateLimitMiddleware;
34use crate::router::Router;
35use jsonwebtoken::{DecodingKey, Validation};
36use webx_core::route::scan::RouteEntry;
37use webx_core::DispatchRuntime;
38use webx_openapi::{generate_openapi_spec, APIUI_HTML};
39use webx_spa::{EmbeddedAssets, SpaMiddleware, SpaSource};
40
41use rustls::pki_types::{CertificateDer, PrivateKeyDer};
42use rustls::ServerConfig;
43use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
44use std::sync::Arc;
45use std::time::Instant;
46use tokio::task::JoinSet;
47use tokio_rustls::TlsAcceptor;
48
49/// Returns true when the JWT secret is missing, too short, or a known placeholder.
50fn is_weak_jwt_secret(secret: &str) -> bool {
51    secret.is_empty()
52        || secret.len() < 32
53        || secret.contains("change-in-production")
54        || secret.contains("please-change")
55        || secret.contains("demo-secret")
56        || secret.contains("dev-secret")
57}
58
59/// Log where the SPA files come from and which embedded files are overridden.
60///
61/// Overrides are the point of the feature, but a stale one silently defeats an
62/// updated build, so the list is logged instead of waiting to be discovered.
63fn log_spa_source(spa: &SpaMiddleware) {
64    tracing::info!("[Host] SPA root: {}", spa.resolved_root().display());
65    let Some(assets) = spa.embedded() else {
66        return;
67    };
68    tracing::info!(
69        "[Host] Embedded assets: {} files, {:.1} MiB embedded ({:.1} MiB uncompressed), built from `{}`",
70        assets.len(),
71        assets.total_bytes() as f64 / (1024.0 * 1024.0),
72        assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
73        assets.root(),
74    );
75    let shadowed = assets.shadowed_by(spa.resolved_root());
76    if !shadowed.is_empty() {
77        tracing::warn!(
78            "[Host] {} embedded file(s) are overridden on disk: {}. \
79             An override older than the current build stays in effect until it is \
80             updated or deleted.",
81            shadowed.len(),
82            shadowed.join(", "),
83        );
84    }
85}
86
87/// Layer a compiled-in table under `source` when one was registered.
88///
89/// Opt-in is `#[webx::main(embed)]` (plus `build.rs` `web_root`).
90/// `use_spa` names the disk overlay; without it, the table's build-time root
91/// is used as the overlay path.
92fn with_embedded_assets(
93    source: Option<SpaSource>,
94    assets: Option<EmbeddedAssets>,
95) -> Option<SpaSource> {
96    let Some(assets) = assets else {
97        return source;
98    };
99    let overlay_root = match source.as_ref() {
100        Some(existing) => existing.disk_root().to_string(),
101        None => assets.root().to_string(),
102    };
103    Some(SpaSource::Embedded {
104        assets,
105        overlay_root,
106    })
107}
108
109/// Shutdown signal shared by the host, its handle and every listener.
110///
111/// `watch` (not `Notify`) because a signal must not be lost: `Notify::notify_waiters`
112/// drops the wakeup when no listener is parked yet, which is exactly the case in
113/// the documented "spawn(run) then handle.shutdown()" pattern. A `watch` keeps
114/// the `true` value, so a listener that subscribes later still sees it.
115type ShutdownSignal = Arc<tokio::sync::watch::Sender<bool>>;
116
117pub struct Host {
118    #[allow(dead_code)]
119    provider: Arc<ServiceProvider>,
120    dispatch_runtime: Arc<DispatchRuntime>,
121    pub options: AppOptions,
122    /// Endpoint metadata for this host, built once from the compile-time routes.
123    endpoints: Arc<dyn IEndpointCatalog>,
124    /// Resolves endpoint resource keys for the host's configured culture.
125    localizer: Arc<dyn IStringLocalizer>,
126    /// Body and upload limits, derived from `App` + `Form` configuration.
127    body_limits: BodyLimits,
128    pipeline: Arc<MiddlewarePipeline>,
129    /// The matchit router (retained for introspection).
130    #[allow(dead_code)]
131    router: Arc<Router>,
132    /// Pre-built router handler —eliminates per-request Arc::new.
133    router_handler: HandlerFn,
134    mode: AppMode,
135    /// Kept for introspection; the middleware already holds its own copy.
136    #[allow(dead_code)]
137    spa_source: Option<SpaSource>,
138    shutdown: ShutdownSignal,
139    /// Hosted services that are started on host start
140    /// and stopped on graceful shutdown.
141    hosted_services: Vec<Arc<dyn IHostedService>>,
142}
143
144/// Factory for a user-registered middleware.
145type MiddlewareFactory = Box<dyn Fn() -> Arc<dyn IMiddleware> + Send + Sync>;
146
147/// A user middleware waiting to be placed in the pipeline at its slot.
148struct MiddlewareSpec {
149    slot: webx_core::middleware::MiddlewareSlot,
150    factory: MiddlewareFactory,
151}
152
153#[allow(clippy::type_complexity)]
154pub struct HostBuilder {
155    service_configs: Vec<Box<dyn FnOnce(ServiceCollection) -> ServiceCollection + Send>>,
156    mode: AppMode,
157    spa_source: Option<SpaSource>,
158    spa_disabled: bool,
159    options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
160    cors_config: Option<CorsConfig>,
161    auth_enabled: bool,
162    localization: Option<LocalizationOptions>,
163    health_registry: Arc<HealthCheckRegistry>,
164    /// Middlewares registered through the builder, kept with their slot so
165    /// ordering does not depend on DI enumeration order.
166    middleware_specs: Vec<MiddlewareSpec>,
167}
168
169#[allow(clippy::type_complexity)]
170pub struct HostAppBuilder {
171    options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
172}
173
174impl HostAppBuilder {
175    fn new() -> Self {
176        Self {
177            options_modifiers: Vec::new(),
178        }
179    }
180
181    #[allow(non_snake_case)]
182    pub fn useOptions<F>(&mut self, f: F)
183    where
184        F: FnOnce(&mut AppOptions) + Send + 'static,
185    {
186        self.options_modifiers.push(Box::new(f));
187    }
188}
189
190impl HostBuilder {
191    pub fn new() -> Self {
192        Self {
193            service_configs: Vec::new(),
194            // 默认从 `APP_ENV` 环境变量解析运行模式;未设置时为 Production。
195            // 应用可通过 `.mode()` 显式覆盖(本机开发通常设 APP_ENV=Development)。
196            mode: AppMode::from_env(),
197            spa_source: None,
198            spa_disabled: false,
199            options_modifiers: Vec::new(),
200            cors_config: None,
201            auth_enabled: false,
202            localization: None,
203            health_registry: HealthCheckRegistry::new(),
204            middleware_specs: Vec::new(),
205        }
206    }
207
208    pub fn register<F>(mut self, f: F) -> Self
209    where
210        F: FnOnce(ServiceCollection) -> ServiceCollection + Send + 'static,
211    {
212        self.service_configs.push(Box::new(f));
213        self
214    }
215
216    pub fn configure<F>(mut self, f: F) -> Self
217    where
218        F: FnOnce(&mut HostAppBuilder) + Send + 'static,
219    {
220        let mut builder = HostAppBuilder::new();
221        f(&mut builder);
222        self.options_modifiers
223            .append(&mut builder.options_modifiers);
224        self
225    }
226
227    pub fn mode(mut self, mode: AppMode) -> Self {
228        self.mode = mode;
229        self
230    }
231
232    /// 自动绑定 appsettings 配置节到 `T`,并以 `Arc<T>` 注册到 DI 容器。
233    ///
234    /// 参考 ASP.NET Core 的 `services.Configure<T>(configuration.GetSection(...))`:
235    /// 框架在 `build()` 时读取已合并的 appsettings(含 `{Mode}` overlay 与 env override),
236    /// 取 `section` 子节点反序列化为 `T`,注册为单例供 handler 通过 `Arc<T>` 注入。
237    ///
238    /// ```ignore
239    /// Host::builder()
240    ///     .add_options::<SiteConfig>("Site")
241    ///     .build()
242    /// ```
243    pub fn add_options<T>(self, section: &str) -> Self
244    where
245        T: for<'de> serde::Deserialize<'de> + Default + Send + Sync + 'static,
246    {
247        let mode = self.mode;
248        let section = section.to_string();
249        self.register(move |svc| {
250            let appsettings = config::load_appsettings(mode).unwrap_or_default();
251            let bound: T = config::bind_config(&appsettings, &section);
252            tracing::info!(
253                "[Host] add_options<{}>(\"{}\") registered",
254                std::any::type_name::<T>(),
255                section
256            );
257            svc.instance(Arc::new(bound))
258        })
259    }
260
261    /// Serve SPA static files from a directory on disk.
262    ///
263    /// ```ignore
264    /// Host::builder().use_spa("wwwroot").build()
265    /// ```
266    ///
267    /// **Runtime disk root** — the directory next to the process (or under
268    /// `app_base`) where operators may drop overrides. This is not
269    /// `build.rs` `web_root` (that path only names what was baked in).
270    ///
271    /// When the binary was built with `#[webx::main(embed)]` (and
272    /// `build.rs` wrote a table), that table is layered underneath this
273    /// directory at [`build`](Self::build): same-named disk files win.
274    /// Without `(embed)`, only the disk directory is served.
275    pub fn use_spa(mut self, source: impl Into<SpaSource>) -> Self {
276        self.spa_source = Some(match (self.spa_source.take(), source.into()) {
277            // Compiled-in files are already configured: only move the directory
278            // overrides are read from, rather than dropping the baseline.
279            (Some(SpaSource::Embedded { assets, .. }), SpaSource::Disk(root)) => {
280                SpaSource::Embedded {
281                    assets,
282                    overlay_root: root,
283                }
284            }
285            (_, incoming) => incoming,
286        });
287        self
288    }
289
290    /// Explicitly disable SPA, including auto-detection.
291    ///
292    /// By default, the framework auto-detects `wwwroot/` under `app_base()`.
293    /// Call this when you want a pure API host without SPA fallback,
294    /// or in tests to isolate framework behavior from filesystem state.
295    pub fn no_spa(mut self) -> Self {
296        self.spa_disabled = true;
297        self
298    }
299
300    pub fn use_cors(mut self, config: CorsConfig) -> Self {
301        self.cors_config = Some(config);
302        self
303    }
304
305    /// Register the JWT authentication service and middleware.
306    ///
307    /// The ASP.NET Core `AddAuthentication()` + `UseAuthentication()` pair: it
308    /// enables JWT bearer authentication so endpoints that declare
309    /// `#[authorize]` receive claims. Route authorization itself is part of each
310    /// endpoint's compile-time metadata and is always enforced after routing —
311    /// there is no second switch to turn it on.
312    ///
313    /// ```ignore
314    /// Host::builder()
315    ///     .add_authentication()
316    ///     .build()
317    /// ```
318    pub fn add_authentication(mut self) -> Self {
319        self.auth_enabled = true;
320        self
321    }
322
323    /// Enable localization with the conventional resource layout.
324    ///
325    /// Reads `<culture>.json` files from `locales/` under the application base
326    /// (for example `locales/zh-CN.json`, `locales/en.json`). The active culture
327    /// is `Culture.Default` from appsettings (or `APP__Culture__Default`); it
328    /// decides the language of endpoint names, OpenAPI, and the endpoint
329    /// catalog.
330    ///
331    /// ```ignore
332    /// Host::builder().add_localization().build()
333    /// ```
334    ///
335    /// A missing `locales/` directory is not an error; keys then fall back to
336    /// their literal or the key itself. For a single-file deployment, register
337    /// compiled-in resources with [`Self::add_localization_with`].
338    pub fn add_localization(mut self) -> Self {
339        self.localization = Some(LocalizationOptions::new());
340        self
341    }
342
343    /// Enable localization with explicit options.
344    ///
345    /// Use this for compiled-in resources (`include_str!`) or a non-conventional
346    /// resource directory. Explicit resources override files with the same
347    /// culture and key.
348    ///
349    /// ```ignore
350    /// Host::builder()
351    ///     .add_localization_with(
352    ///         LocalizationOptions::none()
353    ///             .resources("zh-CN", include_str!("../locales/zh-CN.json"))
354    ///             .resources("en", include_str!("../locales/en.json")),
355    ///     )
356    ///     .build()
357    /// ```
358    pub fn add_localization_with(mut self, options: LocalizationOptions) -> Self {
359        self.localization = Some(options);
360        self
361    }
362
363    /// Register a memory cache instance for use via `IDistributedCache` trait.
364    ///
365    /// The cache is registered as a singleton in the DI container.
366    /// Handlers can access it by implementing `From<Arc<MemoryCache>>`.
367    ///
368    /// ```ignore
369    /// Host::builder()
370    ///     .add_memory_cache()
371    ///     .build()
372    ///     .run().await;
373    /// ```
374    pub fn add_memory_cache(mut self) -> Self {
375        let cache = Arc::new(MemoryCache::new());
376        self.service_configs
377            .push(Box::new(move |svc| svc.instance(cache)));
378        self
379    }
380
381    /// Register a memory cache with custom configuration.
382    pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
383        let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
384        self.service_configs
385            .push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
386        self
387    }
388
389    /// Register a middleware by type in the default slot
390    /// ([`MiddlewareSlot::AfterAuth`](webx_core::middleware::MiddlewareSlot::AfterAuth)).
391    ///
392    /// The default slot runs **after** authentication, so middleware that reads
393    /// `ctx.claims()` actually sees the principal. Use
394    /// [`Self::use_middleware_at`] to choose a different slot.
395    ///
396    /// ```ignore
397    /// Host::builder()
398    ///     .use_middleware::<TimingMiddleware>()
399    ///     .build()
400    /// ```
401    pub fn use_middleware<T: IMiddleware + Default + 'static>(self) -> Self {
402        self.use_middleware_at::<T>(webx_core::middleware::MiddlewareSlot::AfterAuth)
403    }
404
405    /// Register a middleware by type in a specific pipeline slot.
406    pub fn use_middleware_at<T: IMiddleware + Default + 'static>(
407        mut self,
408        slot: webx_core::middleware::MiddlewareSlot,
409    ) -> Self {
410        self.middleware_specs.push(MiddlewareSpec {
411            slot,
412            factory: Box::new(|| Arc::new(T::default()) as Arc<dyn IMiddleware>),
413        });
414        self
415    }
416
417    /// Register a middleware with a custom factory function (default slot:
418    /// [`MiddlewareSlot::AfterAuth`](webx_core::middleware::MiddlewareSlot::AfterAuth)).
419    ///
420    /// Use this when the middleware requires constructor parameters:
421    ///
422    /// ```ignore
423    /// Host::builder()
424    ///     .use_middleware_with(|| {
425    ///         Arc::new(TimingMiddleware::new("api")) as Arc<dyn IMiddleware>
426    ///     })
427    ///     .build()
428    /// ```
429    pub fn use_middleware_with<F>(self, factory: F) -> Self
430    where
431        F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
432    {
433        self.use_middleware_with_at(webx_core::middleware::MiddlewareSlot::AfterAuth, factory)
434    }
435
436    /// Register a middleware with a custom factory in a specific pipeline slot.
437    pub fn use_middleware_with_at<F>(
438        mut self,
439        slot: webx_core::middleware::MiddlewareSlot,
440        factory: F,
441    ) -> Self
442    where
443        F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
444    {
445        self.middleware_specs.push(MiddlewareSpec {
446            slot,
447            factory: Box::new(factory),
448        });
449        self
450    }
451
452    /// Register a health check probe.
453    ///
454    /// Health checks are exposed via `/health` and `/health/ready` endpoints
455    /// following RFC 8407 (`application/health+json` content type).
456    ///
457    /// ```ignore
458    /// Host::builder()
459    ///     .add_health_check("database", || -> HealthStatus {
460    ///         if db_ping() { HealthStatus::pass() } else { HealthStatus::fail("db unreachable") }
461    ///     })
462    ///     .build()
463    /// ```
464    pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
465    where
466        F: Fn() -> HealthStatus + Send + Sync + 'static,
467    {
468        self.health_registry.register(name, Arc::new(check));
469        self
470    }
471
472    /// Build the host, reporting configuration problems as `Err` instead of
473    /// panicking.
474    ///
475    /// This is the non-panicking form of [`HostBuilder::build`]. Production
476    /// guards (missing/weak JWT secret, wildcard CORS), a failing DI provider
477    /// build, invalid localization resources and inconsistent route/handler
478    /// metadata all come back as [`Error::Di`] carrying the same operator-facing
479    /// message [`HostBuilder::build`] panics with.
480    ///
481    /// ```ignore
482    /// match Host::builder().mode(AppMode::Production).try_build() {
483    ///     Ok(host) => host.run().await?,
484    ///     Err(error) => eprintln!("invalid configuration: {error}"),
485    /// }
486    /// ```
487    pub fn try_build(self) -> Result<Host> {
488        // Initialize structured logging based on app mode.
489        // This is idempotent —subsequent calls are no-ops.
490        let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
491        if self.mode == AppMode::Development {
492            let _ = tracing_subscriber::fmt()
493                .with_env_filter(env_filter)
494                .without_time()
495                .with_target(false)
496                .try_init();
497        } else {
498            let _ = tracing_subscriber::fmt()
499                .json()
500                .with_env_filter(env_filter)
501                .try_init();
502        }
503
504        // Load configuration first: the localizer and endpoint catalog need the
505        // host-wide culture, and both are registered in DI.
506        let appsettings =
507            config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
508        let mut options: AppOptions = config::bind_root(&appsettings);
509        for modifier in self.options_modifiers {
510            modifier(&mut options);
511        }
512
513        let default_culture = if options.culture.default.trim().is_empty() {
514            "en".to_string()
515        } else {
516            options.culture.default.clone()
517        };
518        let localization = self.localization.unwrap_or_else(LocalizationOptions::none);
519        let localizer: Arc<dyn IStringLocalizer> = Arc::new(
520            StringLocalizer::new(default_culture, &localization)
521                .map_err(|error| Error::Di(format!("Invalid localization resources: {error}")))?,
522        );
523        let endpoints: Arc<dyn IEndpointCatalog> = Arc::new(EndpointCatalog::from_inventory());
524
525        let mut svc = ServiceCollection::from_injected();
526        for cfg in self.service_configs {
527            svc = cfg(svc);
528        }
529
530        // Register HealthCheckRegistry as a singleton for handler injection.
531        let health_registry = Arc::clone(&self.health_registry);
532        svc = svc.instance(health_registry);
533
534        // Endpoint metadata and localization are host services, not globals.
535        let endpoints_for_di = Arc::clone(&endpoints);
536        svc = svc.singleton::<dyn IEndpointCatalog>(move |_| Arc::clone(&endpoints_for_di));
537        let localizer_for_di = Arc::clone(&localizer);
538        svc = svc.singleton::<dyn IStringLocalizer>(move |_| Arc::clone(&localizer_for_di));
539
540        let provider = svc.build().map_err(|e| {
541            Error::Di(format!(
542                "Failed to build ServiceProvider: {}. Check your DI registrations.",
543                e
544            ))
545        })?;
546
547        // Per-host dispatch runtime (provider + handler registry).
548        let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
549        let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
550
551        // Upload spooling and body limits come from the same merged config.
552        let body_limits = BodyLimits::from_options(&options);
553        body_limits.apply_spool_root();
554        tracing::info!(
555            "[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
556            body_limits.multipart_body,
557            body_limits.file,
558            body_limits.memory_threshold,
559            body_limits.spool_dir.display()
560        );
561
562        let http_metrics = HttpMetrics::new();
563
564        let mut pipeline = MiddlewarePipeline::new();
565
566        // Pipeline order (see docs/rust-webx/07-middleware/ordering-strategy.md):
567        //
568        //   SecurityHeaders → RequestId → user(BeforeCors) → CORS → Metrics →
569        //   RateLimit → auth → user(AfterAuth, default) → SPA → user(AfterSpa) → Router
570        //
571        // CORS runs before the short-circuiting middlewares so a 429/413/401
572        // still carries Access-Control-Allow-Origin; Metrics runs before
573        // RateLimit so rate-limited requests are counted; user middleware
574        // defaults to AfterAuth so it can read the authenticated principal.
575        pipeline.add_middleware(Arc::new(
576            crate::security_headers::SecurityHeadersMiddleware::new(),
577        ));
578        pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
579
580        for spec in self
581            .middleware_specs
582            .iter()
583            .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::BeforeCors)
584        {
585            pipeline.add_middleware((spec.factory)());
586        }
587
588        let cors = self.cors_config.unwrap_or_else(|| {
589            let cs = &options.cors;
590            CorsConfig {
591                origins: cs.origins.clone(),
592                methods: cs.methods.clone(),
593                headers: cs.headers.clone(),
594                expose_headers: cs.expose_headers.clone(),
595                allow_credentials: cs.allow_credentials,
596                max_age: cs.max_age,
597            }
598        });
599        pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
600
601        if options.metrics.enabled {
602            tracing::info!("[Host] Metrics enabled at GET /metrics");
603            pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
604        }
605
606        if options.rate_limit.enabled {
607            tracing::info!(
608                "[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
609                options.rate_limit.requests_per_second,
610                options.rate_limit.burst_size,
611                options.rate_limit.max_tracked_ips
612            );
613            pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
614                &options.rate_limit,
615            )));
616        }
617
618        if self.auth_enabled {
619            let secret = options.jwt.secret.clone();
620            if !secret.is_empty() {
621                let jwt_auth = Arc::new(JwtAuth::new(
622                    DecodingKey::from_secret(secret.as_bytes()),
623                    Validation::default(),
624                ));
625                pipeline.add_middleware(Arc::new(jwt_middleware(jwt_auth)));
626                init_jwt_secret(&secret);
627            } else {
628                tracing::warn!(
629                    "add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
630                );
631            }
632        }
633
634        // User middlewares in the default slot: after authentication, so
635        // `ctx.claims()` is populated. Middlewares registered directly on the
636        // DI container join the same slot, after the builder-registered ones.
637        for spec in self
638            .middleware_specs
639            .iter()
640            .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterAuth)
641        {
642            pipeline.add_middleware((spec.factory)());
643        }
644        for mw in provider.get_all::<dyn IMiddleware>() {
645            pipeline.add_middleware(mw);
646        }
647
648        // SPA 自动启用:若应用显式调用 `use_spa`,使用指定的文件源;
649        // 否则框架自动检测应用基准目录下的 `wwwroot/`,存在即启用 SPA。
650        // 这样新应用无需在 main.rs 手写 `use_spa("wwwroot")` 样板。
651        // `no_spa()` 可显式禁用此行为(含自动检测),用于纯 API 主机或测试隔离。
652        // A registered `embed_assets!()` table is layered underneath automatically.
653        // SPA runs after JWT so auth middleware sees API requests first; SpaMiddleware
654        // skips /api/* paths so unmatched API routes return 404 from the router.
655        let spa_source = if self.spa_disabled {
656            None
657        } else {
658            let base = self.spa_source.clone().or_else(|| {
659                let candidate = webx_core::paths::app_base().join("wwwroot");
660                if candidate.is_dir() {
661                    tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
662                    Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
663                } else {
664                    None
665                }
666            });
667            with_embedded_assets(base, EmbeddedAssets::registered().copied())
668        };
669        let mut spa_fallback: Option<Arc<SpaMiddleware>> = None;
670        if let Some(ref source) = spa_source {
671            let spa = Arc::new(SpaMiddleware::from_source(source.clone()));
672            log_spa_source(&spa);
673            pipeline.add_middleware(Arc::clone(&spa) as Arc<dyn IMiddleware>);
674            // Kept for the post-routing index fallback: static files are served
675            // before routing, but the SPA shell is only served when the router
676            // has no match.
677            spa_fallback = Some(spa);
678        }
679
680        // Last-chance hooks: after static files/SPA, immediately before routing.
681        for spec in self
682            .middleware_specs
683            .iter()
684            .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterSpa)
685        {
686            pipeline.add_middleware((spec.factory)());
687        }
688
689        let pipeline = Arc::new(pipeline);
690
691        // Security: fail fast in production when auth is enabled but JWT secret is missing or weak
692        if self.mode == AppMode::Production
693            && self.auth_enabled
694            && is_weak_jwt_secret(&options.jwt.secret)
695        {
696            return Err(Error::Di(
697                "Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
698                    .to_string(),
699            ));
700        }
701
702        // Security: fail fast in production when CORS allows all origins
703        if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
704            return Err(Error::Di(
705                "Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
706                    .to_string(),
707            ));
708        }
709
710        let mut router = Router::new();
711        let mut route_count = 0usize;
712
713        crate::diagnostics::validate_route_configuration()?;
714
715        // Build dispatch map: handler_type → dispatch function
716        let mut dispatch_map: std::collections::HashMap<
717            &'static str,
718            webx_core::route::scan::RouteDispatchFn,
719        > = std::collections::HashMap::new();
720
721        for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
722            dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
723        }
724
725        for entry in inventory::iter::<RouteEntry> {
726            route_count += 1;
727            let stub = Arc::new(StubEndpoint {
728                method: entry.method.as_str(),
729                path: entry.path,
730                handler_type: entry.handler_type,
731                dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
732                authorization: entry.authorization,
733                authorizers: collect_authorizers(provider.as_ref()),
734            });
735            router.register(entry.method, entry.path, stub);
736        }
737
738        let openapi_spec = generate_openapi_spec(
739            "Rust WebX API",
740            env!("CARGO_PKG_VERSION"),
741            localizer.as_ref(),
742        );
743        if self.mode == AppMode::Development {
744            let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
745            router.register(
746                HttpMethod::Get,
747                "/api/openapi.json",
748                Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
749            );
750            router.register(
751                HttpMethod::Get,
752                "/api/openapi.html",
753                Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
754            );
755        }
756
757        // Health check endpoints (RFC 8407 application/health+json)
758        // Probes are evaluated on each request (not baked at build time).
759        let health_registry = Arc::clone(&self.health_registry);
760        let health_endpoint: Arc<dyn IEndpoint> =
761            Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
762        router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
763        router.register(HttpMethod::Get, "/healthz", health_endpoint);
764
765        // /health/live: liveness (process alive → pass, no probe queries)
766        let live_body =
767            serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
768        router.register(
769            HttpMethod::Get,
770            "/health/live",
771            Arc::new(StaticJsonEndpoint {
772                body: live_body,
773                content_type: "application/health+json",
774            }),
775        );
776
777        // /health/ready: readiness (queries registry, same as /health)
778        router.register(
779            HttpMethod::Get,
780            "/health/ready",
781            Arc::new(HealthCheckEndpoint::new(health_registry)),
782        );
783
784        if options.metrics.enabled {
785            router.register(
786                HttpMethod::Get,
787                "/metrics",
788                Arc::new(MetricsEndpoint::new(http_metrics)),
789            );
790        }
791
792        if self.mode == AppMode::Development {
793            let version = env!("CARGO_PKG_VERSION");
794            tracing::info!("");
795            tracing::info!("  ----------------------------------------------------------------");
796            tracing::info!("    Rust WebX Framework v{}", version);
797            tracing::info!("  ----------------------------------------------------------------");
798            tracing::info!("    App:      {}", options.app.name);
799            tracing::info!("    CORS:     enabled");
800            if let Some(source) = spa_source.as_ref() {
801                tracing::info!("    SPA Root: {}", source.disk_root());
802                if let Some(assets) = source.embedded() {
803                    tracing::info!(
804                        "    Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
805                        assets.len(),
806                        assets.total_bytes() as f64 / (1024.0 * 1024.0),
807                        assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
808                        assets.root(),
809                    );
810                }
811            }
812            if route_count > 0 {
813                tracing::info!("    Routes:   {} registered", route_count);
814            }
815            crate::diagnostics::log_startup_diagnostics();
816            let banner_urls = if options.app.urls.is_empty() {
817                vec!["http://localhost:5000".to_string()]
818            } else {
819                options
820                    .app
821                    .urls
822                    .iter()
823                    .map(|u| u.replace("0.0.0.0", "localhost"))
824                    .collect::<Vec<_>>()
825            };
826            for url in &banner_urls {
827                tracing::info!("    OpenAPI:  {}/api/openapi.html", url);
828                tracing::info!("    OpenAPI:  {}/api/openapi.json", url);
829            }
830            tracing::info!("  ----------------------------------------------------------------");
831            tracing::info!("");
832        } else if route_count > 0 {
833            tracing::info!("{} route(s) registered", route_count);
834        }
835
836        let router = Arc::new(router);
837        let router_handler = make_router_handler(Arc::clone(&router), spa_fallback);
838
839        // Resolve all registered hosted services from the DI container.
840        // These will be started when `run()` is called and stopped on shutdown.
841        let hosted_services: Vec<Arc<dyn IHostedService>> =
842            provider.get_all::<dyn IHostedService>();
843
844        Ok(Host {
845            provider,
846            dispatch_runtime,
847            options,
848            endpoints,
849            localizer,
850            body_limits,
851            pipeline,
852            router,
853            router_handler,
854            mode: self.mode,
855            spa_source,
856            shutdown: Arc::new(tokio::sync::watch::channel(false).0),
857            hosted_services,
858        })
859    }
860
861    /// Build the host, panicking on configuration errors (fail fast at startup).
862    ///
863    /// Equivalent to [`HostBuilder::try_build`] followed by a panic that keeps
864    /// the historical message, so a misconfigured deployment stops the process
865    /// instead of serving with weakened guards. Call `try_build` directly when
866    /// the caller wants to handle the error itself.
867    pub fn build(self) -> Host {
868        match self.try_build() {
869            Ok(host) => host,
870            // `Error::Di` carries the operator-facing text without a variant
871            // prefix, keeping the panic message identical to pre-0.10 builds.
872            Err(error) => panic!("{}", build_failure_message(&error)),
873        }
874    }
875}
876
877/// Operator-facing text of a `try_build` failure, without the `Error` variant
878/// prefix (the `build()` panic message must not change in 0.10).
879fn build_failure_message(error: &Error) -> String {
880    match error {
881        Error::Di(message) => message.clone(),
882        other => other.to_string(),
883    }
884}
885
886/// Handle for a running server, allowing programmatic graceful shutdown.
887pub struct ServerHandle {
888    shutdown: ShutdownSignal,
889}
890
891impl ServerHandle {
892    /// Signal the server to stop accepting new connections and begin
893    /// draining existing ones.
894    ///
895    /// The signal is stored in a `watch` channel, so calling this before the
896    /// server's accept loops start is not lost.
897    pub fn shutdown(self) {
898        trigger_shutdown(&self.shutdown);
899    }
900}
901
902/// A fully built server that owns its runtime lifecycle.
903///
904/// Created via `Host::into_server()`. Provides a graceful shutdown
905/// handle alongside the `run()` method.
906///
907/// ```ignore
908/// let server = Host::builder().build().into_server();
909/// let handle = server.handle();
910/// tokio::spawn(async move { server.run().await });
911/// handle.shutdown();
912/// ```
913pub struct Server {
914    host: Host,
915}
916
917impl Server {
918    /// Start listening on all configured URLs.
919    pub async fn run(self) -> Result<()> {
920        self.host.run().await
921    }
922
923    /// Start listening on a single address (convenience).
924    pub async fn run_at(self, addr: &str) -> Result<()> {
925        self.host.run_at(addr).await
926    }
927
928    /// Obtain a shutdown handle before starting.
929    pub fn handle(&self) -> ServerHandle {
930        self.host.server_handle()
931    }
932}
933
934impl Default for HostBuilder {
935    fn default() -> Self {
936        Self::new()
937    }
938}
939
940impl Host {
941    pub fn builder() -> HostBuilder {
942        HostBuilder::new()
943    }
944
945    pub fn options(&self) -> &AppOptions {
946        &self.options
947    }
948
949    /// Root DI container for this host instance.
950    pub fn provider(&self) -> &Arc<ServiceProvider> {
951        &self.provider
952    }
953
954    /// Instance-scoped dispatch runtime (provider + handler cache).
955    pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
956        &self.dispatch_runtime
957    }
958
959    /// Endpoint metadata for this host.
960    ///
961    /// Built once from the compile-time route table; use it instead of reading
962    /// `inventory` directly. Resolve names through [`Self::localizer`] or the
963    /// DI-registered `IStringLocalizer`.
964    pub fn endpoints(&self) -> &dyn IEndpointCatalog {
965        &*self.endpoints
966    }
967
968    /// Localizer for this host's configured `Culture.Default`.
969    pub fn localizer(&self) -> &dyn IStringLocalizer {
970        &*self.localizer
971    }
972
973    /// Start the server on all URLs configured in AppOptions.app.Urls.
974    ///
975    /// Automatically detects http:// and https:// URLs from the array.
976    /// Starts a plain TCP listener for each http:// URL and a TLS listener
977    /// for each https:// URL. All listeners run concurrently.
978    ///
979    /// Supports graceful shutdown on Ctrl+C/SIGTERM with 30s connection drain.
980    ///
981    /// # Example
982    ///
983    /// ```json
984    /// { "App": { "Urls": ["http://localhost:5000", "https://localhost:5030"] } }
985    /// ```
986    pub async fn run(&self) -> Result<()> {
987        let urls = if self.options.app.urls.is_empty() {
988            vec!["http://0.0.0.0:5000".to_string()]
989        } else {
990            self.options.app.urls.clone()
991        };
992
993        let mut http_addrs: Vec<String> = Vec::new();
994        let mut https_addrs: Vec<String> = Vec::new();
995
996        for url in &urls {
997            let (scheme, addr) = parse_url(url)?;
998            match scheme {
999                "http" => http_addrs.push(addr),
1000                "https" => https_addrs.push(addr),
1001                other => {
1002                    return Err(webx_core::error::Error::Http(format!(
1003                        "Unsupported URL scheme '{}' in '{}'",
1004                        other, url
1005                    )))
1006                }
1007            }
1008        }
1009
1010        let acceptor = if !https_addrs.is_empty() {
1011            let tls = &self.options.tls;
1012            if tls.cert_path.is_empty() || tls.key_path.is_empty() {
1013                return Err(webx_core::error::Error::Http(
1014                    "HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
1015                ));
1016            }
1017            Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
1018        } else {
1019            None
1020        };
1021
1022        // Bind every listener *before* starting hosted services or spawning
1023        // accept loops. A port conflict must fail `run()`, not be logged and
1024        // forgotten (which used to leave the process exiting with code 0).
1025        let mut http_listeners = Vec::with_capacity(http_addrs.len());
1026        for addr in &http_addrs {
1027            http_listeners.push(bind_listener(addr, "HTTP").await?);
1028        }
1029        let mut https_listeners = Vec::with_capacity(https_addrs.len());
1030        for addr in &https_addrs {
1031            https_listeners.push(bind_listener(addr, "HTTPS").await?);
1032        }
1033
1034        start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
1035
1036        // Banner
1037        if self.mode == AppMode::Development {
1038            tracing::info!("");
1039            for url in &urls {
1040                let display_url = url.replace("0.0.0.0", "localhost");
1041                tracing::info!("  Listening on {}", display_url);
1042            }
1043        } else {
1044            tracing::info!("Listening on {} url(s)", urls.len());
1045        }
1046
1047        install_shutdown_handler(Arc::clone(&self.shutdown));
1048        spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1049
1050        let mut handles = Vec::new();
1051        let pipeline = Arc::clone(&self.pipeline);
1052        let router_handler = self.router_handler.clone();
1053        let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
1054        let mode = self.mode;
1055        let body_limits = self.body_limits.clone();
1056        let max_connections = self.options.app.max_connections;
1057
1058        for (addr, listener) in http_addrs.into_iter().zip(http_listeners) {
1059            handles.push(tokio::spawn(serve_http(
1060                addr,
1061                listener,
1062                self.shutdown.subscribe(),
1063                Arc::clone(&pipeline),
1064                router_handler.clone(),
1065                Arc::clone(&dispatch_runtime),
1066                mode,
1067                body_limits.clone(),
1068                max_connections,
1069            )));
1070        }
1071
1072        if let Some(ref tls_acceptor) = acceptor {
1073            for (addr, listener) in https_addrs.into_iter().zip(https_listeners) {
1074                handles.push(tokio::spawn(serve_https(
1075                    addr,
1076                    tls_acceptor.clone(),
1077                    listener,
1078                    self.shutdown.subscribe(),
1079                    Arc::clone(&pipeline),
1080                    router_handler.clone(),
1081                    Arc::clone(&dispatch_runtime),
1082                    mode,
1083                    body_limits.clone(),
1084                    max_connections,
1085                )));
1086            }
1087        }
1088
1089        // Wait for all listeners to finish (they exit after the shutdown
1090        // signal); a panicked listener is a host failure, not a silent exit.
1091        let mut run_error: Option<webx_core::error::Error> = None;
1092        for h in handles {
1093            if let Err(join_err) = h.await {
1094                if run_error.is_none() {
1095                    run_error = Some(webx_core::error::Error::Internal(format!(
1096                        "a listener task failed: {join_err}"
1097                    )));
1098                }
1099            }
1100        }
1101
1102        stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
1103
1104        match run_error {
1105            Some(err) => Err(err),
1106            None => Ok(()),
1107        }
1108    }
1109
1110    /// Start the server at a single explicit address (convenience wrapper).
1111    pub async fn run_at(&self, addr: &str) -> Result<()> {
1112        let listener = bind_listener(addr, "HTTP").await?;
1113        start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
1114
1115        install_shutdown_handler(Arc::clone(&self.shutdown));
1116        spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1117        serve_http(
1118            addr.to_string(),
1119            listener,
1120            self.shutdown.subscribe(),
1121            Arc::clone(&self.pipeline),
1122            self.router_handler.clone(),
1123            Arc::clone(&self.dispatch_runtime),
1124            self.mode,
1125            self.body_limits.clone(),
1126            self.options.app.max_connections,
1127        )
1128        .await;
1129
1130        stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
1131        Ok(())
1132    }
1133
1134    /// Return a `ServerHandle` that can be used to signal graceful shutdown
1135    /// from application code (e.g., integration tests, health checks).
1136    ///
1137    /// ```ignore
1138    /// let host = Host::builder().build();
1139    /// let handle = host.server_handle();
1140    /// tokio::spawn(async move { host.run().await });
1141    /// // ... later:
1142    /// handle.shutdown();
1143    /// ```
1144    pub fn server_handle(&self) -> ServerHandle {
1145        ServerHandle {
1146            shutdown: Arc::clone(&self.shutdown),
1147        }
1148    }
1149
1150    /// Consume the host and return a `Server` that owns the runtime lifecycle.
1151    ///
1152    /// The returned `Server` can be `.run().await`-ed and provides a
1153    /// handle for graceful shutdown.
1154    pub fn into_server(self) -> Server {
1155        Server { host: self }
1156    }
1157}
1158
1159#[async_trait::async_trait]
1160impl IHost for Host {
1161    async fn run(&self, addr: &str) -> Result<()> {
1162        self.run_at(addr).await
1163    }
1164    async fn stop(&self) -> Result<()> {
1165        tracing::info!("Stop requested.");
1166        trigger_shutdown(&self.shutdown);
1167        Ok(())
1168    }
1169}
1170
1171fn make_router_handler(router: Arc<Router>, spa_fallback: Option<Arc<SpaMiddleware>>) -> HandlerFn {
1172    Arc::new(move |ctx: &mut dyn IHttpContext| {
1173        let router = Arc::clone(&router);
1174        let spa_fallback = spa_fallback.clone();
1175        Box::pin(async move {
1176            // The context may carry a rejection detected from the request line
1177            // (oversized declared Content-Length). Failing here — inside the
1178            // pipeline — keeps CORS/security headers/logging/metrics in play.
1179            if let Some(err) = ctx.take_early_error() {
1180                return Err(err);
1181            }
1182            match router.match_route(ctx).await? {
1183                Some((endpoint, params, pattern)) => {
1184                    drop(router);
1185                    for (key, value) in params {
1186                        ctx.request_mut().route_params_mut().insert(key, value);
1187                    }
1188                    *ctx.request_mut().route_pattern_mut() = Some(pattern);
1189                    endpoint.handle(ctx).await
1190                }
1191                None => {
1192                    let path_exists = router.path_exists(ctx.request().path());
1193                    let allowed = if path_exists {
1194                        router.allowed_methods(ctx.request().path())
1195                    } else {
1196                        Vec::new()
1197                    };
1198                    drop(router);
1199                    // Don't overwrite if a middleware (e.g. a static file
1200                    // response) already wrote a body.
1201                    if !ctx.response().has_body() {
1202                        // SPA routing: only a genuine router miss gets the app
1203                        // shell, and only when the client asked for HTML.
1204                        if let Some(spa) = spa_fallback {
1205                            if spa.try_index_fallback(ctx).await? {
1206                                return Ok(());
1207                            }
1208                        }
1209                        if path_exists {
1210                            if ctx.request().method().eq_ignore_ascii_case("OPTIONS") {
1211                                // Method discovery (RFC 9110 §9.3.7): CORS
1212                                // preflights are answered earlier, so this is a
1213                                // plain OPTIONS and must report what the path
1214                                // actually allows.
1215                                ctx.response_mut().set_status(204);
1216                                ctx.response_mut().set_header("allow", &allowed.join(", "));
1217                            } else {
1218                                // RFC 9110 §15.5.6: a 405 must carry `Allow`.
1219                                write_method_not_allowed(ctx, &allowed).await;
1220                            }
1221                        } else {
1222                            write_plain_error(ctx, 404, "Not Found").await;
1223                        }
1224                    }
1225                    Ok(())
1226                }
1227            }
1228        })
1229    })
1230}
1231
1232async fn handle_request(
1233    req: Request<Incoming>,
1234    pipeline: Arc<MiddlewarePipeline>,
1235    router_handler: HandlerFn,
1236    dispatch_runtime: Arc<DispatchRuntime>,
1237    body_limits: BodyLimits,
1238    remote_addr: Option<std::net::SocketAddr>,
1239) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
1240    let mut ctx = HttpContext::new(req, body_limits, remote_addr);
1241
1242    let result = dispatch_runtime
1243        .run(async { pipeline.execute(&mut ctx, router_handler).await })
1244        .await;
1245    if let Err(e) = result {
1246        match e {
1247            // A binding/validation failure already carries its full
1248            // ProblemDetails (including the `errors` extension); write it
1249            // verbatim instead of flattening it into a detail string.
1250            webx_core::error::Error::Problem(problem) => {
1251                write_problem_response(&mut ctx, *problem).await
1252            }
1253            other => {
1254                write_framework_error(&mut ctx, &other).await;
1255            }
1256        }
1257    }
1258
1259    Ok(ctx.into_response().await)
1260}
1261
1262/// Write an error the framework itself produced (not an `Error::Problem`).
1263///
1264/// * 401 goes through the single unauthorized writer so every challenge is
1265///   identical and carries `WWW-Authenticate`; the variant's own message
1266///   ("authentication required" vs "invalid or expired token") is kept so a
1267///   client can tell missing credentials from a rejected token.
1268/// * 5xx never echoes the internal message: it is logged and replaced with a
1269///   stable detail, because database errors and file paths must not reach
1270///   clients.
1271/// * 4xx keep their (client-facing) detail, taken from the variant without the
1272///   operator-facing `Display` prefix.
1273async fn write_framework_error(ctx: &mut dyn IHttpContext, error: &webx_core::error::Error) {
1274    let status = error.status_code();
1275    if status == 401 {
1276        write_unauthorized(
1277            ctx,
1278            error.public_detail().unwrap_or("authentication required"),
1279        )
1280        .await;
1281    } else if status >= 500 {
1282        tracing::error!(status, error = %error, "unhandled server error");
1283        write_problem_response(ctx, build_problem(status, "an unexpected error occurred")).await;
1284    } else {
1285        write_problem_response(
1286            ctx,
1287            build_problem(status, error.public_detail().unwrap_or("request failed")),
1288        )
1289        .await;
1290    }
1291}
1292
1293/// Write a framework-generated 4xx that has no [`Error`] value behind it
1294/// (404 from an unmatched route).
1295async fn write_plain_error(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
1296    write_problem_response(ctx, build_problem(status, message)).await;
1297}
1298
1299/// Write a 405 with the `Allow` header RFC 9110 §15.5.6 requires.
1300async fn write_method_not_allowed(ctx: &mut dyn IHttpContext, allowed: &[&str]) {
1301    let allow = allowed.join(", ");
1302    ctx.response_mut().set_header("allow", &allow);
1303    write_plain_error(ctx, 405, "Method Not Allowed").await;
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Shutdown helpers
1308// ---------------------------------------------------------------------------
1309
1310/// Raise the shutdown flag.
1311///
1312/// `watch::Sender::send` stores the value, so a listener that subscribes later
1313/// (or a listener between two `select!` iterations) still observes it — unlike
1314/// `Notify::notify_waiters`, which drops the wakeup when nobody is parked.
1315fn trigger_shutdown(shutdown: &ShutdownSignal) {
1316    // `send_replace` stores the value unconditionally. `send` would fail while
1317    // no receiver exists (e.g. `handle.shutdown()` called before `run()`
1318    // subscribes) and silently drop the signal — the exact bug this type
1319    // replaces `Notify` for.
1320    shutdown.send_replace(true);
1321}
1322
1323/// Resolve once the host has been asked to shut down.
1324///
1325/// Returns immediately when the flag is already set; also returns when the
1326/// sender disappears (host dropped) so a connection task never hangs forever.
1327async fn wait_for_shutdown(shutdown: &mut tokio::sync::watch::Receiver<bool>) {
1328    if *shutdown.borrow() {
1329        return;
1330    }
1331    loop {
1332        if shutdown.changed().await.is_err() {
1333            return;
1334        }
1335        if *shutdown.borrow() {
1336            return;
1337        }
1338    }
1339}
1340
1341/// Register a process-wide Ctrl+C / SIGINT handler.
1342///
1343/// On Windows, `cargo run` may exit the parent without stopping the child;
1344/// registering here ensures the server process itself receives the signal.
1345/// A second Ctrl+C forces immediate exit.
1346///
1347/// `ctrlc::set_handler` can only be installed once per process, so every host
1348/// registers its signal (weakly) here and the single handler raises all of
1349/// them. That keeps two hosts in one process — integration tests, embedded
1350/// scenarios — individually stoppable by Ctrl+C.
1351fn install_shutdown_handler(shutdown: ShutdownSignal) {
1352    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1353    use std::sync::{Mutex, OnceLock, Weak};
1354
1355    static HANDLERS: OnceLock<Mutex<Vec<Weak<tokio::sync::watch::Sender<bool>>>>> = OnceLock::new();
1356    static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
1357    static INSTALLED: AtomicBool = AtomicBool::new(false);
1358
1359    let handlers = HANDLERS.get_or_init(|| Mutex::new(Vec::new()));
1360    {
1361        let mut registered = handlers
1362            .lock()
1363            .unwrap_or_else(|poisoned| poisoned.into_inner());
1364        // Drop hosts that have already exited before adding this one.
1365        registered.retain(|weak| weak.strong_count() > 0);
1366        registered.push(Arc::downgrade(&shutdown));
1367    }
1368
1369    // `ctrlc::set_handler` is process-wide and may only be called once. The
1370    // flag (not the registry length) decides: hosts may come and go, but the
1371    // handler stays installed and raises whichever hosts are alive.
1372    if INSTALLED.swap(true, Ordering::SeqCst) {
1373        return;
1374    }
1375
1376    if let Err(e) = ctrlc::set_handler(move || {
1377        let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
1378        if n == 0 {
1379            tracing::info!("Shutdown signal received, draining connections...");
1380            let registered = handlers
1381                .lock()
1382                .unwrap_or_else(|poisoned| poisoned.into_inner());
1383            for weak in registered.iter() {
1384                if let Some(shutdown) = weak.upgrade() {
1385                    trigger_shutdown(&shutdown);
1386                }
1387            }
1388        } else {
1389            tracing::warn!("Second interrupt — force exiting.");
1390            std::process::exit(130);
1391        }
1392    }) {
1393        tracing::warn!("Could not install Ctrl+C handler: {}", e);
1394    }
1395}
1396
1397/// Tokio-based listener for Ctrl+C (all platforms) and SIGTERM (Unix).
1398fn spawn_async_shutdown_listener(shutdown: ShutdownSignal) {
1399    tokio::spawn(async move {
1400        wait_for_shutdown_signal().await;
1401        tracing::info!("Async shutdown signal received, draining connections...");
1402        trigger_shutdown(&shutdown);
1403    });
1404}
1405
1406async fn wait_for_shutdown_signal() {
1407    #[cfg(unix)]
1408    {
1409        use tokio::signal::unix::{signal, SignalKind};
1410        let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
1411        tokio::select! {
1412            res = tokio::signal::ctrl_c() => {
1413                if let Err(e) = res {
1414                    tracing::warn!("Ctrl+C listener error: {}", e);
1415                }
1416            }
1417            _ = sigterm.recv() => {}
1418        }
1419    }
1420    #[cfg(not(unix))]
1421    {
1422        if let Err(e) = tokio::signal::ctrl_c().await {
1423            tracing::warn!("Ctrl+C listener error: {}", e);
1424        }
1425    }
1426}
1427
1428/// How long a hosted service may take to start before the host gives up.
1429const HOSTED_START_TIMEOUT_SECS: u64 = 30;
1430/// How long a hosted service may take to stop before it is skipped.
1431const HOSTED_STOP_TIMEOUT_SECS: u64 = 30;
1432
1433async fn start_hosted_services(
1434    services: &[Arc<dyn IHostedService>],
1435    dispatch_runtime: &Arc<DispatchRuntime>,
1436) -> Result<()> {
1437    if services.is_empty() {
1438        return Ok(());
1439    }
1440    tracing::info!("Starting {} hosted service(s)...", services.len());
1441    let mut started = 0usize;
1442    let mut failure: Option<webx_core::error::Error> = None;
1443
1444    for svc in services {
1445        // Each start gets its own DI scope: a `dispatch_provider().get_owned()`
1446        // inside `start()` resolves scoped dependencies (DbContext, connections)
1447        // against this scope instead of the root provider's cache, so they are
1448        // disposed when the start finishes rather than living for the process.
1449        let attempt = tokio::time::timeout(
1450            std::time::Duration::from_secs(HOSTED_START_TIMEOUT_SECS),
1451            dispatch_runtime.run_in_scope(async { svc.start().await }),
1452        )
1453        .await;
1454        match attempt {
1455            Ok(Ok(())) => started += 1,
1456            Ok(Err(err)) => {
1457                failure = Some(err);
1458                break;
1459            }
1460            Err(_) => {
1461                failure = Some(webx_core::error::Error::Internal(format!(
1462                    "hosted service start timed out after {HOSTED_START_TIMEOUT_SECS}s"
1463                )));
1464                break;
1465            }
1466        }
1467    }
1468
1469    match failure {
1470        None => {
1471            tracing::info!("All {started} hosted service(s) started.");
1472            Ok(())
1473        }
1474        Some(err) => {
1475            // Roll back the services that did start, newest first, so a failed
1476            // startup does not leave pools/loops running.
1477            for (index, svc) in services[..started].iter().enumerate().rev() {
1478                stop_hosted_service(dispatch_runtime, index, svc).await;
1479            }
1480            Err(err)
1481        }
1482    }
1483}
1484
1485async fn stop_hosted_services(
1486    services: &[Arc<dyn IHostedService>],
1487    dispatch_runtime: &Arc<DispatchRuntime>,
1488) {
1489    if services.is_empty() {
1490        return;
1491    }
1492    tracing::info!("Stopping {} hosted service(s)...", services.len());
1493    for (index, svc) in services.iter().enumerate().rev() {
1494        stop_hosted_service(dispatch_runtime, index, svc).await;
1495    }
1496    tracing::info!("All hosted services stopped.");
1497}
1498
1499/// Stop one service with a timeout (and a fresh DI scope), logging failures.
1500async fn stop_hosted_service(
1501    dispatch_runtime: &Arc<DispatchRuntime>,
1502    index: usize,
1503    svc: &Arc<dyn IHostedService>,
1504) {
1505    match tokio::time::timeout(
1506        std::time::Duration::from_secs(HOSTED_STOP_TIMEOUT_SECS),
1507        dispatch_runtime.run_in_scope(async { svc.stop().await }),
1508    )
1509    .await
1510    {
1511        Ok(Ok(())) => {}
1512        Ok(Err(err)) => {
1513            tracing::warn!(service_index = index, error = %err, "hosted service stop error")
1514        }
1515        Err(_) => tracing::error!(
1516            service_index = index,
1517            timeout_secs = HOSTED_STOP_TIMEOUT_SECS,
1518            "hosted service stop timed out"
1519        ),
1520    }
1521}
1522
1523async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
1524    let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
1525    let drain_future = async {
1526        while let Some(result) = join_set.join_next().await {
1527            if let Err(e) = result {
1528                tracing::error!("Connection task panicked: {:?}", e);
1529            }
1530        }
1531    };
1532
1533    match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
1534        Ok(_) => tracing::info!("{}: drained.", label),
1535        Err(_) => {
1536            tracing::warn!("{}: drain timeout, aborting connections.", label);
1537            join_set.abort_all();
1538            while join_set.join_next().await.is_some() {}
1539        }
1540    }
1541}
1542
1543// ---------------------------------------------------------------------------
1544// URL parsing & binding helpers
1545// ---------------------------------------------------------------------------
1546
1547/// Bind a TCP listener, turning an OS-level failure into a host error.
1548///
1549/// Binding happens before any accept loop starts, so a port conflict fails
1550/// `run()`/`run_at()` with a non-zero exit instead of a log line and a silent
1551/// `Ok(())`.
1552async fn bind_listener(addr: &str, label: &str) -> Result<tokio::net::TcpListener> {
1553    tokio::net::TcpListener::bind(addr).await.map_err(|err| {
1554        webx_core::error::Error::Http(format!("failed to bind {label} on {addr}: {err}"))
1555    })
1556}
1557
1558/// Parse a URL string into (scheme, addr) pair.
1559/// e.g., "https://0.0.0.0:5030" →("https", "0.0.0.0:5030")
1560fn parse_url(url: &str) -> Result<(&str, String)> {
1561    if let Some(rest) = url.strip_prefix("https://") {
1562        Ok(("https", rest.to_string()))
1563    } else if let Some(rest) = url.strip_prefix("http://") {
1564        Ok(("http", rest.to_string()))
1565    } else {
1566        Err(webx_core::error::Error::Http(format!(
1567            "Invalid URL '{}'. Use http://host:port or https://host:port",
1568            url
1569        )))
1570    }
1571}
1572
1573/// Serve plain HTTP on an already-bound listener.
1574#[allow(clippy::too_many_arguments)]
1575async fn serve_http(
1576    addr: String,
1577    listener: tokio::net::TcpListener,
1578    mut shutdown: tokio::sync::watch::Receiver<bool>,
1579    pipeline: Arc<MiddlewarePipeline>,
1580    router_handler: HandlerFn,
1581    dispatch_runtime: Arc<DispatchRuntime>,
1582    mode: AppMode,
1583    body_limits: BodyLimits,
1584    max_connections: usize,
1585) {
1586    let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1587    let mut join_set = JoinSet::new();
1588
1589    loop {
1590        // Check the stored flag first: a shutdown requested before this loop
1591        // started (the documented `spawn(run); handle.shutdown()` pattern) must
1592        // not be missed.
1593        if *shutdown.borrow() {
1594            tracing::info!("HTTP {}: stop accepting, draining...", addr);
1595            break;
1596        }
1597        tokio::select! {
1598            _ = shutdown.changed() => {
1599                tracing::info!("HTTP {}: stop accepting, draining...", addr);
1600                break;
1601            }
1602            accept_result = listener.accept() => {
1603                let (stream, peer_addr) = match accept_result {
1604                    Ok(v) => v,
1605                    Err(e) => {
1606                        tracing::error!("Accept error (will retry): {}", e);
1607                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1608                        continue;
1609                    }
1610                };
1611                let remote_addr = Some(peer_addr);
1612
1613                while join_set.try_join_next().is_some() {}
1614
1615                let permit = match sem.clone().acquire_owned().await {
1616                    Ok(p) => p,
1617                    Err(_) => continue,
1618                };
1619
1620                let io = TokioIo::new(stream);
1621                let pipeline = Arc::clone(&pipeline);
1622                let router_handler = router_handler.clone();
1623                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1624                let body_limits = body_limits.clone();
1625                let conn_shutdown = shutdown.clone();
1626
1627                join_set.spawn(async move {
1628                    let _permit = permit;
1629                    let svc_fn = service_fn(move |req: Request<Incoming>| {
1630                        let pipeline = Arc::clone(&pipeline);
1631                        let router_handler = router_handler.clone();
1632                        let dispatch_runtime = Arc::clone(&dispatch_runtime);
1633                        let body_limits = body_limits.clone();
1634                        let mode = mode;
1635                        // Peer address for rate limiting and audit; captured per
1636                        // connection and carried into the request context.
1637                        let remote_addr = remote_addr;
1638                        async move {
1639                            let start = Instant::now();
1640                            let method = req.method().to_string();
1641                            let path = req.uri().path().to_string();
1642                            let result = handle_request(
1643                                req,
1644                                pipeline,
1645                                router_handler,
1646                                dispatch_runtime,
1647                                body_limits,
1648                                remote_addr,
1649                            )
1650                            .await;
1651                            let elapsed = start.elapsed();
1652                            if mode == AppMode::Development {
1653                                let status =
1654                                    result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1655                                tracing::info!(
1656                                    "[{}] {} -> {} ({:.0}ms)",
1657                                    method,
1658                                    path,
1659                                    status,
1660                                    elapsed.as_secs_f64() * 1000.0
1661                                );
1662                            }
1663                            result
1664                        }
1665                    });
1666
1667                    let conn = hyper::server::conn::http1::Builder::new()
1668                        .serve_connection(io, svc_fn);
1669                    let mut conn = std::pin::pin!(conn);
1670                    let mut conn_shutdown = conn_shutdown;
1671                    tokio::select! {
1672                        result = conn.as_mut() => {
1673                            if let Err(err) = result {
1674                                tracing::error!("Connection error: {}", err);
1675                            }
1676                        }
1677                        _ = wait_for_shutdown(&mut conn_shutdown) => {
1678                            // Let in-flight requests finish and answer with
1679                            // `Connection: close` instead of aborting the socket
1680                            // after the drain timeout.
1681                            conn.as_mut().graceful_shutdown();
1682                            if let Err(err) = conn.await {
1683                                tracing::debug!(error = %err, "connection closed during shutdown");
1684                            }
1685                        }
1686                    }
1687                });
1688            }
1689        }
1690    }
1691
1692    drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
1693}
1694
1695/// Serve HTTPS (TLS) on an already-bound listener.
1696#[allow(clippy::too_many_arguments)]
1697async fn serve_https(
1698    addr: String,
1699    acceptor: TlsAcceptor,
1700    listener: tokio::net::TcpListener,
1701    mut shutdown: tokio::sync::watch::Receiver<bool>,
1702    pipeline: Arc<MiddlewarePipeline>,
1703    router_handler: HandlerFn,
1704    dispatch_runtime: Arc<DispatchRuntime>,
1705    mode: AppMode,
1706    body_limits: BodyLimits,
1707    max_connections: usize,
1708) {
1709    let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1710    let mut join_set = JoinSet::new();
1711
1712    loop {
1713        if *shutdown.borrow() {
1714            tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1715            break;
1716        }
1717        tokio::select! {
1718            _ = shutdown.changed() => {
1719                tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1720                break;
1721            }
1722            accept_result = listener.accept() => {
1723                let (stream, peer_addr) = match accept_result {
1724                    Ok(v) => v,
1725                    Err(e) => {
1726                        tracing::error!("Accept error (will retry): {}", e);
1727                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1728                        continue;
1729                    }
1730                };
1731                let remote_addr = Some(peer_addr);
1732
1733                while join_set.try_join_next().is_some() {}
1734
1735                let permit = match sem.clone().acquire_owned().await {
1736                    Ok(p) => p,
1737                    Err(_) => continue,
1738                };
1739
1740                let acceptor = acceptor.clone();
1741                let pipeline = Arc::clone(&pipeline);
1742                let router_handler = router_handler.clone();
1743                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1744                let body_limits = body_limits.clone();
1745                let conn_shutdown = shutdown.clone();
1746
1747                join_set.spawn(async move {
1748                    let _permit = permit;
1749                    match acceptor.accept(stream).await {
1750                        Ok(tls_stream) => {
1751                            let io = TokioIo::new(tls_stream);
1752                            let svc_fn = service_fn(move |req: Request<Incoming>| {
1753                                let pipeline = Arc::clone(&pipeline);
1754                                let router_handler = router_handler.clone();
1755                                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1756                                let body_limits = body_limits.clone();
1757                                let mode = mode;
1758                                // Peer address for rate limiting and audit.
1759                                let remote_addr = remote_addr;
1760                                async move {
1761                                    let start = Instant::now();
1762                                    let method = req.method().to_string();
1763                                    let path = req.uri().path().to_string();
1764                                    let result = handle_request(
1765                                        req,
1766                                        pipeline,
1767                                        router_handler,
1768                                        dispatch_runtime,
1769                                        body_limits,
1770                                        remote_addr,
1771                                    )
1772                                    .await;
1773                                    let elapsed = start.elapsed();
1774                                    if mode == AppMode::Development {
1775                                        let status =
1776                                            result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1777                                        tracing::info!(
1778                                            "[{}] {} -> {} ({:.0}ms)",
1779                                            method,
1780                                            path,
1781                                            status,
1782                                            elapsed.as_secs_f64() * 1000.0
1783                                        );
1784                                    }
1785                                    result
1786                                }
1787                            });
1788
1789                            let conn = hyper::server::conn::http1::Builder::new()
1790                                .serve_connection(io, svc_fn);
1791                            let mut conn = std::pin::pin!(conn);
1792                            let mut conn_shutdown = conn_shutdown;
1793                            tokio::select! {
1794                                result = conn.as_mut() => {
1795                                    if let Err(err) = result {
1796                                        tracing::error!("TLS connection error: {}", err);
1797                                    }
1798                                }
1799                                _ = wait_for_shutdown(&mut conn_shutdown) => {
1800                                    conn.as_mut().graceful_shutdown();
1801                                    if let Err(err) = conn.await {
1802                                        tracing::debug!(error = %err, "TLS connection closed during shutdown");
1803                                    }
1804                                }
1805                            }
1806                        }
1807                        Err(e) => {
1808                            tracing::error!("TLS handshake error: {}", e);
1809                        }
1810                    }
1811                });
1812            }
1813        }
1814    }
1815
1816    drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
1817}
1818
1819// ---------------------------------------------------------------------------
1820// TLS helpers
1821// ---------------------------------------------------------------------------
1822
1823/// Build a TLS acceptor from PEM certificate and key files.
1824fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
1825    use std::fs::File;
1826    use std::io::BufReader;
1827
1828    if cert_path.is_empty() || key_path.is_empty() {
1829        return Err(webx_core::error::Error::Http(
1830            "TLS certificate or key path not configured.".into(),
1831        ));
1832    }
1833
1834    let cert_file = File::open(cert_path).map_err(|e| {
1835        webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
1836    })?;
1837    let mut cert_reader = BufReader::new(cert_file);
1838    let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
1839    if certs.is_empty() {
1840        return Err(webx_core::error::Error::Http(format!(
1841            "No valid certs in '{}'",
1842            cert_path
1843        )));
1844    }
1845
1846    let key_file = File::open(key_path).map_err(|e| {
1847        webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
1848    })?;
1849    let mut key_reader = BufReader::new(key_file);
1850    let key = pkcs8_private_keys(&mut key_reader)
1851        .filter_map(|r| r.ok())
1852        .map(PrivateKeyDer::from)
1853        .next()
1854        .or_else(|| {
1855            let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
1856            let mut kr2 = key_file2;
1857            let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
1858                .filter_map(|r| r.ok())
1859                .map(PrivateKeyDer::from)
1860                .collect();
1861            rsa_keys.into_iter().next()
1862        })
1863        .ok_or_else(|| {
1864            webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
1865        })?;
1866
1867    let config = ServerConfig::builder()
1868        .with_no_client_auth()
1869        .with_single_cert(certs, key)
1870        .map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
1871
1872    Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
1873}
1874
1875#[cfg(test)]
1876mod embedded_attach_tests {
1877    use super::with_embedded_assets;
1878    use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
1879
1880    const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
1881        path: "index.html",
1882        bytes: b"hi",
1883        raw_len: 2,
1884        encoding: ContentEncoding::Identity,
1885        content_type: "text/html",
1886        etag: "\"x\"",
1887    }];
1888
1889    #[test]
1890    fn no_table_leaves_disk_source() {
1891        let source = Some(SpaSource::Disk("wwwroot".into()));
1892        let merged = with_embedded_assets(source, None);
1893        assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
1894    }
1895
1896    #[test]
1897    fn table_layers_under_use_spa_overlay() {
1898        let assets = EmbeddedAssets::new("../wwwroot", FILES);
1899        let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
1900        match merged {
1901            Some(SpaSource::Embedded {
1902                overlay_root,
1903                assets,
1904            }) => {
1905                assert_eq!(overlay_root, "wwwroot");
1906                assert_eq!(assets.root(), "../wwwroot");
1907            }
1908            other => panic!("expected embedded, got {other:?}"),
1909        }
1910    }
1911
1912    #[test]
1913    fn table_alone_uses_build_root_as_overlay() {
1914        let assets = EmbeddedAssets::new("../wwwroot", FILES);
1915        let merged = with_embedded_assets(None, Some(assets));
1916        match merged {
1917            Some(SpaSource::Embedded { overlay_root, .. }) => {
1918                assert_eq!(overlay_root, "../wwwroot");
1919            }
1920            other => panic!("expected embedded, got {other:?}"),
1921        }
1922    }
1923
1924    #[test]
1925    fn host_crate_registers_no_asset_table() {
1926        assert!(EmbeddedAssets::registered().is_none());
1927    }
1928}