Skip to main content

resuma/server/
app.rs

1//! `ResumaApp` — high-level builder used by example apps & the CLI dev server.
2
3use 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
41/// HTTP application builder for single-page and manual-route apps.
42///
43/// Register routes with [`page`](Self::page) or [`page_with_request`](Self::page_with_request)
44/// (the latter receives [`FlowRequest`] — query, headers, method). Mount the axum router with
45/// [`serve`](Self::serve).
46///
47/// Built-in endpoints include `/_resuma/loader.js`, `/_resuma/handler/:chunk.js`, and
48/// `POST /_resuma/action/:name` for [`#[server]`](macro@crate::server) actions.
49pub 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    /// When true, HTML is sent as chunked stream (head → body → tail).
55    streaming: bool,
56    /// Optional catch-all page renderer (used by Resuma Flow for param routes).
57    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/// Listen options for [`ResumaApp::serve`].
64///
65/// [`Default`] and [`Self::from_env`] read `RESUMA_ADDR` or `HOST` + `PORT`
66/// (defaults to `127.0.0.1:3000`). Security settings come from [`SecurityConfig::from_env`].
67#[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    /// Read bind address from `RESUMA_ADDR` or `HOST` + `PORT` (same as [`crate::FlowServeOptions`]).
81    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    /// SEO / GEO / analytics kit (Meta Pixel, JSON-LD, llms.txt helpers).
131    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    /// Append raw markup to the document `<head>`. Useful for embedding
156    /// inline `<style>` blocks during development.
157    pub fn with_head(mut self, head: impl Into<String>) -> Self {
158        self.page_options.head = head.into();
159        self
160    }
161
162    /// Enable chunked streaming SSR (lower TTFB — head sent before body).
163    pub fn with_streaming(mut self, enabled: bool) -> Self {
164        self.streaming = enabled;
165        self
166    }
167
168    /// Register a page route with per-request HTTP context (query, headers, method).
169    ///
170    /// Prefer this over [`page`](Self::page) when the handler reads `FlowRequest` fields.
171    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    /// Register a page route without HTTP context (legacy / simple apps).
181    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    /// Register a no-props component route without spelling
189    /// `Component::render(ComponentProps::default())`.
190    ///
191    /// ```rust,ignore
192    /// ResumaApp::new().component("/", App)
193    /// ```
194    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    /// Catch-all renderer for dynamic routes (Resuma Flow param patterns).
203    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    /// Catch-all without HTTP context.
212    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    /// Register a precompiled handler chunk to be served at
220    /// `/_resuma/handler/<chunk>.js`.
221    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    /// Register a precompiled island chunk to be served at
236    /// `/_resuma/island-chunk/<chunk>.js`.
237    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        // Liveness / readiness probes (skipped if the app defines its own).
299        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
345/// Apply standard security headers to every HTTP response.
346pub 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    // Error / non-200 pages must not be advertised as indexable.
440    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    // Bound argument size and JSON nesting before dispatch (defense against
642    // deeply-nested / oversized payloads that survive the byte body limit).
643    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}