1use std::marker::PhantomData;
22use std::sync::Arc;
23use std::time::Duration;
24
25use axum::response::{Html, IntoResponse};
26use axum::routing::get;
27
28use arcly_http_core::core::engine::{
29 DiContainerBuilder, HttpMethod, Module, ModuleDescriptor, RouteDescriptor,
30};
31use arcly_http_core::core::plugins::{
32 build_plugin_route, ArclyPlugin, ArclyPluginContext, PluginError, PluginStage,
33};
34use arcly_http_core::openapi::{build_spec_filtered, OpenApiInfo, SWAGGER_UI_HTML};
35use arcly_http_core::web::boundary::adapt;
36#[cfg(feature = "realtime")]
37use arcly_http_realtime::realtime::gateway::GatewayDescriptor;
38#[cfg(feature = "realtime")]
39use arcly_http_realtime::realtime::{ws_route, ConnectionRegistry};
40
41#[derive(Clone, Debug)]
52#[non_exhaustive]
53pub struct LaunchConfig {
54 pub drain_budget: Duration,
59 pub request_timeout: Duration,
63 pub max_in_flight: usize,
67 pub max_body_bytes: usize,
69 pub cache_max_entries: usize,
72 pub cache_sweep_interval: Duration,
74 pub ws_drain_deadline: Duration,
79 pub cors: Option<arcly_http_core::web::cors::CorsConfig>,
81 pub shutdown_trigger: Option<std::sync::Arc<tokio::sync::Notify>>,
86 pub expose_docs: bool,
89 pub ws_outbound_buffer: usize,
92 pub ws_max_connections: usize,
96 pub ws_ping_interval: Duration,
99 pub adaptive_shed_target: Duration,
104 pub ws_idle_timeout: Duration,
108}
109
110impl Default for LaunchConfig {
111 fn default() -> Self {
112 Self {
113 drain_budget: Duration::from_secs(5),
114 request_timeout: Duration::from_secs(30),
115 max_in_flight: 0,
116 max_body_bytes: 8 * 1024 * 1024,
117 cache_max_entries: 10_000,
118 cache_sweep_interval: Duration::from_secs(30),
119 ws_drain_deadline: Duration::from_secs(10),
120 adaptive_shed_target: Duration::ZERO,
121 cors: None,
122 shutdown_trigger: None,
123 expose_docs: true,
124 ws_outbound_buffer: 256,
125 ws_max_connections: 0,
126 ws_ping_interval: Duration::from_secs(20),
127 ws_idle_timeout: Duration::from_secs(60),
128 }
129 }
130}
131
132impl LaunchConfig {
133 pub fn drain_budget(mut self, v: Duration) -> Self {
134 self.drain_budget = v;
135 self
136 }
137 pub fn request_timeout(mut self, v: Duration) -> Self {
138 self.request_timeout = v;
139 self
140 }
141 pub fn max_in_flight(mut self, v: usize) -> Self {
142 self.max_in_flight = v;
143 self
144 }
145 pub fn max_body_bytes(mut self, v: usize) -> Self {
146 self.max_body_bytes = v;
147 self
148 }
149 pub fn cache_max_entries(mut self, v: usize) -> Self {
150 self.cache_max_entries = v;
151 self
152 }
153 pub fn cache_sweep_interval(mut self, v: Duration) -> Self {
154 self.cache_sweep_interval = v;
155 self
156 }
157 pub fn ws_drain_deadline(mut self, v: Duration) -> Self {
158 self.ws_drain_deadline = v;
159 self
160 }
161 pub fn adaptive_shed_target(mut self, v: Duration) -> Self {
162 self.adaptive_shed_target = v;
163 self
164 }
165 pub fn cors(mut self, v: arcly_http_core::web::cors::CorsConfig) -> Self {
166 self.cors = Some(v);
167 self
168 }
169 pub fn shutdown_trigger(mut self, v: std::sync::Arc<tokio::sync::Notify>) -> Self {
170 self.shutdown_trigger = Some(v);
171 self
172 }
173 pub fn expose_docs(mut self, v: bool) -> Self {
174 self.expose_docs = v;
175 self
176 }
177 pub fn ws_outbound_buffer(mut self, v: usize) -> Self {
178 self.ws_outbound_buffer = v;
179 self
180 }
181 pub fn ws_max_connections(mut self, v: usize) -> Self {
182 self.ws_max_connections = v;
183 self
184 }
185 pub fn ws_ping_interval(mut self, v: Duration) -> Self {
186 self.ws_ping_interval = v;
187 self
188 }
189 pub fn ws_idle_timeout(mut self, v: Duration) -> Self {
190 self.ws_idle_timeout = v;
191 self
192 }
193
194 pub fn with_env_overrides(self) -> Self {
211 self.apply_overrides(|k| std::env::var(k).ok())
212 }
213
214 pub(crate) fn apply_overrides(mut self, get: impl Fn(&str) -> Option<String>) -> Self {
216 fn parse<T: std::str::FromStr>(key: &str, raw: String) -> Option<T> {
217 match raw.parse() {
218 Ok(v) => Some(v),
219 Err(_) => {
220 tracing::warn!(key, value = raw, "ignoring unparseable ARCLY_* override");
221 None
222 }
223 }
224 }
225 if let Some(v) =
226 get("ARCLY_REQUEST_TIMEOUT_MS").and_then(|r| parse("ARCLY_REQUEST_TIMEOUT_MS", r))
227 {
228 self.request_timeout = Duration::from_millis(v);
229 }
230 if let Some(v) = get("ARCLY_MAX_IN_FLIGHT").and_then(|r| parse("ARCLY_MAX_IN_FLIGHT", r)) {
231 self.max_in_flight = v;
232 }
233 if let Some(v) = get("ARCLY_MAX_BODY_BYTES").and_then(|r| parse("ARCLY_MAX_BODY_BYTES", r))
234 {
235 self.max_body_bytes = v;
236 }
237 if let Some(v) =
238 get("ARCLY_CACHE_MAX_ENTRIES").and_then(|r| parse("ARCLY_CACHE_MAX_ENTRIES", r))
239 {
240 self.cache_max_entries = v;
241 }
242 if let Some(v) =
243 get("ARCLY_WS_DRAIN_DEADLINE_MS").and_then(|r| parse("ARCLY_WS_DRAIN_DEADLINE_MS", r))
244 {
245 self.ws_drain_deadline = Duration::from_millis(v);
246 }
247 if let Some(v) =
248 get("ARCLY_DRAIN_BUDGET_MS").and_then(|r| parse("ARCLY_DRAIN_BUDGET_MS", r))
249 {
250 self.drain_budget = Duration::from_millis(v);
251 }
252 if let Some(v) =
253 get("ARCLY_WS_OUTBOUND_BUFFER").and_then(|r| parse("ARCLY_WS_OUTBOUND_BUFFER", r))
254 {
255 self.ws_outbound_buffer = v;
256 }
257 if let Some(v) =
258 get("ARCLY_WS_MAX_CONNECTIONS").and_then(|r| parse("ARCLY_WS_MAX_CONNECTIONS", r))
259 {
260 self.ws_max_connections = v;
261 }
262 if let Some(v) =
263 get("ARCLY_WS_PING_INTERVAL_MS").and_then(|r| parse("ARCLY_WS_PING_INTERVAL_MS", r))
264 {
265 self.ws_ping_interval = Duration::from_millis(v);
266 }
267 if let Some(v) =
268 get("ARCLY_WS_IDLE_TIMEOUT_MS").and_then(|r| parse("ARCLY_WS_IDLE_TIMEOUT_MS", r))
269 {
270 self.ws_idle_timeout = Duration::from_millis(v);
271 }
272 if let Some(v) = get("ARCLY_ADAPTIVE_SHED_TARGET_MS")
273 .and_then(|r| parse("ARCLY_ADAPTIVE_SHED_TARGET_MS", r))
274 {
275 self.adaptive_shed_target = Duration::from_millis(v);
276 }
277 if let Some(raw) = get("ARCLY_EXPOSE_DOCS") {
278 match raw.as_str() {
279 "true" | "1" => self.expose_docs = true,
280 "false" | "0" => self.expose_docs = false,
281 _ => tracing::warn!(value = raw, "ignoring unparseable ARCLY_EXPOSE_DOCS"),
282 }
283 }
284 self
285 }
286}
287
288pub struct App;
289
290impl App {
291 pub async fn launch<RootMod: Module>(addr: &str) -> std::io::Result<()> {
292 let info = OpenApiInfo::new("arcly-http service", env!("CARGO_PKG_VERSION"));
293 Self::launch_with_info::<RootMod>(addr, info).await
294 }
295
296 pub async fn launch_named<RootMod: Module>(
297 addr: &str,
298 title: &'static str,
299 version: &'static str,
300 ) -> std::io::Result<()> {
301 let info = OpenApiInfo::new(title, version);
302 Self::launch_with_info::<RootMod>(addr, info).await
303 }
304
305 pub async fn launch_with_info<RootMod: Module>(
306 addr: &str,
307 info: OpenApiInfo,
308 ) -> std::io::Result<()> {
309 Self::launch_with_plugins::<RootMod>(addr, info, Vec::new()).await
310 }
311
312 pub async fn launch_with_plugins<RootMod: Module>(
315 addr: &str,
316 info: OpenApiInfo,
317 plugins: Vec<Box<dyn ArclyPlugin>>,
318 ) -> std::io::Result<()> {
319 Self::launch_configured::<RootMod>(addr, info, plugins, LaunchConfig::default()).await
320 }
321
322 pub async fn launch_configured<RootMod: Module>(
324 addr: &str,
325 info: OpenApiInfo,
326 plugins: Vec<Box<dyn ArclyPlugin>>,
327 config: LaunchConfig,
328 ) -> std::io::Result<()> {
329 let listener = tokio::net::TcpListener::bind(addr).await?;
330 Self::launch_on_listener::<RootMod>(listener, info, plugins, config).await
331 }
332
333 pub async fn launch_on_listener<RootMod: Module>(
339 listener: tokio::net::TcpListener,
340 info: OpenApiInfo,
341 mut plugins: Vec<Box<dyn ArclyPlugin>>,
342 config: LaunchConfig,
343 ) -> std::io::Result<()> {
344 let _root: PhantomData<RootMod> = PhantomData;
345 let config = config.with_env_overrides();
347 tracing::info!(
348 request_timeout = ?config.request_timeout,
349 max_in_flight = config.max_in_flight,
350 max_body_bytes = config.max_body_bytes,
351 expose_docs = config.expose_docs,
352 "launch config (effective)"
353 );
354
355 let reachable_modules = collect_reachable_modules(RootMod::descriptor());
357 let allowed_controllers: std::collections::HashSet<&'static str> = reachable_modules
358 .iter()
359 .flat_map(|m| m.controllers.iter().copied())
360 .collect();
361
362 let mut b = DiContainerBuilder::new();
364 for m in &reachable_modules {
365 for p in m.providers {
366 b.add_provider(p);
367 }
368 }
369
370 let mut plugin_ctx = ArclyPluginContext::new();
372 for p in plugins.iter_mut() {
373 plugin_ctx.current_plugin = p.name();
374 if let Err(e) = p.on_init(&mut plugin_ctx).await {
375 return Err(plugin_io_err(e));
376 }
377 }
378
379 for f in plugin_ctx.pending_providers.drain(..) {
381 f(&mut b);
382 }
383
384 b.register(arcly_http_core::web::dynamic::DynamicRouteTable::new());
388 b.register(arcly_http_core::web::boundary::BodyLimit(
391 config.max_body_bytes,
392 ));
393 let container = b.freeze();
394
395 let mut spec_value = build_spec_filtered(&info, Some(&allowed_controllers));
397 for mutator in plugin_ctx.openapi_mutators.drain(..) {
398 mutator(&mut spec_value);
399 }
400 let spec_bytes: bytes::Bytes = serde_json::to_vec(&spec_value)
404 .unwrap_or_else(|e| {
405 panic!("Arcly: OpenAPI spec serialization failed: {e}")
408 })
409 .into();
410
411 let globals: &'static [&'static dyn arcly_http_core::web::interceptors::Interceptor] =
415 Box::leak(std::mem::take(&mut plugin_ctx.global_interceptors).into_boxed_slice());
416 let filters: &'static [&'static dyn arcly_http_core::web::boundary::BoundaryFilter] =
418 Box::leak(std::mem::take(&mut plugin_ctx.boundary_filters).into_boxed_slice());
419
420 let mut router: axum::Router<Arc<arcly_http_core::core::engine::FrozenDiContainer>> =
421 axum::Router::new();
422 let mut mounted: std::collections::HashSet<(&'static str, HttpMethod)> =
423 std::collections::HashSet::new();
424 mounted.insert(("/openapi.json", HttpMethod::GET));
426 mounted.insert(("/docs", HttpMethod::GET));
427 for rt in inventory::iter::<&'static RouteDescriptor> {
428 if !rt.controller.is_empty() && !allowed_controllers.contains(rt.controller) {
431 continue;
432 }
433 mounted.insert((rt.path, rt.method));
434 router = router.route(&axum_path(rt.path), adapt(rt, globals, filters));
435 if rt.path.len() > 1 && rt.path.ends_with('/') {
439 let trimmed: &'static str = &rt.path[..rt.path.len() - 1];
440 if mounted.insert((trimmed, rt.method)) {
441 router = router.route(&axum_path(trimmed), adapt(rt, globals, filters));
442 }
443 }
444 }
445 let mut app = router.with_state(container.clone());
446 for r in &plugin_ctx.extra_routes {
447 if !mounted.insert((r.path, r.method)) {
450 return Err(plugin_io_err(PluginError::new(
451 r.plugin,
452 PluginStage::Init,
453 format!(
454 "route `{:?} {}` is already mounted by another route or plugin",
455 r.method, r.path
456 ),
457 )));
458 }
459 app = app.route(
460 &axum_path(r.path),
461 build_plugin_route(container.clone(), r, globals, filters),
462 );
463 }
464
465 container
469 .get::<arcly_http_core::web::dynamic::DynamicRouteTable>()
470 .set_globals(globals);
471 app = app.route(
472 "/_plugins/{*rest}",
473 arcly_http_core::web::dynamic::dynamic_dispatch_route(container.clone(), filters),
474 );
475
476 #[cfg(feature = "realtime")]
484 let registry: &'static ConnectionRegistry = Box::leak(Box::new(ConnectionRegistry::new()));
485 #[cfg(feature = "realtime")]
486 {
487 let allowed_gateways: std::collections::HashSet<&'static str> = reachable_modules
488 .iter()
489 .flat_map(|m| m.gateways.iter().copied())
490 .collect();
491 let ws_tuning = arcly_http_realtime::realtime::ws::WsTuning::new(
492 config.ws_outbound_buffer,
493 config.ws_max_connections,
494 config.ws_ping_interval,
495 );
496 if !config.ws_idle_timeout.is_zero() {
500 let idle = config.ws_idle_timeout;
501 tokio::spawn(async move {
502 let mut tick = tokio::time::interval(idle / 2);
503 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
504 loop {
505 tick.tick().await;
506 let reaped = registry.sweep_idle(idle.as_secs());
507 if !reaped.is_empty() {
508 tracing::info!(
509 count = reaped.len(),
510 "reaped idle WebSocket connections"
511 );
512 }
513 }
514 });
515 }
516 for gd in inventory::iter::<&'static GatewayDescriptor> {
517 if !allowed_gateways.contains(gd.name) {
518 continue;
519 }
520 let runtime = (gd.build)(container.clone());
521 app = app.route(
522 &axum_path(gd.path),
523 ws_route(runtime, registry, container.clone(), ws_tuning),
524 );
525 }
526 }
527
528 if config.expose_docs {
532 app = app
533 .route(
534 "/openapi.json",
535 get(move || {
536 let spec_bytes = spec_bytes.clone();
537 async move {
538 (
539 [(axum::http::header::CONTENT_TYPE, "application/json")],
540 spec_bytes,
541 )
542 .into_response()
543 }
544 }),
545 )
546 .route(
547 "/docs",
548 get(|| async { Html(SWAGGER_UI_HTML).into_response() }),
549 );
550 }
551 let mut app = app.layer(axum::middleware::from_fn(
552 arcly_http_core::web::security::apply_security_headers,
553 ));
554
555 if let Some(cors_cfg) = config.cors.clone() {
560 let cors_cfg = Arc::new(cors_cfg);
561 app = app.layer(axum::middleware::from_fn(
562 move |req: axum::extract::Request, next: axum::middleware::Next| {
563 arcly_http_core::web::cors::apply_cors(Arc::clone(&cors_cfg), req, next)
564 },
565 ));
566 }
567 let gov = arcly_http_core::web::governor::Governor::new(
568 config.request_timeout,
569 config.max_in_flight,
570 config.adaptive_shed_target,
571 );
572 let app = app.layer(axum::middleware::from_fn(
573 move |req: axum::extract::Request, next: axum::middleware::Next| {
574 arcly_http_core::web::governor::govern(Arc::clone(&gov), req, next)
575 },
576 ));
577
578 arcly_http_core::web::cache::set_capacity(config.cache_max_entries);
580 arcly_http_core::web::cache::spawn_sweeper(config.cache_sweep_interval);
581
582 let plugins_arc: Arc<Vec<Box<dyn ArclyPlugin>>> = Arc::new(plugins);
593 let mut started = 0usize;
594 #[allow(clippy::explicit_counter_loop)] for p in plugins_arc.iter() {
596 if let Err(e) = p.on_start(container.clone()).await {
597 for already in plugins_arc[..started].iter().rev() {
598 drain_plugin(
599 already.as_ref(),
600 container.clone(),
601 "rollback",
602 config.drain_budget,
603 )
604 .await;
605 }
606 return Err(plugin_io_err(e));
607 }
608 started += 1;
609 }
610
611 let plugins_for_draining = Arc::clone(&plugins_arc);
617 let draining_budget = config.drain_budget;
618 #[cfg(feature = "realtime")]
619 let ws_deadline = config.ws_drain_deadline;
620 let trigger = config.shutdown_trigger.clone();
621 let container_drain = container.clone();
624 let serve = axum::serve(
628 listener,
629 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
630 )
631 .with_graceful_shutdown(async move {
632 match trigger {
633 Some(n) => {
634 tokio::select! {
635 _ = shutdown_signal() => {}
636 _ = n.notified() => {}
637 }
638 }
639 None => shutdown_signal().await,
640 }
641 arcly_http_core::observability::health::set_draining(true);
644 tracing::info!("shutdown signal received — HTTP draining");
645 #[cfg(feature = "realtime")]
648 tokio::spawn(async move {
649 tokio::time::sleep(ws_deadline).await;
650 let closed = registry.close_all();
651 if closed > 0 {
652 tracing::warn!(closed, "WS drain deadline reached — closed live sockets");
653 }
654 });
655 tokio::spawn(async move {
660 for p in plugins_for_draining.iter() {
661 match tokio::time::timeout(
662 draining_budget,
663 p.on_draining(container_drain.clone()),
664 )
665 .await
666 {
667 Ok(Ok(())) => {}
668 Ok(Err(e)) => {
669 tracing::error!(plugin = p.name(), error = %e, "plugin draining error")
670 }
671 Err(_) => tracing::warn!(
672 plugin = p.name(),
673 budget = ?draining_budget,
674 "plugin on_draining exceeded budget"
675 ),
676 }
677 }
678 });
679 });
680 let serve_res = serve.await;
681
682 tracing::info!(
684 budget = ?config.drain_budget,
685 "HTTP fully drained — running plugin on_shutdown (per-plugin budget)"
686 );
687 for p in plugins_arc.iter().rev() {
688 drain_plugin(
689 p.as_ref(),
690 container.clone(),
691 "shutdown",
692 config.drain_budget,
693 )
694 .await;
695 }
696 serve_res
697 }
698}
699
700fn axum_path(path: &str) -> String {
709 path.split('/')
710 .map(|seg| {
711 if let Some(name) = seg.strip_prefix(':') {
712 format!("{{{name}}}")
713 } else if let Some(name) = seg.strip_prefix('*') {
714 format!("{{*{name}}}")
715 } else {
716 seg.to_owned()
717 }
718 })
719 .collect::<Vec<_>>()
720 .join("/")
721}
722
723fn collect_reachable_modules(root: &'static ModuleDescriptor) -> Vec<&'static ModuleDescriptor> {
724 use std::collections::HashSet;
725 let mut visited: HashSet<*const ModuleDescriptor> = HashSet::new();
726 let mut queue: std::collections::VecDeque<&'static ModuleDescriptor> =
727 std::collections::VecDeque::new();
728 let mut order: Vec<&'static ModuleDescriptor> = Vec::new();
729 queue.push_back(root);
730 while let Some(m) = queue.pop_front() {
731 if !visited.insert(m as *const _) {
732 continue;
733 }
734 order.push(m);
735 for getter in m.imports {
736 queue.push_back(getter());
737 }
738 }
739 order
740}
741
742#[cfg(unix)]
747async fn shutdown_signal() {
748 use tokio::signal::unix::{signal, SignalKind};
749 match signal(SignalKind::terminate()) {
750 Ok(mut sigterm) => {
751 tokio::select! {
752 _ = tokio::signal::ctrl_c() => {}
753 _ = sigterm.recv() => {}
754 }
755 }
756 Err(e) => {
757 tracing::warn!(error = %e, "SIGTERM handler unavailable, falling back to SIGINT only");
758 let _ = tokio::signal::ctrl_c().await;
759 }
760 }
761}
762
763#[cfg(not(unix))]
764async fn shutdown_signal() {
765 let _ = tokio::signal::ctrl_c().await;
766}
767
768async fn drain_plugin(
772 p: &dyn ArclyPlugin,
773 container: Arc<arcly_http_core::core::engine::FrozenDiContainer>,
774 phase: &str,
775 budget: Duration,
776) {
777 match tokio::time::timeout(budget, p.on_shutdown(container)).await {
778 Ok(Ok(())) => {}
779 Ok(Err(e)) => {
780 tracing::error!(plugin = p.name(), phase, error = %e, "plugin shutdown error")
781 }
782 Err(_) => tracing::warn!(
783 plugin = p.name(),
784 phase,
785 budget = ?budget,
786 "plugin shutdown exceeded budget — skipped"
787 ),
788 }
789}
790
791fn plugin_io_err(e: PluginError) -> std::io::Error {
792 let kind = match e.stage {
793 PluginStage::Init => std::io::ErrorKind::InvalidInput,
794 PluginStage::Start => std::io::ErrorKind::ConnectionRefused,
795 PluginStage::Shutdown => std::io::ErrorKind::Other,
796 };
797 std::io::Error::new(kind, e)
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 #[test]
805 fn env_overrides_apply_and_ignore_garbage() {
806 let cfg = LaunchConfig::default().apply_overrides(|k| match k {
807 "ARCLY_REQUEST_TIMEOUT_MS" => Some("1500".into()),
808 "ARCLY_MAX_IN_FLIGHT" => Some("notanumber".into()), "ARCLY_MAX_BODY_BYTES" => Some("1024".into()),
810 "ARCLY_EXPOSE_DOCS" => Some("false".into()),
811 _ => None,
812 });
813 assert_eq!(cfg.request_timeout, Duration::from_millis(1500));
814 assert_eq!(cfg.max_in_flight, 0, "unparseable override is ignored");
815 assert_eq!(cfg.max_body_bytes, 1024);
816 assert!(!cfg.expose_docs);
817 assert_eq!(cfg.drain_budget, Duration::from_secs(5));
819 }
820}