Skip to main content

mesofact_core/proxy/
router.rs

1//! axum router and request dispatch for the mesofact proxy.
2//!
3//! Mode dispatch:
4//! - **Mode 1 (static)**: 302 redirect to `cdn_base_url{path}`, or stream
5//!   from `fallback_dir/{path}.html` / `fallback_dir/{path}/index.html`.
6//! - **Mode 2 (ssr)**: session resolve → cache lookup (fresh / stale-SWR /
7//!   miss) → Bun pool render → LRU store. See §"Cache-key composition" and
8//!   §"Mode 2 caching beyond TTL".
9//! - **Mode 3 (spa)**: serve the prerendered SPA shell — identical delivery to
10//!   Mode 1 (302 to CDN or local fallback). The shell is built once like a
11//!   static page; the client hydrates from the embedded `__MESOFACT_STATE__`
12//!   and takes over (mesofact is then out of the request path). See
13//!   architecture §"Bundle splitting & hydration boundary (Mode 3)".
14//! - **No match**: 404.
15//!
16//! The route table is rebuilt from the manifest on each reload by calling
17//! `build_matcher`. The matchit `Router` is stored in `AppState` alongside
18//! the current `Arc<WorkerPool>` so both can be swapped atomically via
19//! `Arc<RwLock<AppState>>`. Per-request work clones the Arcs it needs and
20//! drops the read guard before the (potentially slow) render await.
21//!
22//! @yah:ticket(R012-T2, "Proxy Mode 3 dispatch: serve the prerendered SPA shell (CDN redirect / local fallback), replacing the 501 stub")
23//! @yah:at(2026-05-26T16:12:02Z)
24//! @yah:status(review)
25//! @yah:assignee(agent:claude)
26//! @yah:phase(P10)
27//! @yah:parent(R012)
28//! @yah:handoff("Proxy Mode 3 dispatch shipped. router.rs handle() now routes RouteMode::Static | RouteMode::Spa through the same dispatch_static() path — the prerendered SPA shell is delivered identically to a Mode 1 static page (302 to cdn_base_url{path}, or local fallback serve_local() → {path}.html / {path}/index.html). Removed the not_implemented() 501 stub (no longer referenced). The client hydrates from the build-injected __MESOFACT_STATE__ and takes over; mesofact is then out of the request path (architecture §Bundle splitting & hydration boundary). Module doc updated. Two new proxy tests (bun-guarded like the Mode 1 ones): mode3_spa_redirects_to_cdn (302 → cdn/app) and mode3_spa_serves_shell_from_fallback (200, body carries the state tag).")
29//! @yah:verify("cargo test -p mesofact --test proxy")
30//! @yah:verify("cargo check --workspace")
31
32use crate::manifest::{ErrorRoutes, Manifest, Requires, Route, RouteMode};
33use crate::proxy::cache::{cache_window, compose_key, CacheEntry, CacheState, KeyInputs, ResponseCache};
34use crate::proxy::metrics::Metrics;
35use crate::proxy::session::{SessionResolver, User};
36use crate::proxy::source_gen::Generations;
37use crate::proxy::trace::TraceParent;
38use crate::proxy::worker_client::{RenderResult, WorkerError};
39use crate::proxy::worker_pool::WorkerPool;
40use axum::body::Body;
41use axum::extract::{Request, State};
42use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
43use axum::response::Response;
44use std::collections::BTreeMap;
45use std::path::PathBuf;
46use std::sync::atomic::{AtomicU32, Ordering};
47use std::sync::Arc;
48use std::time::{Duration, Instant};
49use tokio::sync::RwLock;
50use tracing::{debug, warn};
51
52/// Per-render envelope id (`id=0` is reserved for lifecycle). Process-global so
53/// ids stay unique across workers even though matching is per-connection.
54static RENDER_ID: AtomicU32 = AtomicU32::new(1);
55
56/// Render deadline handed to the worker (`deadline_ms` in the IPC envelope).
57/// The architecture's worked example uses 2000ms; not yet per-route configurable.
58const RENDER_DEADLINE_MS: u64 = 2000;
59
60/// Default negative-cache TTL when a route omits `cache_policy.negative_ttl`.
61const DEFAULT_NEGATIVE_TTL: u64 = 10;
62
63/// Shared state accessed by every request handler.
64pub struct AppState {
65    pub manifest: Arc<Manifest>,
66    /// matchit router mapping URL patterns → index into `manifest.routes`.
67    pub matcher: matchit::Router<usize>,
68    pub pool: Arc<WorkerPool>,
69    /// If set, Mode 1 routes redirect here: `{cdn_base_url}{path}`.
70    pub cdn_base_url: Option<String>,
71    /// Local fallback directory for Mode 1 when no CDN is configured.
72    pub fallback_dir: Option<PathBuf>,
73    /// Mode 2 LRU response cache.
74    pub cache: Arc<ResponseCache>,
75    /// Source generation provider (cache-key input 6).
76    pub generations: Arc<Generations>,
77    /// Session resolver (`None` = sessions disabled; `requires: ["user"]`
78    /// routes then always redirect/401).
79    pub session: Option<Arc<dyn SessionResolver>>,
80    /// Where to 302 a request that lacks a session on a `requires: ["user"]`
81    /// route. `?next=<original-url>` is appended. `None` → 401.
82    pub login_url: Option<String>,
83    /// Prometheus metrics registry, shared with the `/metrics` handler and the
84    /// worker pool (restarting gauge).
85    pub metrics: Arc<Metrics>,
86}
87
88impl AppState {
89    pub fn new(
90        manifest: Arc<Manifest>,
91        pool: Arc<WorkerPool>,
92        cdn_base_url: Option<String>,
93        fallback_dir: Option<PathBuf>,
94    ) -> Self {
95        let matcher = build_matcher(&manifest);
96        Self {
97            manifest,
98            matcher,
99            pool,
100            cdn_base_url,
101            fallback_dir,
102            cache: Arc::new(ResponseCache::new()),
103            generations: Arc::new(Generations::empty()),
104            session: None,
105            login_url: None,
106            metrics: Arc::new(Metrics::new()),
107        }
108    }
109
110    pub fn with_metrics(mut self, metrics: Arc<Metrics>) -> Self {
111        self.metrics = metrics;
112        self
113    }
114
115    pub fn with_cache(mut self, cache: Arc<ResponseCache>) -> Self {
116        self.cache = cache;
117        self
118    }
119
120    pub fn with_generations(mut self, generations: Arc<Generations>) -> Self {
121        self.generations = generations;
122        self
123    }
124
125    pub fn with_session(mut self, session: Arc<dyn SessionResolver>) -> Self {
126        self.session = Some(session);
127        self
128    }
129
130    pub fn with_login_url(mut self, login_url: Option<String>) -> Self {
131        self.login_url = login_url;
132        self
133    }
134}
135
136/// Translate a mesofact route pattern into matchit 0.8 syntax.
137///
138/// mesofact's manifest format is Express-style `:param` (and, defensively,
139/// `*rest`) — which is exactly what matchit **0.7** accepted, so this used to be
140/// a no-op. matchit **0.8** switched to brace syntax (`{param}` / `{*rest}`) and
141/// now treats `:` and `*` as ordinary literal characters. That makes the failure
142/// silent rather than loud: an untranslated `/c/:slug` *inserts successfully*
143/// (so the `Err` warning below never fires) but then only ever matches the
144/// literal path `/c/:slug`, so every parameterized route 404s.
145///
146/// Translating here keeps the public manifest format stable while speaking 0.8
147/// to matchit. Segments with an empty name (a bare `:` or `*`) are passed
148/// through untouched so matchit reports them as the malformed patterns they are.
149fn to_matchit_pattern(route: &str) -> String {
150    route
151        .split('/')
152        .map(|seg| match seg.split_at_checked(1) {
153            Some((":", name)) if !name.is_empty() => format!("{{{name}}}"),
154            Some(("*", name)) if !name.is_empty() => format!("{{*{name}}}"),
155            _ => seg.to_string(),
156        })
157        .collect::<Vec<_>>()
158        .join("/")
159}
160
161/// Build a matchit router from manifest routes (route pattern → route index).
162pub fn build_matcher(manifest: &Manifest) -> matchit::Router<usize> {
163    let mut router = matchit::Router::new();
164    for (i, route) in manifest.routes.iter().enumerate() {
165        let pattern = to_matchit_pattern(&route.route);
166        if let Err(e) = router.insert(&pattern, i) {
167            tracing::warn!("failed to register route '{}': {e}", route.route);
168        }
169    }
170    router
171}
172
173pub type SharedState = Arc<RwLock<AppState>>;
174
175/// Everything Mode 2 dispatch needs, snapshotted under the read guard so the
176/// guard can be dropped before the render await (a reload only blocks briefly).
177struct SsrCall {
178    path: String,
179    route: Route,
180    build_id: String,
181    params: BTreeMap<String, String>,
182    query: BTreeMap<String, String>,
183    headers: HeaderMap,
184    pool: Arc<WorkerPool>,
185    cache: Arc<ResponseCache>,
186    generations: Arc<Generations>,
187    session: Option<Arc<dyn SessionResolver>>,
188    login_url: Option<String>,
189    metrics: Arc<Metrics>,
190    /// `traceparent` value passed to the worker as `req.ctx.trace`.
191    trace: String,
192}
193
194/// The single catch-all axum handler. Route matching, dispatch, traceparent,
195/// and metrics recording all funnel through here so every response is observed
196/// exactly once.
197pub async fn handle(State(state): State<SharedState>, req: Request) -> Response {
198    let path = req.uri().path().to_string();
199    // Continue an inbound trace or mint a fresh one (§"Observability").
200    let trace = TraceParent::incoming_or_new(
201        req.headers().get("traceparent").and_then(|v| v.to_str().ok()),
202    );
203
204    // Snapshot the route + everything dispatch needs under the read guard, then
205    // drop it before the (possibly slow) render await.
206    enum Plan {
207        NotFound,
208        Static { cdn: Option<String>, fallback: Option<PathBuf> },
209        Ssr(Box<SsrCall>),
210    }
211
212    let (route_label, mode_label, metrics, error_pages, plan) = {
213        let st = state.read().await;
214        let metrics = st.metrics.clone();
215        let error_pages = ErrorPages::from_state(&st);
216        match st.matcher.at(&path) {
217            Err(_) => (
218                "<unmatched>".to_string(),
219                "none",
220                metrics,
221                error_pages,
222                Plan::NotFound,
223            ),
224            Ok(m) => {
225                let idx = *m.value;
226                let params = m
227                    .params
228                    .iter()
229                    .map(|(k, v)| (k.to_string(), v.to_string()))
230                    .collect::<BTreeMap<_, _>>();
231                let route = st.manifest.routes[idx].clone();
232                let route_label = route.route.clone();
233                let mode_label = mode_str(&route.mode);
234                debug!(trace = %trace.trace_id, "dispatching {} → mode={:?}", path, route.mode);
235                let plan = match route.mode {
236                    // Mode 1 (static) and Mode 3 (spa) both deliver a
237                    // prerendered HTML document from the CDN/local fallback.
238                    RouteMode::Static | RouteMode::Spa => Plan::Static {
239                        cdn: st.cdn_base_url.clone(),
240                        fallback: st.fallback_dir.clone(),
241                    },
242                    RouteMode::Ssr => Plan::Ssr(Box::new(SsrCall {
243                        path: path.clone(),
244                        route,
245                        build_id: st.manifest.build_id.clone(),
246                        params,
247                        query: parse_kv(req.uri().query()),
248                        headers: req.headers().clone(),
249                        pool: st.pool.clone(),
250                        cache: st.cache.clone(),
251                        generations: st.generations.clone(),
252                        session: st.session.clone(),
253                        login_url: st.login_url.clone(),
254                        metrics: metrics.clone(),
255                        trace: trace.header_value(),
256                    })),
257                };
258                (route_label, mode_label, metrics, error_pages, plan)
259            }
260        }
261    };
262
263    let mut resp = match plan {
264        Plan::NotFound => error_pages.not_found().await,
265        Plan::Static { cdn, fallback } => {
266            dispatch_static(&path, cdn, fallback, &error_pages).await
267        }
268        Plan::Ssr(call) => dispatch_ssr(*call).await,
269    };
270
271    // Echo the traceparent so a downstream collector can stitch the trace, then
272    // record the request outcome (route × mode × status).
273    if let Ok(v) = HeaderValue::from_str(&trace.header_value()) {
274        resp.headers_mut()
275            .insert(HeaderName::from_static("traceparent"), v);
276    }
277    metrics.record_request(&route_label, mode_label, resp.status().as_u16());
278    resp
279}
280
281/// `/metrics` — Prometheus text exposition. Mounted as a dedicated route in the
282/// proxy binary so it bypasses the manifest matcher (a manifest `/metrics`
283/// route would never be reached, by design).
284pub async fn metrics_handler(State(state): State<SharedState>) -> Response {
285    let (metrics, ready) = {
286        let st = state.read().await;
287        (st.metrics.clone(), st.pool.live_count().await)
288    };
289    Response::builder()
290        .status(StatusCode::OK)
291        .header(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")
292        .body(Body::from(metrics.render(ready)))
293        .unwrap()
294}
295
296fn mode_str(mode: &RouteMode) -> &'static str {
297    match mode {
298        RouteMode::Static => "static",
299        RouteMode::Ssr => "ssr",
300        RouteMode::Spa => "spa",
301    }
302}
303
304// ─── Mode 1 ───────────────────────────────────────────────────────────────
305
306async fn dispatch_static(
307    path: &str,
308    cdn: Option<String>,
309    fallback: Option<PathBuf>,
310    error_pages: &ErrorPages,
311) -> Response {
312    // CDN redirect takes priority over local fallback.
313    if let Some(cdn) = cdn {
314        let target = format!("{cdn}{path}");
315        return Response::builder()
316            .status(StatusCode::FOUND)
317            .header(header::LOCATION, target)
318            .body(Body::empty())
319            .unwrap();
320    }
321
322    if let Some(dir) = fallback {
323        return serve_local(path, &dir, error_pages).await;
324    }
325
326    // Neither CDN nor fallback configured — return 502.
327    Response::builder()
328        .status(StatusCode::BAD_GATEWAY)
329        .body(Body::from("no CDN or fallback configured for Mode 1 route"))
330        .unwrap()
331}
332
333/// Serve a static file from the local fallback directory.
334///
335/// Path mapping: `/` → `index.html`, `/about` → `about.html`,
336/// then falls back to `{path}/index.html` for directory-style routes.
337async fn serve_local(path: &str, dir: &PathBuf, error_pages: &ErrorPages) -> Response {
338    let relative = path.trim_start_matches('/');
339
340    let candidates: Vec<PathBuf> = if relative.is_empty() {
341        vec![dir.join("index.html")]
342    } else if relative.ends_with('/') {
343        vec![dir.join(relative).join("index.html")]
344    } else {
345        vec![
346            dir.join(format!("{relative}.html")),
347            dir.join(relative).join("index.html"),
348        ]
349    };
350
351    for candidate in &candidates {
352        match tokio::fs::read(candidate).await {
353            Ok(bytes) => {
354                return Response::builder()
355                    .status(StatusCode::OK)
356                    .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
357                    .body(Body::from(bytes))
358                    .unwrap();
359            }
360            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
361            Err(e) => {
362                tracing::error!("fallback read error for {}: {e}", candidate.display());
363                return error_pages.internal_error().await;
364            }
365        }
366    }
367
368    error_pages.not_found().await
369}
370
371// ─── Mode 2 ───────────────────────────────────────────────────────────────
372
373async fn dispatch_ssr(call: SsrCall) -> Response {
374    let requires_user = call
375        .route
376        .requires
377        .as_ref()
378        .is_some_and(|r| r.contains(&Requires::User));
379
380    // 1. Session resolution. The proxy owns this lookup (§"Request context").
381    let cookie_header = call
382        .headers
383        .get(header::COOKIE)
384        .and_then(|v| v.to_str().ok());
385    let user = call.session.as_ref().and_then(|s| s.resolve(cookie_header));
386
387    if requires_user && user.is_none() {
388        return redirect_to_login(&call.path, &call.query, call.login_url.as_deref());
389    }
390
391    // 2. Cache key (§"Cache-key composition"). User id folds in only when the
392    //    route requires user; the resolved (or `_anon`) id keys the entry.
393    let user_key = if requires_user {
394        Some(user.as_ref().map_or("_anon", |u| u.id.as_str()))
395    } else {
396        None
397    };
398    let vary = collect_vary(&call.route, &call.headers);
399    let gens = collect_generations(&call.route, &call.generations);
400    let key = compose_key(&KeyInputs {
401        build_id: &call.build_id,
402        route_pattern: &call.route.route,
403        params: &call.params,
404        query: &call.query,
405        vary: &vary,
406        source_generations: &gens,
407        user_id: user_key,
408    });
409
410    let cp = &call.route.cache_policy;
411    let ttl = Duration::from_secs(cp.ttl);
412    let swr = Duration::from_secs(cp.swr.unwrap_or(0));
413    let negative_ttl = Duration::from_secs(cp.negative_ttl.unwrap_or(DEFAULT_NEGATIVE_TTL));
414
415    // The RenderRequest JSON is independent of cache state — build it once so a
416    // background SWR refresh can reuse it.
417    let req_json = build_render_request(&call, user.as_ref());
418
419    // 3. Cache lookup.
420    if let Some(entry) = call.cache.get(&key) {
421        match entry.state() {
422            CacheState::Fresh => {
423                call.metrics.record_cache(&call.route.route, "fresh");
424                return build_cached_response(&entry, "fresh", false);
425            }
426            CacheState::Stale => {
427                // Serve stale immediately; refresh in the background.
428                call.metrics.record_cache(&call.route.route, "stale");
429                spawn_refresh(
430                    call.pool.clone(),
431                    call.cache.clone(),
432                    call.metrics.clone(),
433                    call.route.route.clone(),
434                    req_json.clone(),
435                    key.clone(),
436                    ttl,
437                    swr,
438                    negative_ttl,
439                );
440                return build_cached_response(&entry, "stale", false);
441            }
442            CacheState::Expired => {
443                // Fall through to a synchronous miss, but keep the expired body
444                // for the on-error stale fallback below.
445                return render_miss(&call, &key, req_json, ttl, swr, negative_ttl, Some(entry)).await;
446            }
447        }
448    }
449
450    render_miss(&call, &key, req_json, ttl, swr, negative_ttl, None).await
451}
452
453/// Synchronous miss: render through the pool, cache per the status class, and
454/// serve. On render error, serve a still-present (expired) entry marked
455/// `X-Mesofact-Stale: true`, else 503 + `Retry-After`.
456async fn render_miss(
457    call: &SsrCall,
458    key: &str,
459    req_json: serde_json::Value,
460    ttl: Duration,
461    swr: Duration,
462    negative_ttl: Duration,
463    fallback: Option<CacheEntry>,
464) -> Response {
465    call.metrics.inflight_inc();
466    let started = Instant::now();
467    let outcome = render_once(&call.pool, &call.route.route, req_json).await;
468    call.metrics
469        .observe_render(&call.route.route, started.elapsed().as_secs_f64());
470    call.metrics.inflight_dec();
471
472    match outcome {
473        Ok(rr) => {
474            let status = 200; // RenderResult carries no status; Mode 2 render → 200.
475            let entry = entry_from_render(status, rr, ttl, swr);
476            if let Some((store_ttl, store_swr)) =
477                cache_window(status, ttl, swr, negative_ttl)
478            {
479                let mut to_store = entry.clone();
480                to_store.ttl = store_ttl;
481                to_store.swr = store_swr;
482                call.cache.insert(key.to_string(), to_store);
483            }
484            call.metrics.record_cache(&call.route.route, "miss");
485            build_cached_response(&entry, "miss", false)
486        }
487        Err(e) => {
488            warn!("render failed for {}: {e}", call.route.route);
489            match fallback {
490                // On-error stale fallback within an existing entry.
491                Some(entry) => {
492                    call.metrics.record_cache(&call.route.route, "stale");
493                    build_cached_response(&entry, "stale", true)
494                }
495                None => service_unavailable(ttl),
496            }
497        }
498    }
499}
500
501/// Background SWR re-render: render and replace the entry under the same key.
502/// Best-effort — errors leave the stale entry in place for the next request.
503#[allow(clippy::too_many_arguments)]
504fn spawn_refresh(
505    pool: Arc<WorkerPool>,
506    cache: Arc<ResponseCache>,
507    metrics: Arc<Metrics>,
508    route_pattern: String,
509    req_json: serde_json::Value,
510    key: String,
511    ttl: Duration,
512    swr: Duration,
513    negative_ttl: Duration,
514) {
515    tokio::spawn(async move {
516        metrics.inflight_inc();
517        let started = Instant::now();
518        let outcome = render_once(&pool, &route_pattern, req_json).await;
519        metrics.observe_render(&route_pattern, started.elapsed().as_secs_f64());
520        metrics.inflight_dec();
521        match outcome {
522            Ok(rr) => {
523                let status = 200;
524                if let Some((store_ttl, store_swr)) = cache_window(status, ttl, swr, negative_ttl) {
525                    let mut entry = entry_from_render(status, rr, store_ttl, store_swr);
526                    entry.ttl = store_ttl;
527                    entry.swr = store_swr;
528                    cache.insert(key, entry);
529                } else {
530                    cache.remove(&key);
531                }
532            }
533            Err(e) => warn!("SWR refresh failed for {route_pattern}: {e}"),
534        }
535    });
536}
537
538/// Pick a worker and invoke render. Maps `queue_overflow` to a retryable error;
539/// the caller's error path handles all worker errors uniformly.
540async fn render_once(
541    pool: &WorkerPool,
542    route_pattern: &str,
543    req_json: serde_json::Value,
544) -> Result<RenderResult, WorkerError> {
545    let worker = pool.get().await.ok_or(WorkerError::Closed)?;
546    let id = RENDER_ID.fetch_add(1, Ordering::Relaxed);
547    worker.render(id, route_pattern, req_json, RENDER_DEADLINE_MS).await
548}
549
550fn entry_from_render(status: u16, rr: RenderResult, ttl: Duration, swr: Duration) -> CacheEntry {
551    CacheEntry {
552        status,
553        html: rr.html,
554        headers: rr.headers.into_iter().collect(),
555        ttl,
556        swr,
557        stored_at: Instant::now(),
558    }
559}
560
561/// Construct the `RenderRequest` JSON the worker expects (§"Request context").
562/// `project`/`region` resolution is post-MVP; omitted here.
563fn build_render_request(call: &SsrCall, user: Option<&User>) -> serde_json::Value {
564    let url = match call.query.is_empty() {
565        true => call.path.clone(),
566        false => format!("{}?{}", call.path, encode_query(&call.query)),
567    };
568    let headers: BTreeMap<&str, String> = call
569        .headers
570        .iter()
571        .map(|(k, v)| (k.as_str(), v.to_str().unwrap_or("").to_string()))
572        .collect();
573    let cookies = parse_cookies(&call.headers);
574
575    let mut req = serde_json::json!({
576        "url": url,
577        "params": call.params,
578        "query": call.query,
579        "headers": headers,
580        "cookies": cookies,
581        // W3C trace context handed to the worker (§"Observability"). `ctx` is
582        // the per-deployment escape hatch; `trace` is the one key mesofact owns.
583        "ctx": { "trace": call.trace },
584    });
585    if let Some(u) = user {
586        req["user"] = serde_json::json!({ "id": u.id, "attrs": u.attrs });
587    }
588    req
589}
590
591/// Collect the cache-key's `vary` inputs: each `cache_policy.vary` header's
592/// value (missing headers contribute the empty string so presence/absence is
593/// itself part of the key).
594fn collect_vary(route: &Route, headers: &HeaderMap) -> BTreeMap<String, String> {
595    let mut out = BTreeMap::new();
596    if let Some(vary) = &route.cache_policy.vary {
597        for name in vary {
598            let value = headers
599                .get(name.as_str())
600                .and_then(|v| v.to_str().ok())
601                .unwrap_or("")
602                .to_string();
603            out.insert(name.clone(), value);
604        }
605    }
606    out
607}
608
609/// Resolve a generation token for every source the route reads.
610fn collect_generations(route: &Route, generations: &Generations) -> BTreeMap<String, String> {
611    let mut out = BTreeMap::new();
612    if let Some(reads) = &route.source_reads {
613        for name in reads {
614            out.insert(name.clone(), generations.token(name));
615        }
616    }
617    out
618}
619
620// ─── response builders ──────────────────────────────────────────────────────
621
622fn build_cached_response(entry: &CacheEntry, cache_label: &str, stale: bool) -> Response {
623    let mut builder = Response::builder()
624        .status(StatusCode::from_u16(entry.status).unwrap_or(StatusCode::OK));
625    let mut has_content_type = false;
626    for (k, v) in &entry.headers {
627        if k.eq_ignore_ascii_case("content-type") {
628            has_content_type = true;
629        }
630        builder = builder.header(k, v);
631    }
632    if !has_content_type {
633        builder = builder.header(header::CONTENT_TYPE, "text/html; charset=utf-8");
634    }
635    builder = builder.header("x-mesofact-cache", cache_label);
636    if stale {
637        builder = builder.header("x-mesofact-stale", "true");
638    }
639    builder.body(Body::from(entry.html.clone())).unwrap()
640}
641
642fn redirect_to_login(path: &str, query: &BTreeMap<String, String>, login_url: Option<&str>) -> Response {
643    let Some(login) = login_url else {
644        return Response::builder()
645            .status(StatusCode::UNAUTHORIZED)
646            .body(Body::from("401 Unauthorized — session required"))
647            .unwrap();
648    };
649    let original = if query.is_empty() {
650        path.to_string()
651    } else {
652        format!("{path}?{}", encode_query(query))
653    };
654    let sep = if login.contains('?') { '&' } else { '?' };
655    let target = format!("{login}{sep}next={}", percent_encode(&original));
656    Response::builder()
657        .status(StatusCode::FOUND)
658        .header(header::LOCATION, target)
659        .body(Body::empty())
660        .unwrap()
661}
662
663fn service_unavailable(retry_after: Duration) -> Response {
664    Response::builder()
665        .status(StatusCode::SERVICE_UNAVAILABLE)
666        .header(header::RETRY_AFTER, retry_after.as_secs().max(1).to_string())
667        .body(Body::from("503 Service Unavailable"))
668        .unwrap()
669}
670
671/// Renders the manifest's `error_routes` pages (W270 §3, R595-T5), retiring the
672/// old hardcoded plaintext 404. Snapshotted from [`AppState`] under the read
673/// guard so the error builders can run after it is dropped.
674///
675/// `error_routes` values are ROUTE PATHS (e.g. `"/404"` → the `/404` static
676/// route), not asset keys — each is resolved to its prerendered asset in the
677/// local fallback dir exactly like a normal static request (`/404` →
678/// `404.html`). CDN-only deployments (no `fallback_dir`) fall back to plaintext:
679/// in prod the always-up edge (`@mesofact/edge`) serves the branded page, and
680/// this proxy sits behind it.
681#[derive(Clone)]
682struct ErrorPages {
683    routes: Option<ErrorRoutes>,
684    fallback_dir: Option<PathBuf>,
685}
686
687impl ErrorPages {
688    fn from_state(st: &AppState) -> Self {
689        Self {
690            routes: st.manifest.error_routes.clone(),
691            fallback_dir: st.fallback_dir.clone(),
692        }
693    }
694
695    async fn not_found(&self) -> Response {
696        self.render(StatusCode::NOT_FOUND, "404 Not Found").await
697    }
698
699    async fn internal_error(&self) -> Response {
700        self.render(StatusCode::INTERNAL_SERVER_ERROR, "500 Internal Server Error")
701            .await
702    }
703
704    /// Serve the branded error page for `status` from the fallback dir, or the
705    /// plaintext `default_text` when unconfigured / no fallback dir / page
706    /// missing on disk. Served *with* `status`.
707    async fn render(&self, status: StatusCode, default_text: &'static str) -> Response {
708        if let Some(dir) = &self.fallback_dir {
709            if let Some(route) = self
710                .routes
711                .as_ref()
712                .and_then(|r| error_route_for(r, status))
713            {
714                for candidate in route_to_candidates(route) {
715                    if let Ok(bytes) = tokio::fs::read(dir.join(&candidate)).await {
716                        return Response::builder()
717                            .status(status)
718                            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
719                            .body(Body::from(bytes))
720                            .unwrap();
721                    }
722                }
723            }
724        }
725        Response::builder()
726            .status(status)
727            .body(Body::from(default_text))
728            .unwrap()
729    }
730}
731
732/// The configured error route for an HTTP status class: 5xx → `5xx`, 404 → `404`.
733fn error_route_for(routes: &ErrorRoutes, status: StatusCode) -> Option<&str> {
734    if status.as_u16() >= 500 {
735        routes.server_error.as_deref()
736    } else if status == StatusCode::NOT_FOUND {
737        routes.not_found.as_deref()
738    } else {
739        None
740    }
741}
742
743/// A route path (`/404`) → the ordered relative asset candidates a prerendered
744/// static route emits — the same clean-URL rule [`serve_local`] uses.
745fn route_to_candidates(route: &str) -> Vec<PathBuf> {
746    let rel = route.trim_start_matches('/');
747    if rel.is_empty() {
748        return vec![PathBuf::from("index.html")];
749    }
750    let last = rel.rsplit('/').next().unwrap_or(rel);
751    if last.contains('.') {
752        vec![PathBuf::from(rel)]
753    } else {
754        vec![
755            PathBuf::from(format!("{rel}.html")),
756            PathBuf::from(rel).join("index.html"),
757        ]
758    }
759}
760
761// ─── small parsers ──────────────────────────────────────────────────────────
762
763/// Parse `a=b&c=d` into a sorted map. Values are kept raw (already percent-
764/// encoded as the client sent them) — the cache key only needs determinism.
765fn parse_kv(query: Option<&str>) -> BTreeMap<String, String> {
766    let mut out = BTreeMap::new();
767    if let Some(q) = query {
768        for pair in q.split('&').filter(|p| !p.is_empty()) {
769            match pair.split_once('=') {
770                Some((k, v)) => out.insert(k.to_string(), v.to_string()),
771                None => out.insert(pair.to_string(), String::new()),
772            };
773        }
774    }
775    out
776}
777
778fn parse_cookies(headers: &HeaderMap) -> BTreeMap<String, String> {
779    let mut out = BTreeMap::new();
780    if let Some(raw) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
781        for pair in raw.split(';') {
782            if let Some((k, v)) = pair.split_once('=') {
783                out.insert(k.trim().to_string(), v.trim().to_string());
784            }
785        }
786    }
787    out
788}
789
790fn encode_query(query: &BTreeMap<String, String>) -> String {
791    query
792        .iter()
793        .map(|(k, v)| format!("{k}={v}"))
794        .collect::<Vec<_>>()
795        .join("&")
796}
797
798/// Minimal percent-encoding for a redirect `next=` value — encodes the
799/// characters that would break the query string. Sufficient for the login
800/// round-trip; a full RFC 3986 encoder isn't warranted here.
801fn percent_encode(s: &str) -> String {
802    let mut out = String::with_capacity(s.len());
803    for b in s.bytes() {
804        match b {
805            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
806                out.push(b as char)
807            }
808            _ => out.push_str(&format!("%{b:02X}")),
809        }
810    }
811    out
812}
813
814#[cfg(test)]
815mod matchit_pattern_tests {
816    use super::to_matchit_pattern;
817
818    #[test]
819    fn translates_colon_params_and_star_wildcards() {
820        assert_eq!(to_matchit_pattern("/c/:slug"), "/c/{slug}");
821        assert_eq!(to_matchit_pattern("/api/users/:id"), "/api/users/{id}");
822        assert_eq!(to_matchit_pattern("/x/:a/y/:b"), "/x/{a}/y/{b}");
823        assert_eq!(to_matchit_pattern("/assets/*rest"), "/assets/{*rest}");
824        // Static routes and malformed bare markers pass through untouched.
825        assert_eq!(to_matchit_pattern("/about"), "/about");
826        assert_eq!(to_matchit_pattern("/"), "/");
827        assert_eq!(to_matchit_pattern("/c/:"), "/c/:");
828    }
829
830    /// Regression guard for the matchit 0.7→0.8 syntax break: `:param` inserts
831    /// *successfully* under 0.8 (so no warning fires) but matches nothing,
832    /// silently 404-ing every parameterized route. Pin real matching behavior.
833    #[test]
834    fn translated_params_match_real_segments_and_bind_by_name() {
835        let mut r = matchit::Router::new();
836        r.insert(to_matchit_pattern("/c/:slug"), 1usize).unwrap();
837
838        let m = r.at("/c/hello").expect(":slug must match a real segment");
839        assert_eq!(*m.value, 1);
840        assert_eq!(m.params.get("slug"), Some("hello"));
841
842        // The literal ':slug' path must NOT match once translated.
843        assert!(r.at("/c/hello/extra").is_err());
844    }
845
846    /// The untranslated form is the bug: proves the shim is load-bearing, so a
847    /// future "simplification" that drops it fails here instead of in prod.
848    #[test]
849    fn untranslated_colon_param_is_inert_under_matchit_08() {
850        let mut r = matchit::Router::new();
851        r.insert("/c/:slug", 1usize)
852            .expect("0.8 accepts ':' as a literal — this is why the bug was silent");
853        assert!(
854            r.at("/c/hello").is_err(),
855            "if this now matches, matchit restored ':' support and the shim can be revisited"
856        );
857    }
858}