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}