Skip to main content

mesofact/
server.rs

1//! `mesofact-dev` — axum static-file server for `mesofact-static` workload
2//! artifacts, with optional file-watch + auto-rebuild + atomic pointer swap.
3//!
4//! Two modes, both share the same handler:
5//!
6//! - **No-watch (T1)** — [`Server::from_workload`] points at
7//!   `<workload>/dist/html/`. Whatever's on disk is served; no rebuild
8//!   orchestration. Useful for the local-static reconciler (R255-T3) when it
9//!   owns the build pipeline itself.
10//! - **Watch (`mesofact_dev::Watcher`)** — `mesofact_dev::Watcher::start` watches `<workload>/src/`,
11//!   debounces edits, runs `bun run build`, snapshots `dist/` into
12//!   `<workload>/.mesofact-dev/gen-<N>/`, and flips the shared [`DistPointer`]
13//!   to the new snapshot. Build stdout/stderr inherits the parent's, so it
14//!   shows up in the operator's terminal or the Run-tab log surface.
15//!
16//! The pointer swap is the "atomic" part: each generation is its own
17//! directory; the handler clones the current `PathBuf` per request, so an
18//! in-flight read against `gen-N` keeps reading from `gen-N` even after the
19//! pointer flips to `gen-N+1`. GC keeps the last two generations.
20//!
21//! Defaults to port 4321 per `.yah/services/dev-yah/mirrors/local.toml`.
22//!
23//! Sibling tickets under R255:
24//! - R255-T1 — scaffolded the static handler + CLI (review).
25//! - R255-T3 — local-static reconciler that spawns this binary.
26//! - R255-T4 — Run-tab iframe consumes the served `dev_url`.
27//!
28//! @yah:relay(R434, "Mesofact SSR support — yah-side rollout (cube + placement)")
29//! @yah:at(2026-06-04T19:11:39Z)
30//! @yah:status(open)
31//! @yah:next("P1 tickets (T1 dev.toml sweep, T2 dashboard dev.toml) are independent of the mesofact runtime delta — start there")
32//! @yah:next("P2 tickets (T3/T4/T5) need the external mesofact RouteEntry.placement field + SSR build-pipeline path live; coordinate via @mesofact/runtime version bump")
33//! @yah:next("Open question from W173: which marketing route becomes the first mode:\"ssr\" consumer? T5 depends on resolving this")
34//! @arch:see(.yah/docs/working/W173-mesofact-render-cube.md)
35//! @yah:assumes("@mesofact/runtime ships RouteEntry.placement?: Placement and the build pipeline accepts mode:\"ssr\" entrypoints with the Fetch signature — tracked at mesofact subcamp relay R015 (R015-F1 schema, R015-F2 build path, R015-F3 hydration handoff, R015-F4 boundary lint). cd external/mesofact && yah board show R015 for state.")
36//!
37//! @yah:ticket(R434-F3, "mesofact-dev SSR subprocess + proxy — spawn bun child, route SSR prefixes")
38//! @yah:assignee(agent:claude)
39//! @yah:at(2026-06-04T19:12:00Z)
40//! @yah:status(review)
41//! @yah:phase(P2)
42//! @yah:parent(R434)
43//! @yah:next("Spawn bun child during mesofact-dev startup; bind ephemeral port. Persist the port to <workload>/.mesofact-dev/ssr-port (new file in the existing watcher state dir, STATE_DIR_NAME at watcher.rs:86) so other tools can discover it; log on startup")
44//! @yah:next("Gate on Bun on PATH ONLY when the routes manifest has at least one mode:\"ssr\" route. Static/SPA-only workloads must keep working without Bun installed. On the SSR-needed path: bun missing → clear error + refuse to start, not a later crash")
45//! @yah:next("Read SSR-prefix set from the routes manifest; route matching paths to bun via segment-aware match (path === prefix || path.startsWith(prefix + '/'), NOT naive startsWith), fall through to static handler")
46//! @yah:next("Crash recovery: restart with capped backoff; surface last N lines of stderr through existing LogBuffer")
47//! @yah:next("Lazy import on first request is acceptable for dev tier — cold preheating is a later optimization")
48//! @yah:next("Dev tier ignores placement entirely (every mode:\"ssr\" route lands in the same bun subprocess, host or edge)")
49//! @yah:verify("A test mode:\"ssr\" route returns its Fetch handler's Response under mesofact-dev with no docker running")
50//! @yah:verify("Static routes still serve from dist/html/ unchanged")
51//! @yah:verify("Static/SPA-only workload starts cleanly with no Bun installed (no spawn attempted)")
52//! @yah:verify("Bun child crash restarts and stderr surfaces in the dev log")
53//! @yah:verify("Prefix /api/health does NOT match /api/healthcheck (segment boundary)")
54//! @yah:assumes("@mesofact/runtime has emitted the SSR-prefix set into the manifest — derivation rule per W173 § \"SSR_PREFIXES derivation rule\" (prefix up to first :param or *)")
55//! @arch:see(.yah/docs/working/W173-mesofact-render-cube.md)
56//! @yah:handoff("SSR subprocess + reverse proxy shipped (mesofact-dev). New src/ssr.rs: Manifest reader, W173 prefix derivation + segment-aware match, LogBuffer ring (500 lines), SsrChild handle, spawn() that returns Ok(None) for static/SPA-only workloads. When SSR routes exist: gates on `bun` PATH lookup with a clear error, writes ssr-wrapper.ts into the state dir, allocates an ephemeral 127.0.0.1 port, persists it to <workload>/.mesofact-dev/ssr-port, supervises the child with a 250ms→10s exponential-backoff restart loop, and streams stdout+stderr into the LogBuffer. New src/ssr_wrapper.ts: Bun program that reads MESOFACT_GEN_DIR / MESOFACT_SSR_PORT, dynamic-imports each mode:\"ssr\" route's render_entrypoint, dispatches via Bun.serve with the same segment-aware matcher as the Rust side. lib.rs: Server::with_ssr builder; serve_dynamic checks SSR match first and proxies via reqwest (hop-by-hop headers stripped, request + response bodies streamed) before falling through to the static handler. Cargo: +serde_json, +reqwest (default-features=false, features=[stream]), +futures, +tokio io-util feature. SsrChild::drop aborts the supervisor task and removes the port file. cargo test -p mesofact-dev clean: 37 passed. cargo check --workspace clean.")
57//! @yah:verify("cargo test -p mesofact-dev --offline --lib  # 37 passed (incl. bun-gated ssr_wrapper_serves_real_fetch_handler_via_bun)")
58//! @yah:verify("cargo check --workspace --offline  # clean")
59//! @yah:cleanup("Bun caches imported modules — after a watcher rebuild the SSR child keeps serving the old route entrypoints until the next child restart. F3 ships lazy first-request import but no proactive SIGTERM+respawn on DistPointer flip; wire the watcher → ssr-supervisor reload signal if dev-loop SSR edits become painful.")
60//! @yah:cleanup("ssr_wrapper.ts resolves render_entrypoint by stripping the first segment (conventionally 'dist/') and joining with MESOFACT_GEN_DIR. Workloads that override build.out_dir to a non-'dist' name will land at the wrong path — thread the out_dir name into the wrapper env when this bites.")
61//! @yah:cleanup("SsrChild drop only removes the port file synchronously; Drop can't await the supervisor's full teardown, so a follow-up may want a graceful shutdown() async method for camp wiring.")
62//!
63//! @yah:ticket(R443-B4, "mesofact-dev serve_from: clean-URL fallback — /releases (and /issues post-T1) 404 without .html extension")
64//! @yah:assignee(agent:claude)
65//! @yah:at(2026-06-05T00:20:43Z)
66//! @yah:status(review)
67//! @yah:parent(R443)
68//! @yah:severity(moderate)
69//! @yah:next("serve_from at crates/yah/mesofact-dev/src/lib.rs:314 doesn't try a `.html` extension on clean URLs. GET /releases returns 404 (file not found) but GET /releases.html returns 200. Surfaced during R434-F5 verification.")
70//! @yah:next("After the literal target miss, try `${target}.html` before falling back to 404.html. sanitize() already rejects path traversal so the .html append is safe.")
71//! @yah:next("Check the Worker's path-resolution (crates/yah/cloud/worker/router.bundle.js + router.ts) and mirror its rule so dev and prod agree. If sharing the resolver isn't practical, port the rule explicitly and add a parity test.")
72//! @yah:next("Don't try `.html` on SSR-prefix paths — the SSR proxy short-circuits before serve_from in serve_dynamic (lib.rs:212), so the ordering is already safe today, but flag if that ordering ever changes.")
73//! @yah:verify("mesofact-dev: GET /releases returns 200 with HTML body matching dist/html/releases.html")
74//! @yah:verify("Existing 37 mesofact-dev tests still pass; add serves_clean_url_via_html_fallback regression test")
75//! @yah:verify("Behavior matches the Cloudflare Worker's path-resolution for static routes (parity check against pond miniflare)")
76//! @yah:gotcha("Pre-existing; not a regression. Surfaced because R434-F5 verification ran curl against /releases and found the 404. R443-F2 will hit the same gap on /issues once T1 lands, which is why this bug is parented here — it blocks F2's verify path.")
77//! @yah:handoff("Shipped. crates/yah/mesofact-dev/src/lib.rs serve_from() now appends `.html` after a literal-path miss (only when the path has no extension), before falling through to 404.html. Mirrors what the CDN does for prerendered routes. Added two regression tests: `serves_clean_url_via_html_fallback` (GET /releases → 200 with releases.html body) and `clean_url_fallback_skips_paths_with_extension` (GET /style.css → 404, doesn't try /style.css.html).")
78//! @yah:handoff("Verified end-to-end via ./target/debug/mesofact-dev app/yah/web/marketing --no-watch --port 4399: /releases 200, /issues 200 (both via .html fallback), /releases.html 200 + /issues.html 200 (literal, unchanged), /issues_id.html 200, /404 200 (clean URL of the 404 route resolves to 404.html), /nonsense 404 (fallback miss → 404.html as 404), /style.css 404 (has extension, no fallback), /api/issues GET 405 + POST 200 (SSR proxy short-circuits before serve_from, ordering preserved). 42 mesofact-dev tests pass (was 37; added 2 here + 3 from intervening work).")
79//! @yah:handoff("Worker / prod parity: surveyed crates/yah/cloud/worker/router.ts — it does NOT have the .html-append rule either. So prod also 404s on /releases today, just hasn't been tripped because the marketing site isn't live + the deploy may rely on a CF-side asset router that adds the extension. Flagging as a follow-up in @yah:next; if it turns out the Worker needs the same rule, file a separate ticket against router.ts + router.bundle.js with the same shape.")
80//! @yah:handoff("Out of scope (separate gap): GET /issues/42 still 404 in dev. That's the parametric-SPA routing gap noted in R342-B5 + R434-F5 gotcha — mesofact-dev would need to know about route schemas to map any /issues/:id → issues_id.html. Not B4's problem; tracked elsewhere.")
81//! @yah:next("Follow-up worth filing: does the Cloudflare Worker need the same .html append? Today (crates/yah/cloud/worker/router.ts:60-65) it slices the leading `/` off the path and fetches it directly from ASSET_ORIGIN; a literal miss falls through to 404.html. If prod /releases currently works, there's CF-side asset routing doing the append — confirm before changing. If it doesn't work, mirror this fix in router.ts (segment-aware: only append when extension is empty).")
82//! @yah:verify("cargo test -p mesofact-dev --lib — VERIFIED 42 passed.")
83//! @yah:verify("./target/debug/mesofact-dev app/yah/web/marketing --no-watch --port 4399: /releases 200, /issues 200, /releases.html 200, /issues.html 200, /nonsense 404, /style.css 404, /api/issues GET 405 + POST 200. VERIFIED 2026-06-05.")
84//! @yah:verify("Manual parity check: crates/yah/cloud/worker/router.ts inspected; it does NOT have the .html-append rule today. Dev now has it; if prod needs it too, that's a separate ticket against router.ts.")
85//! @yah:gotcha("The added rule runs ONLY when target.extension().is_none() — so `/style.css` doesn't get tried as `/style.css.html`. That avoids serving wrong content if someone accidentally has a `style.css.html` file in dist. Test `clean_url_fallback_skips_paths_with_extension` enforces this.")
86//! @yah:gotcha("SSR ordering preserved: serve_dynamic checks ssr.matches(path) before serve_from (lib.rs:236-239), so SSR-prefix paths can't accidentally hit the .html fallback. Verified by GET /api/issues returning the F5 handler's 405, not a 404 from the static branch.")
87//!
88//! @yah:ticket(R443-B9, "mesofact-dev serve_from: hydrate bundles 404 — /{build_id}/hydrate/*.js never reaches dist/hydrate/")
89//! @yah:assignee(agent:claude)
90//! @yah:at(2026-06-05T07:34:31Z)
91//! @yah:status(review)
92//! @yah:parent(R443)
93//! @yah:handoff("Shipped. serve_from now intercepts /{build_id}/hydrate/<file> and /hydrate/<file> paths before the normal html/ resolution. hydrate_suffix() helper detects the two-form pattern (with or without build_id prefix) and redirects to <dist>/../hydrate/ (peer of html/). sanitize() still runs first so path traversal is rejected before hydrate_suffix is consulted. 4 new regression tests added: serves_hydrate_bundle_with_build_id_prefix, serves_hydrate_bundle_build_id_opaque, serves_hydrate_bundle_no_build_id_prefix, hydrate_path_traversal_rejected. 46 tests pass (was 42). cargo check --workspace clean.")
94//! @yah:verify("cargo test -p mesofact-dev --lib — 46 passed")
95//! @yah:verify("./target/debug/mesofact-dev app/yah/web/marketing --no-watch --port 4400; curl -sS -o /dev/null -w '%{http_code}\\n' http://127.0.0.1:4400/<build_id>/hydrate/issues.<hash>.js → 200")
96//! @yah:gotcha("Pre-existing — R342-F3 (SPA mode) hit the same gap but was never exercised end-to-end against mesofact-dev. The form's progressive-enhancement claim depends on this fix landing.")
97
98// Module declarations + the public re-exports of these names now live in the
99// facade crate root (`lib.rs`); this module only needs them in scope. The
100// dev-only `watcher` / `s3` modules stayed behind in `mesofact-dev` — the whole
101// point of the split (W225 §2: prod must not link dev affordances).
102use crate::proxy::{ProxyMap, ProxyState};
103#[cfg(feature = "ssr")]
104use crate::ssr::{ResiliencePolicy, SsrChild, SsrSlot};
105
106// The test module below reaches these as module paths (`ssr::…`, `proxy::…`),
107// which used to resolve because both modules were declared in this file. They
108// now live at the crate root, so bring the module names into scope for tests
109// only — gated so non-test builds don't carry unused imports.
110#[cfg(test)]
111use crate::proxy;
112#[cfg(all(test, feature = "ssr"))]
113use crate::{ssr, ssr::RetryPolicy};
114
115use std::{
116    net::{IpAddr, Ipv4Addr, SocketAddr},
117    path::{Path, PathBuf},
118    sync::{
119        atomic::{AtomicI64, AtomicU64, Ordering},
120        Arc, RwLock,
121    },
122    time::{Duration, SystemTime, UNIX_EPOCH},
123};
124
125#[cfg(feature = "ssr")]
126use std::time::Instant;
127
128#[cfg(feature = "ssr")]
129use axum::body::Body;
130use axum::{
131    extract::{Request, State},
132    http::{header, StatusCode},
133    middleware::Next,
134    response::{IntoResponse, Response},
135    routing::{any, get},
136    Router,
137};
138#[cfg(feature = "ssr")]
139use futures::StreamExt;
140#[cfg(feature = "ssr")]
141use mesofact_publisher::ObjectStore;
142#[cfg(feature = "ssr")]
143use mesofact_ssr::{DispatchRequest, DispatchResponse};
144use tower_http::trace::TraceLayer;
145use tracing::{info, warn};
146use yah_mesofact_bundle::BundleManifest;
147
148/// Default port for the local-static provider slot.
149pub const DEFAULT_PORT: u16 = 4321;
150
151/// Cache-Control for content-addressed instance bytes (W270 §9), byte-parallel
152/// with the `@mesofact/edge` worker's `IMMUTABLE_CACHE_CONTROL`: a published
153/// instance page is immutable, the pointer is the only mutable object.
154#[cfg(feature = "ssr")]
155const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
156
157/// Shared, atomically-swappable pointer to the currently-served `html/`
158/// directory. Cheap to clone; reads take a short read-lock.
159#[derive(Clone)]
160pub struct DistPointer {
161    inner: Arc<RwLock<PathBuf>>,
162}
163
164impl DistPointer {
165    pub fn new(initial: PathBuf) -> Self {
166        Self {
167            inner: Arc::new(RwLock::new(initial)),
168        }
169    }
170
171    /// Current served path; clones the underlying `PathBuf` so the handler
172    /// can hold it across `.await` without keeping the lock.
173    pub fn current(&self) -> PathBuf {
174        self.inner.read().expect("dist pointer poisoned").clone()
175    }
176
177    /// Atomically replace the served path. Subsequent requests see the new
178    /// value; in-flight requests keep reading from the old `PathBuf` they
179    /// already cloned.
180    pub fn set(&self, path: PathBuf) {
181        *self.inner.write().expect("dist pointer poisoned") = path;
182    }
183}
184
185/// Logical identity of a running mesofact-dev: the `(service, component)` the
186/// camp/reconciler spawned it for. Served verbatim at `/__mesofact/info` so an
187/// adopter can confirm a listener on a given port is *its* dev server before
188/// adopting it, rather than blindly hijacking whatever holds the port (a
189/// cross-service host-port collision would otherwise silently serve the wrong
190/// site — R602-B4).
191#[derive(Clone, serde::Serialize)]
192pub struct Identity {
193    pub service: String,
194    pub component: String,
195}
196
197/// Static-file dev server for one `mesofact-static` workload.
198pub struct Server {
199    workload: PathBuf,
200    pointer: DistPointer,
201    #[cfg(feature = "ssr")]
202    ssr: SsrSlot,
203    proxy: Option<ProxyState>,
204    config_json: Option<Arc<Vec<u8>>>,
205    /// `(service, component)` this dev server was spawned to serve, surfaced at
206    /// `/__mesofact/info` for the adopt identity-check (R602-B4). `None` when
207    /// the server was launched without an explicit identity (e.g. a hand-run
208    /// `mesofact-dev` from a terminal) — `/__mesofact/info` then 404s, and an
209    /// identity-checking adopter refuses to adopt it.
210    identity: Option<Identity>,
211    /// Object store for instance-addressed (deferred) route resolution
212    /// (W270 §9). Set → a static miss on a path matching a `prerender:
213    /// { deferred: true }` route in the manifest resolves through the pointer
214    /// store against this store, exactly as the `@mesofact/edge` worker
215    /// resolves against R2. `None` → deferred routes fall to the 404 page.
216    #[cfg(feature = "ssr")]
217    instance_store: Option<Arc<dyn ObjectStore>>,
218    /// Probe state behind `/livez` + `/readyz`. Held on the server (not built
219    /// per-router) so [`Server::serve_on_listener`] can mark it started after
220    /// the bind and drain it on SIGTERM.
221    health: Arc<crate::Health>,
222    /// Whether an SSR isolate is *expected*. Set by [`Server::with_ssr`], and
223    /// the difference between "the slot is empty because this is a static site"
224    /// and "the slot is empty because the isolate has not booted yet" — only the
225    /// second may hold readiness down. A dev watcher that populates the slot via
226    /// [`Server::ssr_slot`] alone leaves this false, so dev rebuilds never gate
227    /// readiness on a transient respawn.
228    #[cfg(feature = "ssr")]
229    expects_ssr: bool,
230    /// Whether [`Server::router`] mounts the standard probe routes. Cleared by
231    /// [`Server::without_standard_probes`] for a service that mounts its own.
232    standard_probes: bool,
233}
234
235#[derive(Clone)]
236struct ServerState {
237    pointer: DistPointer,
238    #[cfg(feature = "ssr")]
239    ssr: SsrSlot,
240    proxy: Option<ProxyState>,
241    config_json: Option<Arc<Vec<u8>>>,
242    identity: Option<Arc<Identity>>,
243    #[cfg(feature = "ssr")]
244    instance_store: Option<Arc<dyn ObjectStore>>,
245}
246
247impl Server {
248    /// Construct a server for a workload directory. Fails if the directory
249    /// is missing; tolerates a missing `dist/html/`.
250    pub fn from_workload(workload: impl Into<PathBuf>) -> anyhow::Result<Self> {
251        let workload = workload.into();
252        if !workload.is_dir() {
253            anyhow::bail!("workload directory not found: {}", workload.display());
254        }
255        let pointer = DistPointer::new(workload.join("dist").join("html"));
256        Ok(Self {
257            workload,
258            pointer,
259            #[cfg(feature = "ssr")]
260            ssr: SsrSlot::new(),
261            proxy: None,
262            config_json: None,
263            identity: None,
264            #[cfg(feature = "ssr")]
265            instance_store: None,
266            health: crate::Health::new(),
267            #[cfg(feature = "ssr")]
268            expects_ssr: false,
269            standard_probes: true,
270        })
271    }
272
273    /// Construct a server for a materialized **W272 bundle** directory
274    /// (R599-F3; canonical `@yah:` block in the parent-camp W272 doc — this is
275    /// the mesofact-subcamp implementation, so a prose pointer only).
276    ///
277    /// A bundle's `app/` subtree is structurally a workload dir — the parent of
278    /// `dist/`, which carries `dist/html/<key>.html` + `dist/manifest.json` — so
279    /// bundle serving is [`Server::from_workload`] pointed at `<bundle>/app`,
280    /// after validating the bundle's own `manifest.toml` (identity + runtime).
281    ///
282    /// v0 serves **static only** (clean-URLs + 404, no V8), which is the whole
283    /// point: this path builds and runs with the crate compiled
284    /// `--no-default-features` (no `ssr`), so it dogfoods on the current glibc
285    /// fleet ahead of the musl-static V8 runtime (W272 §5). The isolate / JIT
286    /// tiers that execute `mesofact.routes.ts` are R599-F6 follow-on.
287    ///
288    /// A `runtime = "self"` bundle carries its own `bins/<triple>/serve` and is
289    /// meant to be served by *that* binary, not the stock runtime — we still
290    /// serve its prerendered static tree, but warn, since executing its custom
291    /// runtime is out of scope here.
292    pub fn from_bundle(bundle: impl Into<PathBuf>) -> anyhow::Result<Self> {
293        let bundle = bundle.into();
294        let manifest_path = bundle.join("manifest.toml");
295        let raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
296            anyhow::anyhow!(
297                "not a mesofact bundle — reading {}: {e}",
298                manifest_path.display()
299            )
300        })?;
301        let manifest = BundleManifest::from_toml_str(&raw)
302            .map_err(|e| anyhow::anyhow!("invalid bundle manifest {}: {e}", manifest_path.display()))?;
303        if manifest.runtime.is_self_contained() {
304            warn!(
305                bundle = %manifest.name,
306                "bundle declares runtime=\"self\" (carries bins/<triple>/serve) — the stock \
307                 `mesofact serve` serves its static tree but does not execute its custom runtime",
308            );
309        }
310        let app = bundle.join("app");
311        let server = Self::from_workload(&app).map_err(|e| {
312            anyhow::anyhow!("bundle {} has no servable app tree: {e}", manifest.name)
313        })?;
314        info!(
315            bundle = %manifest.name,
316            runtime = %manifest.runtime.as_wire(),
317            app = %app.display(),
318            "serving mesofact bundle (static v0)",
319        );
320        Ok(server)
321    }
322
323    /// Stamp this server with its logical `(service, component)` identity,
324    /// exposed at `/__mesofact/info` for the adopt identity-check (R602-B4).
325    pub fn with_identity(mut self, service: impl Into<String>, component: impl Into<String>) -> Self {
326        self.identity = Some(Identity {
327            service: service.into(),
328            component: component.into(),
329        });
330        self
331    }
332
333    pub fn workload(&self) -> &Path {
334        &self.workload
335    }
336
337    /// Clone of the shared pointer — hand to a `mesofact_dev::Watcher` so its rebuilds
338    /// can flip the served snapshot.
339    pub fn pointer(&self) -> DistPointer {
340        self.pointer.clone()
341    }
342
343    /// Current served path. Initial value is `<workload>/dist/html/`; a
344    /// `mesofact_dev::Watcher` will swap this to `<workload>/.mesofact-dev/gen-<N>/html/`.
345    pub fn dist_dir(&self) -> PathBuf {
346        self.pointer.current()
347    }
348
349    /// Attach an SSR child. Requests whose path matches one of its prefixes
350    /// are proxied to the bun subprocess; everything else falls through to
351    /// the static handler. See [`ssr::spawn`] for the spawn contract.
352    ///
353    /// Also marks the workload as SSR-expecting, which puts the isolate on the
354    /// `/readyz` critical path: an SSR site with no live isolate can only 502,
355    /// so it must not be in a Service's endpoint set.
356    #[cfg(feature = "ssr")]
357    pub fn with_ssr(mut self, ssr: SsrChild) -> Self {
358        self.ssr.set(Some(Arc::new(ssr)));
359        self.expects_ssr = true;
360        self
361    }
362
363    /// Install a same-origin reverse proxy. Requests whose path matches one of
364    /// the map's prefixes (`/auth/*`, `/dev/*`, `/api/*` …) are forwarded to the
365    /// mapped backend port *before* static serving; everything else falls
366    /// through to the SPA. A no-op when the map is empty. See [`crate::proxy`] and
367    /// W207 Gap #1 (R513-F10).
368    pub fn with_proxy(mut self, map: ProxyMap) -> Self {
369        if !map.is_empty() {
370            self.proxy = Some(ProxyState::new(map));
371        }
372        self
373    }
374
375    /// Attach the object store that backs instance-addressed (deferred) route
376    /// resolution (W270 §9). In dev this is an [`S3Store`](mesofact_publisher::S3Store)
377    /// pointed at the local dev-S3 surface (`mesofact_dev::DevS3`) — the same store the
378    /// publisher flips pointers and writes render-root bytes into, so the local
379    /// `publish → view` loop resolves a `/<slug>` the way the edge worker does
380    /// against R2. Absent → deferred routes fall through to the 404 page.
381    #[cfg(feature = "ssr")]
382    pub fn with_instance_store(mut self, store: Arc<dyn ObjectStore>) -> Self {
383        self.instance_store = Some(store);
384        self
385    }
386
387    /// Serve `bytes` verbatim at `/config.json` (R513-F10, the F5 config seam).
388    /// This is *runtime* config the camp emits at SPA-service spawn — NOT a
389    /// build artifact, so it is injected by the server rather than dropped into
390    /// the served `dist/`. Absent → `/config.json` falls through to the SPA
391    /// (and the browser adapter uses its mock fallback), so an Option-A pipeline
392    /// serving the same `dist/` never inherits a stale `env:ci` config.
393    pub fn with_config_json(mut self, bytes: Vec<u8>) -> Self {
394        self.config_json = Some(Arc::new(bytes));
395        self
396    }
397
398    /// Clone of the SSR slot — hand to the watcher's post-build hook so it
399    /// can swap in (or restart) the bun child on each successful rebuild.
400    /// Reads via [`SsrSlot::current`] are lock-free for the request path.
401    #[cfg(feature = "ssr")]
402    pub fn ssr_slot(&self) -> SsrSlot {
403        self.ssr.clone()
404    }
405
406    /// The probe handle behind `/livez` + `/readyz`. Hand to a supervisor that
407    /// wants to drain this server on its own schedule.
408    pub fn health(&self) -> Arc<crate::Health> {
409        self.health.clone()
410    }
411
412    /// Install the readiness checks for this workload's shape.
413    ///
414    /// One *engine* check, because "can this process serve a request" has one
415    /// answer per mode and stacking both would break the other mode:
416    ///
417    /// - **SSR workload** → the isolate. An SSR-only site legitimately has no
418    ///   `dist/html/` (that is why `/__mesofact/health` was introduced in the
419    ///   first place, per R449-F3), so gating it on the tree would leave it
420    ///   permanently unready.
421    /// - **static / SPA** → the served tree. Without it every request 404s,
422    ///   which is precisely a pod that should be out of rotation.
423    ///
424    /// Then, when the app declares `/readyz` as an SSR route, the `app` check
425    /// from [`AppReadyCheck`]. Ordered second so the cheap engine answer is
426    /// already in the listing before anything dispatches into V8.
427    fn install_gates(&self) {
428        let mut checks: Vec<Arc<dyn crate::health::ReadyCheck>> = Vec::new();
429
430        #[cfg(feature = "ssr")]
431        if self.expects_ssr {
432            let ssr = self.ssr.clone();
433            checks.push(Arc::new(crate::health::Gate::new("ssr", move || {
434                ssr.current().is_some()
435            })));
436            checks.push(Arc::new(AppReadyCheck {
437                ssr: self.ssr.clone(),
438            }));
439        }
440        if checks.is_empty() {
441            let pointer = self.pointer.clone();
442            checks.push(Arc::new(crate::health::Gate::new("dist", move || {
443                pointer.current().exists()
444            })));
445        }
446        self.health.set_checks(checks);
447    }
448
449    /// Skip mounting the standard probe routes on [`Server::router`].
450    ///
451    /// The Rust-level override seam: a service that wants its own `/livez` or
452    /// `/readyz` — different wire format, an auth gate, a different set of
453    /// invariants — mounts them itself instead of fighting axum's
454    /// overlapping-route panic. [`Server::health`] still works, so the drain
455    /// half of the shutdown path is unaffected by opting out.
456    ///
457    /// Extending rather than replacing is the cheaper move: install a
458    /// [`ReadyCheck`](crate::health::ReadyCheck) and keep the standard wire
459    /// contract that every chart and dashboard already understands.
460    pub fn without_standard_probes(mut self) -> Self {
461        self.standard_probes = false;
462        self
463    }
464
465    /// Build the axum [`Router`]. Exposed for tests + the future embedded
466    /// paths (T3 reconciler).
467    pub fn router(&self) -> Router {
468        self.install_gates();
469        let state = ServerState {
470            pointer: self.pointer.clone(),
471            #[cfg(feature = "ssr")]
472            ssr: self.ssr.clone(),
473            proxy: self.proxy.clone(),
474            config_json: self.config_json.clone(),
475            identity: self.identity.clone().map(Arc::new),
476            #[cfg(feature = "ssr")]
477            instance_store: self.instance_store.clone(),
478        };
479        let mut router = Router::new()
480            // Logical-identity probe for the adopt path (R602-B4). Returns the
481            // `(service, component)` this dev server was spawned for so an
482            // adopter can confirm a port holds *its* server before adopting it,
483            // instead of blindly hijacking a colliding foreign listener. 404s
484            // when no identity was stamped. Reserved path; never a route key.
485            .route("/__mesofact/info", get(serve_info));
486        // Server-injected runtime config (R513-F10). A dedicated route wins over
487        // the catch-all only when config was supplied; otherwise `/config.json`
488        // falls through to `serve_dynamic` (static 404 / SPA), so no stale
489        // build-tree config leaks across pipelines.
490        if state.config_json.is_some() {
491            router = router.route("/config.json", get(serve_config_json));
492        }
493        let router = router
494            .route("/", any(serve_dynamic))
495            .route("/{*path}", any(serve_dynamic))
496            .with_state(state);
497
498        // `/livez` + `/readyz` (+ the `/healthz` and `/__mesofact/health`
499        // aliases). R449-F3 added the original single endpoint because an
500        // SSR-only workload has no static `/` to probe and the generous
501        // "any non-5xx is alive" criterion would also accept a 404 — but it
502        // answered 200 on bind, so it never delivered the "the isolate booted"
503        // meaning its comment claimed. `/readyz` does; see
504        // [`Server::install_gates`].
505        //
506        // Merged after `with_state` because these routes carry their own state;
507        // merging them into the `Router<ServerState>` above would make it
508        // `Router<()>` and orphan every `serve_dynamic` handler. They still win
509        // over `/{*path}` — matchit ranks literal segments above wildcards
510        // irrespective of registration order.
511        //
512        // That win is also why an app-declared `mode:"ssr"` `/readyz` cannot
513        // simply be routed to: the probe route shadows it. The app's handler is
514        // reached through `AppReadyCheck` instead, which is the better contract
515        // anyway — mesofact keeps ownership of the status code and the wire
516        // format, and the app contributes a verdict.
517        let router = if self.standard_probes {
518            router.merge(crate::health::probe_routes(self.health.clone()))
519        } else {
520            router
521        };
522        router.layer(TraceLayer::new_for_http())
523    }
524
525    /// Bind to `127.0.0.1:port` and serve until Ctrl+C / SIGTERM. The dev
526    /// loopback default; the `mesofact serve` container path uses
527    /// [`Server::serve_on`] to bind a routable address instead.
528    pub async fn serve(self, port: u16) -> anyhow::Result<()> {
529        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
530        self.serve_on(addr).await
531    }
532
533    /// Bind an explicit `addr` and serve until Ctrl+C / SIGTERM. The
534    /// SSR-host container (`mesofact serve`, R449-F3) binds `0.0.0.0:<port>`
535    /// so miniflare — running in a sibling container — can reach it over the
536    /// pond docker bridge; loopback-only would be unreachable.
537    pub async fn serve_on(self, addr: SocketAddr) -> anyhow::Result<()> {
538        let dist = self.pointer.current();
539        if !dist.exists() {
540            warn!(
541                dist = %dist.display(),
542                "served dir missing — run `bun run build` or start a watcher; 404s until it appears",
543            );
544        }
545        let listener = tokio::net::TcpListener::bind(addr).await?;
546        self.serve_on_listener(listener, None).await
547    }
548
549    /// Serve on an **already-bound** listener, with an optional JIT idle-reap
550    /// (R599-F6). Two properties beyond [`serve_on`]:
551    ///
552    /// - **Adopts the given socket.** The listener may have been created by
553    ///   someone else — the on-demand ("serverless") lifecycle has kamaji's
554    ///   [`SocketCustodian`](../kamaji/socket_custody) bind + hold the listen
555    ///   socket and hand this process the fd (systemd `LISTEN_FDS`, since
556    ///   the mesofact binary is our own). Adopting the fd instead of binding
557    ///   fresh is what lets the socket (and its accept queue) outlive each
558    ///   forked runtime process, so no connection is dropped across a reap →
559    ///   re-fork (W272 §3).
560    /// - **Self-reaps when idle.** With `idle_ttl` set, a background watcher
561    ///   triggers graceful shutdown once no request has been in flight for that
562    ///   long. The runtime — not kamaji — owns idle detection, keeping the
563    ///   supervisor out of the data path (no-impressive-mesh); kamaji just
564    ///   re-forks on the next connection to the socket it still holds. `None`
565    ///   = keep-alive (today's resident behavior).
566    pub async fn serve_on_listener(
567        self,
568        listener: tokio::net::TcpListener,
569        idle_ttl: Option<Duration>,
570    ) -> anyhow::Result<()> {
571        let local = listener.local_addr()?;
572        let idle = Arc::new(IdleTracker::default());
573        idle.touch();
574
575        let mut app = self.router();
576        if idle_ttl.is_some() {
577            // Count in-flight requests + stamp last-activity so the watcher never
578            // reaps mid-request and every request resets the idle clock.
579            app = app.layer(axum::middleware::from_fn_with_state(
580                idle.clone(),
581                track_activity,
582            ));
583        }
584        info!(
585            addr = %local,
586            workload = %self.workload.display(),
587            idle_ttl_s = idle_ttl.map(|d| d.as_secs_f64()),
588            "mesofact-dev listening",
589        );
590
591        // Serving now — `/readyz` from here on is decided by the gates alone.
592        self.health.mark_started();
593
594        let shutdown = {
595            let idle = idle.clone();
596            let health = self.health.clone();
597            async move {
598                match idle_ttl {
599                    Some(ttl) => {
600                        tokio::select! {
601                            _ = crate::shutdown_signal_for(health.clone()) => {}
602                            // An idle reap is not a rollout: nothing is holding
603                            // an endpoint for this process (kamaji keeps the
604                            // listen socket and re-forks on the next
605                            // connection), and by definition no request is in
606                            // flight — so there is nothing to drain, and a
607                            // grace window would just bill idle seconds.
608                            _ = idle_reaper(idle, ttl) => {
609                                health.begin_drain();
610                                info!(idle_ttl_s = ttl.as_secs_f64(), "idle TTL elapsed — self-reaping (JIT)");
611                            }
612                        }
613                    }
614                    None => crate::shutdown_signal_for(health).await,
615                }
616            }
617        };
618
619        axum::serve(listener, app)
620            .with_graceful_shutdown(shutdown)
621            .await?;
622        Ok(())
623    }
624}
625
626/// The TSX half of the `/readyz` extension point (W225 dual-language seam).
627///
628/// Two ways in, both meaning "this app contributes a readiness verdict".
629/// Declare the hook (R756-F6 — the general Mode 2 declaration site):
630///
631/// ```ts
632/// // mesofact.routes.ts
633/// hooks: { readyz: "src/readyz.ts" }
634/// ```
635///
636/// …or claim the route, which is how this shipped and still works:
637///
638/// ```ts
639/// // mesofact.routes.ts
640/// { route: "/readyz", mode: "ssr", entrypoint: "src/readyz.ts", cache_policy: { ttl: 0 } }
641/// ```
642///
643/// The hook declaration is the better one for a new app: the module is not a
644/// route, so it never enters `ssr_prefixes`, the edge Worker never forwards
645/// `/readyz` to the SSR origin, and it is never shadowed by the Rust probe
646/// route mounted on the same path. Declaring both is rejected at build time.
647/// Either way the module's contract is identical:
648///
649/// ```ts
650/// // src/readyz.ts — the ordinary Fetch-handler contract. 2xx means ready;
651/// // anything else takes the pod out of rotation.
652/// import { defineReadyz } from "@mesofact/runtime";
653/// export default defineReadyz([{ name: "db", check: () => db.ping() }]);
654/// ```
655///
656/// `defineReadyz` is optional sugar that emits the same `[+]name ok` listing
657/// this side emits under `?verbose`; a bare
658/// `export default async () => new Response("ok")` works identically. Worked
659/// example: `examples/hello/src/readyz.ts`.
660///
661/// The app never sees the request unless the engine is already healthy, and its
662/// answer can only subtract: a 200 from app code cannot overrule a failed `ssr`
663/// or `dist` check, and cannot un-drain a process. That asymmetry is the point.
664/// Readiness is about *routing traffic away*, and the situations where the
665/// answer matters most are exactly the ones where the app is the unreliable
666/// narrator.
667///
668/// Absent (the app declares neither) the check reports ready, so this costs
669/// nothing for the workloads that don't want it — and costs nothing at
670/// runtime either, since the absence is a registry lookup rather than a
671/// dispatch into V8 that comes back empty.
672///
673/// Internally this is Mode 2 (R756-F3 / W311 §2), not Mode 1: `ready()` calls
674/// [`SsrChild::invoke_hook`] rather than [`SsrChild::dispatch`], so only
675/// `{method, url}` crosses into the isolate and only `{status}` crosses back
676/// — no header vec, no byte body, no `Request`/`Response` envelope.
677/// `defineReadyz`'s public contract (`Request -> Response`) is unchanged; the
678/// hook adapter lives in the JS harness (`ssr_harness.js`'s `HOOK_ADAPTERS`),
679/// not in app code.
680#[cfg(feature = "ssr")]
681struct AppReadyCheck {
682    ssr: SsrSlot,
683}
684
685/// The hook name the engine invokes for the `app` readiness check. Matches
686/// `HOOK_NAMES` in `@mesofact/runtime` and `HOOK_ADAPTERS` in the JS harness.
687#[cfg(feature = "ssr")]
688const READYZ_HOOK: &str = "readyz";
689
690#[cfg(feature = "ssr")]
691impl crate::health::ReadyCheck for AppReadyCheck {
692    fn name(&self) -> &'static str {
693        "app"
694    }
695
696    fn ready(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
697        let slot = self.ssr.clone();
698        Box::pin(async move {
699            let Some(child) = slot.current() else {
700                // No isolate: the `ssr` check has already reported this. Don't
701                // double-count it as an app failure — report ready and let the
702                // real cause be the one line an operator reads.
703                return true;
704            };
705            if !child.has_hook(READYZ_HOOK) {
706                return true;
707            }
708            // Mode 2 (R756-F3 / W311 §2): plain JSON in, plain JSON out — no
709            // DispatchRequest/DispatchResponse envelope (header vec, byte
710            // body) crosses the isolate boundary just to move one status
711            // code, which under F2's isolate serialization would otherwise
712            // contend the same lock SSR requests queue on.
713            let input = serde_json::json!({
714                "method": "GET",
715                "url": format!("http://localhost{}", crate::READY_PATH),
716            });
717            match child.invoke_hook(READYZ_HOOK, input).await {
718                Ok(verdict) => verdict
719                    .get("status")
720                    .and_then(|s| s.as_u64())
721                    .is_some_and(|status| (200..300).contains(&status)),
722                // A handler that throws (or an unrecognised hook) is not a
723                // handler that says "ready".
724                Err(err) => {
725                    warn!(?err, "app /readyz handler failed — reporting not ready");
726                    false
727                }
728            }
729        })
730    }
731}
732
733/// In-flight-request + last-activity bookkeeping for the JIT idle-reap
734/// (R599-F6). `last_active_ms` is epoch-millis of the most recent request
735/// boundary; `inflight` guards against reaping while a request is still being
736/// served.
737#[derive(Default)]
738struct IdleTracker {
739    inflight: AtomicI64,
740    last_active_ms: AtomicU64,
741}
742
743impl IdleTracker {
744    fn touch(&self) {
745        self.last_active_ms.store(now_ms(), Ordering::Relaxed);
746    }
747
748    fn enter(&self) {
749        self.inflight.fetch_add(1, Ordering::Relaxed);
750        self.touch();
751    }
752
753    fn leave(&self) {
754        self.inflight.fetch_sub(1, Ordering::Relaxed);
755        self.touch();
756    }
757
758    /// How long the server has been idle (zero in-flight), or `None` while any
759    /// request is in flight.
760    fn idle_for(&self) -> Option<Duration> {
761        if self.inflight.load(Ordering::Relaxed) > 0 {
762            return None;
763        }
764        let last = self.last_active_ms.load(Ordering::Relaxed);
765        Some(Duration::from_millis(now_ms().saturating_sub(last)))
766    }
767}
768
769fn now_ms() -> u64 {
770    SystemTime::now()
771        .duration_since(UNIX_EPOCH)
772        .unwrap_or_default()
773        .as_millis() as u64
774}
775
776/// Middleware that brackets each request with [`IdleTracker::enter`] /
777/// [`IdleTracker::leave`], so the idle reaper sees live traffic.
778async fn track_activity(
779    State(idle): State<Arc<IdleTracker>>,
780    req: Request,
781    next: Next,
782) -> Response {
783    idle.enter();
784    let resp = next.run(req).await;
785    idle.leave();
786    resp
787}
788
789/// Resolve once the server has been idle for `ttl`. Polls at a fraction of the
790/// TTL (floored at 200ms) so a short TTL still reaps promptly without busy-
791/// waiting.
792async fn idle_reaper(idle: Arc<IdleTracker>, ttl: Duration) {
793    let tick = (ttl / 4).max(Duration::from_millis(200));
794    loop {
795        tokio::time::sleep(tick).await;
796        if idle.idle_for().is_some_and(|d| d >= ttl) {
797            return;
798        }
799    }
800}
801
802/// Logical-identity endpoint (R602-B4). Returns `{"service","component"}` as
803/// JSON when the server was stamped via [`Server::with_identity`]; 404
804/// otherwise so an identity-checking adopter refuses to adopt an unstamped (or
805/// foreign) listener rather than guessing.
806async fn serve_info(State(state): State<ServerState>) -> Response {
807    match state.identity {
808        Some(identity) => (
809            StatusCode::OK,
810            [(header::CONTENT_TYPE, "application/json")],
811            serde_json::to_vec(&*identity).unwrap_or_default(),
812        )
813            .into_response(),
814        None => StatusCode::NOT_FOUND.into_response(),
815    }
816}
817
818/// Serve the camp-emitted runtime config at `/config.json` (R513-F10). Only
819/// registered when `--config-json` was supplied; the bytes are served verbatim
820/// as `application/json`.
821async fn serve_config_json(State(state): State<ServerState>) -> Response {
822    match state.config_json {
823        Some(bytes) => (
824            StatusCode::OK,
825            [(header::CONTENT_TYPE, "application/json")],
826            bytes.to_vec(),
827        )
828            .into_response(),
829        None => StatusCode::NOT_FOUND.into_response(),
830    }
831}
832
833async fn serve_dynamic(State(state): State<ServerState>, req: Request) -> Response {
834    let uri_path = req.uri().path().to_string();
835    #[cfg(feature = "ssr")]
836    if let Some(ssr) = state.ssr.current() {
837        if ssr.matches(&uri_path) {
838            let policy = ssr.policy_for(&uri_path);
839            return dispatch_to_ssr(ssr, policy, req).await;
840        }
841    }
842    // Same-origin reverse proxy (R513-F10): forward `/auth/*`, `/dev/*`, `/api/*`
843    // to their camp-vended backend ports before falling through to the SPA, so
844    // the browser stays single-origin. SSR prefixes are checked first (above);
845    // the proxy and SSR maps are disjoint by construction.
846    if let Some(proxy) = &state.proxy {
847        if let Some(base) = proxy.map().match_base(&uri_path) {
848            let base = base.to_string();
849            return proxy.forward(&base, req).await;
850        }
851    }
852    let dist = state.pointer.current();
853
854    // Static disk resolution first (the common hit). `None` = a normal-path
855    // miss, eligible for instance-addressed resolution then the error page.
856    if let Some(resp) = serve_static(&dist, &uri_path).await {
857        return resp;
858    }
859
860    // Static miss (W270 §9): consult the manifest once — a path matching an
861    // instance-addressed (`prerender: { deferred: true }`) route resolves
862    // through the pointer store against the local object store, mirroring the
863    // `@mesofact/edge` worker's resolution against R2. Everything else (and any
864    // build with no instance store wired) falls to the branded 404 page.
865    #[cfg(feature = "ssr")]
866    if let Some(store) = &state.instance_store {
867        if let Some(resp) = serve_instance(&dist, &uri_path, store.clone()).await {
868            return resp;
869        }
870    }
871
872    serve_error_page(&dist, StatusCode::NOT_FOUND).await
873}
874
875/// Materialise the axum Request into a `DispatchRequest`, then invoke the
876/// in-process SSR handler with W181 retry/timeout semantics wrapped around
877/// the call. Replaces the prior reqwest reverse-proxy hop (R434-F3) with a
878/// direct V8 dispatch — no port, no HTTP encoding, no streaming.
879#[cfg(feature = "ssr")]
880async fn dispatch_to_ssr(
881    ssr: Arc<SsrChild>,
882    policy: Option<ResiliencePolicy>,
883    req: Request,
884) -> Response {
885    let (parts, body) = req.into_parts();
886    let route_path = parts.uri.path().to_string();
887    let path_and_query = parts
888        .uri
889        .path_and_query()
890        .map(|p| p.as_str())
891        .unwrap_or(parts.uri.path())
892        .to_string();
893    let method = parts.method.as_str().to_uppercase();
894
895    let headers: Vec<(String, String)> = parts
896        .headers
897        .iter()
898        .filter_map(|(k, v)| {
899            // Hop-by-hop and host-shaped headers don't make sense in-process;
900            // strip them at the ingress boundary, same shape the reverse
901            // proxy used to (RFC 7230 §6.1).
902            let name = k.as_str().to_ascii_lowercase();
903            if matches!(
904                name.as_str(),
905                "connection"
906                    | "keep-alive"
907                    | "proxy-authenticate"
908                    | "proxy-authorization"
909                    | "te"
910                    | "trailer"
911                    | "transfer-encoding"
912                    | "upgrade"
913                    | "host"
914                    | "content-length"
915            ) {
916                return None;
917            }
918            v.to_str().ok().map(|s| (k.as_str().to_string(), s.to_string()))
919        })
920        .collect();
921
922    let body_bytes = if matches!(method.as_str(), "GET" | "HEAD") {
923        None
924    } else {
925        match collect_body(body.into_data_stream()).await {
926            Ok(b) if b.is_empty() => None,
927            Ok(b) => Some(b),
928            Err(e) => {
929                warn!(error = %e, "failed to buffer SSR request body");
930                return (StatusCode::BAD_GATEWAY, "request buffer failed").into_response();
931            }
932        }
933    };
934
935    // dispatch_url mirrors the absolute URL the bun wrapper used to construct
936    // from the request line — keeps `req.url` parsing identical for routes
937    // that read pathname/search.
938    let dispatch_url = format!("http://dev{path_and_query}");
939
940    let retry = policy.as_ref().and_then(|p| p.retry.as_ref());
941    let attempts = retry.map(|r| r.attempts.max(1)).unwrap_or(1);
942    let backoff_ms = retry.map(|r| r.backoff_ms.clone()).unwrap_or_default();
943    let retry_on: String = retry
944        .and_then(|r| r.retry_on.clone())
945        .unwrap_or_else(|| "connection".to_string());
946    let budget_ms = retry.and_then(|r| r.budget_ms);
947    let timeout_ms = policy.as_ref().and_then(|p| p.timeout_ms);
948    let start = Instant::now();
949
950    let mut last_resp: Option<DispatchResponse> = None;
951    let mut last_err: Option<anyhow::Error> = None;
952
953    for attempt in 0..attempts {
954        if attempt > 0 {
955            let gap = backoff_ms.get((attempt - 1) as usize).copied().unwrap_or(0);
956            if gap > 0 {
957                tokio::time::sleep(Duration::from_millis(gap)).await;
958            }
959            if let Some(budget) = budget_ms {
960                if start.elapsed() >= Duration::from_millis(budget) {
961                    break;
962                }
963            }
964        }
965        let req = DispatchRequest {
966            method: method.clone(),
967            url: dispatch_url.clone(),
968            headers: headers.clone(),
969            body: body_bytes.clone(),
970        };
971        let call = ssr.dispatch(&route_path, req);
972        let outcome = match timeout_ms {
973            Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), call).await {
974                Ok(r) => r,
975                Err(_) => Err(anyhow::anyhow!("ssr dispatch timed out after {ms}ms")),
976            },
977            None => call.await,
978        };
979        match outcome {
980            Ok(r) => {
981                if should_retry_status(r.status, &retry_on) && attempt + 1 < attempts {
982                    last_resp = Some(r);
983                    continue;
984                }
985                emit_telemetry(&route_path, attempt + 1, "ok", start.elapsed());
986                return forward_response(r);
987            }
988            Err(e) => {
989                warn!(error = %e, attempt = attempt + 1, "ssr dispatch attempt failed");
990                last_err = Some(e);
991            }
992        }
993    }
994
995    let latency = start.elapsed();
996    if let Some(r) = last_resp {
997        emit_telemetry(&route_path, attempts, "exhausted_5xx", latency);
998        return forward_response(r);
999    }
1000    emit_telemetry(&route_path, attempts, "exhausted_connection", latency);
1001    let msg = last_err
1002        .map(|e| format!("ssr dispatch failed: {e}"))
1003        .unwrap_or_else(|| "ssr dispatch failed".to_string());
1004    (StatusCode::BAD_GATEWAY, msg).into_response()
1005}
1006
1007#[cfg(feature = "ssr")]
1008fn should_retry_status(status: u16, retry_on: &str) -> bool {
1009    match retry_on {
1010        "any" => status >= 400,
1011        "5xx" => status >= 500,
1012        _ => false,
1013    }
1014}
1015
1016#[cfg(feature = "ssr")]
1017async fn collect_body(mut stream: axum::body::BodyDataStream) -> Result<Vec<u8>, axum::Error> {
1018    let mut buf = Vec::new();
1019    while let Some(chunk) = stream.next().await {
1020        let bytes = chunk?;
1021        buf.extend_from_slice(&bytes);
1022    }
1023    Ok(buf)
1024}
1025
1026#[cfg(feature = "ssr")]
1027fn emit_telemetry(route: &str, attempts: u32, outcome: &str, latency: Duration) {
1028    info!(
1029        target: "mesofact_dev::resilience",
1030        route = route,
1031        attempts = attempts,
1032        outcome = outcome,
1033        latency_ms = latency.as_millis() as u64,
1034        "ssr dispatch outcome",
1035    );
1036}
1037
1038#[cfg(feature = "ssr")]
1039fn forward_response(resp: DispatchResponse) -> Response {
1040    let status = StatusCode::from_u16(resp.status).unwrap_or(StatusCode::BAD_GATEWAY);
1041    let mut builder = Response::builder().status(status);
1042    for (k, v) in resp.headers {
1043        let name = k.to_ascii_lowercase();
1044        if matches!(
1045            name.as_str(),
1046            "connection"
1047                | "keep-alive"
1048                | "proxy-authenticate"
1049                | "proxy-authorization"
1050                | "te"
1051                | "trailer"
1052                | "transfer-encoding"
1053                | "upgrade"
1054        ) {
1055            continue;
1056        }
1057        builder = builder.header(k, v);
1058    }
1059    builder
1060        .body(Body::from(resp.body))
1061        .unwrap_or_else(|_| (StatusCode::BAD_GATEWAY, "response build failed").into_response())
1062}
1063
1064/// Resolve a request against the on-disk static tree. Returns `Some(response)`
1065/// for a hit, a bad request, or a hydrate-bundle outcome (all terminal); `None`
1066/// for a normal-path miss, which [`serve_dynamic`] resolves as an
1067/// instance-addressed route (W270 §9) then the error page.
1068async fn serve_static(dist: &Path, uri_path: &str) -> Option<Response> {
1069    let Some(rel) = sanitize(uri_path) else {
1070        return Some((StatusCode::BAD_REQUEST, "invalid path").into_response());
1071    };
1072
1073    // Hydrate bundles live at <dist>/../hydrate/ (peer of html/).
1074    // Prerendered HTML references them as /{build_id}/hydrate/<hash>.js;
1075    // strip the opaque build_id prefix (or serve /hydrate/<file> directly).
1076    if let Some(hydrate_rel) = hydrate_suffix(&rel) {
1077        let hydrate_dir = dist.parent().unwrap_or(dist).join("hydrate");
1078        let target = hydrate_dir.join(&hydrate_rel);
1079        if let Ok(bytes) = tokio::fs::read(&target).await {
1080            let mime = mime_for(&target);
1081            return Some(([(header::CONTENT_TYPE, mime)], bytes).into_response());
1082        }
1083        // A hydrate-bundle miss is an asset 404, not an instance-addressed
1084        // route — resolve the error page directly (terminal).
1085        return Some(serve_error_page(dist, StatusCode::NOT_FOUND).await);
1086    }
1087
1088    // Candidate order mirrors the edge worker's `assetCandidates`
1089    // (packages/mesofact-edge/src/router.ts) and `serve_error_page` below: the
1090    // literal key, then `<key>.html`, then `<key>/index.html`. A key that
1091    // already carries an extension is taken verbatim — `/style.css` must not
1092    // fall through to a stray `style.css.html`.
1093    //
1094    // `.html` before the directory index is load-bearing, not cosmetic. Since
1095    // R600-B1 made prerender emissions path-shaped, a site with both a
1096    // `/issues` list page and `/issues/:id` instances has `issues.html` and an
1097    // `issues/` directory side by side; resolving the directory first would
1098    // serve a nonexistent `issues/index.html` and 404 the list page.
1099    let base = if rel.as_os_str().is_empty() {
1100        dist.join("index.html")
1101    } else {
1102        dist.join(&rel)
1103    };
1104    let mut candidates = vec![base.clone()];
1105    if base.extension().is_none() {
1106        candidates.push(base.with_extension("html"));
1107        candidates.push(base.join("index.html"));
1108    }
1109    for target in &candidates {
1110        if let Ok(bytes) = tokio::fs::read(target).await {
1111            let mime = mime_for(target);
1112            return Some(([(header::CONTENT_TYPE, mime)], bytes).into_response());
1113        }
1114    }
1115
1116    None
1117}
1118
1119/// Resolve an instance-addressed (deferred) route (W270 §9), mirroring the
1120/// `@mesofact/edge` worker's `serveInstance`. Returns `None` when the path is
1121/// not an instance-addressed route (the manifest is absent, or no
1122/// `prerender: { deferred: true }` route matches) so the caller falls through
1123/// to the 404 page. When it matches, the pointer key is the request path minus
1124/// its leading slash (`/c/abc` → `c/abc`) — the same key the publisher flipped:
1125/// present → the render-root bytes (immutable cache); deleted → 410; absent or
1126/// pointer-names-missing-bytes → 404; malformed record → 5xx.
1127#[cfg(feature = "ssr")]
1128async fn serve_instance(
1129    dist: &Path,
1130    uri_path: &str,
1131    store: Arc<dyn ObjectStore>,
1132) -> Option<Response> {
1133    use mesofact_publisher::{ObjectPointerStore, PointerError, PointerState, PointerStore};
1134
1135    if !matches_deferred_route(dist, uri_path).await {
1136        return None;
1137    }
1138
1139    let key = uri_path.trim_start_matches('/');
1140    let pointers = ObjectPointerStore::new(store.clone());
1141    let resp = match pointers.resolve(key).await {
1142        Ok(PointerState::Present(ptr)) => match store.get(&ptr.content_root).await {
1143            Ok(Some(bytes)) => (
1144                StatusCode::OK,
1145                [
1146                    (header::CONTENT_TYPE, mime_for(Path::new(&ptr.content_root))),
1147                    (header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL),
1148                ],
1149                bytes.to_vec(),
1150            )
1151                .into_response(),
1152            // Pointer names bytes that aren't there — treat as not found.
1153            Ok(None) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1154            Err(_) => serve_error_page(dist, StatusCode::INTERNAL_SERVER_ERROR).await,
1155        },
1156        // Published then unpublished — 410 Gone, distinct from a never-existed 404.
1157        Ok(PointerState::Deleted) => serve_error_page(dist, StatusCode::GONE).await,
1158        Ok(PointerState::Absent) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1159        // An un-mintable key never named a pointer → 404 (matches the worker,
1160        // whose validateKey miss resolves `absent`).
1161        Err(PointerError::InvalidKey(..)) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1162        // Malformed record / unknown version → 5xx, never a guess.
1163        Err(e) => {
1164            warn!(key, error = %e, "instance pointer resolve failed");
1165            serve_error_page(dist, StatusCode::INTERNAL_SERVER_ERROR).await
1166        }
1167    };
1168    Some(resp)
1169}
1170
1171/// True when `uri_path` matches an instance-addressed (`prerender:
1172/// { deferred: true }`) route in the manifest beside the served dist dir.
1173/// Mirrors the worker's `matchesDeferredRoute`; a lenient local slice keeps the
1174/// read independent of the full [`mesofact_core::manifest::Manifest`] shape.
1175#[cfg(feature = "ssr")]
1176async fn matches_deferred_route(dist: &Path, uri_path: &str) -> bool {
1177    #[derive(serde::Deserialize)]
1178    struct RoutesSlice {
1179        #[serde(default)]
1180        routes: Vec<RouteSlice>,
1181    }
1182    #[derive(serde::Deserialize)]
1183    struct RouteSlice {
1184        route: String,
1185        #[serde(default)]
1186        prerender: Option<PrerenderSlice>,
1187    }
1188    #[derive(serde::Deserialize)]
1189    struct PrerenderSlice {
1190        #[serde(default)]
1191        deferred: Option<bool>,
1192    }
1193
1194    let Some(dir) = dist.parent() else {
1195        return false;
1196    };
1197    let Ok(bytes) = tokio::fs::read(dir.join("manifest.json")).await else {
1198        return false;
1199    };
1200    let Ok(manifest) = serde_json::from_slice::<RoutesSlice>(&bytes) else {
1201        return false;
1202    };
1203    manifest.routes.iter().any(|r| {
1204        r.prerender.as_ref().and_then(|p| p.deferred).unwrap_or(false)
1205            && match_route_pattern(&r.route, uri_path)
1206    })
1207}
1208
1209/// Segment-aware match of a route pattern (`/c/:slug`) against a concrete path
1210/// (`/c/abc123`) — byte-parallel with the worker's `matchRoutePattern`. A
1211/// `:param` segment matches any single non-empty segment; segment counts must
1212/// be equal, so a trailing `:param` never swallows extra segments.
1213#[cfg(feature = "ssr")]
1214fn match_route_pattern(pattern: &str, pathname: &str) -> bool {
1215    let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1216    let path: Vec<&str> = pathname.split('/').filter(|s| !s.is_empty()).collect();
1217    if pat.len() != path.len() {
1218        return false;
1219    }
1220    pat.iter().zip(path.iter()).all(|(seg, actual)| {
1221        if seg.starts_with(':') {
1222            !actual.is_empty()
1223        } else {
1224            seg == actual
1225        }
1226    })
1227}
1228
1229/// Serve the manifest's branded error page for `status` (W270 §3, R595-T5/T6),
1230/// for parity with `mesofact serve` and the `@mesofact/edge` worker's
1231/// `errorResponse`. The manifest's `error_routes` values are ROUTE PATHS (e.g.
1232/// `"/404"`) resolved to their prerendered asset the same way a normal static
1233/// request resolves (`/404` → `404.html`). 5xx statuses draw from
1234/// `error_routes."5xx"`; 4xx (incl. a 410 `Gone`, which uses the 404 page under
1235/// its own status) draw from `error_routes."404"` then the conventional
1236/// `404.html`. Falls back to plaintext. `dist` is the served html dir; the
1237/// manifest sits beside it (`<dist>/../manifest.json`).
1238async fn serve_error_page(dist: &Path, status: StatusCode) -> Response {
1239    let is_server_error = status.is_server_error();
1240    let mut candidates: Vec<PathBuf> = Vec::new();
1241    if let Some(route) = read_error_route(dist, is_server_error).await {
1242        let rel = route.trim_start_matches('/');
1243        if rel.is_empty() {
1244            candidates.push(PathBuf::from("index.html"));
1245        } else if rel.rsplit('/').next().is_some_and(|s| s.contains('.')) {
1246            candidates.push(PathBuf::from(rel));
1247        } else {
1248            candidates.push(PathBuf::from(format!("{rel}.html")));
1249            candidates.push(PathBuf::from(rel).join("index.html"));
1250        }
1251    }
1252    if !is_server_error {
1253        // Conventional default — also the effective target of the common `/404`
1254        // route, so unconfigured workloads keep serving `404.html` unchanged.
1255        // Not used for 5xx (a 404 page is the wrong page for a server error).
1256        candidates.push(PathBuf::from("404.html"));
1257    }
1258
1259    for cand in &candidates {
1260        if let Ok(bytes) = tokio::fs::read(dist.join(cand)).await {
1261            return (
1262                status,
1263                [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1264                bytes,
1265            )
1266                .into_response();
1267        }
1268    }
1269    (status, default_status_text(status)).into_response()
1270}
1271
1272/// Plaintext fallback body when no branded error page resolves — mirrors the
1273/// worker's `defaultStatusText`.
1274fn default_status_text(status: StatusCode) -> &'static str {
1275    match status {
1276        StatusCode::GONE => "Gone",
1277        s if s.is_server_error() => "Internal Server Error",
1278        _ => "Not Found",
1279    }
1280}
1281
1282/// Read `error_routes."404"` (or `."5xx"` when `server_error`) from the manifest
1283/// beside the served dist dir. Best-effort — a missing or unparseable manifest
1284/// yields `None` (the conventional `404.html` default then applies for 4xx).
1285/// Deliberately a minimal local slice so the static-serving path never depends
1286/// on the optional `mesofact` crate (only the `ssr` feature pulls it in).
1287async fn read_error_route(dist: &Path, server_error: bool) -> Option<String> {
1288    #[derive(serde::Deserialize)]
1289    struct ManifestSlice {
1290        error_routes: Option<ErrorRoutesSlice>,
1291    }
1292    #[derive(serde::Deserialize)]
1293    struct ErrorRoutesSlice {
1294        #[serde(rename = "404")]
1295        not_found: Option<String>,
1296        #[serde(rename = "5xx")]
1297        server_error: Option<String>,
1298    }
1299
1300    let manifest_path = dist.parent()?.join("manifest.json");
1301    let bytes = tokio::fs::read(&manifest_path).await.ok()?;
1302    let manifest: ManifestSlice = serde_json::from_slice(&bytes).ok()?;
1303    let routes = manifest.error_routes?;
1304    if server_error {
1305        routes.server_error
1306    } else {
1307        routes.not_found
1308    }
1309}
1310
1311/// Routes in a workload's built manifest that declare `requires: ["user"]`
1312/// (R556-B13) — the auth gate `mesofact serve` does **not** itself enforce.
1313///
1314/// That check exists only in `mesofact_core::proxy::router`, which the
1315/// `mesofact proxy` subcommand uses. The W272 bundle tier forks `serve`, whose
1316/// `Server` has no session resolver at all, so a declared-authed route was
1317/// served to anyone who reached the port. Callers use this to fail closed at
1318/// startup; the enforcement itself stays an edge concern (passway cheers-verify).
1319///
1320/// Deliberately a minimal local serde slice on the same reasoning
1321/// [`read_error_route`] gives: the static-serving path must not depend on the
1322/// optional `mesofact-core` types. Sorted and deduplicated so the error message
1323/// a caller renders is stable.
1324///
1325/// Best-effort on I/O, **strict on content**: an absent manifest yields an
1326/// empty list (a workload with no built manifest declares no routes at all),
1327/// but a manifest that is present and unparseable yields `Err` — silently
1328/// reading "no authed routes" out of a file we failed to understand is the
1329/// fail-open this function exists to prevent.
1330pub fn routes_requiring_user(workload: &Path) -> std::io::Result<Vec<String>> {
1331    #[derive(serde::Deserialize)]
1332    struct ManifestSlice {
1333        #[serde(default)]
1334        routes: Vec<RouteSlice>,
1335    }
1336    #[derive(serde::Deserialize)]
1337    struct RouteSlice {
1338        route: String,
1339        #[serde(default)]
1340        requires: Option<Vec<String>>,
1341    }
1342
1343    let manifest_path = workload.join("dist").join("manifest.json");
1344    let bytes = match std::fs::read(&manifest_path) {
1345        Ok(b) => b,
1346        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1347        Err(e) => return Err(e),
1348    };
1349    let manifest: ManifestSlice = serde_json::from_slice(&bytes).map_err(|e| {
1350        std::io::Error::new(
1351            std::io::ErrorKind::InvalidData,
1352            format!("parsing {}: {e}", manifest_path.display()),
1353        )
1354    })?;
1355    let mut gated: Vec<String> = manifest
1356        .routes
1357        .into_iter()
1358        .filter(|r| {
1359            r.requires
1360                .as_ref()
1361                .is_some_and(|req| req.iter().any(|s| s == "user"))
1362        })
1363        .map(|r| r.route)
1364        .collect();
1365    gated.sort();
1366    gated.dedup();
1367    Ok(gated)
1368}
1369
1370/// Every `mode:"ssr"` route declared under `workload` (`<workload>/dist/manifest.json`).
1371///
1372/// R746-B7 — compiled unconditionally (unlike [`crate::ssr`], which is
1373/// `ssr`-feature-gated) so a static-only build can still name the routes it
1374/// is about to silently drop, rather than 404ing them one request at a time.
1375/// Same fail-open discipline as [`routes_requiring_user`]: an absent manifest
1376/// is "no routes declared", but a manifest present and unparseable is an
1377/// error, not a shrug.
1378pub fn routes_declaring_ssr(workload: &Path) -> std::io::Result<Vec<String>> {
1379    #[derive(serde::Deserialize)]
1380    struct ManifestSlice {
1381        #[serde(default)]
1382        routes: Vec<RouteSlice>,
1383    }
1384    #[derive(serde::Deserialize)]
1385    struct RouteSlice {
1386        route: String,
1387        #[serde(default)]
1388        mode: String,
1389    }
1390
1391    let manifest_path = workload.join("dist").join("manifest.json");
1392    let bytes = match std::fs::read(&manifest_path) {
1393        Ok(b) => b,
1394        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1395        Err(e) => return Err(e),
1396    };
1397    let manifest: ManifestSlice = serde_json::from_slice(&bytes).map_err(|e| {
1398        std::io::Error::new(
1399            std::io::ErrorKind::InvalidData,
1400            format!("parsing {}: {e}", manifest_path.display()),
1401        )
1402    })?;
1403    let mut ssr_routes: Vec<String> = manifest
1404        .routes
1405        .into_iter()
1406        .filter(|r| r.mode == "ssr")
1407        .map(|r| r.route)
1408        .collect();
1409    ssr_routes.sort();
1410    ssr_routes.dedup();
1411    Ok(ssr_routes)
1412}
1413
1414/// Reject any URI path that would escape `dist/` or carry a NUL byte. Does
1415/// not percent-decode — segments are treated literally, which is safe (an
1416/// encoded `..` like `%2e%2e` becomes a literal filename that doesn't exist
1417/// in `dist/`).
1418fn sanitize(uri_path: &str) -> Option<PathBuf> {
1419    let mut out = PathBuf::new();
1420    for seg in uri_path.split('/') {
1421        if seg.is_empty() || seg == "." {
1422            continue;
1423        }
1424        if seg == ".." || seg.contains('\0') {
1425            return None;
1426        }
1427        out.push(seg);
1428    }
1429    Some(out)
1430}
1431
1432/// Extract the file path under `hydrate/` from paths of the form
1433/// `/<build_id>/hydrate/<rest>` or `/hydrate/<rest>`.
1434/// Returns `None` for any other path shape.
1435fn hydrate_suffix(rel: &Path) -> Option<PathBuf> {
1436    let mut components = rel.components();
1437    let first = match components.next() {
1438        Some(std::path::Component::Normal(s)) => s,
1439        _ => return None,
1440    };
1441    if first == "hydrate" {
1442        Some(components.as_path().to_path_buf())
1443    } else {
1444        match components.next() {
1445            Some(std::path::Component::Normal(s)) if s == "hydrate" => {
1446                Some(components.as_path().to_path_buf())
1447            }
1448            _ => None,
1449        }
1450    }
1451}
1452
1453fn mime_for(path: &Path) -> &'static str {
1454    match path.extension().and_then(|e| e.to_str()) {
1455        Some("html") | Some("htm") => "text/html; charset=utf-8",
1456        Some("css") => "text/css; charset=utf-8",
1457        Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
1458        Some("json") => "application/json; charset=utf-8",
1459        Some("svg") => "image/svg+xml",
1460        Some("png") => "image/png",
1461        Some("jpg") | Some("jpeg") => "image/jpeg",
1462        Some("webp") => "image/webp",
1463        Some("avif") => "image/avif",
1464        Some("ico") => "image/x-icon",
1465        Some("woff2") => "font/woff2",
1466        Some("woff") => "font/woff",
1467        Some("ttf") => "font/ttf",
1468        Some("xml") => "application/xml; charset=utf-8",
1469        Some("txt") | Some("md") => "text/plain; charset=utf-8",
1470        // Not decorative: instantiateStreaming rejects anything but exactly
1471        // application/wasm, and wasm-bindgen then silently degrades to
1472        // buffer-then-compile. This table — not the manifest's
1473        // `static_assets[].content_type` — is what the dev/static server
1474        // actually answers with, so the build-side arm alone wouldn't fix it
1475        // (R821-B1).
1476        Some("wasm") => "application/wasm",
1477        _ => "application/octet-stream",
1478    }
1479}
1480
1481
1482#[cfg(test)]
1483mod tests {
1484    use super::*;
1485    use axum::body::{to_bytes, Body};
1486    use axum::http::{Request, StatusCode};
1487    use tempfile::tempdir;
1488    use tower::ServiceExt;
1489
1490    async fn body_string(response: axum::response::Response) -> String {
1491        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1492        String::from_utf8(bytes.to_vec()).unwrap()
1493    }
1494
1495    // ── R556-B13: declared-auth routes this binary cannot enforce ────────────
1496
1497    /// Write `<dir>/dist/manifest.json` verbatim and return the workload dir.
1498    fn workload_with_manifest(json: &str) -> tempfile::TempDir {
1499        let dir = tempdir().unwrap();
1500        let dist = dir.path().join("dist");
1501        std::fs::create_dir_all(&dist).unwrap();
1502        std::fs::write(dist.join("manifest.json"), json).unwrap();
1503        dir
1504    }
1505
1506    #[test]
1507    fn routes_requiring_user_finds_the_declared_gate() {
1508        let dir = workload_with_manifest(
1509            r#"{"routes":[
1510                {"route":"/","mode":"ssr","requires":["user"]},
1511                {"route":"/health","mode":"static"},
1512                {"route":"/admin","mode":"ssr","requires":["user"]}
1513            ]}"#,
1514        );
1515        assert_eq!(
1516            routes_requiring_user(dir.path()).unwrap(),
1517            vec!["/".to_string(), "/admin".to_string()],
1518            "sorted, so the refusal message a caller renders is stable",
1519        );
1520    }
1521
1522    #[test]
1523    fn routes_requiring_user_ignores_routes_without_the_gate() {
1524        let dir = workload_with_manifest(
1525            r#"{"routes":[
1526                {"route":"/","mode":"static"},
1527                {"route":"/feed","mode":"ssr","requires":[]}
1528            ]}"#,
1529        );
1530        assert!(routes_requiring_user(dir.path()).unwrap().is_empty());
1531    }
1532
1533    /// A workload with nothing built yet declares no routes — that is an absent
1534    /// manifest, not a suspicious one, so it must not block a start.
1535    #[test]
1536    fn routes_requiring_user_treats_an_absent_manifest_as_no_routes() {
1537        let dir = tempdir().unwrap();
1538        assert!(routes_requiring_user(dir.path()).unwrap().is_empty());
1539    }
1540
1541    /// …but a manifest that IS there and does not parse must be an error.
1542    /// Folding it to "no authed routes" would reopen the exact fail-open this
1543    /// function exists to close, on the one input where we know least.
1544    #[test]
1545    fn routes_requiring_user_refuses_an_unparseable_manifest() {
1546        let dir = workload_with_manifest("{ this is not json");
1547        let err = routes_requiring_user(dir.path()).unwrap_err();
1548        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1549    }
1550
1551    /// Unknown `requires` values are not the gate. `requires: ["admin"]` is a
1552    /// scope this binary has never understood; treating any non-empty list as
1553    /// "authed" would refuse to start on a declaration that means something
1554    /// else entirely.
1555    #[test]
1556    fn routes_requiring_user_matches_the_user_scope_specifically() {
1557        let dir = workload_with_manifest(
1558            r#"{"routes":[{"route":"/x","mode":"ssr","requires":["admin"]}]}"#,
1559        );
1560        assert!(routes_requiring_user(dir.path()).unwrap().is_empty());
1561    }
1562
1563    fn workload_with(files: &[(&str, &str)]) -> tempfile::TempDir {
1564        let dir = tempdir().unwrap();
1565        let dist = dir.path().join("dist").join("html");
1566        std::fs::create_dir_all(&dist).unwrap();
1567        for (name, body) in files {
1568            let path = dist.join(name);
1569            // Emissions are path-shaped, so a name may be nested (`p/3.html`).
1570            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1571            std::fs::write(path, body).unwrap();
1572        }
1573        dir
1574    }
1575
1576    #[tokio::test]
1577    async fn serves_index_at_root() {
1578        let workload = workload_with(&[("index.html", "<h1>hello</h1>")]);
1579        let app = Server::from_workload(workload.path()).unwrap().router();
1580        let response = app
1581            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
1582            .await
1583            .unwrap();
1584        assert_eq!(response.status(), StatusCode::OK);
1585        assert!(body_string(response).await.contains("hello"));
1586    }
1587
1588    #[tokio::test]
1589    async fn health_endpoint_returns_200() {
1590        // R449-F3: the SSR-host container's readiness probe target. Must be
1591        // 200 even when the workload has no static `/` and no SSR child.
1592        let workload = workload_with(&[]);
1593        let app = Server::from_workload(workload.path()).unwrap().router();
1594        let response = app
1595            .oneshot(
1596                Request::builder()
1597                    .uri("/__mesofact/health")
1598                    .body(Body::empty())
1599                    .unwrap(),
1600            )
1601            .await
1602            .unwrap();
1603        assert_eq!(response.status(), StatusCode::OK);
1604        assert_eq!(body_string(response).await, "ok");
1605    }
1606
1607    async fn probe_status(app: Router, path: &str) -> StatusCode {
1608        app.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
1609            .await
1610            .unwrap()
1611            .status()
1612    }
1613
1614    #[tokio::test]
1615    async fn readyz_tracks_the_served_tree_for_a_static_workload() {
1616        // The state the old single endpoint reported as ready: bound, serving,
1617        // and unable to answer anything but 404 because dist/html/ isn't there
1618        // yet. `serve_on` only warns about it, so nothing else catches this.
1619        let workload = tempdir().unwrap();
1620        let server = Server::from_workload(workload.path()).unwrap();
1621        server.health().mark_started();
1622
1623        assert_eq!(
1624            probe_status(server.router(), crate::READY_PATH).await,
1625            StatusCode::SERVICE_UNAVAILABLE,
1626        );
1627        assert_eq!(
1628            probe_status(server.router(), crate::LIVE_PATH).await,
1629            StatusCode::OK,
1630            "no restart can produce a dist tree, so liveness must not gate on it",
1631        );
1632
1633        std::fs::create_dir_all(workload.path().join("dist").join("html")).unwrap();
1634        assert_eq!(
1635            probe_status(server.router(), crate::READY_PATH).await,
1636            StatusCode::OK,
1637        );
1638    }
1639
1640    /// An SSR workload gates on the isolate instead — it legitimately has no
1641    /// static tree, which is the case R449-F3 introduced the health path for.
1642    #[cfg(feature = "ssr")]
1643    #[tokio::test]
1644    async fn readyz_tracks_the_isolate_for_an_ssr_workload() {
1645        let workload = tempdir().unwrap();
1646        let ssr = ssr::detached_for_test_with_policies(
1647            vec!["/api/x".to_string()],
1648            vec![],
1649            mock_dispatch_resp(200, "ok"),
1650        );
1651        let server = Server::from_workload(workload.path())
1652            .unwrap()
1653            .with_ssr(ssr);
1654        server.health().mark_started();
1655
1656        // No dist/html/ anywhere, and still ready: the isolate is what serves.
1657        assert_eq!(
1658            probe_status(server.router(), crate::READY_PATH).await,
1659            StatusCode::OK,
1660        );
1661
1662        // Isolate gone (crashed, or awaiting respawn) → out of rotation.
1663        server.ssr_slot().set(None);
1664        assert_eq!(
1665            probe_status(server.router(), crate::READY_PATH).await,
1666            StatusCode::SERVICE_UNAVAILABLE,
1667        );
1668        assert_eq!(
1669            probe_status(server.router(), crate::LIVE_PATH).await,
1670            StatusCode::OK,
1671        );
1672    }
1673
1674    // ── The TSX seam: an app-declared `mode:"ssr"` /readyz ───────────────────
1675
1676    #[cfg(feature = "ssr")]
1677    fn server_with_app_readyz(workload: &tempfile::TempDir, status: u16) -> Server {
1678        let ssr = ssr::detached_for_test_with_policies(
1679            vec![crate::READY_PATH.to_string()],
1680            vec![],
1681            mock_dispatch_resp(status, "app"),
1682        );
1683        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
1684        server.health().mark_started();
1685        server
1686    }
1687
1688    #[cfg(feature = "ssr")]
1689    #[tokio::test]
1690    async fn an_app_declared_readyz_contributes_its_verdict() {
1691        let workload = tempdir().unwrap();
1692        let server = server_with_app_readyz(&workload, 503);
1693        let response = server
1694            .router()
1695            .oneshot(
1696                Request::builder()
1697                    .uri(format!("{}?verbose", crate::READY_PATH))
1698                    .body(Body::empty())
1699                    .unwrap(),
1700            )
1701            .await
1702            .unwrap();
1703
1704        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1705        let body = body_string(response).await;
1706        assert!(body.contains("[+]ssr ok"), "{body}");
1707        assert!(body.contains("[-]app failed"), "{body}");
1708    }
1709
1710    #[cfg(feature = "ssr")]
1711    #[tokio::test]
1712    async fn an_app_readyz_returning_200_is_ready() {
1713        let workload = tempdir().unwrap();
1714        let server = server_with_app_readyz(&workload, 200);
1715        assert_eq!(
1716            probe_status(server.router(), crate::READY_PATH).await,
1717            StatusCode::OK,
1718        );
1719    }
1720
1721    /// The asymmetry that makes the seam safe: an app verdict is additive.
1722    #[cfg(feature = "ssr")]
1723    #[tokio::test]
1724    async fn an_app_readyz_cannot_overrule_the_engine() {
1725        let workload = tempdir().unwrap();
1726        let server = server_with_app_readyz(&workload, 200);
1727        server.health().begin_drain();
1728        assert_eq!(
1729            probe_status(server.router(), crate::READY_PATH).await,
1730            StatusCode::SERVICE_UNAVAILABLE,
1731            "a 200 from app code must not un-drain a terminating process",
1732        );
1733
1734        // Same for a dead isolate — and it reports as `ssr`, not `app`, so the
1735        // operator reads one cause rather than two.
1736        let workload = tempdir().unwrap();
1737        let server = server_with_app_readyz(&workload, 200);
1738        server.ssr_slot().set(None);
1739        let response = server
1740            .router()
1741            .oneshot(
1742                Request::builder()
1743                    .uri(format!("{}?verbose", crate::READY_PATH))
1744                    .body(Body::empty())
1745                    .unwrap(),
1746            )
1747            .await
1748            .unwrap();
1749        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1750        let body = body_string(response).await;
1751        assert!(body.contains("[-]ssr failed"), "{body}");
1752        assert!(body.contains("[+]app ok"), "no double-reporting: {body}");
1753    }
1754
1755    #[cfg(feature = "ssr")]
1756    #[tokio::test]
1757    async fn an_ssr_workload_without_an_app_readyz_still_passes() {
1758        // Opt-in: declaring no such route costs nothing.
1759        let workload = tempdir().unwrap();
1760        let ssr = ssr::detached_for_test_with_policies(
1761            vec!["/api/x".to_string()],
1762            vec![],
1763            mock_dispatch_resp(200, "x"),
1764        );
1765        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
1766        server.health().mark_started();
1767        assert_eq!(
1768            probe_status(server.router(), crate::READY_PATH).await,
1769            StatusCode::OK,
1770        );
1771    }
1772
1773    /// The Rust-level override seam: opt out and mount your own.
1774    #[tokio::test]
1775    async fn without_standard_probes_leaves_the_paths_free() {
1776        let workload = workload_with(&[("index.html", "<h1>hello</h1>")]);
1777        let app = Server::from_workload(workload.path())
1778            .unwrap()
1779            .without_standard_probes()
1780            .router()
1781            // Nothing panics on overlap, because nothing was mounted.
1782            .route(crate::READY_PATH, get(|| async { "mine" }));
1783        let response = app
1784            .oneshot(
1785                Request::builder()
1786                    .uri(crate::READY_PATH)
1787                    .body(Body::empty())
1788                    .unwrap(),
1789            )
1790            .await
1791            .unwrap();
1792
1793        assert_eq!(response.status(), StatusCode::OK);
1794        assert_eq!(body_string(response).await, "mine");
1795    }
1796
1797    #[tokio::test]
1798    async fn probe_paths_win_over_the_catch_all_route() {
1799        // They are merged after `with_state`, i.e. after `/{*path}` is already
1800        // registered. Pinning this because the ordering reads backwards.
1801        let workload = workload_with(&[("index.html", "<h1>hello</h1>")]);
1802        let server = Server::from_workload(workload.path()).unwrap();
1803        server.health().mark_started();
1804        let response = server
1805            .router()
1806            .oneshot(
1807                Request::builder()
1808                    .uri(format!("{}?verbose", crate::READY_PATH))
1809                    .body(Body::empty())
1810                    .unwrap(),
1811            )
1812            .await
1813            .unwrap();
1814
1815        assert_eq!(response.status(), StatusCode::OK);
1816        let body = body_string(response).await;
1817        assert!(body.starts_with("[+]started ok"), "served the SPA shell: {body}");
1818    }
1819
1820    #[tokio::test]
1821    async fn info_endpoint_returns_identity_when_stamped() {
1822        // R602-B4: /__mesofact/info surfaces the (service, component) an adopter
1823        // matches against before adopting the port.
1824        let workload = workload_with(&[]);
1825        let app = Server::from_workload(workload.path())
1826            .unwrap()
1827            .with_identity("scrabcake", "site")
1828            .router();
1829        let response = app
1830            .oneshot(
1831                Request::builder()
1832                    .uri("/__mesofact/info")
1833                    .body(Body::empty())
1834                    .unwrap(),
1835            )
1836            .await
1837            .unwrap();
1838        assert_eq!(response.status(), StatusCode::OK);
1839        let body: serde_json::Value =
1840            serde_json::from_str(&body_string(response).await).unwrap();
1841        assert_eq!(body["service"], "scrabcake");
1842        assert_eq!(body["component"], "site");
1843    }
1844
1845    #[tokio::test]
1846    async fn info_endpoint_404s_without_identity() {
1847        // No identity stamped → 404, so an identity-checking adopter refuses to
1848        // adopt an unstamped (or foreign) listener rather than guessing.
1849        let workload = workload_with(&[]);
1850        let app = Server::from_workload(workload.path()).unwrap().router();
1851        let response = app
1852            .oneshot(
1853                Request::builder()
1854                    .uri("/__mesofact/info")
1855                    .body(Body::empty())
1856                    .unwrap(),
1857            )
1858            .await
1859            .unwrap();
1860        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1861    }
1862
1863    #[tokio::test]
1864    async fn serves_named_file() {
1865        let workload = workload_with(&[("404.html", "<h1>oops</h1>")]);
1866        let app = Server::from_workload(workload.path()).unwrap().router();
1867        let response = app
1868            .oneshot(
1869                Request::builder()
1870                    .uri("/404.html")
1871                    .body(Body::empty())
1872                    .unwrap(),
1873            )
1874            .await
1875            .unwrap();
1876        assert_eq!(response.status(), StatusCode::OK);
1877        assert!(body_string(response).await.contains("oops"));
1878    }
1879
1880    #[tokio::test]
1881    async fn serves_clean_url_via_html_fallback() {
1882        // The CDN serves /releases → releases.html for prerendered routes;
1883        // mesofact-dev mirrors that so verify scripts don't have to hand-type
1884        // the extension. Regression for R443-B4.
1885        let workload = workload_with(&[("releases.html", "<h1>releases</h1>")]);
1886        let app = Server::from_workload(workload.path()).unwrap().router();
1887        let response = app
1888            .oneshot(
1889                Request::builder()
1890                    .uri("/releases")
1891                    .body(Body::empty())
1892                    .unwrap(),
1893            )
1894            .await
1895            .unwrap();
1896        assert_eq!(response.status(), StatusCode::OK);
1897        assert!(body_string(response).await.contains("releases"));
1898    }
1899
1900    /// R600-B1, third serving layer. A parametric static instance emits at
1901    /// `dist/html/<path>.html`, so the same clean-URL rule that resolves
1902    /// `/releases` resolves `/issues/<id>` — no route-schema knowledge needed
1903    /// here, which is exactly why the fix moved the write instead of teaching
1904    /// three resolvers the route-key rule. Closes the gap R443-B4 parked as
1905    /// "GET /issues/42 still 404 in dev" (see this module's handoff notes).
1906    #[tokio::test]
1907    async fn serves_parametric_instance_at_its_public_path() {
1908        let workload = workload_with(&[
1909            ("issues.html", "<h1>issue list</h1>"),
1910            ("issues/01KZVGVT0DV61ZGGNVHAWQW2CS.html", "<h1>issue detail</h1>"),
1911        ]);
1912        let app = Server::from_workload(workload.path()).unwrap().router();
1913        let detail = app
1914            .clone()
1915            .oneshot(
1916                Request::builder()
1917                    .uri("/issues/01KZVGVT0DV61ZGGNVHAWQW2CS")
1918                    .body(Body::empty())
1919                    .unwrap(),
1920            )
1921            .await
1922            .unwrap();
1923        assert_eq!(detail.status(), StatusCode::OK);
1924        assert!(body_string(detail).await.contains("issue detail"));
1925
1926        // The list route at the parent path is not shadowed by the dir.
1927        let list = app
1928            .oneshot(Request::builder().uri("/issues").body(Body::empty()).unwrap())
1929            .await
1930            .unwrap();
1931        assert_eq!(list.status(), StatusCode::OK);
1932        assert!(body_string(list).await.contains("issue list"));
1933    }
1934
1935    #[tokio::test]
1936    async fn clean_url_fallback_skips_paths_with_extension() {
1937        // A miss on /style.css must NOT try /style.css.html — the asset
1938        // extension is unambiguous, fall straight through to 404.
1939        let workload = workload_with(&[
1940            ("404.html", "<h1>oops</h1>"),
1941            ("style.css.html", "this should not be served"),
1942        ]);
1943        let app = Server::from_workload(workload.path()).unwrap().router();
1944        let response = app
1945            .oneshot(
1946                Request::builder()
1947                    .uri("/style.css")
1948                    .body(Body::empty())
1949                    .unwrap(),
1950            )
1951            .await
1952            .unwrap();
1953        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1954        assert!(body_string(response).await.contains("oops"));
1955    }
1956
1957    #[tokio::test]
1958    async fn missing_path_falls_back_to_404_html() {
1959        let workload = workload_with(&[("404.html", "<h1>oops</h1>")]);
1960        let app = Server::from_workload(workload.path()).unwrap().router();
1961        let response = app
1962            .oneshot(
1963                Request::builder()
1964                    .uri("/does-not-exist")
1965                    .body(Body::empty())
1966                    .unwrap(),
1967            )
1968            .await
1969            .unwrap();
1970        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1971        assert!(body_string(response).await.contains("oops"));
1972    }
1973
1974    #[tokio::test]
1975    async fn missing_path_without_404_file_returns_plain_404() {
1976        let workload = workload_with(&[]);
1977        let app = Server::from_workload(workload.path()).unwrap().router();
1978        let response = app
1979            .oneshot(
1980                Request::builder()
1981                    .uri("/missing")
1982                    .body(Body::empty())
1983                    .unwrap(),
1984            )
1985            .await
1986            .unwrap();
1987        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1988        assert!(body_string(response).await.contains("Not Found"));
1989    }
1990
1991    /// W270 §3 / R595-T5: a miss serves the manifest's `error_routes."404"`
1992    /// route (`/custom-nf` → `custom-nf.html`) in preference to the default
1993    /// `404.html`, for parity with `mesofact serve` and the edge worker.
1994    #[tokio::test]
1995    async fn missing_path_uses_error_routes_from_manifest() {
1996        let dir = tempdir().unwrap();
1997        let dist = dir.path().join("dist");
1998        let html = dist.join("html");
1999        std::fs::create_dir_all(&html).unwrap();
2000        std::fs::write(html.join("custom-nf.html"), "<h1>custom nf</h1>").unwrap();
2001        std::fs::write(html.join("404.html"), "<h1>default 404</h1>").unwrap();
2002        std::fs::write(
2003            dist.join("manifest.json"),
2004            r#"{"version":"1","build_id":"b","routes":[],"error_routes":{"404":"/custom-nf"}}"#,
2005        )
2006        .unwrap();
2007
2008        let app = Server::from_workload(dir.path()).unwrap().router();
2009        let response = app
2010            .oneshot(
2011                Request::builder()
2012                    .uri("/does-not-exist")
2013                    .body(Body::empty())
2014                    .unwrap(),
2015            )
2016            .await
2017            .unwrap();
2018        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2019        assert!(body_string(response).await.contains("custom nf"));
2020    }
2021
2022    // ── W272 bundle serving (R599-F3) ───────────────────────────────────────
2023    //
2024    // `Server::from_bundle` points the static server at a materialized W272
2025    // bundle's `app/` subtree after validating its `manifest.toml`. v0 serves
2026    // static only (clean-URLs + 404); these reuse the same clean-URL / 404
2027    // machinery the workload path exercises above, just entered via a bundle.
2028
2029    /// Assemble a minimal materialized bundle: `<root>/manifest.toml` +
2030    /// `<root>/app/dist/html/<files>`. `runtime` is the raw manifest value
2031    /// (`"mesofact/<ver>"` or `"self"`).
2032    fn bundle_with(runtime: &str, html: &[(&str, &str)]) -> tempfile::TempDir {
2033        let dir = tempdir().unwrap();
2034        let html_dir = dir.path().join("app").join("dist").join("html");
2035        std::fs::create_dir_all(&html_dir).unwrap();
2036        for (name, body) in html {
2037            std::fs::write(html_dir.join(name), body).unwrap();
2038        }
2039        std::fs::write(
2040            dir.path().join("manifest.toml"),
2041            format!("schema_version = 1\nname = \"test-bundle\"\nruntime = \"{runtime}\"\n"),
2042        )
2043        .unwrap();
2044        dir
2045    }
2046
2047    #[tokio::test]
2048    async fn serves_bundle_index_and_clean_url() {
2049        let bundle = bundle_with(
2050            "mesofact/0.8.20",
2051            &[("index.html", "<h1>home</h1>"), ("releases.html", "<h1>rel</h1>")],
2052        );
2053        let app = Server::from_bundle(bundle.path()).unwrap().router();
2054
2055        let root = app
2056            .clone()
2057            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2058            .await
2059            .unwrap();
2060        assert_eq!(root.status(), StatusCode::OK);
2061        assert!(body_string(root).await.contains("home"));
2062
2063        // Clean-URL: `/releases` → `releases.html`, same rule as the workload path.
2064        let clean = app
2065            .oneshot(Request::builder().uri("/releases").body(Body::empty()).unwrap())
2066            .await
2067            .unwrap();
2068        assert_eq!(clean.status(), StatusCode::OK);
2069        assert!(body_string(clean).await.contains("rel"));
2070    }
2071
2072    #[tokio::test]
2073    async fn bundle_miss_serves_404_page() {
2074        let bundle = bundle_with("mesofact/0.8.20", &[("404.html", "<h1>nope</h1>")]);
2075        let app = Server::from_bundle(bundle.path()).unwrap().router();
2076        let response = app
2077            .oneshot(Request::builder().uri("/absent").body(Body::empty()).unwrap())
2078            .await
2079            .unwrap();
2080        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2081        assert!(body_string(response).await.contains("nope"));
2082    }
2083
2084    #[tokio::test]
2085    async fn self_runtime_bundle_still_serves_static() {
2086        // A `runtime = "self"` bundle warns (its custom bin isn't executed) but
2087        // its prerendered static tree still serves.
2088        let bundle = bundle_with("self", &[("index.html", "<h1>custom</h1>")]);
2089        let app = Server::from_bundle(bundle.path()).unwrap().router();
2090        let response = app
2091            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2092            .await
2093            .unwrap();
2094        assert_eq!(response.status(), StatusCode::OK);
2095        assert!(body_string(response).await.contains("custom"));
2096    }
2097
2098    /// R703-T7: a bundle carries its publish beacon at
2099    /// `app/dist/html/.well-known/yah-publish.json`, and `yah cloud apply`
2100    /// fails the deploy unless the apex serves it back. That contract rests
2101    /// entirely on this path resolving — a leading-dot directory is exactly the
2102    /// shape a static server is apt to reject or rewrite — so pin it here
2103    /// rather than in the yubaba crate, which cannot reach this server.
2104    ///
2105    /// It must also come back as JSON, not `text/plain`: the verifier parses
2106    /// the body, and the clean-URL fallback must not go looking for
2107    /// `yah-publish.json.html`.
2108    #[tokio::test]
2109    async fn serves_the_publish_beacon_from_a_dot_well_known_path() {
2110        let bundle = bundle_with("self", &[("index.html", "<h1>home</h1>")]);
2111        let beacon_dir = bundle.path().join("app/dist/html/.well-known");
2112        std::fs::create_dir_all(&beacon_dir).unwrap();
2113        let body = r#"{"prefix":"bundle/yah-marketing","published_at":null,"digest":"ab","files":2}"#;
2114        std::fs::write(beacon_dir.join("yah-publish.json"), body).unwrap();
2115
2116        let response = Server::from_bundle(bundle.path())
2117            .unwrap()
2118            .router()
2119            .oneshot(
2120                Request::builder()
2121                    .uri("/.well-known/yah-publish.json")
2122                    .body(Body::empty())
2123                    .unwrap(),
2124            )
2125            .await
2126            .unwrap();
2127
2128        assert_eq!(response.status(), StatusCode::OK);
2129        assert_eq!(
2130            response
2131                .headers()
2132                .get(header::CONTENT_TYPE)
2133                .and_then(|v| v.to_str().ok()),
2134            Some("application/json; charset=utf-8"),
2135        );
2136        assert!(body_string(response).await.contains("bundle/yah-marketing"));
2137    }
2138
2139    /// A `.wasm` asset must come back as `application/wasm` and nothing else.
2140    /// `WebAssembly.instantiateStreaming` rejects every other Content-Type, and
2141    /// wasm-bindgen's loader swallows that rejection — it warns and falls back
2142    /// to `arrayBuffer()` + `instantiate()`, so a multi-megabyte module gets
2143    /// fully downloaded before compilation starts instead of compiling as it
2144    /// streams. The failure is a silent perf cliff, not an error, which is why
2145    /// it needs a test (R821-B1).
2146    ///
2147    /// This asserts the *served* header specifically: this server answers from
2148    /// [`mime_for`] over the on-disk tree and never reads the manifest's
2149    /// `static_assets[].content_type`, so the build-side table being right
2150    /// proves nothing about what a browser receives.
2151    #[tokio::test]
2152    async fn serves_wasm_with_the_mime_instantiate_streaming_accepts() {
2153        let bundle = bundle_with("self", &[("index.html", "<h1>home</h1>")]);
2154        let wasm_dir = bundle.path().join("app/dist/html/wasm");
2155        std::fs::create_dir_all(&wasm_dir).unwrap();
2156        std::fs::write(wasm_dir.join("demo_bg.wasm"), b"\0asm\x01\0\0\0").unwrap();
2157
2158        let response = Server::from_bundle(bundle.path())
2159            .unwrap()
2160            .router()
2161            .oneshot(
2162                Request::builder()
2163                    .uri("/wasm/demo_bg.wasm")
2164                    .body(Body::empty())
2165                    .unwrap(),
2166            )
2167            .await
2168            .unwrap();
2169
2170        assert_eq!(response.status(), StatusCode::OK);
2171        assert_eq!(
2172            response
2173                .headers()
2174                .get(header::CONTENT_TYPE)
2175                .and_then(|v| v.to_str().ok()),
2176            Some("application/wasm"),
2177        );
2178    }
2179
2180    /// The other half of the same contract: a bundle with no beacon must 404,
2181    /// not answer 200 with the index or a branded error page. A 200-with-HTML
2182    /// is the exact shape that hid two yah.dev freezes, and the verifier
2183    /// classifies it as `NotABeacon` only because the status is honest here.
2184    #[tokio::test]
2185    async fn an_unstamped_bundle_does_not_answer_the_beacon_url_with_200() {
2186        let bundle = bundle_with("self", &[("index.html", "<h1>home</h1>")]);
2187        let response = Server::from_bundle(bundle.path())
2188            .unwrap()
2189            .router()
2190            .oneshot(
2191                Request::builder()
2192                    .uri("/.well-known/yah-publish.json")
2193                    .body(Body::empty())
2194                    .unwrap(),
2195            )
2196            .await
2197            .unwrap();
2198        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2199    }
2200
2201    #[test]
2202    fn from_bundle_rejects_missing_manifest() {
2203        // A bare dir with no manifest.toml isn't a bundle.
2204        let dir = tempdir().unwrap();
2205        std::fs::create_dir_all(dir.path().join("app")).unwrap();
2206        let err = Server::from_bundle(dir.path()).err().unwrap().to_string();
2207        assert!(err.contains("not a mesofact bundle"), "got: {err}");
2208    }
2209
2210    #[test]
2211    fn from_bundle_rejects_unknown_schema() {
2212        let dir = tempdir().unwrap();
2213        std::fs::create_dir_all(dir.path().join("app")).unwrap();
2214        std::fs::write(
2215            dir.path().join("manifest.toml"),
2216            "schema_version = 99\nname = \"x\"\nruntime = \"mesofact/0.8.20\"\n",
2217        )
2218        .unwrap();
2219        let err = Server::from_bundle(dir.path()).err().unwrap().to_string();
2220        assert!(err.contains("invalid bundle manifest"), "got: {err}");
2221    }
2222
2223    #[test]
2224    fn from_bundle_rejects_missing_app_tree() {
2225        // Valid manifest but no `app/` subtree → clear error, not a silent
2226        // empty-serve.
2227        let dir = tempdir().unwrap();
2228        std::fs::write(
2229            dir.path().join("manifest.toml"),
2230            "schema_version = 1\nname = \"x\"\nruntime = \"mesofact/0.8.20\"\n",
2231        )
2232        .unwrap();
2233        let err = Server::from_bundle(dir.path()).err().unwrap().to_string();
2234        assert!(err.contains("no servable app tree"), "got: {err}");
2235    }
2236
2237    // ── JIT runtime contract (R599-F6): adopt a handed-over socket + idle-reap ─
2238    //
2239    // The on-demand ("serverless") tier has kamaji's SocketCustodian bind+hold
2240    // the listen socket and hand this process the fd, and the runtime self-exit
2241    // once idle so kamaji stays out of the data path. These drive
2242    // `serve_on_listener` directly (the lib seam); the binary-level LISTEN_FDS
2243    // env dance is a thin adapter over the same call.
2244
2245    #[tokio::test]
2246    async fn serve_on_listener_adopts_the_given_socket() {
2247        // A pre-bound listener (the fd kamaji's custodian would hand over) is
2248        // adopted rather than re-bound — the server serves on THAT socket.
2249        let std_l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2250        let port = std_l.local_addr().unwrap().port();
2251        std_l.set_nonblocking(true).unwrap();
2252        let listener = tokio::net::TcpListener::from_std(std_l).unwrap();
2253
2254        let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>adopted</h1>")]);
2255        let server = Server::from_bundle(bundle.path()).unwrap();
2256        let serve = tokio::spawn(async move { server.serve_on_listener(listener, None).await });
2257
2258        let resp = reqwest::get(format!("http://127.0.0.1:{port}/")).await.unwrap();
2259        assert_eq!(resp.status(), 200);
2260        assert!(resp.text().await.unwrap().contains("adopted"));
2261        serve.abort();
2262    }
2263
2264    #[tokio::test]
2265    async fn jit_idle_ttl_self_reaps_after_last_request() {
2266        // With an idle TTL set, the server serves requests and then self-exits
2267        // (serve future returns Ok) once no request has been in flight for the
2268        // TTL — kamaji re-forks on the next connection.
2269        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2270        let port = listener.local_addr().unwrap().port();
2271
2272        let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>hi</h1>")]);
2273        let server = Server::from_bundle(bundle.path()).unwrap();
2274        let serve = tokio::spawn(async move {
2275            server
2276                .serve_on_listener(listener, Some(Duration::from_millis(300)))
2277                .await
2278        });
2279
2280        // Serve at least one request first (also proves the idle clock resets).
2281        let resp = reqwest::get(format!("http://127.0.0.1:{port}/")).await.unwrap();
2282        assert_eq!(resp.status(), 200);
2283
2284        // Within a few TTLs the idle reaper fires and the serve future returns.
2285        let out = tokio::time::timeout(Duration::from_secs(3), serve)
2286            .await
2287            .expect("server should self-reap within the timeout")
2288            .expect("serve task panicked");
2289        out.expect("serve returned an error");
2290    }
2291
2292    // ── Instance-addressed (deferred) route resolution (W270 §9, R595-T6) ────
2293    //
2294    // mesofact-dev resolves `prerender: { deferred: true }` routes through the
2295    // PointerStore against the local object store, mirroring the @mesofact/edge
2296    // worker's resolution against R2. These tests wire an InMemoryStore (the
2297    // dev/prod S3Store is exercised E2E against the dev-S3 surface).
2298
2299    /// Build a workload whose manifest declares one deferred route (`/c/:slug`).
2300    #[cfg(feature = "ssr")]
2301    fn deferred_workload(extra_html: &[(&str, &str)], error_routes_json: &str) -> tempfile::TempDir {
2302        let dir = tempdir().unwrap();
2303        let dist = dir.path().join("dist");
2304        let html = dist.join("html");
2305        std::fs::create_dir_all(&html).unwrap();
2306        for (name, body) in extra_html {
2307            std::fs::write(html.join(name), body).unwrap();
2308        }
2309        std::fs::write(
2310            dist.join("manifest.json"),
2311            format!(
2312                r#"{{"version":"1","build_id":"b","routes":[{{"route":"/c/:slug","mode":"static","render_entrypoint":"dist/server/c_slug.js","cache_policy":{{"ttl":0}},"prerender":{{"deferred":true}}}}]{error_routes_json}}}"#
2313            ),
2314        )
2315        .unwrap();
2316        dir
2317    }
2318
2319    #[cfg(feature = "ssr")]
2320    fn mem_store() -> std::sync::Arc<dyn ObjectStore> {
2321        std::sync::Arc::new(mesofact_publisher::InMemoryStore::new())
2322    }
2323
2324    #[cfg(feature = "ssr")]
2325    async fn flip_instance(store: &std::sync::Arc<dyn ObjectStore>, key: &str, content_root: &str) {
2326        use mesofact_publisher::{ObjectPointerStore, Pointer, PointerStore};
2327        ObjectPointerStore::new(store.clone())
2328            .flip(
2329                key,
2330                Pointer { content_root: content_root.into(), source_root: None, published_at: None },
2331            )
2332            .await
2333            .unwrap();
2334    }
2335
2336    #[cfg(feature = "ssr")]
2337    async fn put_bytes(store: &std::sync::Arc<dyn ObjectStore>, key: &str, body: &'static [u8]) {
2338        use mesofact_publisher::PutOpts;
2339        store
2340            .put(
2341                key,
2342                axum::body::Bytes::from_static(body),
2343                PutOpts { content_type: "text/html".into(), content_hash: "h".into(), cache_control: None },
2344            )
2345            .await
2346            .unwrap();
2347    }
2348
2349    /// Present pointer → the render-root bytes, immutable cache, html mime.
2350    #[cfg(feature = "ssr")]
2351    #[tokio::test]
2352    async fn deferred_route_present_serves_instance_bytes() {
2353        let dir = deferred_workload(&[], "");
2354        let store = mem_store();
2355        flip_instance(&store, "c/abc", "content/abc.html").await;
2356        put_bytes(&store, "content/abc.html", b"<h1>chat abc</h1>").await;
2357
2358        let app = Server::from_workload(dir.path())
2359            .unwrap()
2360            .with_instance_store(store)
2361            .router();
2362        let response = app
2363            .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2364            .await
2365            .unwrap();
2366        assert_eq!(response.status(), StatusCode::OK);
2367        assert_eq!(
2368            response.headers().get("cache-control").unwrap(),
2369            "public, max-age=31536000, immutable"
2370        );
2371        assert!(response
2372            .headers()
2373            .get("content-type")
2374            .unwrap()
2375            .to_str()
2376            .unwrap()
2377            .contains("text/html"));
2378        assert!(body_string(response).await.contains("chat abc"));
2379    }
2380
2381    /// Deleted pointer (tombstone) → 410 Gone, distinct from a never-existed 404;
2382    /// the branded 404 page is served under the 410 status.
2383    #[cfg(feature = "ssr")]
2384    #[tokio::test]
2385    async fn deferred_route_deleted_returns_410() {
2386        use mesofact_publisher::{ObjectPointerStore, PointerStore};
2387        let dir = deferred_workload(&[("404.html", "<h1>gone-page</h1>")], "");
2388        let store = mem_store();
2389        flip_instance(&store, "c/abc", "content/abc.html").await;
2390        ObjectPointerStore::new(store.clone())
2391            .delete("c/abc", Some("2026-07-14T00:00:00Z".into()))
2392            .await
2393            .unwrap();
2394
2395        let app = Server::from_workload(dir.path())
2396            .unwrap()
2397            .with_instance_store(store)
2398            .router();
2399        let response = app
2400            .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2401            .await
2402            .unwrap();
2403        assert_eq!(response.status(), StatusCode::GONE);
2404        assert!(body_string(response).await.contains("gone-page"));
2405    }
2406
2407    /// Absent pointer on a deferred-route path → 404 branded page.
2408    #[cfg(feature = "ssr")]
2409    #[tokio::test]
2410    async fn deferred_route_absent_returns_404() {
2411        let dir = deferred_workload(&[("404.html", "<h1>nf</h1>")], "");
2412        let store = mem_store();
2413        let app = Server::from_workload(dir.path())
2414            .unwrap()
2415            .with_instance_store(store)
2416            .router();
2417        let response = app
2418            .oneshot(Request::builder().uri("/c/never").body(Body::empty()).unwrap())
2419            .await
2420            .unwrap();
2421        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2422        assert!(body_string(response).await.contains("nf"));
2423    }
2424
2425    /// A path that does NOT match the deferred route pattern is never routed
2426    /// through the pointer store — it 404s as an ordinary static miss even with
2427    /// an instance store wired (segment-count guard: `/c/a/b` ≠ `/c/:slug`).
2428    #[cfg(feature = "ssr")]
2429    #[tokio::test]
2430    async fn non_deferred_path_not_routed_through_pointer_store() {
2431        let dir = deferred_workload(&[("404.html", "<h1>nf</h1>")], "");
2432        let store = mem_store();
2433        // A pointer exists at this exact key, but the path has an extra segment
2434        // so it must not match `/c/:slug` — the store is never consulted.
2435        flip_instance(&store, "c/a/b", "content/ab.html").await;
2436        put_bytes(&store, "content/ab.html", b"<h1>should not serve</h1>").await;
2437
2438        let app = Server::from_workload(dir.path())
2439            .unwrap()
2440            .with_instance_store(store)
2441            .router();
2442        let response = app
2443            .oneshot(Request::builder().uri("/c/a/b").body(Body::empty()).unwrap())
2444            .await
2445            .unwrap();
2446        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2447        assert!(body_string(response).await.contains("nf"));
2448    }
2449
2450    /// Malformed pointer record (unknown version) → 5xx, drawing the branded
2451    /// `error_routes."5xx"` page (never the 404 page).
2452    #[cfg(feature = "ssr")]
2453    #[tokio::test]
2454    async fn deferred_route_malformed_record_returns_5xx() {
2455        let dir = deferred_workload(
2456            &[("5xx.html", "<h1>boom</h1>"), ("404.html", "<h1>nf</h1>")],
2457            r#","error_routes":{"5xx":"/5xx"}"#,
2458        );
2459        let store = mem_store();
2460        // Write a raw record the resolver can't read (version 99).
2461        put_bytes(
2462            &store,
2463            "p/c/bad",
2464            br#"{"v":99,"pointer":{"content_root":"x"}}"#,
2465        )
2466        .await;
2467
2468        let app = Server::from_workload(dir.path())
2469            .unwrap()
2470            .with_instance_store(store)
2471            .router();
2472        let response = app
2473            .oneshot(Request::builder().uri("/c/bad").body(Body::empty()).unwrap())
2474            .await
2475            .unwrap();
2476        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
2477        assert!(body_string(response).await.contains("boom"));
2478    }
2479
2480    /// Deferred resolution requires an instance store — without one wired, a
2481    /// deferred-route path is an ordinary 404 (the reconciler / prod worker owns
2482    /// resolution there, not this static path).
2483    #[cfg(feature = "ssr")]
2484    #[tokio::test]
2485    async fn deferred_route_without_store_is_404() {
2486        let dir = deferred_workload(&[("404.html", "<h1>nf</h1>")], "");
2487        let app = Server::from_workload(dir.path()).unwrap().router();
2488        let response = app
2489            .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2490            .await
2491            .unwrap();
2492        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2493        assert!(body_string(response).await.contains("nf"));
2494    }
2495
2496    #[cfg(feature = "ssr")]
2497    #[test]
2498    fn match_route_pattern_is_segment_aware() {
2499        assert!(match_route_pattern("/c/:slug", "/c/abc"));
2500        assert!(match_route_pattern("/a/:x/b/:y", "/a/1/b/2"));
2501        assert!(match_route_pattern("/about", "/about"));
2502        assert!(match_route_pattern("/", "/"));
2503        // Trailing :param does not swallow extra segments.
2504        assert!(!match_route_pattern("/c/:slug", "/c/abc/def"));
2505        // Segment counts must be equal.
2506        assert!(!match_route_pattern("/c/:slug", "/c"));
2507        // Literal mismatch.
2508        assert!(!match_route_pattern("/about", "/abou"));
2509    }
2510
2511
2512    #[tokio::test]
2513    async fn from_workload_rejects_missing_directory() {
2514        let result = Server::from_workload(tempdir().unwrap().path().join("nope"));
2515        assert!(result.is_err());
2516    }
2517
2518    #[tokio::test]
2519    async fn dist_dir_resolves_under_workload() {
2520        let workload = tempdir().unwrap();
2521        let server = Server::from_workload(workload.path()).unwrap();
2522        assert_eq!(server.dist_dir(), workload.path().join("dist").join("html"));
2523    }
2524
2525    #[tokio::test]
2526    async fn pointer_swap_changes_served_content() {
2527        let workload_a = tempdir().unwrap();
2528        let dist_a = workload_a.path().join("dist").join("html");
2529        std::fs::create_dir_all(&dist_a).unwrap();
2530        std::fs::write(dist_a.join("index.html"), "<h1>A</h1>").unwrap();
2531
2532        let dir_b = tempdir().unwrap();
2533        let dist_b = dir_b.path().join("html");
2534        std::fs::create_dir_all(&dist_b).unwrap();
2535        std::fs::write(dist_b.join("index.html"), "<h1>B</h1>").unwrap();
2536
2537        let server = Server::from_workload(workload_a.path()).unwrap();
2538        let pointer = server.pointer();
2539
2540        // Initial: serves A.
2541        let response = server
2542            .router()
2543            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2544            .await
2545            .unwrap();
2546        assert!(body_string(response).await.contains("A"));
2547
2548        // Flip pointer to B.
2549        pointer.set(dist_b);
2550
2551        // Same router, new content.
2552        let response = server
2553            .router()
2554            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2555            .await
2556            .unwrap();
2557        assert!(body_string(response).await.contains("B"));
2558    }
2559
2560    #[tokio::test]
2561    async fn sanitize_rejects_dot_dot() {
2562        assert!(sanitize("/../etc/passwd").is_none());
2563        assert!(sanitize("/foo/../bar").is_none());
2564    }
2565
2566    #[tokio::test]
2567    async fn sanitize_accepts_normal_paths() {
2568        assert_eq!(sanitize("/"), Some(PathBuf::new()));
2569        assert_eq!(sanitize("/index.html"), Some(PathBuf::from("index.html")));
2570        assert_eq!(sanitize("/a/b/c"), Some(PathBuf::from("a/b/c")));
2571    }
2572
2573    // ── SSR dispatch integration tests ──────────────────────────────────
2574    //
2575    // Under R449-F2 the SSR child runs in-process. The tests below inject a
2576    // mock dispatch closure (no V8, no axum mock origin) and exercise the
2577    // router → SsrChild → handler chain end-to-end.
2578
2579    #[cfg(feature = "ssr")]
2580    use mesofact_ssr::DispatchResponse;
2581
2582    #[cfg(feature = "ssr")]
2583    fn mock_dispatch_resp(
2584        status: u16,
2585        body: &str,
2586    ) -> impl Fn(DispatchRequest) -> Result<DispatchResponse, anyhow::Error> + Send + Sync + 'static
2587    {
2588        let body = body.to_owned();
2589        move |_req| {
2590            Ok(DispatchResponse {
2591                status,
2592                headers: vec![("content-type".into(), "text/plain".into())],
2593                body: body.as_bytes().to_vec(),
2594            })
2595        }
2596    }
2597
2598    /// Verify item #1: an SSR-prefixed request reaches the in-process
2599    /// handler and its Response is forwarded back to the client.
2600    #[cfg(feature = "ssr")]
2601    #[tokio::test]
2602    async fn ssr_proxied_path_returns_handler_response() {
2603        let workload = workload_with(&[("index.html", "<h1>static</h1>")]);
2604        let ssr = ssr::detached_for_test_with_policies(
2605            vec!["/api/health".to_string()],
2606            vec![],
2607            mock_dispatch_resp(200, "healthy"),
2608        );
2609
2610        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2611        let response = server
2612            .router()
2613            .oneshot(
2614                Request::builder()
2615                    .uri("/api/health")
2616                    .body(Body::empty())
2617                    .unwrap(),
2618            )
2619            .await
2620            .unwrap();
2621        assert_eq!(response.status(), StatusCode::OK);
2622        assert_eq!(body_string(response).await, "healthy");
2623    }
2624
2625    /// Verify item #2: with SSR wired, static routes still serve from disk.
2626    #[cfg(feature = "ssr")]
2627    #[tokio::test]
2628    async fn ssr_does_not_swallow_static_routes() {
2629        let workload = workload_with(&[("index.html", "<h1>static</h1>")]);
2630        let ssr = ssr::detached_for_test_with_policies(
2631            vec!["/api/health".to_string()],
2632            vec![],
2633            mock_dispatch_resp(200, "healthy"),
2634        );
2635
2636        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2637        let response = server
2638            .router()
2639            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2640            .await
2641            .unwrap();
2642        assert_eq!(response.status(), StatusCode::OK);
2643        assert!(body_string(response).await.contains("static"));
2644    }
2645
2646    /// Verify item #5: segment-aware prefix matching at the router layer.
2647    /// `/api/health` (SSR) is dispatched; `/api/healthcheck` (no SSR match)
2648    /// falls through to static, which 404s on missing path.
2649    #[cfg(feature = "ssr")]
2650    #[tokio::test]
2651    async fn ssr_segment_boundary_not_naive_starts_with() {
2652        let workload = workload_with(&[("404.html", "static-404")]);
2653        let ssr = ssr::detached_for_test_with_policies(
2654            vec!["/api/health".to_string()],
2655            vec![],
2656            mock_dispatch_resp(200, "healthy"),
2657        );
2658
2659        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2660        let router = server.router();
2661
2662        // /api/health → SSR → mock dispatch → "healthy"
2663        let r1 = router
2664            .clone()
2665            .oneshot(
2666                Request::builder()
2667                    .uri("/api/health")
2668                    .body(Body::empty())
2669                    .unwrap(),
2670            )
2671            .await
2672            .unwrap();
2673        assert_eq!(r1.status(), StatusCode::OK);
2674        assert_eq!(body_string(r1).await, "healthy");
2675
2676        // /api/healthcheck → not SSR → static 404 (proves naive startsWith
2677        // would have wrongly dispatched this).
2678        let r2 = router
2679            .oneshot(
2680                Request::builder()
2681                    .uri("/api/healthcheck")
2682                    .body(Body::empty())
2683                    .unwrap(),
2684            )
2685            .await
2686            .unwrap();
2687        assert_eq!(r2.status(), StatusCode::NOT_FOUND);
2688        assert_eq!(body_string(r2).await, "static-404");
2689    }
2690
2691    // ── Hydrate bundle routing tests ────────────────────────────────────────
2692
2693    fn workload_with_hydrate(
2694        html_files: &[(&str, &str)],
2695        hydrate_files: &[(&str, &str)],
2696    ) -> tempfile::TempDir {
2697        let dir = tempdir().unwrap();
2698        let html_dir = dir.path().join("dist").join("html");
2699        let hydrate_dir = dir.path().join("dist").join("hydrate");
2700        std::fs::create_dir_all(&html_dir).unwrap();
2701        std::fs::create_dir_all(&hydrate_dir).unwrap();
2702        for (name, body) in html_files {
2703            std::fs::write(html_dir.join(name), body).unwrap();
2704        }
2705        for (name, body) in hydrate_files {
2706            std::fs::write(hydrate_dir.join(name), body).unwrap();
2707        }
2708        dir
2709    }
2710
2711    /// (a) GET /<build_id>/hydrate/<file>.js → 200 + application/javascript.
2712    #[tokio::test]
2713    async fn serves_hydrate_bundle_with_build_id_prefix() {
2714        let workload = workload_with_hydrate(
2715            &[],
2716            &[("issues.abc123.js", "console.log('hydrate')")],
2717        );
2718        let app = Server::from_workload(workload.path()).unwrap().router();
2719        let response = app
2720            .oneshot(
2721                Request::builder()
2722                    .uri("/gen-1/hydrate/issues.abc123.js")
2723                    .body(Body::empty())
2724                    .unwrap(),
2725            )
2726            .await
2727            .unwrap();
2728        assert_eq!(response.status(), StatusCode::OK);
2729        let ct = response
2730            .headers()
2731            .get("content-type")
2732            .unwrap()
2733            .to_str()
2734            .unwrap();
2735        assert!(ct.contains("application/javascript"), "wrong mime: {ct}");
2736        assert!(body_string(response).await.contains("hydrate"));
2737    }
2738
2739    /// (b) build_id is opaque — any string in the first segment still routes
2740    /// to the same dist/hydrate/ directory.
2741    #[tokio::test]
2742    async fn serves_hydrate_bundle_build_id_opaque() {
2743        let workload = workload_with_hydrate(
2744            &[],
2745            &[("app.xyz.js", "export default 1")],
2746        );
2747        let app = Server::from_workload(workload.path()).unwrap().router();
2748        for prefix in &["no-such-build-id", "gen-99", "abc123"] {
2749            let response = app
2750                .clone()
2751                .oneshot(
2752                    Request::builder()
2753                        .uri(format!("/{prefix}/hydrate/app.xyz.js"))
2754                        .body(Body::empty())
2755                        .unwrap(),
2756                )
2757                .await
2758                .unwrap();
2759            assert_eq!(
2760                response.status(),
2761                StatusCode::OK,
2762                "build_id '{prefix}' should be opaque"
2763            );
2764        }
2765    }
2766
2767    /// No-prefix form: /hydrate/<file> also maps to dist/hydrate/.
2768    #[tokio::test]
2769    async fn serves_hydrate_bundle_no_build_id_prefix() {
2770        let workload =
2771            workload_with_hydrate(&[], &[("app.js", "export default 1")]);
2772        let app = Server::from_workload(workload.path()).unwrap().router();
2773        let response = app
2774            .oneshot(
2775                Request::builder()
2776                    .uri("/hydrate/app.js")
2777                    .body(Body::empty())
2778                    .unwrap(),
2779            )
2780            .await
2781            .unwrap();
2782        assert_eq!(response.status(), StatusCode::OK);
2783    }
2784
2785    /// (c) Path traversal inside a hydrate URL → BAD_REQUEST (sanitizer holds).
2786    #[tokio::test]
2787    async fn hydrate_path_traversal_rejected() {
2788        let workload = workload_with_hydrate(&[], &[]);
2789        let app = Server::from_workload(workload.path()).unwrap().router();
2790        let response = app
2791            .oneshot(
2792                Request::builder()
2793                    .uri("/gen-1/hydrate/../../etc/passwd")
2794                    .body(Body::empty())
2795                    .unwrap(),
2796            )
2797            .await
2798            .unwrap();
2799        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2800    }
2801
2802    /// Parametric prefix coverage: /api/users/ matches /api/users/42 and the
2803    /// full pathname reaches the dispatch closure so it can decode the :id.
2804    #[cfg(feature = "ssr")]
2805    #[tokio::test]
2806    async fn ssr_parametric_prefix_forwards_full_path() {
2807        let workload = workload_with(&[]);
2808        let ssr = ssr::detached_for_test_with_policies(
2809            vec!["/api/users/".to_string()],
2810            vec![],
2811            |req| {
2812                let id = req
2813                    .url
2814                    .rsplit_once('/')
2815                    .map(|(_, t)| t.to_string())
2816                    .unwrap_or_default();
2817                Ok(DispatchResponse {
2818                    status: 200,
2819                    headers: vec![("content-type".into(), "text/plain".into())],
2820                    body: format!("user {id}").into_bytes(),
2821                })
2822            },
2823        );
2824        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2825        let response = server
2826            .router()
2827            .oneshot(
2828                Request::builder()
2829                    .uri("/api/users/42")
2830                    .body(Body::empty())
2831                    .unwrap(),
2832            )
2833            .await
2834            .unwrap();
2835        assert_eq!(response.status(), StatusCode::OK);
2836        assert_eq!(body_string(response).await, "user 42");
2837    }
2838
2839    // ── W181 resilience tests ────────────────────────────────────────────
2840
2841    #[cfg(feature = "ssr")]
2842    fn retry_policy(attempts: u32, backoff_ms: Vec<u64>, retry_on: &str) -> ResiliencePolicy {
2843        ResiliencePolicy {
2844            retry: Some(RetryPolicy {
2845                attempts,
2846                backoff_ms,
2847                retry_on: Some(retry_on.to_string()),
2848                budget_ms: None,
2849            }),
2850            queue: None,
2851            timeout_ms: None,
2852        }
2853    }
2854
2855    /// Counter-backed flaky dispatch: returns 500 the first `ok_after` calls,
2856    /// then 201. Closure form lets resilience tests run without spinning up
2857    /// any axum mock origin.
2858    #[cfg(feature = "ssr")]
2859    fn flaky_dispatch(
2860        ok_after: usize,
2861    ) -> (
2862        impl Fn(DispatchRequest) -> Result<DispatchResponse, anyhow::Error>
2863            + Send
2864            + Sync
2865            + 'static,
2866        Arc<std::sync::atomic::AtomicUsize>,
2867    ) {
2868        use std::sync::atomic::{AtomicUsize, Ordering};
2869        let counter = Arc::new(AtomicUsize::new(0));
2870        let c = counter.clone();
2871        let f = move |_req: DispatchRequest| {
2872            let n = c.fetch_add(1, Ordering::SeqCst);
2873            if n < ok_after {
2874                Ok(DispatchResponse {
2875                    status: 500,
2876                    headers: vec![("content-type".into(), "text/plain".into())],
2877                    body: b"down".to_vec(),
2878                })
2879            } else {
2880                Ok(DispatchResponse {
2881                    status: 201,
2882                    headers: vec![("content-type".into(), "text/plain".into())],
2883                    body: format!("ok after {n}").into_bytes(),
2884                })
2885            }
2886        };
2887        (f, counter)
2888    }
2889
2890    /// Retry on 5xx: 3 attempts, dispatch returns 500/500/201 → 201.
2891    #[cfg(feature = "ssr")]
2892    #[tokio::test]
2893    async fn resilience_retry_on_5xx_succeeds_on_third_attempt() {
2894        let workload = workload_with(&[]);
2895        let (dispatch, counter) = flaky_dispatch(2);
2896        let policy = retry_policy(3, vec![10, 10], "5xx");
2897        let ssr = ssr::detached_for_test_with_policies(
2898            vec!["/api/issues".to_string()],
2899            vec![("/api/issues".to_string(), policy)],
2900            dispatch,
2901        );
2902        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2903        let resp = server
2904            .router()
2905            .oneshot(
2906                Request::builder()
2907                    .method("POST")
2908                    .uri("/api/issues")
2909                    .body(Body::from("{\"title\":\"x\"}"))
2910                    .unwrap(),
2911            )
2912            .await
2913            .unwrap();
2914        assert_eq!(resp.status(), StatusCode::CREATED);
2915        assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 3);
2916    }
2917
2918    /// `retry_on:"connection"` does NOT retry HTTP 5xx.
2919    /// Dispatch returns 500 once → proxy returns 500 verbatim, no retry.
2920    #[cfg(feature = "ssr")]
2921    #[tokio::test]
2922    async fn resilience_no_retry_on_5xx_when_retry_on_connection() {
2923        let workload = workload_with(&[]);
2924        let (dispatch, counter) = flaky_dispatch(usize::MAX);
2925        let policy = retry_policy(3, vec![10, 10], "connection");
2926        let ssr = ssr::detached_for_test_with_policies(
2927            vec!["/api/issues".to_string()],
2928            vec![("/api/issues".to_string(), policy)],
2929            dispatch,
2930        );
2931        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2932        let resp = server
2933            .router()
2934            .oneshot(
2935                Request::builder()
2936                    .method("POST")
2937                    .uri("/api/issues")
2938                    .body(Body::from("{\"title\":\"x\"}"))
2939                    .unwrap(),
2940            )
2941            .await
2942            .unwrap();
2943        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
2944        assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
2945    }
2946
2947    /// Per-attempt timeout fires: dispatch sleeps past `timeout_ms`,
2948    /// `tokio::time::timeout` cancels and treats it as a connection failure.
2949    #[cfg(feature = "ssr")]
2950    #[tokio::test]
2951    async fn resilience_per_attempt_timeout_aborts_slow_dispatch() {
2952        let workload = workload_with(&[]);
2953        // The mock dispatch closure runs synchronously; model "slow" by
2954        // returning a sentinel status and forcing the policy to time out
2955        // via a tight budget below. To genuinely test the timeout path we
2956        // do need an async-ish dispatch — we use spawn_blocking sleep
2957        // through a custom DispatchTarget variant in the future, but for
2958        // now an immediate response with a tight policy is verified by
2959        // `resilience_retry_on_5xx_succeeds_on_third_attempt`. Mark this
2960        // test as skipped under the in-process model.
2961        let _ = workload;
2962        // Placeholder kept so the W181 test list documents the gap; the
2963        // proper restoration is a future ticket (see @yah:cleanup below).
2964    }
2965
2966    /// No `resilience` block declared → single attempt, no retry on 5xx.
2967    #[cfg(feature = "ssr")]
2968    #[tokio::test]
2969    async fn resilience_absent_falls_back_to_single_attempt() {
2970        let workload = workload_with(&[]);
2971        let (dispatch, counter) = flaky_dispatch(usize::MAX);
2972        let ssr = ssr::detached_for_test_with_policies(
2973            vec!["/api/issues".to_string()],
2974            vec![],
2975            dispatch,
2976        );
2977        let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2978        let resp = server
2979            .router()
2980            .oneshot(
2981                Request::builder()
2982                    .method("POST")
2983                    .uri("/api/issues")
2984                    .body(Body::from("{\"title\":\"x\"}"))
2985                    .unwrap(),
2986            )
2987            .await
2988            .unwrap();
2989        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
2990        assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
2991    }
2992
2993    // ── Same-origin reverse proxy (R513-F10) ───────────────────────────────
2994
2995    /// Spawn a tiny loopback backend that echoes the method + path + body on
2996    /// `/auth/*` and `/dev/*`, and returns its base URL.
2997    async fn spawn_echo_backend() -> String {
2998        use axum::routing::any;
2999        let app = Router::new().route(
3000            "/{*rest}",
3001            any(|req: axum::extract::Request| async move {
3002                let method = req.method().to_string();
3003                let path = req.uri().path().to_string();
3004                let body = to_bytes(req.into_body(), usize::MAX).await.unwrap();
3005                format!("backend {method} {path} body={}", String::from_utf8_lossy(&body))
3006            }),
3007        );
3008        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3009        let addr = listener.local_addr().unwrap();
3010        tokio::spawn(async move {
3011            axum::serve(listener, app).await.unwrap();
3012        });
3013        format!("http://{addr}")
3014    }
3015
3016    #[tokio::test]
3017    async fn proxy_forwards_matching_prefix_path_preserving() {
3018        let backend = spawn_echo_backend().await;
3019        let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3020        let app = Server::from_workload(workload.path())
3021            .unwrap()
3022            .with_proxy(proxy::ProxyMap::new([("/auth".to_string(), backend.clone())]))
3023            .router();
3024        let resp = app
3025            .oneshot(
3026                Request::builder()
3027                    .method("POST")
3028                    .uri("/auth/magic-link/request")
3029                    .body(Body::from("{\"email\":\"cecil@yah.dev\"}"))
3030                    .unwrap(),
3031            )
3032            .await
3033            .unwrap();
3034        assert_eq!(resp.status(), StatusCode::OK);
3035        let body = body_string(resp).await;
3036        // Path-preserving: the backend saw the full original path, not a stripped one.
3037        assert!(
3038            body.contains("backend POST /auth/magic-link/request"),
3039            "proxy must preserve method + path: {body}",
3040        );
3041        assert!(body.contains("cecil@yah.dev"), "proxy must forward the body: {body}");
3042    }
3043
3044    #[tokio::test]
3045    async fn proxy_falls_through_to_spa_for_unmapped_paths() {
3046        let backend = spawn_echo_backend().await;
3047        let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3048        let app = Server::from_workload(workload.path())
3049            .unwrap()
3050            .with_proxy(proxy::ProxyMap::new([("/auth".to_string(), backend)]))
3051            .router();
3052        // `/` is not a proxy prefix → the SPA index is served, not proxied.
3053        let resp = app
3054            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
3055            .await
3056            .unwrap();
3057        assert_eq!(resp.status(), StatusCode::OK);
3058        assert!(body_string(resp).await.contains("spa"));
3059    }
3060
3061    #[tokio::test]
3062    async fn config_json_served_when_injected() {
3063        let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3064        let app = Server::from_workload(workload.path())
3065            .unwrap()
3066            .with_config_json(br#"{"env":"ci","authBaseUrl":"/auth"}"#.to_vec())
3067            .router();
3068        let resp = app
3069            .oneshot(Request::builder().uri("/config.json").body(Body::empty()).unwrap())
3070            .await
3071            .unwrap();
3072        assert_eq!(resp.status(), StatusCode::OK);
3073        let body = body_string(resp).await;
3074        assert!(body.contains("\"env\":\"ci\""), "serves injected config: {body}");
3075    }
3076
3077    #[tokio::test]
3078    async fn config_json_falls_through_to_static_when_not_injected() {
3079        // No --config-json: /config.json must NOT be a special route — it falls
3080        // through to the static handler, so a pipeline that doesn't inject
3081        // config never inherits a stale one (the cross-pipeline safety).
3082        let workload = workload_with(&[("config.json", r#"{"env":"static-file"}"#)]);
3083        let app = Server::from_workload(workload.path()).unwrap().router();
3084        let resp = app
3085            .oneshot(Request::builder().uri("/config.json").body(Body::empty()).unwrap())
3086            .await
3087            .unwrap();
3088        // The static file in dist/ is what's served (proving no injected route
3089        // shadows it); when dist/ has none, this is a 404 — either way, the
3090        // server invents nothing.
3091        assert_eq!(resp.status(), StatusCode::OK);
3092        assert!(body_string(resp).await.contains("static-file"));
3093    }
3094
3095    #[tokio::test]
3096    async fn proxy_returns_502_on_dead_backend() {
3097        let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3098        // Port 1 is unbindable/unreachable → the upstream request fails.
3099        let app = Server::from_workload(workload.path())
3100            .unwrap()
3101            .with_proxy(proxy::ProxyMap::new([(
3102                "/auth".to_string(),
3103                "http://127.0.0.1:1".to_string(),
3104            )]))
3105            .router();
3106        let resp = app
3107            .oneshot(
3108                Request::builder()
3109                    .uri("/auth/health")
3110                    .body(Body::empty())
3111                    .unwrap(),
3112            )
3113            .await
3114            .unwrap();
3115        assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
3116    }
3117}