1use std::collections::HashMap;
4use std::net::SocketAddr;
5use std::rc::Rc;
6use std::sync::Arc;
7
8use crate::core::context::{with_context, RenderContext, RenderMode};
9use crate::core::view::View;
10use crate::core::Component;
11use crate::core::{FlowRequest, ResumaError};
12use crate::flow::extract_redirect;
13use crate::flow::runtime::with_request;
14use crate::ssr::PageOptions;
15use axum::body::Body;
16use axum::extract::ConnectInfo;
17use axum::extract::DefaultBodyLimit;
18use axum::extract::{Path, State};
19use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode, Uri};
20use axum::middleware::{self, Next};
21use axum::response::{Html, IntoResponse, Response};
22use axum::routing::{get, post};
23use axum::{Json, Router};
24use parking_lot::RwLock;
25use serde::{Deserialize, Serialize};
26use tracing::info;
27
28use super::actions::dispatch as dispatch_action;
29use super::compressed_asset::{
30 self, core_asset, flow_asset, loader_asset, runtime_asset, serve_js,
31};
32use super::deferred_stream::try_deferred_stream;
33use super::page_cache::take_response_cache_control;
34use super::runtime_asset::{CORE_JS, FLOW_JS, LOADER_JS, RUNTIME_JS};
35use super::security::{
36 self, client_ip_from_parts, csrf_set_cookie, guard_mutation, http_status,
37 resolve_page_csp_nonce, resolve_page_csrf, validate_config,
38 request_is_https, CspNonce, SecurityConfig, SecurityHeaderOptions,
39};
40
41pub struct ResumaApp {
50 page_factories: HashMap<String, Arc<PageFactory>>,
51 handler_chunks: Arc<RwLock<HashMap<String, String>>>,
52 island_chunks: Arc<RwLock<HashMap<String, String>>>,
53 page_options: PageOptions,
54 streaming: bool,
56 fallback: Option<Arc<FallbackFactory>>,
58}
59
60type PageFactory = dyn Fn(FlowRequest) -> View + Send + Sync;
61type FallbackFactory = dyn Fn(&str, FlowRequest) -> Option<View> + Send + Sync;
62
63#[derive(Debug, Clone)]
68pub struct ServeOptions {
69 pub addr: SocketAddr,
70 pub security: SecurityConfig,
71}
72
73impl Default for ServeOptions {
74 fn default() -> Self {
75 Self::from_env()
76 }
77}
78
79impl ServeOptions {
80 pub fn from_env() -> Self {
82 Self {
83 addr: super::listen::listen_addr_from_env(),
84 security: SecurityConfig::from_env(),
85 }
86 }
87}
88
89impl ResumaApp {
90 pub fn new() -> Self {
91 Self {
92 page_factories: HashMap::new(),
93 handler_chunks: Arc::new(RwLock::new(HashMap::new())),
94 island_chunks: Arc::new(RwLock::new(HashMap::new())),
95 page_options: PageOptions {
96 lang: "en".into(),
97 title: "Resuma App".into(),
98 ..Default::default()
99 },
100 streaming: false,
101 fallback: None,
102 }
103 }
104
105 pub fn with_title(mut self, title: impl Into<String>) -> Self {
106 self.page_options.title = title.into();
107 self
108 }
109
110 pub fn with_description(mut self, description: impl Into<String>) -> Self {
111 self.page_options.description = description.into();
112 self
113 }
114
115 pub fn with_site_url(mut self, url: impl Into<String>) -> Self {
116 self.page_options.site_url = url.into();
117 self
118 }
119
120 pub fn with_og_image(mut self, image: impl Into<String>) -> Self {
121 self.page_options.og_image = image.into();
122 self
123 }
124
125 pub fn with_json_ld(mut self, json_ld: impl Into<String>) -> Self {
126 self.page_options.json_ld = json_ld.into();
127 self
128 }
129
130 pub fn with_seo_kit(mut self, kit: crate::ssr::seo_kit::SeoKit) -> Self {
132 kit.apply(&mut self.page_options);
133 self.page_options.seo_kit = Some(kit);
134 self
135 }
136
137 pub fn with_pwa(mut self, pwa: crate::ssr::PwaOptions) -> Self {
138 self.page_options.pwa = Some(pwa);
139 self
140 }
141
142 pub(crate) fn page_options(&self) -> &PageOptions {
143 &self.page_options
144 }
145
146 pub(crate) fn page_options_mut(&mut self) -> &mut PageOptions {
147 &mut self.page_options
148 }
149
150 pub fn with_stylesheet(mut self, href: impl Into<String>) -> Self {
151 self.page_options.stylesheet = Some(href.into());
152 self
153 }
154
155 pub fn with_head(mut self, head: impl Into<String>) -> Self {
158 self.page_options.head = head.into();
159 self
160 }
161
162 pub fn with_streaming(mut self, enabled: bool) -> Self {
164 self.streaming = enabled;
165 self
166 }
167
168 pub fn page_with_request<F>(mut self, path: &str, factory: F) -> Self
172 where
173 F: Fn(FlowRequest) -> View + Send + Sync + 'static,
174 {
175 self.page_factories
176 .insert(path.to_string(), Arc::new(factory));
177 self
178 }
179
180 pub fn page<F>(self, path: &str, factory: F) -> Self
182 where
183 F: Fn() -> View + Send + Sync + 'static,
184 {
185 self.page_with_request(path, move |_req| factory())
186 }
187
188 pub fn component<C>(self, path: &str, _component: C) -> Self
195 where
196 C: Component + 'static,
197 C::Props: Default,
198 {
199 self.page(path, || C::render(Default::default()))
200 }
201
202 pub fn fallback_with_request<F>(mut self, factory: F) -> Self
204 where
205 F: Fn(&str, FlowRequest) -> Option<View> + Send + Sync + 'static,
206 {
207 self.fallback = Some(Arc::new(factory));
208 self
209 }
210
211 pub fn fallback<F>(self, factory: F) -> Self
213 where
214 F: Fn(&str) -> Option<View> + Send + Sync + 'static,
215 {
216 self.fallback_with_request(move |path, _req| factory(path))
217 }
218
219 pub fn handler_chunk(self, chunk_id: &str, source: impl Into<String>) -> Self {
222 if security::validate_chunk_id(chunk_id).is_err() {
223 tracing::warn!(
224 chunk = chunk_id,
225 "invalid handler chunk id — not registered"
226 );
227 return self;
228 }
229 self.handler_chunks
230 .write()
231 .insert(chunk_id.to_string(), source.into());
232 self
233 }
234
235 pub fn island_chunk(self, chunk_id: &str, source: impl Into<String>) -> Self {
238 if security::validate_chunk_id(chunk_id).is_err() {
239 tracing::warn!(chunk = chunk_id, "invalid island chunk id — not registered");
240 return self;
241 }
242 self.island_chunks
243 .write()
244 .insert(chunk_id.to_string(), source.into());
245 self
246 }
247
248 pub async fn serve(self, opts: ServeOptions) -> std::io::Result<()> {
249 crate::exec::init_exec().await;
250 validate_config(&opts.security).map_err(|e| {
251 std::io::Error::other(e.to_string())
252 })?;
253 security::configure(opts.security.clone());
254 security::warn_insecure_config(&opts.security);
255 let router = super::limits::apply_server_limits(
256 self.into_router()
257 .layer(DefaultBodyLimit::max(opts.security.body_limit_bytes))
258 .layer(middleware::from_fn(security_headers_middleware))
259 .layer(middleware::from_fn(super::ops::request_id_middleware)),
260 );
261 let (listener, bound) = super::listen::bind_listener(opts.addr).await?;
262 super::limits::warn_if_exposed_without_hardening(bound, opts.security.production);
263 info!(addr = %bound, "resuma server listening");
264 println!("resuma listening on http://{}", bound);
265 axum::serve(
266 listener,
267 router.into_make_service_with_connect_info::<SocketAddr>(),
268 )
269 .with_graceful_shutdown(super::ops::shutdown_signal())
270 .await
271 }
272
273 pub fn into_router(self) -> Router {
274 let seo_kit = self.page_options.seo_kit.clone();
275 let skip_robots = self.page_factories.contains_key("/robots.txt");
276 let skip_llms = self.page_factories.contains_key("/llms.txt");
277 let security_cfg = security::config();
278 let state = Arc::new(AppState {
279 pages: self.page_factories,
280 handler_chunks: self.handler_chunks,
281 island_chunks: self.island_chunks,
282 page_options: self.page_options,
283 streaming: self.streaming,
284 fallback: self.fallback,
285 hide_benchmark: security_cfg.hide_benchmark,
286 });
287
288 let mut router = Router::new();
289 let mut registered = std::collections::HashSet::new();
290 for path in state.pages.keys() {
291 for route_path in page_route_variants(path) {
292 if registered.insert(route_path.clone()) {
293 router = router.route(&route_path, get(serve_page));
294 }
295 }
296 }
297
298 if !state.pages.contains_key(super::ops::HEALTH_PATH) {
300 router = router.route(super::ops::HEALTH_PATH, get(super::ops::health));
301 }
302 if !state.pages.contains_key(super::ops::READY_PATH) {
303 router = router.route(super::ops::READY_PATH, get(super::ops::ready));
304 }
305
306 router = router.fallback(get(serve_fallback));
307
308 let mut router = router
309 .route("/_resuma/loader.js", get(serve_loader))
310 .route("/_resuma/core.js", get(serve_core))
311 .route("/_resuma/flow.js", get(serve_flow))
312 .route("/_resuma/runtime.js", get(serve_runtime))
313 .route("/_resuma/action/{name}", post(serve_action))
314 .route("/_resuma/handler/{chunk}", get(serve_handler_chunk))
315 .route("/_resuma/island-chunk/{chunk}", get(serve_island_chunk));
316
317 if super::dev::dev_mode_enabled() {
318 if !state.hide_benchmark {
319 router = router.route("/_resuma/benchmark.json", get(serve_benchmark));
320 }
321 router = router
322 .route("/_resuma/island/{instance}", get(serve_island_refresh))
323 .route("/_resuma/dev/ws", get(super::dev::dev_ws_handler));
324 }
325
326 if crate::exec::exec_routes_enabled() {
327 router = crate::exec::attach_exec_routes(router);
328 }
329
330 if let Some(kit) = seo_kit {
331 router = crate::flow::routes::attach_seo_kit_routes(
332 router,
333 kit,
334 crate::flow::routes::SeoKitRouteOpts {
335 robots: !skip_robots,
336 llms: !skip_llms,
337 },
338 );
339 }
340
341 router.with_state(state)
342 }
343}
344
345pub fn apply_security_headers(response: Response, opts: &SecurityHeaderOptions) -> Response {
347 security::apply_security_headers(response, opts)
348}
349
350pub async fn security_headers_middleware(req: Request<Body>, next: Next) -> Response {
351 let https = request_is_https(&req);
352 let res = next.run(req).await;
353 let nonce = res.extensions().get::<CspNonce>().map(|n| n.0.clone());
354 apply_security_headers(
355 res,
356 &SecurityHeaderOptions {
357 csp_nonce: nonce,
358 https,
359 },
360 )
361}
362
363impl Default for ResumaApp {
364 fn default() -> Self {
365 Self::new()
366 }
367}
368
369struct AppState {
370 pages: HashMap<String, Arc<PageFactory>>,
371 handler_chunks: Arc<RwLock<HashMap<String, String>>>,
372 island_chunks: Arc<RwLock<HashMap<String, String>>>,
373 page_options: PageOptions,
374 streaming: bool,
375 fallback: Option<Arc<FallbackFactory>>,
376 hide_benchmark: bool,
377}
378
379fn page_security_opts(
380 base: &PageOptions,
381 headers: &HeaderMap,
382) -> std::result::Result<(PageOptions, bool), crate::core::ResumaError> {
383 let cfg = security::config();
384 let mut opts = base.clone();
385 opts.csp_nonce = resolve_page_csp_nonce(cfg.csp.enabled)?;
386 let (token, is_new) = resolve_page_csrf(headers, cfg.csrf)?;
387 opts.csrf_token = token;
388 Ok((opts, is_new))
389}
390
391fn page_security_unavailable(_err: crate::core::ResumaError) -> Response {
392 (
393 StatusCode::SERVICE_UNAVAILABLE,
394 "Service temporarily unavailable — security token generation failed",
395 )
396 .into_response()
397}
398
399fn page_route_variants(pattern: &str) -> Vec<String> {
400 let norm = crate::flow::match_route::normalize_lookup_path(pattern);
401 if norm == "/" {
402 vec![norm]
403 } else {
404 vec![norm.clone(), format!("{norm}/")]
405 }
406}
407
408fn attach_page_security(
409 mut res: Response,
410 opts: &PageOptions,
411 https: bool,
412 set_csrf_cookie: bool,
413) -> Response {
414 if set_csrf_cookie && !opts.csrf_token.is_empty() {
415 res.headers_mut()
416 .insert(header::SET_COOKIE, csrf_set_cookie(&opts.csrf_token, https));
417 }
418 res.extensions_mut()
419 .insert(CspNonce(opts.csp_nonce.clone()));
420 res
421}
422
423fn render_page_response(
424 state: &AppState,
425 view: View,
426 ctx: Rc<RenderContext>,
427 opts: PageOptions,
428 path: &str,
429 https: bool,
430 set_csrf_cookie: bool,
431) -> Response {
432 let cache = super::page_cache::sanitize_cache_for_session(
433 take_response_cache_control(),
434 set_csrf_cookie,
435 );
436 let status_code = super::page_cache::take_response_status()
437 .and_then(|s| StatusCode::from_u16(s).ok())
438 .unwrap_or(StatusCode::OK);
439 let robots_tag = if status_code == StatusCode::OK {
441 "index, follow"
442 } else {
443 "noindex"
444 };
445 if state.streaming {
446 use axum::body::Body;
447 use futures_util::StreamExt;
448
449 let body = crate::ssr::render_view(&view);
450 let payload = ctx.snapshot_full();
451 super::handler_assets::merge_payload_handlers(
452 &state.handler_chunks,
453 &state.island_chunks,
454 &payload,
455 );
456
457 let stream = if let Some(deferred) = try_deferred_stream(view.clone(), &opts, path, &payload) {
458 deferred
459 } else {
460 crate::ssr::build_page_stream(opts.clone(), path, body.clone(), payload, vec![body])
461 };
462
463 let stream = stream.map(|chunk| {
464 chunk
465 .map(axum::body::Bytes::from)
466 .map_err(std::io::Error::other)
467 });
468 let mut builder = Response::builder()
469 .status(status_code)
470 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
471 .header(header::TRANSFER_ENCODING, "chunked");
472 if let Some(ref cache) = cache {
473 builder = builder.header(header::CACHE_CONTROL, cache.as_str());
474 }
475 let res = match builder
476 .header("x-robots-tag", robots_tag)
477 .body(Body::from_stream(stream))
478 {
479 Ok(res) => res,
480 Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
481 };
482 attach_page_security(res, &opts, https, set_csrf_cookie)
483 } else {
484 let payload = ctx.snapshot_full();
485 let html = crate::ssr::render_prebuilt_document(&opts, path, &view, &payload);
486 super::handler_assets::merge_payload_handlers(
487 &state.handler_chunks,
488 &state.island_chunks,
489 &payload,
490 );
491 let mut res = (status_code, Html(html)).into_response();
492 if let Some(cache) = cache {
493 res.headers_mut().insert(
494 header::CACHE_CONTROL,
495 HeaderValue::from_str(&cache)
496 .unwrap_or_else(|_| HeaderValue::from_static("no-store")),
497 );
498 }
499 res.headers_mut().insert(
500 header::HeaderName::from_static("x-robots-tag"),
501 HeaderValue::from_static(robots_tag),
502 );
503 attach_page_security(res, &opts, https, set_csrf_cookie)
504 }
505}
506
507async fn serve_page(uri: Uri, State(state): State<Arc<AppState>>, req: Request<Body>) -> Response {
508 let path = crate::flow::match_route::normalize_lookup_path(uri.path());
509 let factory = match state.pages.get(&path) {
510 Some(f) => f.clone(),
511 None => return (StatusCode::NOT_FOUND, "not found").into_response(),
512 };
513
514 let flow_req = crate::flow::request::from_http_request(&req, &path, Default::default());
515 let https = request_is_https(&req);
516 let (opts, new_csrf) = match page_security_opts(&state.page_options, req.headers()) {
517 Ok(v) => v,
518 Err(e) => return page_security_unavailable(e),
519 };
520 super::page_cache::stage_page_csrf(opts.csrf_token.clone());
521 super::page_cache::stage_page_csp_nonce(opts.csp_nonce.clone());
522 let ctx = RenderContext::new(RenderMode::Ssr);
523 let (view, _final_req) = with_request(flow_req.clone(), || {
524 with_context(ctx.clone(), || factory(flow_req))
525 });
526 render_page_response(&state, view, ctx, opts, &path, https, new_csrf)
527}
528
529async fn serve_fallback(
530 uri: Uri,
531 State(state): State<Arc<AppState>>,
532 req: Request<Body>,
533) -> Response {
534 let path = uri.path();
535 let flow_req = crate::flow::request::from_http_request(&req, path, Default::default());
536 if let Some(fb) = &state.fallback {
537 let https = request_is_https(&req);
538 let (opts, new_csrf) = match page_security_opts(&state.page_options, req.headers()) {
539 Ok(v) => v,
540 Err(e) => return page_security_unavailable(e),
541 };
542 super::page_cache::stage_page_csrf(opts.csrf_token.clone());
543 super::page_cache::stage_page_csp_nonce(opts.csp_nonce.clone());
544 let ctx = RenderContext::new(RenderMode::Ssr);
545 let (view, _final_req) = with_request(flow_req.clone(), || {
546 with_context(ctx.clone(), || fb(path, flow_req))
547 });
548 if let Some(view) = view {
549 return render_page_response(&state, view, ctx, opts, path, https, new_csrf);
550 }
551 }
552 (StatusCode::NOT_FOUND, "not found").into_response()
553}
554
555async fn serve_benchmark() -> Json<BenchmarkReport> {
556 Json(BenchmarkReport {
557 resuma: compressed_asset::asset_sizes()
558 .into_iter()
559 .map(|(name, raw, gzip, brotli)| BundleSize {
560 name: name.to_string(),
561 raw,
562 gzip,
563 brotli,
564 })
565 .collect(),
566 notes: vec![
567 "Resuma static pages ship zero JS — no loader, no payload.".into(),
568 "Interactive pages load loader.js first; core.js loads on first interaction or when reactive bindings exist.".into(),
569 "Compare the same metric: Network transfer size with Content-Encoding enabled.".into(),
570 ],
571 })
572}
573
574#[derive(Debug, Serialize)]
575struct BenchmarkReport {
576 resuma: Vec<BundleSize>,
577 notes: Vec<String>,
578}
579
580#[derive(Debug, Serialize)]
581struct BundleSize {
582 name: String,
583 raw: usize,
584 gzip: usize,
585 brotli: usize,
586}
587
588async fn serve_loader(headers: HeaderMap) -> Response {
589 serve_js(&headers, loader_asset(), LOADER_JS)
590}
591
592async fn serve_core(headers: HeaderMap) -> Response {
593 serve_js(&headers, core_asset(), CORE_JS)
594}
595
596async fn serve_flow(headers: HeaderMap) -> Response {
597 serve_js(&headers, flow_asset(), FLOW_JS)
598}
599
600async fn serve_runtime(headers: HeaderMap) -> Response {
601 serve_js(&headers, runtime_asset(), RUNTIME_JS)
602}
603
604#[derive(Debug, Deserialize)]
605struct ActionRequest {
606 args: Vec<serde_json::Value>,
607}
608
609#[derive(Debug, Serialize)]
610struct ActionResponse {
611 ok: bool,
612 value: Option<serde_json::Value>,
613 error: Option<String>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 redirect: Option<String>,
616}
617
618async fn serve_action(
619 State(_state): State<Arc<AppState>>,
620 Path(name): Path<String>,
621 headers: HeaderMap,
622 connect: ConnectInfo<SocketAddr>,
623 Json(body): Json<ActionRequest>,
624) -> Response {
625 let cfg = security::config();
626 let host = headers
627 .get(header::HOST)
628 .and_then(|v| v.to_str().ok())
629 .unwrap_or("localhost")
630 .to_string();
631 let ip = client_ip_from_parts(&headers, Some(connect.0));
632
633 if let Err(err) = guard_mutation(&headers, &host, &ip, "action", cfg.actions_per_minute, None) {
634 return action_error(err);
635 }
636
637 if let Err(err) = security::validate_action_name(&name) {
638 return action_error(err);
639 }
640
641 if let Err(err) =
644 crate::exec::security::validate_input(&serde_json::Value::Array(body.args.clone()))
645 {
646 return action_error(err);
647 }
648
649 let mut flow_req = FlowRequest::from_parts(
650 "POST",
651 format!("/_resuma/action/{name}"),
652 headers
653 .iter()
654 .filter_map(|(k, v)| {
655 v.to_str()
656 .ok()
657 .map(|s| (k.as_str().to_string(), s.to_string()))
658 })
659 .collect(),
660 std::collections::BTreeMap::from([(String::from("name"), name.clone())]),
661 std::collections::BTreeMap::new(),
662 );
663 crate::flow::extensions::global_extensions().merge_into(&mut flow_req);
664
665 match dispatch_action(&name, body.args, flow_req).await {
666 Ok(value) => {
667 let redirect = extract_redirect(&value);
668 (
669 StatusCode::OK,
670 Json(ActionResponse {
671 ok: true,
672 value: Some(value),
673 error: None,
674 redirect,
675 }),
676 )
677 .into_response()
678 }
679 Err(err) => action_error(err),
680 }
681}
682
683fn action_error(err: ResumaError) -> Response {
684 let cfg = security::config();
685 let status = http_status(&err);
686 (
687 status,
688 Json(ActionResponse {
689 ok: false,
690 value: None,
691 error: Some(err.client_message(cfg.production)),
692 redirect: None,
693 }),
694 )
695 .into_response()
696}
697
698async fn serve_handler_chunk(
699 Path(chunk): Path<String>,
700 State(state): State<Arc<AppState>>,
701) -> Response {
702 let key = chunk.trim_end_matches(".js").to_string();
703 if security::validate_chunk_id(&key).is_err() {
704 return (StatusCode::BAD_REQUEST, "invalid chunk id").into_response();
705 }
706 match state.handler_chunks.read().get(&key).cloned() {
707 Some(src) => {
708 let mut res = Response::new(src.into());
709 res.headers_mut().insert(
710 header::CONTENT_TYPE,
711 HeaderValue::from_static("application/javascript; charset=utf-8"),
712 );
713 res.headers_mut()
714 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
715 res
716 }
717 None => (StatusCode::NOT_FOUND, "handler chunk not found").into_response(),
718 }
719}
720
721async fn serve_island_refresh(Path(instance): Path<String>) -> Response {
722 if security::validate_chunk_id(&instance).is_err() {
723 return (StatusCode::BAD_REQUEST, "invalid island instance id").into_response();
724 }
725 match super::island_cache::island_refresh_html(&instance) {
726 Some(html) => Html(html).into_response(),
727 None => (StatusCode::NOT_FOUND, "island instance not found").into_response(),
728 }
729}
730
731async fn serve_island_chunk(
732 Path(chunk): Path<String>,
733 State(state): State<Arc<AppState>>,
734) -> Response {
735 let key = chunk.trim_end_matches(".js").to_string();
736 if security::validate_chunk_id(&key).is_err() {
737 return (StatusCode::BAD_REQUEST, "invalid chunk id").into_response();
738 }
739 match state.island_chunks.read().get(&key).cloned() {
740 Some(src) => {
741 let mut res = Response::new(src.into());
742 res.headers_mut().insert(
743 header::CONTENT_TYPE,
744 HeaderValue::from_static("application/javascript; charset=utf-8"),
745 );
746 res.headers_mut()
747 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
748 res
749 }
750 None => (StatusCode::NOT_FOUND, "island chunk not found").into_response(),
751 }
752}