mesofact-core 0.8.42

mesofact serve/proxy engine — tri-mode web server core (static push-to-CDN / SSR proxy / SPA shell). Foundation crate re-exported by the `mesofact` facade.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
//! axum router and request dispatch for the mesofact proxy.
//!
//! Mode dispatch:
//! - **Mode 1 (static)**: 302 redirect to `cdn_base_url{path}`, or stream
//!   from `fallback_dir/{path}.html` / `fallback_dir/{path}/index.html`.
//! - **Mode 2 (ssr)**: session resolve → cache lookup (fresh / stale-SWR /
//!   miss) → Bun pool render → LRU store. See §"Cache-key composition" and
//!   §"Mode 2 caching beyond TTL".
//! - **Mode 3 (spa)**: serve the prerendered SPA shell — identical delivery to
//!   Mode 1 (302 to CDN or local fallback). The shell is built once like a
//!   static page; the client hydrates from the embedded `__MESOFACT_STATE__`
//!   and takes over (mesofact is then out of the request path). See
//!   architecture §"Bundle splitting & hydration boundary (Mode 3)".
//! - **No match**: 404.
//!
//! The route table is rebuilt from the manifest on each reload by calling
//! `build_matcher`. The matchit `Router` is stored in `AppState` alongside
//! the current `Arc<WorkerPool>` so both can be swapped atomically via
//! `Arc<RwLock<AppState>>`. Per-request work clones the Arcs it needs and
//! drops the read guard before the (potentially slow) render await.
//!
//! @yah:ticket(R012-T2, "Proxy Mode 3 dispatch: serve the prerendered SPA shell (CDN redirect / local fallback), replacing the 501 stub")
//! @yah:at(2026-05-26T16:12:02Z)
//! @yah:status(review)
//! @yah:assignee(agent:claude)
//! @yah:phase(P10)
//! @yah:parent(R012)
//! @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).")
//! @yah:verify("cargo test -p mesofact --test proxy")
//! @yah:verify("cargo check --workspace")

use crate::manifest::{ErrorRoutes, Manifest, Requires, Route, RouteMode};
use crate::proxy::cache::{cache_window, compose_key, CacheEntry, CacheState, KeyInputs, ResponseCache};
use crate::proxy::metrics::Metrics;
use crate::proxy::session::{SessionResolver, User};
use crate::proxy::source_gen::Generations;
use crate::proxy::trace::TraceParent;
use crate::proxy::worker_client::{RenderResult, WorkerError};
use crate::proxy::worker_pool::WorkerPool;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, warn};

/// Per-render envelope id (`id=0` is reserved for lifecycle). Process-global so
/// ids stay unique across workers even though matching is per-connection.
static RENDER_ID: AtomicU32 = AtomicU32::new(1);

/// Render deadline handed to the worker (`deadline_ms` in the IPC envelope).
/// The architecture's worked example uses 2000ms; not yet per-route configurable.
const RENDER_DEADLINE_MS: u64 = 2000;

/// Default negative-cache TTL when a route omits `cache_policy.negative_ttl`.
const DEFAULT_NEGATIVE_TTL: u64 = 10;

/// Shared state accessed by every request handler.
pub struct AppState {
    pub manifest: Arc<Manifest>,
    /// matchit router mapping URL patterns → index into `manifest.routes`.
    pub matcher: matchit::Router<usize>,
    pub pool: Arc<WorkerPool>,
    /// If set, Mode 1 routes redirect here: `{cdn_base_url}{path}`.
    pub cdn_base_url: Option<String>,
    /// Local fallback directory for Mode 1 when no CDN is configured.
    pub fallback_dir: Option<PathBuf>,
    /// Mode 2 LRU response cache.
    pub cache: Arc<ResponseCache>,
    /// Source generation provider (cache-key input 6).
    pub generations: Arc<Generations>,
    /// Session resolver (`None` = sessions disabled; `requires: ["user"]`
    /// routes then always redirect/401).
    pub session: Option<Arc<dyn SessionResolver>>,
    /// Where to 302 a request that lacks a session on a `requires: ["user"]`
    /// route. `?next=<original-url>` is appended. `None` → 401.
    pub login_url: Option<String>,
    /// Prometheus metrics registry, shared with the `/metrics` handler and the
    /// worker pool (restarting gauge).
    pub metrics: Arc<Metrics>,
}

