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::middleware::IMiddleware;
18use webx_core::mode::AppMode;
19use webx_core::routing::{HttpMethod, IEndpoint, IRouter};
20
21use crate::auth_jwt::{init_jwt_secret, jwt_middleware, JwtAuth};
22use crate::authz::{build_resource_policy_from_routes, collect_authorizers};
23use crate::context::{BodyLimits, HttpContext, RespBody};
24use crate::cors::{CorsConfig, CorsMiddleware};
25use crate::endpoint::{StaticHtmlEndpoint, StaticJsonEndpoint, StubEndpoint};
26use crate::health::{HealthCheckEndpoint, HealthCheckRegistry, HealthStatus};
27use crate::memory_cache::MemoryCache;
28use crate::metrics::{HttpMetrics, MetricsEndpoint, MetricsMiddleware};
29use crate::pipeline::{HandlerFn, MiddlewarePipeline};
30use crate::problem_response::{build_problem, write_problem_response};
31use crate::rate_limit::RateLimitMiddleware;
32use crate::router::Router;
33use jsonwebtoken::{DecodingKey, Validation};
34use webx_core::route::scan::RouteEntry;
35use webx_core::DispatchRuntime;
36use webx_openapi::{generate_openapi_spec, APIUI_HTML};
37use webx_spa::{EmbeddedAssets, SpaMiddleware, SpaSource};
38
39use rustls::pki_types::{CertificateDer, PrivateKeyDer};
40use rustls::ServerConfig;
41use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
42use std::sync::Arc;
43use std::time::Instant;
44use tokio::task::JoinSet;
45use tokio_rustls::TlsAcceptor;
46
47/// Returns true when the JWT secret is missing, too short, or a known placeholder.
48fn is_weak_jwt_secret(secret: &str) -> bool {
49    secret.is_empty()
50        || secret.len() < 32
51        || secret.contains("change-in-production")
52        || secret.contains("please-change")
53        || secret.contains("demo-secret")
54        || secret.contains("dev-secret")
55}
56
57/// Log where the SPA files come from and which embedded files are overridden.
58///
59/// Overrides are the point of the feature, but a stale one silently defeats an
60/// updated build, so the list is logged instead of waiting to be discovered.
61fn log_spa_source(spa: &SpaMiddleware) {
62    tracing::info!("[Host] SPA root: {}", spa.resolved_root().display());
63    let Some(assets) = spa.embedded() else {
64        return;
65    };
66    tracing::info!(
67        "[Host] Embedded assets: {} files, {:.1} MiB embedded ({:.1} MiB uncompressed), built from `{}`",
68        assets.len(),
69        assets.total_bytes() as f64 / (1024.0 * 1024.0),
70        assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
71        assets.root(),
72    );
73    let shadowed = assets.shadowed_by(spa.resolved_root());
74    if !shadowed.is_empty() {
75        tracing::warn!(
76            "[Host] {} embedded file(s) are overridden on disk: {}. \
77             An override older than the current build stays in effect until it is \
78             updated or deleted.",
79            shadowed.len(),
80            shadowed.join(", "),
81        );
82    }
83}
84
85/// Layer a compiled-in table under `source` when one was registered.
86///
87/// Opt-in is `#[webx::main(embed)]` (plus `build.rs` `web_root`).
88/// `use_spa` names the disk overlay; without it, the table's build-time root
89/// is used as the overlay path.
90fn with_embedded_assets(
91    source: Option<SpaSource>,
92    assets: Option<EmbeddedAssets>,
93) -> Option<SpaSource> {
94    let Some(assets) = assets else {
95        return source;
96    };
97    let overlay_root = match source.as_ref() {
98        Some(existing) => existing.disk_root().to_string(),
99        None => assets.root().to_string(),
100    };
101    Some(SpaSource::Embedded {
102        assets,
103        overlay_root,
104    })
105}
106
107pub struct Host {
108    #[allow(dead_code)]
109    provider: Arc<ServiceProvider>,
110    dispatch_runtime: Arc<DispatchRuntime>,
111    pub options: AppOptions,
112    /// Body and upload limits, derived from `App` + `Form` configuration.
113    body_limits: BodyLimits,
114    pipeline: Arc<MiddlewarePipeline>,
115    /// The matchit router (retained for introspection).
116    #[allow(dead_code)]
117    router: Arc<Router>,
118    /// Pre-built router handler —eliminates per-request Arc::new.
119    router_handler: HandlerFn,
120    mode: AppMode,
121    /// Kept for introspection; the middleware already holds its own copy.
122    #[allow(dead_code)]
123    spa_source: Option<SpaSource>,
124    shutdown: Arc<tokio::sync::Notify>,
125    /// Hosted services that are started on host start
126    /// and stopped on graceful shutdown.
127    hosted_services: Vec<Arc<dyn IHostedService>>,
128}
129
130#[allow(clippy::type_complexity)]
131pub struct HostBuilder {
132    service_configs: Vec<Box<dyn FnOnce(ServiceCollection) -> ServiceCollection + Send>>,
133    mode: AppMode,
134    spa_source: Option<SpaSource>,
135    spa_disabled: bool,
136    options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
137    cors_config: Option<CorsConfig>,
138    auth_enabled: bool,
139    resource_auth_enabled: bool,
140    health_registry: Arc<HealthCheckRegistry>,
141}
142
143#[allow(clippy::type_complexity)]
144pub struct HostAppBuilder {
145    options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
146}
147
148impl HostAppBuilder {
149    fn new() -> Self {
150        Self {
151            options_modifiers: Vec::new(),
152        }
153    }
154
155    #[allow(non_snake_case)]
156    pub fn useOptions<F>(&mut self, f: F)
157    where
158        F: FnOnce(&mut AppOptions) + Send + 'static,
159    {
160        self.options_modifiers.push(Box::new(f));
161    }
162}
163
164impl HostBuilder {
165    pub fn new() -> Self {
166        Self {
167            service_configs: Vec::new(),
168            // 默认从 `APP_ENV` 环境变量解析运行模式;未设置时为 Development。
169            // 应用可通过 `.mode()` 显式覆盖。
170            mode: AppMode::from_env(),
171            spa_source: None,
172            spa_disabled: false,
173            options_modifiers: Vec::new(),
174            cors_config: None,
175            auth_enabled: false,
176            resource_auth_enabled: false,
177            health_registry: HealthCheckRegistry::new(),
178        }
179    }
180
181    pub fn register<F>(mut self, f: F) -> Self
182    where
183        F: FnOnce(ServiceCollection) -> ServiceCollection + Send + 'static,
184    {
185        self.service_configs.push(Box::new(f));
186        self
187    }
188
189    pub fn configure<F>(mut self, f: F) -> Self
190    where
191        F: FnOnce(&mut HostAppBuilder) + Send + 'static,
192    {
193        let mut builder = HostAppBuilder::new();
194        f(&mut builder);
195        self.options_modifiers
196            .append(&mut builder.options_modifiers);
197        self
198    }
199
200    pub fn mode(mut self, mode: AppMode) -> Self {
201        self.mode = mode;
202        self
203    }
204
205    /// 自动绑定 appsettings 配置节到 `T`,并以 `Arc<T>` 注册到 DI 容器。
206    ///
207    /// 参考 ASP.NET Core 的 `services.Configure<T>(configuration.GetSection(...))`:
208    /// 框架在 `build()` 时读取已合并的 appsettings(含 `{Mode}` overlay 与 env override),
209    /// 取 `section` 子节点反序列化为 `T`,注册为单例供 handler 通过 `Arc<T>` 注入。
210    ///
211    /// ```ignore
212    /// Host::builder()
213    ///     .add_options::<SiteConfig>("Site")
214    ///     .build()
215    /// ```
216    pub fn add_options<T>(self, section: &str) -> Self
217    where
218        T: for<'de> serde::Deserialize<'de> + Default + Send + Sync + 'static,
219    {
220        let mode = self.mode;
221        let section = section.to_string();
222        self.register(move |svc| {
223            let appsettings = config::load_appsettings(mode).unwrap_or_default();
224            let bound: T = config::bind_config(&appsettings, &section);
225            tracing::info!(
226                "[Host] add_options<{}>(\"{}\") registered",
227                std::any::type_name::<T>(),
228                section
229            );
230            svc.instance(Arc::new(bound))
231        })
232    }
233
234    /// Serve SPA static files from a directory on disk.
235    ///
236    /// ```ignore
237    /// Host::builder().use_spa("wwwroot").build()
238    /// ```
239    ///
240    /// **Runtime disk root** — the directory next to the process (or under
241    /// `app_base`) where operators may drop overrides. This is not
242    /// `build.rs` `web_root` (that path only names what was baked in).
243    ///
244    /// When the binary was built with `#[webx::main(embed)]` (and
245    /// `build.rs` wrote a table), that table is layered underneath this
246    /// directory at [`build`](Self::build): same-named disk files win.
247    /// Without `(embed)`, only the disk directory is served.
248    pub fn use_spa(mut self, source: impl Into<SpaSource>) -> Self {
249        self.spa_source = Some(match (self.spa_source.take(), source.into()) {
250            // Compiled-in files are already configured: only move the directory
251            // overrides are read from, rather than dropping the baseline.
252            (Some(SpaSource::Embedded { assets, .. }), SpaSource::Disk(root)) => {
253                SpaSource::Embedded {
254                    assets,
255                    overlay_root: root,
256                }
257            }
258            (_, incoming) => incoming,
259        });
260        self
261    }
262
263    /// Explicitly disable SPA, including auto-detection.
264    ///
265    /// By default, the framework auto-detects `wwwroot/` under `app_base()`.
266    /// Call this when you want a pure API host without SPA fallback,
267    /// or in tests to isolate framework behavior from filesystem state.
268    pub fn no_spa(mut self) -> Self {
269        self.spa_disabled = true;
270        self
271    }
272
273    pub fn use_cors(mut self, config: CorsConfig) -> Self {
274        self.cors_config = Some(config);
275        self
276    }
277
278    /// Register JWT authentication service and middleware.
279    ///
280    /// 参考 ASP.NET Core 的 `services.AddAuthentication()` + `app.UseAuthentication()`:
281    /// 启用 JWT Bearer Token 认证,中间件自动添加到管道。授权(`#[authorize]`)
282    /// 由框架通过编译期元数据自动收集,无需显式调用。
283    ///
284    /// ```ignore
285    /// Host::builder()
286    ///     .add_authentication()
287    ///     .build()
288    /// ```
289    pub fn add_authentication(mut self) -> Self {
290        self.auth_enabled = true;
291        self
292    }
293
294    /// Enable resource-based authorization using compile-time `#[authorize]` metadata.
295    ///
296    /// Builds a [`ResourceAuthorization`](crate::authz::ResourceAuthorization)
297    /// policy from route inventory and enforces it
298    /// at the endpoint layer (after routing, when `route_pattern()` is available).
299    /// Requires `add_authentication()` for JWT claims.
300    pub fn use_resource_authorization(mut self) -> Self {
301        self.resource_auth_enabled = true;
302        self
303    }
304
305    /// Register a memory cache instance for use via `IDistributedCache` trait.
306    ///
307    /// The cache is registered as a singleton in the DI container.
308    /// Handlers can access it by implementing `From<Arc<MemoryCache>>`.
309    ///
310    /// ```ignore
311    /// Host::builder()
312    ///     .add_memory_cache()
313    ///     .build()
314    ///     .run().await;
315    /// ```
316    pub fn add_memory_cache(mut self) -> Self {
317        let cache = Arc::new(MemoryCache::new());
318        self.service_configs
319            .push(Box::new(move |svc| svc.instance(cache)));
320        self
321    }
322
323    /// Register a memory cache with custom configuration.
324    pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
325        let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
326        self.service_configs
327            .push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
328        self
329    }
330
331    /// Register a middleware by type.
332    ///
333    /// Middleware `T` is registered as Singleton in the DI container and
334    /// automatically collected by `provider.get_all::<dyn IMiddleware>()`
335    /// during `build()`.
336    ///
337    /// ```ignore
338    /// Host::builder()
339    ///     .use_middleware::<RequestIdMiddleware>()
340    ///     .build()
341    /// ```
342    pub fn use_middleware<T: IMiddleware + Default + 'static>(mut self) -> Self {
343        self.service_configs.push(Box::new(|svc| {
344            svc.singleton::<dyn IMiddleware>(|_| Arc::new(T::default()) as Arc<dyn IMiddleware>)
345        }));
346        self
347    }
348
349    /// Register a middleware with a custom factory function.
350    ///
351    /// Use this when the middleware requires constructor parameters:
352    ///
353    /// ```ignore
354    /// Host::builder()
355    ///     .use_middleware_with(|| {
356    ///         Arc::new(RateLimitMiddleware::new(10.0, 20)) as Arc<dyn IMiddleware>
357    ///     })
358    ///     .build()
359    /// ```
360    pub fn use_middleware_with<F>(mut self, factory: F) -> Self
361    where
362        F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
363    {
364        self.service_configs.push(Box::new(move |svc| {
365            svc.singleton::<dyn IMiddleware>(move |_| factory())
366        }));
367        self
368    }
369
370    /// Register a health check probe.
371    ///
372    /// Health checks are exposed via `/health` and `/health/ready` endpoints
373    /// following RFC 8407 (`application/health+json` content type).
374    ///
375    /// ```ignore
376    /// Host::builder()
377    ///     .add_health_check("database", || -> HealthStatus {
378    ///         if db_ping() { HealthStatus::pass() } else { HealthStatus::fail("db unreachable") }
379    ///     })
380    ///     .build()
381    /// ```
382    pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
383    where
384        F: Fn() -> HealthStatus + Send + Sync + 'static,
385    {
386        self.health_registry.register(name, Arc::new(check));
387        self
388    }
389
390    pub fn build(self) -> Host {
391        // Initialize structured logging based on app mode.
392        // This is idempotent —subsequent calls are no-ops.
393        let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
394        if self.mode == AppMode::Development {
395            let _ = tracing_subscriber::fmt()
396                .with_env_filter(env_filter)
397                .without_time()
398                .with_target(false)
399                .try_init();
400        } else {
401            let _ = tracing_subscriber::fmt()
402                .json()
403                .with_env_filter(env_filter)
404                .try_init();
405        }
406
407        let mut svc = ServiceCollection::from_injected();
408        for cfg in self.service_configs {
409            svc = cfg(svc);
410        }
411
412        // Register HealthCheckRegistry as a singleton for handler injection.
413        let health_registry = Arc::clone(&self.health_registry);
414        svc = svc.instance(health_registry);
415
416        let provider = svc.build().unwrap_or_else(|e| {
417            panic!(
418                "Failed to build ServiceProvider: {}. Check your DI registrations.",
419                e
420            )
421        });
422
423        // Per-host dispatch runtime (provider + handler registry).
424        let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
425        let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
426
427        let appsettings =
428            config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
429        let mut options: AppOptions = config::bind_root(&appsettings);
430        for modifier in self.options_modifiers {
431            modifier(&mut options);
432        }
433
434        // Upload spooling and body limits come from the same merged config.
435        let body_limits = BodyLimits::from_options(&options);
436        body_limits.apply_spool_root();
437        tracing::info!(
438            "[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
439            body_limits.multipart_body,
440            body_limits.file,
441            body_limits.memory_threshold,
442            body_limits.spool_dir.display()
443        );
444
445        let http_metrics = HttpMetrics::new();
446
447        let mut pipeline = MiddlewarePipeline::new();
448
449        // Default security & observability middleware (zero-config safe defaults).
450        // Order: SecurityHeaders → RequestId → RateLimit → Metrics → user → CORS → Auth → SPA
451        pipeline.add_middleware(Arc::new(
452            crate::security_headers::SecurityHeadersMiddleware::new(),
453        ));
454        pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
455
456        if options.rate_limit.enabled {
457            tracing::info!(
458                "[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
459                options.rate_limit.requests_per_second,
460                options.rate_limit.burst_size,
461                options.rate_limit.max_tracked_ips
462            );
463            pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
464                &options.rate_limit,
465            )));
466        }
467
468        if options.metrics.enabled {
469            tracing::info!("[Host] Metrics enabled at GET /metrics");
470            pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
471        }
472
473        let middlewares: Vec<Arc<dyn IMiddleware>> = provider.get_all::<dyn IMiddleware>();
474        for mw in middlewares {
475            pipeline.add_middleware(mw);
476        }
477
478        let cors = self.cors_config.unwrap_or_else(|| {
479            let cs = &options.cors;
480            CorsConfig {
481                origins: cs.origins.clone(),
482                methods: cs.methods.clone(),
483                headers: cs.headers.clone(),
484                expose_headers: cs.expose_headers.clone(),
485                allow_credentials: cs.allow_credentials,
486                max_age: cs.max_age,
487            }
488        });
489        pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
490
491        if self.auth_enabled {
492            let secret = options.jwt.secret.clone();
493            if !secret.is_empty() {
494                let jwt_auth = Arc::new(JwtAuth::new(
495                    DecodingKey::from_secret(secret.as_bytes()),
496                    Validation::default(),
497                ));
498                pipeline.add_middleware(Arc::new(jwt_middleware(jwt_auth)));
499                init_jwt_secret(&secret);
500            } else {
501                tracing::warn!(
502                    "add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
503                );
504            }
505        }
506
507        // SPA 自动启用:若应用显式调用 `use_spa`,使用指定的文件源;
508        // 否则框架自动检测应用基准目录下的 `wwwroot/`,存在即启用 SPA。
509        // 这样新应用无需在 main.rs 手写 `use_spa("wwwroot")` 样板。
510        // `no_spa()` 可显式禁用此行为(含自动检测),用于纯 API 主机或测试隔离。
511        // A registered `embed_assets!()` table is layered underneath automatically.
512        // SPA runs after JWT so auth middleware sees API requests first; SpaMiddleware
513        // skips /api/* paths so unmatched API routes return 404 from the router.
514        let spa_source = if self.spa_disabled {
515            None
516        } else {
517            let base = self.spa_source.clone().or_else(|| {
518                let candidate = webx_core::paths::app_base().join("wwwroot");
519                if candidate.is_dir() {
520                    tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
521                    Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
522                } else {
523                    None
524                }
525            });
526            with_embedded_assets(base, EmbeddedAssets::registered().copied())
527        };
528        if let Some(ref source) = spa_source {
529            let spa = SpaMiddleware::from_source(source.clone());
530            log_spa_source(&spa);
531            pipeline.add_middleware(Arc::new(spa));
532        }
533
534        // Users can register additional middleware here, e.g.:
535        // pipeline.add_middleware(Arc::new(RequestIdMiddleware::new()));
536        // pipeline.add_middleware(Arc::new(TimingMiddleware::new()));
537
538        let pipeline = Arc::new(pipeline);
539
540        // Security: fail fast in production when auth is enabled but JWT secret is missing or weak
541        if self.mode == AppMode::Production
542            && self.auth_enabled
543            && is_weak_jwt_secret(&options.jwt.secret)
544        {
545            panic!(
546                "Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
547            );
548        }
549
550        // Security: fail fast in production when CORS allows all origins
551        if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
552            panic!(
553                "Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
554            );
555        }
556
557        let mut router = Router::new();
558        let mut route_count = 0usize;
559
560        crate::diagnostics::assert_route_configuration_valid();
561
562        // Build dispatch map: handler_type → dispatch function
563        let mut dispatch_map: std::collections::HashMap<
564            &'static str,
565            webx_core::route::scan::RouteDispatchFn,
566        > = std::collections::HashMap::new();
567
568        for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
569            dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
570        }
571
572        let resource_policy: Option<Arc<crate::authz::ResourceAuthorization>> =
573            if self.resource_auth_enabled {
574                Some(Arc::new(build_resource_policy_from_routes()))
575            } else {
576                None
577            };
578
579        for entry in inventory::iter::<RouteEntry> {
580            route_count += 1;
581            let stub = Arc::new(StubEndpoint {
582                method: entry.method.as_str(),
583                path: entry.path,
584                handler_type: entry.handler_type,
585                dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
586                auth_required_role: entry.required_role,
587                auth_required_permission: entry.required_permission,
588                resource_policy: resource_policy.clone(),
589                authorizers: collect_authorizers(provider.as_ref()),
590            });
591            router.register(entry.method, entry.path, stub);
592        }
593
594        let openapi_spec = generate_openapi_spec("Rust WebX API", env!("CARGO_PKG_VERSION"));
595        if self.mode == AppMode::Development {
596            let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
597            router.register(
598                HttpMethod::Get,
599                "/api/openapi.json",
600                Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
601            );
602            router.register(
603                HttpMethod::Get,
604                "/api/openapi.html",
605                Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
606            );
607        }
608
609        // Health check endpoints (RFC 8407 application/health+json)
610        // Probes are evaluated on each request (not baked at build time).
611        let health_registry = Arc::clone(&self.health_registry);
612        let health_endpoint: Arc<dyn IEndpoint> =
613            Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
614        router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
615        router.register(HttpMethod::Get, "/healthz", health_endpoint);
616
617        // /health/live: liveness (process alive → pass, no probe queries)
618        let live_body =
619            serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
620        router.register(
621            HttpMethod::Get,
622            "/health/live",
623            Arc::new(StaticJsonEndpoint {
624                body: live_body,
625                content_type: "application/health+json",
626            }),
627        );
628
629        // /health/ready: readiness (queries registry, same as /health)
630        router.register(
631            HttpMethod::Get,
632            "/health/ready",
633            Arc::new(HealthCheckEndpoint::new(health_registry)),
634        );
635
636        if options.metrics.enabled {
637            router.register(
638                HttpMethod::Get,
639                "/metrics",
640                Arc::new(MetricsEndpoint::new(http_metrics)),
641            );
642        }
643
644        if self.mode == AppMode::Development {
645            let version = env!("CARGO_PKG_VERSION");
646            tracing::info!("");
647            tracing::info!("  ----------------------------------------------------------------");
648            tracing::info!("    Rust WebX Framework v{}", version);
649            tracing::info!("  ----------------------------------------------------------------");
650            tracing::info!("    App:      {}", options.app.name);
651            tracing::info!("    CORS:     enabled");
652            if let Some(source) = spa_source.as_ref() {
653                tracing::info!("    SPA Root: {}", source.disk_root());
654                if let Some(assets) = source.embedded() {
655                    tracing::info!(
656                        "    Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
657                        assets.len(),
658                        assets.total_bytes() as f64 / (1024.0 * 1024.0),
659                        assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
660                        assets.root(),
661                    );
662                }
663            }
664            if route_count > 0 {
665                tracing::info!("    Routes:   {} registered", route_count);
666            }
667            crate::diagnostics::log_startup_diagnostics();
668            let banner_urls = if options.app.urls.is_empty() {
669                vec!["http://localhost:5000".to_string()]
670            } else {
671                options
672                    .app
673                    .urls
674                    .iter()
675                    .map(|u| u.replace("0.0.0.0", "localhost"))
676                    .collect::<Vec<_>>()
677            };
678            for url in &banner_urls {
679                tracing::info!("    OpenAPI:  {}/api/openapi.html", url);
680                tracing::info!("    OpenAPI:  {}/api/openapi.json", url);
681            }
682            tracing::info!("  ----------------------------------------------------------------");
683            tracing::info!("");
684        } else if route_count > 0 {
685            tracing::info!("{} route(s) registered", route_count);
686        }
687
688        let router = Arc::new(router);
689        let router_handler = make_router_handler(Arc::clone(&router));
690
691        // Resolve all registered hosted services from the DI container.
692        // These will be started when `run()` is called and stopped on shutdown.
693        let hosted_services: Vec<Arc<dyn IHostedService>> =
694            provider.get_all::<dyn IHostedService>();
695
696        Host {
697            provider,
698            dispatch_runtime,
699            options,
700            body_limits,
701            pipeline,
702            router,
703            router_handler,
704            mode: self.mode,
705            spa_source,
706            shutdown: Arc::new(tokio::sync::Notify::new()),
707            hosted_services,
708        }
709    }
710}
711
712/// Handle for a running server, allowing programmatic graceful shutdown.
713pub struct ServerHandle {
714    shutdown: Arc<tokio::sync::Notify>,
715}
716
717impl ServerHandle {
718    /// Signal the server to stop accepting new connections and begin
719    /// draining existing ones.
720    pub fn shutdown(self) {
721        self.shutdown.notify_waiters();
722    }
723}
724
725/// A fully built server that owns its runtime lifecycle.
726///
727/// Created via `Host::into_server()`. Provides a graceful shutdown
728/// handle alongside the `run()` method.
729///
730/// ```ignore
731/// let server = Host::builder().build().into_server();
732/// let handle = server.handle();
733/// tokio::spawn(async move { server.run().await });
734/// handle.shutdown();
735/// ```
736pub struct Server {
737    host: Host,
738}
739
740impl Server {
741    /// Start listening on all configured URLs.
742    pub async fn run(self) -> Result<()> {
743        self.host.run().await
744    }
745
746    /// Start listening on a single address (convenience).
747    pub async fn run_at(self, addr: &str) -> Result<()> {
748        self.host.run_at(addr).await
749    }
750
751    /// Obtain a shutdown handle before starting.
752    pub fn handle(&self) -> ServerHandle {
753        self.host.server_handle()
754    }
755}
756
757impl Default for HostBuilder {
758    fn default() -> Self {
759        Self::new()
760    }
761}
762
763impl Host {
764    pub fn builder() -> HostBuilder {
765        HostBuilder::new()
766    }
767
768    pub fn options(&self) -> &AppOptions {
769        &self.options
770    }
771
772    /// Root DI container for this host instance.
773    pub fn provider(&self) -> &Arc<ServiceProvider> {
774        &self.provider
775    }
776
777    /// Instance-scoped dispatch runtime (provider + handler cache).
778    pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
779        &self.dispatch_runtime
780    }
781
782    /// Start the server on all URLs configured in AppOptions.app.Urls.
783    ///
784    /// Automatically detects http:// and https:// URLs from the array.
785    /// Starts a plain TCP listener for each http:// URL and a TLS listener
786    /// for each https:// URL. All listeners run concurrently.
787    ///
788    /// Supports graceful shutdown on Ctrl+C/SIGTERM with 30s connection drain.
789    ///
790    /// # Example
791    ///
792    /// ```json
793    /// { "App": { "Urls": ["http://localhost:5000", "https://localhost:5030"] } }
794    /// ```
795    pub async fn run(&self) -> Result<()> {
796        start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
797
798        let urls = if self.options.app.urls.is_empty() {
799            vec!["http://0.0.0.0:5000".to_string()]
800        } else {
801            self.options.app.urls.clone()
802        };
803
804        let mut http_addrs: Vec<String> = Vec::new();
805        let mut https_addrs: Vec<String> = Vec::new();
806
807        for url in &urls {
808            let (scheme, addr) = parse_url(url)?;
809            match scheme {
810                "http" => http_addrs.push(addr),
811                "https" => https_addrs.push(addr),
812                other => {
813                    return Err(webx_core::error::Error::Http(format!(
814                        "Unsupported URL scheme '{}' in '{}'",
815                        other, url
816                    )))
817                }
818            }
819        }
820
821        let acceptor = if !https_addrs.is_empty() {
822            let tls = &self.options.tls;
823            if tls.cert_path.is_empty() || tls.key_path.is_empty() {
824                return Err(webx_core::error::Error::Http(
825                    "HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
826                ));
827            }
828            Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
829        } else {
830            None
831        };
832
833        // Banner
834        if self.mode == AppMode::Development {
835            tracing::info!("");
836            for url in &urls {
837                let display_url = url.replace("0.0.0.0", "localhost");
838                tracing::info!("  Listening on {}", display_url);
839            }
840        } else {
841            tracing::info!("Listening on {} url(s)", urls.len());
842        }
843
844        let notify = Arc::clone(&self.shutdown);
845        install_shutdown_handler(Arc::clone(&notify));
846        spawn_async_shutdown_listener(Arc::clone(&notify));
847
848        let mut handles = Vec::new();
849        let pipeline = Arc::clone(&self.pipeline);
850        let router_handler = self.router_handler.clone();
851        let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
852        let mode = self.mode;
853        let body_limits = self.body_limits.clone();
854        let max_connections = self.options.app.max_connections;
855
856        for addr in &http_addrs {
857            let addr = addr.clone();
858            let n = std::sync::Arc::clone(&notify);
859            let p = Arc::clone(&pipeline);
860            let rh = router_handler.clone();
861            let dr = Arc::clone(&dispatch_runtime);
862            handles.push(tokio::spawn(serve_http(
863                addr,
864                n,
865                p,
866                rh,
867                dr,
868                mode,
869                body_limits.clone(),
870                max_connections,
871            )));
872        }
873
874        if let Some(ref tls_acceptor) = acceptor {
875            for addr in &https_addrs {
876                let addr = addr.clone();
877                let n = std::sync::Arc::clone(&notify);
878                let p = Arc::clone(&pipeline);
879                let rh = router_handler.clone();
880                let dr = Arc::clone(&dispatch_runtime);
881                let a = tls_acceptor.clone();
882                handles.push(tokio::spawn(serve_https(
883                    addr,
884                    a,
885                    n,
886                    p,
887                    rh,
888                    dr,
889                    mode,
890                    body_limits.clone(),
891                    max_connections,
892                )));
893            }
894        }
895
896        // Wait for all HTTP listeners to finish (they exit after shutdown signal).
897        for h in handles {
898            let _ = h.await;
899        }
900
901        stop_hosted_services(&self.hosted_services).await;
902
903        Ok(())
904    }
905
906    /// Start the server at a single explicit address (convenience wrapper).
907    pub async fn run_at(&self, addr: &str) -> Result<()> {
908        start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
909
910        let notify = Arc::clone(&self.shutdown);
911        install_shutdown_handler(Arc::clone(&notify));
912        spawn_async_shutdown_listener(Arc::clone(&notify));
913        serve_http(
914            addr.to_string(),
915            notify,
916            Arc::clone(&self.pipeline),
917            self.router_handler.clone(),
918            Arc::clone(&self.dispatch_runtime),
919            self.mode,
920            self.body_limits.clone(),
921            self.options.app.max_connections,
922        )
923        .await;
924
925        stop_hosted_services(&self.hosted_services).await;
926        Ok(())
927    }
928
929    /// Return a `ServerHandle` that can be used to signal graceful shutdown
930    /// from application code (e.g., integration tests, health checks).
931    ///
932    /// ```ignore
933    /// let host = Host::builder().build();
934    /// let handle = host.server_handle();
935    /// tokio::spawn(async move { host.run().await });
936    /// // ... later:
937    /// handle.shutdown();
938    /// ```
939    pub fn server_handle(&self) -> ServerHandle {
940        ServerHandle {
941            shutdown: Arc::clone(&self.shutdown),
942        }
943    }
944
945    /// Consume the host and return a `Server` that owns the runtime lifecycle.
946    ///
947    /// The returned `Server` can be `.run().await`-ed and provides a
948    /// handle for graceful shutdown.
949    pub fn into_server(self) -> Server {
950        Server { host: self }
951    }
952}
953
954#[async_trait::async_trait]
955impl IHost for Host {
956    async fn run(&self, addr: &str) -> Result<()> {
957        self.run_at(addr).await
958    }
959    async fn stop(&self) -> Result<()> {
960        tracing::info!("Stop requested.");
961        self.shutdown.notify_waiters();
962        Ok(())
963    }
964}
965
966fn make_router_handler(router: Arc<Router>) -> HandlerFn {
967    Arc::new(move |ctx: &mut dyn IHttpContext| {
968        let router = Arc::clone(&router);
969        Box::pin(async move {
970            match router.match_route(ctx).await? {
971                Some((endpoint, params, pattern)) => {
972                    drop(router);
973                    for (key, value) in params {
974                        ctx.request_mut().route_params_mut().insert(key, value);
975                    }
976                    *ctx.request_mut().route_pattern_mut() = Some(pattern);
977                    endpoint.handle(ctx).await
978                }
979                None => {
980                    let path_exists = router.path_exists(ctx.request().path());
981                    drop(router);
982                    // Don't overwrite if a middleware (e.g. SPA) already
983                    // wrote a response body (static file, index.html fallback).
984                    if !ctx.response().has_body() {
985                        if path_exists {
986                            write_error_response(ctx, 405, "Method Not Allowed").await;
987                        } else {
988                            write_error_response(ctx, 404, "Not Found").await;
989                        }
990                    }
991                    Ok(())
992                }
993            }
994        })
995    })
996}
997
998async fn handle_request(
999    req: Request<Incoming>,
1000    pipeline: Arc<MiddlewarePipeline>,
1001    router_handler: HandlerFn,
1002    dispatch_runtime: Arc<DispatchRuntime>,
1003    body_limits: BodyLimits,
1004) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
1005    let mut ctx = HttpContext::new(req, body_limits);
1006
1007    // A request rejected during construction (e.g. a declared Content-Length
1008    // above the limit) already carries its final response.
1009    if ctx.response().status() < 400 {
1010        let result = dispatch_runtime
1011            .run(async { pipeline.execute(&mut ctx, router_handler).await })
1012            .await;
1013        if let Err(e) = result {
1014            let status = e.status_code();
1015            write_error_response(&mut ctx, status, &e.to_string()).await;
1016        }
1017    }
1018
1019    Ok(ctx.into_response().await)
1020}
1021
1022async fn write_error_response(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
1023    write_problem_response(ctx, build_problem(status, message)).await;
1024}
1025
1026// ---------------------------------------------------------------------------
1027// Shutdown helpers
1028// ---------------------------------------------------------------------------
1029
1030/// Register a process-wide Ctrl+C / SIGINT handler.
1031///
1032/// On Windows, `cargo run` may exit the parent without stopping the child;
1033/// registering here ensures the server process itself receives the signal.
1034/// A second Ctrl+C forces immediate exit.
1035fn install_shutdown_handler(shutdown: Arc<tokio::sync::Notify>) {
1036    use std::sync::atomic::{AtomicUsize, Ordering};
1037
1038    static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
1039
1040    if let Err(e) = ctrlc::set_handler(move || {
1041        let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
1042        if n == 0 {
1043            tracing::info!("Shutdown signal received, draining connections...");
1044            shutdown.notify_waiters();
1045        } else {
1046            tracing::warn!("Second interrupt — force exiting.");
1047            std::process::exit(130);
1048        }
1049    }) {
1050        tracing::warn!("Could not install Ctrl+C handler: {}", e);
1051    }
1052}
1053
1054/// Tokio-based listener for Ctrl+C (all platforms) and SIGTERM (Unix).
1055fn spawn_async_shutdown_listener(shutdown: Arc<tokio::sync::Notify>) {
1056    tokio::spawn(async move {
1057        wait_for_shutdown_signal().await;
1058        tracing::info!("Async shutdown signal received, draining connections...");
1059        shutdown.notify_waiters();
1060    });
1061}
1062
1063async fn wait_for_shutdown_signal() {
1064    #[cfg(unix)]
1065    {
1066        use tokio::signal::unix::{signal, SignalKind};
1067        let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
1068        tokio::select! {
1069            res = tokio::signal::ctrl_c() => {
1070                if let Err(e) = res {
1071                    tracing::warn!("Ctrl+C listener error: {}", e);
1072                }
1073            }
1074            _ = sigterm.recv() => {}
1075        }
1076    }
1077    #[cfg(not(unix))]
1078    {
1079        if let Err(e) = tokio::signal::ctrl_c().await {
1080            tracing::warn!("Ctrl+C listener error: {}", e);
1081        }
1082    }
1083}
1084
1085async fn start_hosted_services(
1086    services: &[Arc<dyn IHostedService>],
1087    dispatch_runtime: &DispatchRuntime,
1088) -> Result<()> {
1089    if services.is_empty() {
1090        return Ok(());
1091    }
1092    tracing::info!("Starting {} hosted service(s)...", services.len());
1093    dispatch_runtime
1094        .run(async {
1095            for svc in services {
1096                svc.start().await?;
1097            }
1098            Ok::<(), webx_core::error::Error>(())
1099        })
1100        .await?;
1101    tracing::info!("All hosted services started.");
1102    Ok(())
1103}
1104
1105async fn stop_hosted_services(services: &[Arc<dyn IHostedService>]) {
1106    if services.is_empty() {
1107        return;
1108    }
1109    tracing::info!("Stopping {} hosted service(s)...", services.len());
1110    for svc in services {
1111        if let Err(e) = svc.stop().await {
1112            tracing::warn!("Hosted service stop error: {}", e);
1113        }
1114    }
1115    tracing::info!("All hosted services stopped.");
1116}
1117
1118async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
1119    let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
1120    let drain_future = async {
1121        while let Some(result) = join_set.join_next().await {
1122            if let Err(e) = result {
1123                tracing::error!("Connection task panicked: {:?}", e);
1124            }
1125        }
1126    };
1127
1128    match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
1129        Ok(_) => tracing::info!("{}: drained.", label),
1130        Err(_) => {
1131            tracing::warn!("{}: drain timeout, aborting connections.", label);
1132            join_set.abort_all();
1133            while join_set.join_next().await.is_some() {}
1134        }
1135    }
1136}
1137
1138// ---------------------------------------------------------------------------
1139// URL parsing & binding helpers
1140// ---------------------------------------------------------------------------
1141
1142/// Parse a URL string into (scheme, addr) pair.
1143/// e.g., "https://0.0.0.0:5030" →("https", "0.0.0.0:5030")
1144fn parse_url(url: &str) -> Result<(&str, String)> {
1145    if let Some(rest) = url.strip_prefix("https://") {
1146        Ok(("https", rest.to_string()))
1147    } else if let Some(rest) = url.strip_prefix("http://") {
1148        Ok(("http", rest.to_string()))
1149    } else {
1150        Err(webx_core::error::Error::Http(format!(
1151            "Invalid URL '{}'. Use http://host:port or https://host:port",
1152            url
1153        )))
1154    }
1155}
1156
1157/// Serve plain HTTP on the given address.
1158#[allow(clippy::too_many_arguments)]
1159async fn serve_http(
1160    addr: String,
1161    shutdown: std::sync::Arc<tokio::sync::Notify>,
1162    pipeline: Arc<MiddlewarePipeline>,
1163    router_handler: HandlerFn,
1164    dispatch_runtime: Arc<DispatchRuntime>,
1165    mode: AppMode,
1166    body_limits: BodyLimits,
1167    max_connections: usize,
1168) {
1169    let listener = match tokio::net::TcpListener::bind(&addr).await {
1170        Ok(l) => l,
1171        Err(e) => {
1172            tracing::error!("Failed to bind HTTP on {}: {}", addr, e);
1173            return;
1174        }
1175    };
1176
1177    let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1178    let mut join_set = JoinSet::new();
1179
1180    loop {
1181        tokio::select! {
1182            accept_result = listener.accept() => {
1183                let (stream, _) = match accept_result {
1184                    Ok(v) => v,
1185                    Err(e) => {
1186                        tracing::error!("Accept error (will retry): {}", e);
1187                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1188                        continue;
1189                    }
1190                };
1191
1192                while join_set.try_join_next().is_some() {}
1193
1194                let permit = match sem.clone().acquire_owned().await {
1195                    Ok(p) => p,
1196                    Err(_) => continue,
1197                };
1198
1199                let io = TokioIo::new(stream);
1200                let pipeline = Arc::clone(&pipeline);
1201                let router_handler = router_handler.clone();
1202                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1203                let body_limits = body_limits.clone();
1204
1205                join_set.spawn(async move {
1206                    let _permit = permit;
1207                    let svc_fn = service_fn(move |req: Request<Incoming>| {
1208                        let pipeline = Arc::clone(&pipeline);
1209                        let router_handler = router_handler.clone();
1210                        let dispatch_runtime = Arc::clone(&dispatch_runtime);
1211                        let body_limits = body_limits.clone();
1212                        let mode = mode;
1213                        async move {
1214                            let start = Instant::now();
1215                            let method = req.method().to_string();
1216                            let path = req.uri().path().to_string();
1217                            let result = handle_request(
1218                                req,
1219                                pipeline,
1220                                router_handler,
1221                                dispatch_runtime,
1222                                body_limits,
1223                            )
1224                            .await;
1225                            let elapsed = start.elapsed();
1226                            if mode == AppMode::Development {
1227                                let status =
1228                                    result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1229                                tracing::info!(
1230                                    "[{}] {} -> {} ({:.0}ms)",
1231                                    method,
1232                                    path,
1233                                    status,
1234                                    elapsed.as_secs_f64() * 1000.0
1235                                );
1236                            }
1237                            result
1238                        }
1239                    });
1240
1241                    if let Err(err) = hyper::server::conn::http1::Builder::new()
1242                        .serve_connection(io, svc_fn)
1243                        .await
1244                    {
1245                        tracing::error!("Connection error: {}", err);
1246                    }
1247                });
1248            }
1249            _ = shutdown.notified() => {
1250                tracing::info!("HTTP {}: stop accepting, draining...", addr);
1251                break;
1252            }
1253        }
1254    }
1255
1256    drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
1257}
1258
1259/// Serve HTTPS (TLS) on the given address.
1260#[allow(clippy::too_many_arguments)]
1261async fn serve_https(
1262    addr: String,
1263    acceptor: TlsAcceptor,
1264    shutdown: std::sync::Arc<tokio::sync::Notify>,
1265    pipeline: Arc<MiddlewarePipeline>,
1266    router_handler: HandlerFn,
1267    dispatch_runtime: Arc<DispatchRuntime>,
1268    mode: AppMode,
1269    body_limits: BodyLimits,
1270    max_connections: usize,
1271) {
1272    let listener = match tokio::net::TcpListener::bind(&addr).await {
1273        Ok(l) => l,
1274        Err(e) => {
1275            tracing::error!("Failed to bind HTTPS on {}: {}", addr, e);
1276            return;
1277        }
1278    };
1279
1280    let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1281    let mut join_set = JoinSet::new();
1282
1283    loop {
1284        tokio::select! {
1285            accept_result = listener.accept() => {
1286                let (stream, _) = match accept_result {
1287                    Ok(v) => v,
1288                    Err(e) => {
1289                        tracing::error!("Accept error (will retry): {}", e);
1290                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1291                        continue;
1292                    }
1293                };
1294
1295                while join_set.try_join_next().is_some() {}
1296
1297                let permit = match sem.clone().acquire_owned().await {
1298                    Ok(p) => p,
1299                    Err(_) => continue,
1300                };
1301
1302                let acceptor = acceptor.clone();
1303                let pipeline = Arc::clone(&pipeline);
1304                let router_handler = router_handler.clone();
1305                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1306                let body_limits = body_limits.clone();
1307
1308                join_set.spawn(async move {
1309                    let _permit = permit;
1310                    match acceptor.accept(stream).await {
1311                        Ok(tls_stream) => {
1312                            let io = TokioIo::new(tls_stream);
1313                            let svc_fn = service_fn(move |req: Request<Incoming>| {
1314                                let pipeline = Arc::clone(&pipeline);
1315                                let router_handler = router_handler.clone();
1316                                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1317                                let body_limits = body_limits.clone();
1318                                let mode = mode;
1319                                async move {
1320                                    let start = Instant::now();
1321                                    let method = req.method().to_string();
1322                                    let path = req.uri().path().to_string();
1323                                    let result = handle_request(
1324                                        req,
1325                                        pipeline,
1326                                        router_handler,
1327                                        dispatch_runtime,
1328                                        body_limits,
1329                                    )
1330                                    .await;
1331                                    let elapsed = start.elapsed();
1332                                    if mode == AppMode::Development {
1333                                        let status =
1334                                            result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1335                                        tracing::info!(
1336                                            "[{}] {} -> {} ({:.0}ms)",
1337                                            method,
1338                                            path,
1339                                            status,
1340                                            elapsed.as_secs_f64() * 1000.0
1341                                        );
1342                                    }
1343                                    result
1344                                }
1345                            });
1346
1347                            if let Err(err) = hyper::server::conn::http1::Builder::new()
1348                                .serve_connection(io, svc_fn)
1349                                .await
1350                            {
1351                                tracing::error!("TLS connection error: {}", err);
1352                            }
1353                        }
1354                        Err(e) => {
1355                            tracing::error!("TLS handshake error: {}", e);
1356                        }
1357                    }
1358                });
1359            }
1360            _ = shutdown.notified() => {
1361                tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1362                break;
1363            }
1364        }
1365    }
1366
1367    drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
1368}
1369
1370// ---------------------------------------------------------------------------
1371// TLS helpers
1372// ---------------------------------------------------------------------------
1373
1374/// Build a TLS acceptor from PEM certificate and key files.
1375fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
1376    use std::fs::File;
1377    use std::io::BufReader;
1378
1379    if cert_path.is_empty() || key_path.is_empty() {
1380        return Err(webx_core::error::Error::Http(
1381            "TLS certificate or key path not configured.".into(),
1382        ));
1383    }
1384
1385    let cert_file = File::open(cert_path).map_err(|e| {
1386        webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
1387    })?;
1388    let mut cert_reader = BufReader::new(cert_file);
1389    let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
1390    if certs.is_empty() {
1391        return Err(webx_core::error::Error::Http(format!(
1392            "No valid certs in '{}'",
1393            cert_path
1394        )));
1395    }
1396
1397    let key_file = File::open(key_path).map_err(|e| {
1398        webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
1399    })?;
1400    let mut key_reader = BufReader::new(key_file);
1401    let key = pkcs8_private_keys(&mut key_reader)
1402        .filter_map(|r| r.ok())
1403        .map(PrivateKeyDer::from)
1404        .next()
1405        .or_else(|| {
1406            let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
1407            let mut kr2 = key_file2;
1408            let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
1409                .filter_map(|r| r.ok())
1410                .map(PrivateKeyDer::from)
1411                .collect();
1412            rsa_keys.into_iter().next()
1413        })
1414        .ok_or_else(|| {
1415            webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
1416        })?;
1417
1418    let config = ServerConfig::builder()
1419        .with_no_client_auth()
1420        .with_single_cert(certs, key)
1421        .map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
1422
1423    Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
1424}
1425
1426#[cfg(test)]
1427mod embedded_attach_tests {
1428    use super::with_embedded_assets;
1429    use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
1430
1431    const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
1432        path: "index.html",
1433        bytes: b"hi",
1434        raw_len: 2,
1435        encoding: ContentEncoding::Identity,
1436        content_type: "text/html",
1437        etag: "\"x\"",
1438    }];
1439
1440    #[test]
1441    fn no_table_leaves_disk_source() {
1442        let source = Some(SpaSource::Disk("wwwroot".into()));
1443        let merged = with_embedded_assets(source, None);
1444        assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
1445    }
1446
1447    #[test]
1448    fn table_layers_under_use_spa_overlay() {
1449        let assets = EmbeddedAssets::new("../wwwroot", FILES);
1450        let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
1451        match merged {
1452            Some(SpaSource::Embedded {
1453                overlay_root,
1454                assets,
1455            }) => {
1456                assert_eq!(overlay_root, "wwwroot");
1457                assert_eq!(assets.root(), "../wwwroot");
1458            }
1459            other => panic!("expected embedded, got {other:?}"),
1460        }
1461    }
1462
1463    #[test]
1464    fn table_alone_uses_build_root_as_overlay() {
1465        let assets = EmbeddedAssets::new("../wwwroot", FILES);
1466        let merged = with_embedded_assets(None, Some(assets));
1467        match merged {
1468            Some(SpaSource::Embedded { overlay_root, .. }) => {
1469                assert_eq!(overlay_root, "../wwwroot");
1470            }
1471            other => panic!("expected embedded, got {other:?}"),
1472        }
1473    }
1474
1475    #[test]
1476    fn host_crate_registers_no_asset_table() {
1477        assert!(EmbeddedAssets::registered().is_none());
1478    }
1479}