Skip to main content

mesofact/
lib.rs

1//! `mesofact` — the Rust-native web framework facade.
2//!
3//! **This is the one crate consumers depend on.** Subsystems are selected by
4//! feature rather than by picking crate names out of a 7-crate workspace
5//! (bevy-style). Two distribution strategies, one crate:
6//!
7//! - **Prebuilt binary / container** — we build the `mesofact` bin with
8//!   the `deploy` preset, arch-native per libc, and kamaji fetches it. The
9//!   consumer's project compiles zero Rust, only their TypeScript — the Node.js
10//!   model.
11//! - **Crate dependency** — the same crate from crates.io, where a consumer
12//!   picks features and builds a bespoke binary. What Node can't offer, because
13//!   it's Rust-native all the way down instead of C++ addons.
14//!
15//! They are the same crate at two lifecycle stages; the shared `deploy` feature
16//! preset is what keeps them honest. See W225 §2a.
17//!
18//! # Feature tiers
19//!
20//! - `default` — the lean, **V8-free** Rust-handler harness: [`serve_app`] /
21//!   [`wrap`] + the standard stack ([`HEALTH_PATH`], `TraceLayer`, graceful
22//!   shutdown). For services whose handlers are Rust functions; the dogfood is
23//!   yah's cloud-admin dashboard. Continues the "replacing bun with Rust-native
24//!   SSR" arc (R448 rolldown → R449 deno_core → R450 default-flip).
25//! - `ssr` — the SSR serving tier: the [`Server`] engine's V8 dispatch plus the
26//!   revalidate receiver. Links the *prebuilt* `librusty_v8.a` (a CDN download,
27//!   not a from-source compile).
28//! - `build` — adds the bundler (rolldown + lightningcss), the one genuinely
29//!   uncached from-source compile. Its own crate so a stray feature flip can't
30//!   drag it into a lean consumer.
31//!
32//! # What lives here vs. `mesofact-dev`
33//!
34//! The prod serving engine ([`server`], [`proxy`], and under `ssr` the `ssr`,
35//! `revalidate` and `tenants` modules) lives **here**. `mesofact-dev` holds only
36//! the dev affordances — the file watcher and the local S3 surface — and depends
37//! on this crate. That direction is load-bearing: it is what keeps a prod binary
38//! from linking dev code (W225 §2), and it is enforced by the dependency graph
39//! rather than by dead-stripping. Do not add a dev affordance to this crate, and
40//! do not add a prod-serving bin to `mesofact-dev`.
41//!
42//! Cache / session / resilience layers live in `mesofact_core::proxy` today and are
43//! bundle-shaped; lifting them here as caller-composable `tower::Layer`s is a
44//! follow-up once a second Rust-handler service needs them.
45//!
46//! @yah:relay(R445, "mesofact-app: lean Rust-native app harness for Rust-handler services (continues R448/R449/R450 'replacing bun with rust-native SSR' arc; dogfooded by yah parent R568-T4)")
47//! @yah:at(2026-06-30T07:22:46Z)
48//! @yah:status(review)
49//! @yah:assignee(agent:bundle-anthropic-ashguard)
50//! @arch:see(.yah/docs/working/W174-mesofact-rust-native-pipeline.md)
51//! @yah:next("yah parent camp R568-T4 consumes this via root [patch.crates-io] mesofact-app = { path = \"oss/mesofact/crates/mesofact-app\" } + a path-deferred version dep in crates/yah/cloud-admin.")
52//! @yah:next("Once a 2nd Rust-handler mesofact service exists, lift cache/session/resilience layers from mesofact::core::proxy::* into the mesofact facade as caller-composable tower::Layers (deferred per lib doc until 2nd consumer appears).")
53//! @yah:handoff("Landed mesofact-app crate (oss/mesofact/crates/mesofact-app, publish=false). Lean Rust-handler harness: pub HEALTH_PATH const + pub fn wrap(Router) -> Router (adds /__mesofact/health + tower-http TraceLayer) + pub async fn serve_app(Router, SocketAddr) -> Result<()> (binds, wraps, axum::serve with graceful Ctrl-C/SIGTERM) + pub async fn shutdown_signal. Companion to mesofact-dev: no mesofact-ssr/deno_core/V8 dep so pure-Rust services don't inherit the ~75MB V8 binary. Registered in oss/mesofact workspace members. 3 tests pass (health auto-add, serve_app round-trip, documented panic guard on duplicate health route). Continues R448/R449/R450 arc (replacing bun with Rust-native SSR) -- this is the next milestone after R449 swapped the engine, taking handlers from JS to Rust.")
54//! @yah:verify("cargo test -p mesofact-app  # 3 passed")
55//! @yah:gotcha("Tier: Cleric -- discovery+replicate. Mirrored mesofact-dev's health/shutdown_signal shape so probes are drop-in compatible across JS-bundle and Rust-handler services.")
56//! @yah:gotcha("wrap() panics if the caller already registered HEALTH_PATH (axum::Router::merge rejects overlapping method routes regardless of order). Constraint is documented + pinned by a should_panic test; richer-probe services must bypass wrap.")
57//! @yah:gotcha("RESOLVED 2026-07-23 (W225 §2a) -- was: 'mesofact-dev refactor to delegate its bind/serve to mesofact-app is deferred'. The whole serving engine moved INTO this crate and mesofact-dev now depends on it, so the delegation is structural rather than deferred. Server's router now uses this crate's HEALTH_PATH + default_health + shutdown_signal instead of the byte-identical copies it carried in mesofact-dev.")
58
59// ── Facade re-exports ────────────────────────────────────────────────────
60// Subsystems are namespaced (not glob-flattened) on purpose — consumers reach
61// them as `mesofact::core::…`, `mesofact::render::…`, etc. Each is gated on the
62// feature that pulls the corresponding crate (see Cargo.toml `[features]`).
63//
64// This started as a workaround for an axum-major skew (core/dev on 0.7, facade
65// on 0.8); that skew is now RESOLVED — everything is axum 0.8 — and the
66// namespacing is kept deliberately, bevy-style, so each subsystem keeps its own
67// namespace instead of flattening hundreds of items into the crate root.
68pub use mesofact_core as core;
69// NB: re-exported as `ssr_runtime`, not `ssr` — the `ssr` name at this crate
70// root belongs to the SSR *dispatch* module moved in from mesofact-dev below.
71// `ssr_runtime` is the raw deno_core/V8 runtime crate that dispatch drives.
72#[cfg(feature = "ssr")]
73pub use mesofact_ssr as ssr_runtime;
74#[cfg(feature = "render")]
75pub use mesofact_render as render;
76#[cfg(feature = "build")]
77pub use mesofact_build as build;
78#[cfg(feature = "publish")]
79pub use mesofact_publisher as publisher;
80
81// ── The serving engine (moved out of mesofact-dev, W225 §2a) ─────────────
82// These carry the prod serving path. They used to live in `mesofact-dev`, which
83// meant the prod `mesofact-serve` binary linked the dev crate — and therefore
84// the file watcher and the dev S3 surface — breaking the dev/prod crate
85// boundary W225 §2 claims. `mesofact-dev` now depends on THIS crate and holds
86// only `watcher` + `s3` + the dev bin, so that boundary finally holds.
87pub mod cache_headers;
88pub mod cli;
89pub mod health;
90pub mod proxy;
91pub mod route_headers;
92pub mod server;
93#[cfg(feature = "ssr")]
94pub mod revalidate;
95#[cfg(feature = "ssr")]
96pub mod ssr;
97#[cfg(feature = "ssr")]
98pub mod tenants;
99
100pub use cache_headers::CachePolicyTable;
101pub use health::{Health, LEGACY_HEALTH_PATH, LIVE_PATH, READY_PATH};
102pub use proxy::{ProxyMap, ProxyState};
103pub use route_headers::RouteHeaderTable;
104pub use server::{
105    declared_cache_policy, read_manifest_bytes, routes_declaring_ssr, routes_requiring_user,
106    DistPointer, Identity, Server, DEFAULT_PORT,
107};
108#[cfg(feature = "ssr")]
109pub use ssr::{
110    ResiliencePolicy, RetryPolicy, SpawnOptions as SsrSpawnOptions, SsrChild, SsrSlot,
111    DEFAULT_RESILIENCE_TIMEOUT_MS,
112};
113
114use std::{net::SocketAddr, sync::Arc, time::Duration};
115
116use anyhow::{Context, Result};
117use axum::Router;
118use tower_http::trace::TraceLayer;
119use tracing::{info, warn};
120
121/// The historical probe path, predating the `/livez` + `/readyz` split.
122///
123/// Kept, and kept meaning exactly what it always meant — **liveness**, 200 as
124/// soon as the process is serving — because deployed yubaba reconcilers point
125/// `ready_path` here and `Dockerfile.ssr-runtime` documents it. Its doc comment
126/// used to claim readiness; the check never did more than prove the socket was
127/// accepting, which is why [`health`] exists. New probes should target
128/// [`READY_PATH`] / [`LIVE_PATH`].
129pub const HEALTH_PATH: &str = "/__mesofact/health";
130
131/// How long `/readyz` reports failure before the listener stops accepting, on
132/// SIGTERM. Covers endpoint-removal propagation (kube-proxy, ingress, sidecar
133/// caches), which is concurrent with — not ordered before — the signal.
134///
135/// Override with `MESOFACT_DRAIN_GRACE_SECS`; `0` restores the old behavior of
136/// closing the listener immediately.
137pub const DEFAULT_DRAIN_GRACE: Duration = Duration::from_secs(5);
138
139/// Wrap a caller's [`Router`] with the standard mesofact middleware stack —
140/// the [`health`] probe routes, the `tower-http` trace layer, and nothing else
141/// magical. The caller keeps full control of the route table.
142///
143/// The returned health handle is already started and carries no readiness
144/// gates: a Rust-handler service is ready once its routes are mounted. Add
145/// gates with [`Health::set_gates`] if it depends on something that can be
146/// absent (a warmed cache, an upstream connection) — but only ever things
147/// whose remedy is "route traffic elsewhere", never things a restart would fix.
148///
149/// **Constraint:** the caller's router must NOT already register any probe
150/// path — `axum::Router::merge` panics on overlapping method routes regardless
151/// of merge order.
152pub fn wrap(app: Router) -> Router {
153    wrap_with(app, Health::ready())
154}
155
156/// [`wrap`] against a caller-owned [`Health`], for services that need to hold
157/// the handle — to flip gates as subsystems come up, or to drain on their own
158/// shutdown path.
159pub fn wrap_with(app: Router, health: Arc<Health>) -> Router {
160    health::probe_routes(health)
161        .merge(app)
162        .layer(TraceLayer::new_for_http())
163}
164
165/// Bind `addr`, wrap `app` with the standard stack via [`wrap`], and serve
166/// until Ctrl+C or SIGTERM — failing readiness for [`DEFAULT_DRAIN_GRACE`]
167/// before the listener closes.
168///
169/// Errors only on bind/listener failure; per-request errors are surfaced
170/// through axum's normal Response shape.
171pub async fn serve_app(app: Router, addr: SocketAddr) -> Result<()> {
172    let health = Health::ready();
173    let listener = tokio::net::TcpListener::bind(addr)
174        .await
175        .with_context(|| format!("binding {addr}"))?;
176    let local = listener.local_addr().context("reading bound addr")?;
177    info!(addr = %local, "mesofact-app listening");
178    axum::serve(listener, wrap_with(app, health.clone()))
179        .with_graceful_shutdown(shutdown_signal_for(health))
180        .await
181        .context("axum::serve")
182}
183
184/// Resolve when Ctrl+C or (on unix) SIGTERM is received.
185///
186/// Prefer [`shutdown_signal_for`] anywhere a [`Health`] handle exists: this
187/// form returns the instant the signal lands, so the listener closes while
188/// load balancers still believe the pod is in rotation.
189pub async fn shutdown_signal() {
190    let _ = wait_for_signal().await;
191}
192
193/// [`shutdown_signal`] with the readiness half of a graceful shutdown: flip
194/// `/readyz` to 503, hold for the drain grace, *then* resolve so axum closes
195/// the listener.
196///
197/// The hold applies to SIGTERM only. Ctrl+C is a human at a terminal in dev who
198/// wants the port back now, and no orchestrator is watching that process's
199/// probes.
200pub async fn shutdown_signal_for(health: Arc<Health>) {
201    let signal = wait_for_signal().await;
202    health.begin_drain();
203
204    let grace = match signal {
205        Signal::Terminate => drain_grace(),
206        Signal::Interrupt => Duration::ZERO,
207    };
208    if grace.is_zero() {
209        return;
210    }
211    info!(
212        grace_s = grace.as_secs_f64(),
213        "draining — /readyz now 503, listener closes after the grace window",
214    );
215    tokio::time::sleep(grace).await;
216}
217
218/// Which signal ended the process — they warrant different drain behavior.
219enum Signal {
220    /// SIGTERM: an orchestrator is rolling us; other components still route here.
221    Terminate,
222    /// Ctrl+C: a human in dev.
223    Interrupt,
224}
225
226fn drain_grace() -> Duration {
227    parse_grace(std::env::var("MESOFACT_DRAIN_GRACE_SECS").ok().as_deref())
228}
229
230/// Unset → the default. Set but unparseable → the default *and* a warning: a
231/// typo'd grace must not silently become zero, since that is exactly the
232/// connection-dropping behavior the operator was configuring away from.
233fn parse_grace(raw: Option<&str>) -> Duration {
234    let Some(raw) = raw else {
235        return DEFAULT_DRAIN_GRACE;
236    };
237    match raw.trim().parse::<f64>() {
238        Ok(secs) if secs.is_finite() && secs >= 0.0 => Duration::from_secs_f64(secs),
239        _ => {
240            warn!(%raw, "MESOFACT_DRAIN_GRACE_SECS is not a non-negative number — using default");
241            DEFAULT_DRAIN_GRACE
242        }
243    }
244}
245
246async fn wait_for_signal() -> Signal {
247    let ctrl_c = async {
248        if let Err(err) = tokio::signal::ctrl_c().await {
249            warn!(?err, "failed to install Ctrl+C handler");
250            // Never resolve: a broken handler must not look like a signal and
251            // shut the server down on its own.
252            std::future::pending::<()>().await;
253        }
254    };
255    #[cfg(unix)]
256    let terminate = async {
257        use tokio::signal::unix::{signal, SignalKind};
258        match signal(SignalKind::terminate()) {
259            Ok(mut s) => {
260                s.recv().await;
261            }
262            Err(err) => {
263                warn!(?err, "failed to install SIGTERM handler");
264                std::future::pending::<()>().await;
265            }
266        }
267    };
268    #[cfg(not(unix))]
269    let terminate = std::future::pending::<()>();
270
271    let signal = tokio::select! {
272        _ = ctrl_c => Signal::Interrupt,
273        _ = terminate => Signal::Terminate,
274    };
275    info!("shutdown signal received");
276    signal
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use axum::{response::Html, routing::get};
283
284    async fn home() -> Html<&'static str> {
285        Html("<h1>hello</h1>")
286    }
287
288    #[tokio::test]
289    async fn wrap_adds_health_to_caller_router() {
290        let app = wrap(Router::new().route("/", get(home)));
291        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
292        let addr = listener.local_addr().unwrap();
293        let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
294        tokio::task::yield_now().await;
295
296        let base = format!("http://{addr}");
297        let client = reqwest::Client::new();
298
299        for path in [HEALTH_PATH, LIVE_PATH, READY_PATH, LEGACY_HEALTH_PATH] {
300            let probe = client.get(format!("{base}{path}")).send().await.unwrap();
301            assert_eq!(probe.status(), 200, "{path}");
302        }
303
304        let home = client.get(format!("{base}/")).send().await.unwrap();
305        assert_eq!(home.status(), 200);
306        assert!(home.text().await.unwrap().contains("hello"));
307
308        handle.abort();
309    }
310
311    #[test]
312    #[should_panic(expected = "Overlapping method route")]
313    fn wrap_panics_if_caller_already_registered_health_path() {
314        // Pin the documented constraint: callers that pre-register
315        // HEALTH_PATH must not pass through wrap(). Acts as a regression
316        // guard if axum ever changes merge semantics.
317        let _ = wrap(Router::new().route(HEALTH_PATH, get(|| async { "x" })));
318    }
319
320    #[test]
321    fn drain_grace_falls_back_rather_than_to_zero() {
322        assert_eq!(parse_grace(None), DEFAULT_DRAIN_GRACE);
323        assert_eq!(parse_grace(Some(" 12 ")), Duration::from_secs(12));
324        assert_eq!(parse_grace(Some("0")), Duration::ZERO);
325        assert_eq!(parse_grace(Some("0.5")), Duration::from_millis(500));
326        // A typo must not read as "close the listener immediately".
327        assert_eq!(parse_grace(Some("5s")), DEFAULT_DRAIN_GRACE);
328        assert_eq!(parse_grace(Some("-1")), DEFAULT_DRAIN_GRACE);
329        assert_eq!(parse_grace(Some("")), DEFAULT_DRAIN_GRACE);
330        assert_eq!(parse_grace(Some("inf")), DEFAULT_DRAIN_GRACE);
331    }
332
333    #[tokio::test]
334    async fn serve_app_binds_and_responds() {
335        let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
336        // serve_app binds and never returns until shutdown; instead, drive
337        // it via wrap() + a manual bind so we can poll a real request.
338        let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
339        let local = listener.local_addr().unwrap();
340        let app = wrap(Router::new().route("/x", get(|| async { "x" })));
341        let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
342        tokio::task::yield_now().await;
343
344        let body = reqwest::get(format!("http://{local}/x"))
345            .await
346            .unwrap()
347            .text()
348            .await
349            .unwrap();
350        assert_eq!(body, "x");
351
352        handle.abort();
353    }
354}