impl AppState {
    pub fn new(
        manifest: Arc<Manifest>,
        pool: Arc<WorkerPool>,
        cdn_base_url: Option<String>,
        fallback_dir: Option<PathBuf>,
    ) -> Self {
        let matcher = build_matcher(&manifest);
        Self {
            manifest,
            matcher,
            pool,
            cdn_base_url,
            fallback_dir,
            cache: Arc::new(ResponseCache::new()),
            generations: Arc::new(Generations::empty()),
            session: None,
            login_url: None,
            metrics: Arc::new(Metrics::new()),
        }
    }

    pub fn with_metrics(mut self, metrics: Arc<Metrics>) -> Self {
        self.metrics = metrics;
        self
    }

    pub fn with_cache(mut self, cache: Arc<ResponseCache>) -> Self {
        self.cache = cache;
        self
    }

    pub fn with_generations(mut self, generations: Arc<Generations>) -> Self {
        self.generations = generations;
        self
    }

    pub fn with_session(mut self, session: Arc<dyn SessionResolver>) -> Self {
        self.session = Some(session);
        self
    }

    pub fn with_login_url(mut self, login_url: Option<String>) -> Self {
        self.login_url = login_url;
        self
    }
}

/// Translate a mesofact route pattern into matchit 0.8 syntax.
///
/// mesofact's manifest format is Express-style `:param` (and, defensively,
/// `*rest`) — which is exactly what matchit **0.7** accepted, so this used to be
/// a no-op. matchit **0.8** switched to brace syntax (`{param}` / `{*rest}`) and
/// now treats `:` and `*` as ordinary literal characters. That makes the failure
/// silent rather than loud: an untranslated `/c/:slug` *inserts successfully*
/// (so the `Err` warning below never fires) but then only ever matches the
/// literal path `/c/:slug`, so every parameterized route 404s.
///
/// Translating here keeps the public manifest format stable while speaking 0.8
/// to matchit. Segments with an empty name (a bare `:` or `*`) are passed
/// through untouched so matchit reports them as the malformed patterns they are.
fn to_matchit_pattern(route: &str) -> String {
    route
        .split('/')
        .map(|seg| match seg.split_at_checked(1) {
            Some((":", name)) if !name.is_empty() => format!("{{{name}}}"),
            Some(("*", name)) if !name.is_empty() => format!("{{*{name}}}"),
            _ => seg.to_string(),
        })
        .collect::<Vec<_>>()
        .join("/")
}

/// Build a matchit router from manifest routes (route pattern → route index).
pub fn build_matcher(manifest: &Manifest) -> matchit::Router<usize> {
    let mut router = matchit::Router::new();
    for (i, route) in manifest.routes.iter().enumerate() {
        let pattern = to_matchit_pattern(&route.route);
        if let Err(e) = router.insert(&pattern, i) {
            tracing::warn!("failed to register route '{}': {e}", route.route);
        }
    }
    router
}

pub type SharedState = Arc<RwLock<AppState>>;

/// Everything Mode 2 dispatch needs, snapshotted under the read guard so the
/// guard can be dropped before the render await (a reload only blocks briefly).
struct SsrCall {
    path: String,
    route: Route,
    build_id: String,
    params: BTreeMap<String, String>,
    query: BTreeMap<String, String>,
    headers: HeaderMap,
    pool: Arc<WorkerPool>,
    cache: Arc<ResponseCache>,
    generations: Arc<Generations>,
    session: Option<Arc<dyn SessionResolver>>,
    login_url: Option<String>,
    metrics: Arc<Metrics>,
    /// `traceparent` value passed to the worker as `req.ctx.trace`.
    trace: String,
}

