Skip to main content

mesofact_dev/
app.rs

1//! The **library-tier dev entry point** — [`serve_app`], the counterpart to
2//! [`mesofact::serve_app`] for a consumer whose routes are Rust handlers.
3//!
4//! This is the `mesofact_dev::serve(app::router())` that W225 §2's Consumer DX
5//! sketch promised and that nothing implemented (R832-T1). The two thin bin
6//! targets a library-tier project carries differ by one identifier:
7//!
8//! ```ignore
9//! // src/bin/yah-dashboard.rs      — the binary CI ships
10//! mesofact::serve_app(yah_dashboard::router(), addr).await
11//!
12//! // src/bin/yah-dashboard-dev.rs  — the binary you run locally
13//! mesofact_dev::serve_app(yah_dashboard::router(), addr).await
14//! ```
15//!
16//! # What the dev half of a library-tier consumer actually is
17//!
18//! R832-T1 existed because this was an open design question, and the answer is
19//! narrower than the standalone tier's dev binary. Recorded here rather than in
20//! a doc, because the next person to add a dev affordance needs it:
21//!
22//! **The watcher and live-reload do not carry over, and cannot.** Both are
23//! functions of a *built `dist/` tree*: [`crate::Watcher`] re-runs the bundler
24//! and rotates `dist` into `.mesofact-dev/gen-N`, and the SSR pool is
25//! re-spawned against the new generation. A library-tier consumer has no such
26//! tree — its routes are Rust functions, and the only edit that changes one is
27//! a `.rs` edit, which requires re-linking the very process that would have to
28//! perform the reload. No in-process affordance can close that loop. The loop
29//! is `cargo watch -x 'run --bin <name>-dev'`, and it lives outside the binary
30//! by necessity, not by omission.
31//!
32//! **The dev object store does carry over, and is the whole of the
33//! difference.** A handler that reads or writes R2 builds its store from
34//! environment coordinates. In prod those point at Cloudflare R2; in dev
35//! nothing points anywhere, so without this the consumer either stubs the store
36//! or runs a MinIO container — precisely the "local pond emulation" W225 §2
37//! puts in this crate to avoid. [`DevServer::start`] brings up the FS-backed
38//! [`DevS3`] surface, [`DevServer::export_env`] publishes its coordinates into
39//! this process's environment, and `.mesofact-dev/s3.json` carries them for
40//! out-of-process tooling (`aws s3 --endpoint-url …`, a test harness).
41//!
42//! So `mesofact_dev::serve_app` is `mesofact::serve_app` plus a local R2 and a
43//! banner. **The smallness is the finding, not a shortfall.** What the two-bin
44//! pattern buys is the link-graph boundary (W225 §2, "always-release,
45//! two-binary pattern") — prod clean by construction because its dependency
46//! closure cannot reach this crate — and that boundary is worth having on day
47//! one, when the dev side adds a single service. Every dev affordance added
48//! here later reaches consumers without any of them changing a line.
49//!
50//! Not implemented, and deliberately not stubbed: permissive CORS and verbose
51//! error overlays. W225 §2 lists both among the dev affordances, but neither
52//! exists anywhere in mesofact today (no `tower_http::cors` use in either
53//! crate), so there is nothing to lift — writing them here would be new
54//! product, not an entry point over existing behaviour.
55//!
56//! @arch:see(.yah/docs/working/W225-mesofact-consumer-deployment-model.md)
57
58use std::net::SocketAddr;
59use std::path::{Path, PathBuf};
60
61use anyhow::{Context, Result};
62use axum::Router;
63use tracing::{info, warn};
64
65use crate::{DevS3, DEV_S3_BUCKET};
66
67/// Directory, relative to a project root, that holds dev-only scratch state —
68/// the S3 surface's backing store and its discovery file. The standalone tier's
69/// `mes dev` uses the same name, so a project that starts standalone and grows
70/// a Rust half keeps one `.gitignore` line.
71pub const DEV_STATE_DIR: &str = ".mesofact-dev";
72
73/// The dev-tier ambient services, started and ready to serve a caller's
74/// [`Router`].
75///
76/// Prefer the one-call [`serve_app`] unless your `router()` reads the store
77/// coordinates *at construction time* — see [`serve_app`]'s ordering note.
78pub struct DevServer {
79    s3: DevS3,
80    state_dir: PathBuf,
81}
82
83impl DevServer {
84    /// Bring up the dev-tier services for a project rooted at `root`.
85    ///
86    /// Starts the local object store under `<root>/.mesofact-dev/s3` and writes
87    /// its coordinates to `<root>/.mesofact-dev/s3.json`. Does **not** touch
88    /// the process environment — call [`export_env`](Self::export_env) for
89    /// that, before building any router that reads it.
90    pub async fn start(root: impl AsRef<Path>) -> Result<Self> {
91        let root = root.as_ref();
92        // Canonicalize so the state dir does not move under a handler that
93        // changed the cwd, and so the logged path is the one on disk. A root
94        // that does not exist yet is left as given — `create_dir_all` below
95        // reports it far more clearly than `canonicalize` would.
96        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
97        let state_dir = root.join(DEV_STATE_DIR);
98        tokio::fs::create_dir_all(&state_dir)
99            .await
100            .with_context(|| format!("creating dev state dir {}", state_dir.display()))?;
101
102        let s3 = DevS3::start(state_dir.join("s3"), DEV_S3_BUCKET).await?;
103        info!(
104            endpoint = %s3.endpoint,
105            bucket = %s3.bucket,
106            "mesofact-dev: local object store ready (stands in for R2)",
107        );
108
109        // Discovery file for out-of-process tooling. Best-effort on purpose:
110        // a read-only project dir is a reason to log, not to refuse to serve.
111        let discovery = state_dir.join("s3.json");
112        if let Err(e) = std::fs::write(
113            &discovery,
114            serde_json::json!({ "endpoint": s3.endpoint, "bucket": s3.bucket }).to_string(),
115        ) {
116            warn!(error = %e, path = %discovery.display(), "dev S3: could not write discovery file");
117        }
118
119        Ok(Self { s3, state_dir })
120    }
121
122    /// Coordinates of the running local object store.
123    pub fn s3(&self) -> &DevS3 {
124        &self.s3
125    }
126
127    /// The `.mesofact-dev` directory backing this server's scratch state.
128    pub fn state_dir(&self) -> &Path {
129        &self.state_dir
130    }
131
132    /// Publish the store coordinates into this process's environment
133    /// (`R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`
134    /// — see [`DevS3::env_vars`]), so a handler that builds its store from env
135    /// resolves against the local bucket with no dev-specific code.
136    ///
137    /// Existing values are **overwritten**: a dev binary that left a real
138    /// `R2_ENDPOINT` in place would write to production from a laptop, which is
139    /// the one outcome this surface exists to make impossible. Anything already
140    /// set is logged so the override is never silent.
141    pub fn export_env(&self) {
142        for (key, value) in self.s3.env_vars() {
143            if let Ok(prior) = std::env::var(&key) {
144                if prior != value {
145                    warn!(%key, %prior, "mesofact-dev: overriding inherited value with the local store's");
146                }
147            }
148            std::env::set_var(&key, &value);
149        }
150    }
151
152    /// Serve `app` with the standard mesofact stack — the `/livez` + `/readyz`
153    /// probes, the trace layer, and graceful shutdown — until Ctrl+C or
154    /// SIGTERM, exactly as [`mesofact::serve_app`] does in prod.
155    pub async fn serve(self, app: Router, addr: SocketAddr) -> Result<()> {
156        warn!(
157            addr = %addr,
158            s3_endpoint = %self.s3.endpoint,
159            "mesofact-dev: DEV BINARY — dev affordances are linked in; do not ship this target",
160        );
161        mesofact::serve_app(app, addr).await
162    }
163}
164
165/// Start the dev-tier services, publish their coordinates into the environment,
166/// and serve `app` until Ctrl+C or SIGTERM. The dev counterpart of
167/// [`mesofact::serve_app`], and the whole body of a library-tier project's
168/// `src/bin/<name>-dev.rs`.
169///
170/// State lands under `./.mesofact-dev`, relative to the process's current
171/// directory — the project root, when run via `cargo run`.
172///
173/// **Ordering note.** `app` is already built by the time this is called, so a
174/// `router()` that constructs its object store *at construction time* has
175/// already read an unset `R2_ENDPOINT`. Handlers that build the store per
176/// request (or lazily) are unaffected. If yours reads env eagerly, drive the
177/// two halves in order instead:
178///
179/// ```ignore
180/// let dev = mesofact_dev::DevServer::start(".").await?;
181/// dev.export_env();
182/// dev.serve(yah_dashboard::router(), addr).await
183/// ```
184pub async fn serve_app(app: Router, addr: SocketAddr) -> Result<()> {
185    let dev = DevServer::start(std::env::current_dir().context("reading current directory")?)
186        .await?;
187    dev.export_env();
188    dev.serve(app, addr).await
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use axum::http::{Request, StatusCode};
195    use tempfile::tempdir;
196
197    #[tokio::test]
198    async fn start_creates_state_dir_and_discovery_file() {
199        let root = tempdir().unwrap();
200        let dev = DevServer::start(root.path()).await.unwrap();
201
202        assert_eq!(dev.state_dir(), dev.state_dir().canonicalize().unwrap());
203        assert!(dev.state_dir().ends_with(DEV_STATE_DIR));
204        assert!(dev.state_dir().join("s3").join(DEV_S3_BUCKET).is_dir());
205
206        let discovery: serde_json::Value = serde_json::from_slice(
207            &std::fs::read(dev.state_dir().join("s3.json")).expect("discovery file written"),
208        )
209        .unwrap();
210        assert_eq!(discovery["endpoint"], dev.s3().endpoint);
211        assert_eq!(discovery["bucket"], DEV_S3_BUCKET);
212    }
213
214    /// The point of the entry point: a plain Rust handler that builds its
215    /// object store the way a prod handler does — from `R2_*` env — resolves
216    /// against the local surface, with nothing dev-specific in the handler.
217    ///
218    /// Drives the router through the same [`mesofact::wrap`] stack
219    /// [`DevServer::serve`] serves it under, so the probe routes and the
220    /// caller's routes are proven to coexist rather than assumed to.
221    #[cfg(feature = "ssr")]
222    #[tokio::test]
223    async fn handler_reads_r2_from_env_against_the_dev_surface() {
224        use axum::body::Body;
225        use axum::routing::get;
226        use mesofact_publisher::{ObjectStore, S3Store};
227        use tower::ServiceExt;
228
229        let root = tempdir().unwrap();
230        let dev = DevServer::start(root.path()).await.unwrap();
231        dev.export_env();
232
233        // Seed the bucket over plain HTTP, as any out-of-process tool would.
234        let put = reqwest::Client::new()
235            .put(format!("{}/{}/greeting.txt", dev.s3().endpoint, dev.s3().bucket))
236            .body("hello from the dev store")
237            .send()
238            .await
239            .unwrap();
240        assert!(put.status().is_success(), "seed PUT status: {}", put.status());
241
242        // A handler with no knowledge of dev: env in, bytes out.
243        async fn greeting() -> String {
244            let store = S3Store::new(
245                std::env::var("R2_ENDPOINT").unwrap(),
246                std::env::var("R2_BUCKET").unwrap(),
247                "auto",
248                std::env::var("R2_ACCESS_KEY_ID").unwrap(),
249                std::env::var("R2_SECRET_ACCESS_KEY").unwrap(),
250            )
251            .unwrap();
252            let bytes = store.get("greeting.txt").await.unwrap().unwrap();
253            String::from_utf8(bytes.to_vec()).unwrap()
254        }
255
256        let app = mesofact::wrap(Router::new().route("/greeting", get(greeting)));
257
258        let response = app
259            .clone()
260            .oneshot(Request::builder().uri("/greeting").body(Body::empty()).unwrap())
261            .await
262            .unwrap();
263        assert_eq!(response.status(), StatusCode::OK);
264        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
265            .await
266            .unwrap();
267        assert_eq!(
268            String::from_utf8(body.to_vec()).unwrap(),
269            "hello from the dev store"
270        );
271
272        // …and the standard stack is still there around it.
273        let probe = app
274            .oneshot(
275                Request::builder()
276                    .uri(mesofact::LIVE_PATH)
277                    .body(Body::empty())
278                    .unwrap(),
279            )
280            .await
281            .unwrap();
282        assert_eq!(probe.status(), StatusCode::OK);
283    }
284}