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