/// The single catch-all axum handler. Route matching, dispatch, traceparent,
/// and metrics recording all funnel through here so every response is observed
/// exactly once.
pub async fn handle(State(state): State<SharedState>, req: Request) -> Response {
    let path = req.uri().path().to_string();
    // Continue an inbound trace or mint a fresh one (§"Observability").
    let trace = TraceParent::incoming_or_new(
        req.headers().get("traceparent").and_then(|v| v.to_str().ok()),
    );

    // Snapshot the route + everything dispatch needs under the read guard, then
    // drop it before the (possibly slow) render await.
    enum Plan {
        NotFound,
        Static { cdn: Option<String>, fallback: Option<PathBuf> },
        Ssr(Box<SsrCall>),
    }

    let (route_label, mode_label, metrics, error_pages, plan) = {
        let st = state.read().await;
        let metrics = st.metrics.clone();
        let error_pages = ErrorPages::from_state(&st);
        match st.matcher.at(&path) {
            Err(_) => (
                "<unmatched>".to_string(),
                "none",
                metrics,
                error_pages,
                Plan::NotFound,
            ),
            Ok(m) => {
                let idx = *m.value;
                let params = m
                    .params
                    .iter()
                    .map(|(k, v)| (k.to_string(), v.to_string()))
                    .collect::<BTreeMap<_, _>>();
                let route = st.manifest.routes[idx].clone();
                let route_label = route.route.clone();
                let mode_label = mode_str(&route.mode);
                debug!(trace = %trace.trace_id, "dispatching {} → mode={:?}", path, route.mode);
                let plan = match route.mode {
                    // Mode 1 (static) and Mode 3 (spa) both deliver a
                    // prerendered HTML document from the CDN/local fallback.
                    RouteMode::Static | RouteMode::Spa => Plan::Static {
                        cdn: st.cdn_base_url.clone(),
                        fallback: st.fallback_dir.clone(),
                    },
                    RouteMode::Ssr => Plan::Ssr(Box::new(SsrCall {
                        path: path.clone(),
                        route,
                        build_id: st.manifest.build_id.clone(),
                        params,
                        query: parse_kv(req.uri().query()),
                        headers: req.headers().clone(),
                        pool: st.pool.clone(),
                        cache: st.cache.clone(),
                        generations: st.generations.clone(),
                        session: st.session.clone(),
                        login_url: st.login_url.clone(),
                        metrics: metrics.clone(),
                        trace: trace.header_value(),
                    })),
                };
                (route_label, mode_label, metrics, error_pages, plan)
            }
        }
    };

    let mut resp = match plan {
        Plan::NotFound => error_pages.not_found().await,
        Plan::Static { cdn, fallback } => {
            dispatch_static(&path, cdn, fallback, &error_pages).await
        }
        Plan::Ssr(call) => dispatch_ssr(*call).await,
    };

    // Echo the traceparent so a downstream collector can stitch the trace, then
    // record the request outcome (route × mode × status).
    if let Ok(v) = HeaderValue::from_str(&trace.header_value()) {
        resp.headers_mut()
            .insert(HeaderName::from_static("traceparent"), v);
    }
    metrics.record_request(&route_label, mode_label, resp.status().as_u16());
    resp
}

/// `/metrics` — Prometheus text exposition. Mounted as a dedicated route in the
/// proxy binary so it bypasses the manifest matcher (a manifest `/metrics`
/// route would never be reached, by design).
pub async fn metrics_handler(State(state): State<SharedState>) -> Response {
    let (metrics, ready) = {
        let st = state.read().await;
        (st.metrics.clone(), st.pool.live_count().await)
    };
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")
        .body(Body::from(metrics.render(ready)))
        .unwrap()
}

fn mode_str(mode: &RouteMode) -> &'static str {
    match mode {
        RouteMode::Static => "static",
        RouteMode::Ssr => "ssr",
        RouteMode::Spa => "spa",
    }
}

// ─── Mode 1 ───────────────────────────────────────────────────────────────

async fn dispatch_static(
    path: &str,
    cdn: Option<String>,
    fallback: Option<PathBuf>,
    error_pages: &ErrorPages,
) -> Response {
    // CDN redirect takes priority over local fallback.
    if let Some(cdn) = cdn {
        let target = format!("{cdn}{path}");
        return Response::builder()
            .status(StatusCode::FOUND)
            .header(header::LOCATION, target)
            .body(Body::empty())
            .unwrap();
    }

    if let Some(dir) = fallback {
        return serve_local(path, &dir, error_pages).await;
    }

    // Neither CDN nor fallback configured — return 502.
    Response::builder()
        .status(StatusCode::BAD_GATEWAY)
        .body(Body::from("no CDN or fallback configured for Mode 1 route"))
        .unwrap()
}

/// Serve a static file from the local fallback directory.
///
/// Path mapping: `/` → `index.html`, `/about` → `about.html`,
/// then falls back to `{path}/index.html` for directory-style routes.
async fn serve_local(path: &str, dir: &PathBuf, error_pages: &ErrorPages) -> Response {
    let relative = path.trim_start_matches('/');

    let candidates: Vec<PathBuf> = if relative.is_empty() {
        vec![dir.join("index.html")]
    } else if relative.ends_with('/') {
        vec![dir.join(relative).join("index.html")]
    } else {
        vec![
            dir.join(format!("{relative}.html")),
            dir.join(relative).join("index.html"),
        ]
    };

    for candidate in &candidates {
        match tokio::fs::read(candidate).await {
            Ok(bytes) => {
                return Response::builder()
                    .status(StatusCode::OK)
                    .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
                    .body(Body::from(bytes))
                    .unwrap();
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
            Err(e) => {
                tracing::error!("fallback read error for {}: {e}", candidate.display());
                return error_pages.internal_error().await;
            }
        }
    }

    error_pages.not_found().await
}

// ─── Mode 2 ───────────────────────────────────────────────────────────────

async fn dispatch_ssr(call: SsrCall) -> Response {
    let requires_user = call
        .route
        .requires
        .as_ref()
        .is_some_and(|r| r.contains(&Requires::User));

    // 1. Session resolution. The proxy owns this lookup (§"Request context").
    let cookie_header = call
        .headers
        .get(header::COOKIE)
        .and_then(|v| v.to_str().ok());
    let user = call.session.as_ref().and_then(|s| s.resolve(cookie_header));

    if requires_user && user.is_none() {
        return redirect_to_login(&call.path, &call.query, call.login_url.as_deref());
    }

    // 2. Cache key (§"Cache-key composition"). User id folds in only when the
    //    route requires user; the resolved (or `_anon`) id keys the entry.
    let user_key = if requires_user {
        Some(user.as_ref().map_or("_anon", |u| u.id.as_str()))
    } else {
        None
    };
    let vary = collect_vary(&call.route, &call.headers);
    let gens = collect_generations(&call.route, &call.generations);
    let key = compose_key(&KeyInputs {
        build_id: &call.build_id,
        route_pattern: &call.route.route,
        params: &call.params,
        query: &call.query,
        vary: &vary,
        source_generations: &gens,
        user_id: user_key,
    });

    let cp = &call.route.cache_policy;
    let ttl = Duration::from_secs(cp.ttl);
    let swr = Duration::from_secs(cp.swr.unwrap_or(0));
    let negative_ttl = Duration::from_secs(cp.negative_ttl.unwrap_or(DEFAULT_NEGATIVE_TTL));

    // The RenderRequest JSON is independent of cache state — build it once so a
    // background SWR refresh can reuse it.
    let req_json = build_render_request(&call, user.as_ref());

    // 3. Cache lookup.
    if let Some(entry) = call.cache.get(&key) {
        match entry.state() {
            CacheState::Fresh => {
                call.metrics.record_cache(&call.route.route, "fresh");
                return build_cached_response(&entry, "fresh", false);
            }
            CacheState::Stale => {
                // Serve stale immediately; refresh in the background.
                call.metrics.record_cache(&call.route.route, "stale");
                spawn_refresh(
                    call.pool.clone(),
                    call.cache.clone(),
                    call.metrics.clone(),
                    call.route.route.clone(),
                    req_json.clone(),
                    key.clone(),
                    ttl,
                    swr,
                    negative_ttl,
                );
                return build_cached_response(&entry, "stale", false);
            }
            CacheState::Expired => {
                // Fall through to a synchronous miss, but keep the expired body
                // for the on-error stale fallback below.
                return render_miss(&call, &key, req_json, ttl, swr, negative_ttl, Some(entry)).await;
            }
        }
    }

    render_miss(&call, &key, req_json, ttl, swr, negative_ttl, None).await
}

/// Synchronous miss: render through the pool, cache per the status class, and
/// serve. On render error, serve a still-present (expired) entry marked
/// `X-Mesofact-Stale: true`, else 503 + `Retry-After`.
async fn render_miss(
    call: &SsrCall,
    key: &str,
    req_json: serde_json::Value,
    ttl: Duration,
    swr: Duration,
    negative_ttl: Duration,
    fallback: Option<CacheEntry>,
) -> Response {
    call.metrics.inflight_inc();
    let started = Instant::now();
    let outcome = render_once(&call.pool, &call.route.route, req_json).await;
    call.metrics
        .observe_render(&call.route.route, started.elapsed().as_secs_f64());
    call.metrics.inflight_dec();

    match outcome {
        Ok(rr) => {
            let status = 200; // RenderResult carries no status; Mode 2 render → 200.
            let entry = entry_from_render(status, rr, ttl, swr);
            if let Some((store_ttl, store_swr)) =
                cache_window(status, ttl, swr, negative_ttl)
            {
                let mut to_store = entry.clone();
                to_store.ttl = store_ttl;
                to_store.swr = store_swr;
                call.cache.insert(key.to_string(), to_store);
            }
            call.metrics.record_cache(&call.route.route, "miss");
            build_cached_response(&entry, "miss", false)
        }
        Err(e) => {
            warn!("render failed for {}: {e}", call.route.route);
            match fallback {
                // On-error stale fallback within an existing entry.
                Some(entry) => {
                    call.metrics.record_cache(&call.route.route, "stale");
                    build_cached_response(&entry, "stale", true)
                }
                None => service_unavailable(ttl),
            }
        }
    }
}

/// Background SWR re-render: render and replace the entry under the same key.
/// Best-effort — errors leave the stale entry in place for the next request.
#[allow(clippy::too_many_arguments)]
fn spawn_refresh(
    pool: Arc<WorkerPool>,
    cache: Arc<ResponseCache>,
    metrics: Arc<Metrics>,
    route_pattern: String,
    req_json: serde_json::Value,
    key: String,
    ttl: Duration,
    swr: Duration,
    negative_ttl: Duration,
) {
    tokio::spawn(async move {
        metrics.inflight_inc();
        let started = Instant::now();
        let outcome = render_once(&pool, &route_pattern, req_json).await;
        metrics.observe_render(&route_pattern, started.elapsed().as_secs_f64());
        metrics.inflight_dec();
        match outcome {
            Ok(rr) => {
                let status = 200;
                if let Some((store_ttl, store_swr)) = cache_window(status, ttl, swr, negative_ttl) {
                    let mut entry = entry_from_render(status, rr, store_ttl, store_swr);
                    entry.ttl = store_ttl;
                    entry.swr = store_swr;
                    cache.insert(key, entry);
                } else {
                    cache.remove(&key);
                }
            }
            Err(e) => warn!("SWR refresh failed for {route_pattern}: {e}"),
        }
    });
}

/// Pick a worker and invoke render. Maps `queue_overflow` to a retryable error;
/// the caller's error path handles all worker errors uniformly.
async fn render_once(
    pool: &WorkerPool,
    route_pattern: &str,
    req_json: serde_json::Value,
) -> Result<RenderResult, WorkerError> {
    let worker = pool.get().await.ok_or(WorkerError::Closed)?;
    let id = RENDER_ID.fetch_add(1, Ordering::Relaxed);
    worker.render(id, route_pattern, req_json, RENDER_DEADLINE_MS).await
}

fn entry_from_render(status: u16, rr: RenderResult, ttl: Duration, swr: Duration) -> CacheEntry {
    CacheEntry {
        status,
        html: rr.html,
        headers: rr.headers.into_iter().collect(),
        ttl,
        swr,
        stored_at: Instant::now(),
    }
}

/// Construct the `RenderRequest` JSON the worker expects (§"Request context").
/// `project`/`region` resolution is post-MVP; omitted here.
fn build_render_request(call: &SsrCall, user: Option<&User>) -> serde_json::Value {
    let url = match call.query.is_empty() {
        true => call.path.clone(),
        false => format!("{}?{}", call.path, encode_query(&call.query)),
    };
    let headers: BTreeMap<&str, String> = call
        .headers
        .iter()
        .map(|(k, v)| (k.as_str(), v.to_str().unwrap_or("").to_string()))
        .collect();
    let cookies = parse_cookies(&call.headers);

    let mut req = serde_json::json!({
        "url": url,
        "params": call.params,
        "query": call.query,
        "headers": headers,
        "cookies": cookies,
        // W3C trace context handed to the worker (§"Observability"). `ctx` is
        // the per-deployment escape hatch; `trace` is the one key mesofact owns.
        "ctx": { "trace": call.trace },
    });
    if let Some(u) = user {
        req["user"] = serde_json::json!({ "id": u.id, "attrs": u.attrs });
    }
    req
}

/// Collect the cache-key's `vary` inputs: each `cache_policy.vary` header's
/// value (missing headers contribute the empty string so presence/absence is
/// itself part of the key).
fn collect_vary(route: &Route, headers: &HeaderMap) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    if let Some(vary) = &route.cache_policy.vary {
        for name in vary {
            let value = headers
                .get(name.as_str())
                .and_then(|v| v.to_str().ok())
                .unwrap_or("")
                .to_string();
            out.insert(name.clone(), value);
        }
    }
    out
}

/// Resolve a generation token for every source the route reads.
fn collect_generations(route: &Route, generations: &Generations) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    if let Some(reads) = &route.source_reads {
        for name in reads {
            out.insert(name.clone(), generations.token(name));
        }
    }
    out
}

// ─── response builders ──────────────────────────────────────────────────────

fn build_cached_response(entry: &CacheEntry, cache_label: &str, stale: bool) -> Response {
    let mut builder = Response::builder()
        .status(StatusCode::from_u16(entry.status).unwrap_or(StatusCode::OK));
    let mut has_content_type = false;
    for (k, v) in &entry.headers {
        if k.eq_ignore_ascii_case("content-type") {
            has_content_type = true;
        }
        builder = builder.header(k, v);
    }
    if !has_content_type {
        builder = builder.header(header::CONTENT_TYPE, "text/html; charset=utf-8");
    }
    builder = builder.header("x-mesofact-cache", cache_label);
    if stale {
        builder = builder.header("x-mesofact-stale", "true");
    }
    builder.body(Body::from(entry.html.clone())).unwrap()
}

fn redirect_to_login(path: &str, query: &BTreeMap<String, String>, login_url: Option<&str>) -> Response {
    let Some(login) = login_url else {
        return Response::builder()
            .status(StatusCode::UNAUTHORIZED)
            .body(Body::from("401 Unauthorized — session required"))
            .unwrap();
    };
    let original = if query.is_empty() {
        path.to_string()
    } else {
        format!("{path}?{}", encode_query(query))
    };
    let sep = if login.contains('?') { '&' } else { '?' };
    let target = format!("{login}{sep}next={}", percent_encode(&original));
    Response::builder()
        .status(StatusCode::FOUND)
        .header(header::LOCATION, target)
        .body(Body::empty())
        .unwrap()
}

fn service_unavailable(retry_after: Duration) -> Response {
    Response::builder()
        .status(StatusCode::SERVICE_UNAVAILABLE)
        .header(header::RETRY_AFTER, retry_after.as_secs().max(1).to_string())
        .body(Body::from("503 Service Unavailable"))
        .unwrap()
}

/// Renders the manifest's `error_routes` pages (W270 §3, R595-T5), retiring the
/// old hardcoded plaintext 404. Snapshotted from [`AppState`] under the read
/// guard so the error builders can run after it is dropped.
///
/// `error_routes` values are ROUTE PATHS (e.g. `"/404"` → the `/404` static
/// route), not asset keys — each is resolved to its prerendered asset in the
/// local fallback dir exactly like a normal static request (`/404` →
/// `404.html`). CDN-only deployments (no `fallback_dir`) fall back to plaintext:
/// in prod the always-up edge (`@mesofact/edge`) serves the branded page, and
/// this proxy sits behind it.
#[derive(Clone)]
struct ErrorPages {
    routes: Option<ErrorRoutes>,
    fallback_dir: Option<PathBuf>,
}

impl ErrorPages {
    fn from_state(st: &AppState) -> Self {
        Self {
            routes: st.manifest.error_routes.clone(),
            fallback_dir: st.fallback_dir.clone(),
        }
    }

    async fn not_found(&self) -> Response {
        self.render(StatusCode::NOT_FOUND, "404 Not Found").await
    }

    async fn internal_error(&self) -> Response {
        self.render(StatusCode::INTERNAL_SERVER_ERROR, "500 Internal Server Error")
            .await
    }

    /// Serve the branded error page for `status` from the fallback dir, or the
    /// plaintext `default_text` when unconfigured / no fallback dir / page
    /// missing on disk. Served *with* `status`.
    async fn render(&self, status: StatusCode, default_text: &'static str) -> Response {
        if let Some(dir) = &self.fallback_dir {
            if let Some(route) = self
                .routes
                .as_ref()
                .and_then(|r| error_route_for(r, status))
            {
                for candidate in route_to_candidates(route) {
                    if let Ok(bytes) = tokio::fs::read(dir.join(&candidate)).await {
                        return Response::builder()
                            .status(status)
                            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
                            .body(Body::from(bytes))
                            .unwrap();
                    }
                }
            }
        }
        Response::builder()
            .status(status)
            .body(Body::from(default_text))
            .unwrap()
    }
}

/// The configured error route for an HTTP status class: 5xx → `5xx`, 404 → `404`.
fn error_route_for(routes: &ErrorRoutes, status: StatusCode) -> Option<&str> {
    if status.as_u16() >= 500 {
        routes.server_error.as_deref()
    } else if status == StatusCode::NOT_FOUND {
        routes.not_found.as_deref()
    } else {
        None
    }
}

/// A route path (`/404`) → the ordered relative asset candidates a prerendered
/// static route emits — the same clean-URL rule [`serve_local`] uses.
fn route_to_candidates(route: &str) -> Vec<PathBuf> {
    let rel = route.trim_start_matches('/');
    if rel.is_empty() {
        return vec![PathBuf::from("index.html")];
    }
    let last = rel.rsplit('/').next().unwrap_or(rel);
    if last.contains('.') {
        vec![PathBuf::from(rel)]
    } else {
        vec![
            PathBuf::from(format!("{rel}.html")),
            PathBuf::from(rel).join("index.html"),
        ]
    }
}

// ─── small parsers ──────────────────────────────────────────────────────────

/// Parse `a=b&c=d` into a sorted map. Values are kept raw (already percent-
/// encoded as the client sent them) — the cache key only needs determinism.
fn parse_kv(query: Option<&str>) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    if let Some(q) = query {
        for pair in q.split('&').filter(|p| !p.is_empty()) {
            match pair.split_once('=') {
                Some((k, v)) => out.insert(k.to_string(), v.to_string()),
                None => out.insert(pair.to_string(), String::new()),
            };
        }
    }
    out
}

fn parse_cookies(headers: &HeaderMap) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    if let Some(raw) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
        for pair in raw.split(';') {
            if let Some((k, v)) = pair.split_once('=') {
                out.insert(k.trim().to_string(), v.trim().to_string());
            }
        }
    }
    out
}

fn encode_query(query: &BTreeMap<String, String>) -> String {
    query
        .iter()
        .map(|(k, v)| format!("{k}={v}"))
        .collect::<Vec<_>>()
        .join("&")
}

/// Minimal percent-encoding for a redirect `next=` value — encodes the
/// characters that would break the query string. Sufficient for the login
/// round-trip; a full RFC 3986 encoder isn't warranted here.
fn percent_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
                out.push(b as char)
            }
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

#[cfg(test)]
mod matchit_pattern_tests {
    use super::to_matchit_pattern;

    #[test]
    fn translates_colon_params_and_star_wildcards() {
        assert_eq!(to_matchit_pattern("/c/:slug"), "/c/{slug}");
        assert_eq!(to_matchit_pattern("/api/users/:id"), "/api/users/{id}");
        assert_eq!(to_matchit_pattern("/x/:a/y/:b"), "/x/{a}/y/{b}");
        assert_eq!(to_matchit_pattern("/assets/*rest"), "/assets/{*rest}");
        // Static routes and malformed bare markers pass through untouched.
        assert_eq!(to_matchit_pattern("/about"), "/about");
        assert_eq!(to_matchit_pattern("/"), "/");
        assert_eq!(to_matchit_pattern("/c/:"), "/c/:");
    }

    /// Regression guard for the matchit 0.7→0.8 syntax break: `:param` inserts
    /// *successfully* under 0.8 (so no warning fires) but matches nothing,
    /// silently 404-ing every parameterized route. Pin real matching behavior.
    #[test]
    fn translated_params_match_real_segments_and_bind_by_name() {
        let mut r = matchit::Router::new();
        r.insert(to_matchit_pattern("/c/:slug"), 1usize).unwrap();

        let m = r.at("/c/hello").expect(":slug must match a real segment");
        assert_eq!(*m.value, 1);
        assert_eq!(m.params.get("slug"), Some("hello"));

        // The literal ':slug' path must NOT match once translated.
        assert!(r.at("/c/hello/extra").is_err());
    }

    /// The untranslated form is the bug: proves the shim is load-bearing, so a
    /// future "simplification" that drops it fails here instead of in prod.
    #[test]
    fn untranslated_colon_param_is_inert_under_matchit_08() {
        let mut r = matchit::Router::new();
        r.insert("/c/:slug", 1usize)
            .expect("0.8 accepts ':' as a literal — this is why the bug was silent");
        assert!(
            r.at("/c/hello").is_err(),
            "if this now matches, matchit restored ':' support and the shim can be revisited"
        );
    }
}