Skip to main content

mesofact_dev/
lib.rs

1//! `mesofact-dev` — the dev-tier affordances, and nothing else.
2//!
3//! **This crate is deliberately small.** The serving engine (`Server`, SSR
4//! dispatch, the revalidate receiver, tenants, the same-origin proxy) used to
5//! live here, which meant the *prod* `mesofact-serve` binary — which shipped
6//! from this crate — linked the file watcher and the dev S3 surface. That broke
7//! the dev/prod crate boundary W225 §2 relies on for its security claim
8//! ("prod is clean by construction … the crate boundary already keeps it out of
9//! prod"). It wasn't: cleanliness rested on linker dead-stripping.
10//!
11//! The engine now lives in the `mesofact` facade and this crate *depends on*
12//! it, holding only the pieces that must never reach a prod binary:
13//!
14//! - [`watcher`] — the rebuild-on-change file watcher.
15//! - [`s3`] — the local S3 surface that stands in for R2 during `dev`
16//!   (W225 §2 "local pond emulation").
17//! - [`app`] — the **library-tier** dev entry point, [`serve_app`]: the dev
18//!   counterpart of [`mesofact::serve_app`] for a consumer whose routes are
19//!   Rust handlers rather than a built `dist/` tree. Read its module doc for
20//!   what the dev half of that tier is and, just as load-bearing, what it
21//!   deliberately is not.
22//! - [`cli`] — the `mes` toolchain CLI, and the two bin targets over it.
23//!
24//! [`cli`] carries the prod verbs (`serve`, `publish`, `new`) as well as the
25//! dev ones, so consumers learn one CLI — but it gets them by *calling into*
26//! [`mesofact::cli`], which is the direction that costs the prod binary
27//! nothing. The boundary above is about what links into a binary, not about
28//! which verbs a binary spells.
29//!
30//! Engine types are re-exported below so existing `mesofact_dev::Server`-style
31//! callsites keep working; new code should prefer `mesofact::…` directly.
32//!
33//! @arch:see(.yah/docs/working/W225-mesofact-consumer-deployment-model.md)
34
35pub mod app;
36pub mod cli;
37pub mod s3;
38pub mod watcher;
39
40pub use app::{serve_app, DevServer, DEV_STATE_DIR};
41pub use s3::{DevS3, DEFAULT_BUCKET as DEV_S3_BUCKET};
42pub use watcher::{BuildDriver, WatchOptions, Watcher};
43
44// Engine re-exports — the serving path now lives in the `mesofact` facade.
45pub use mesofact::proxy;
46pub use mesofact::server;
47pub use mesofact::{DistPointer, Identity, ProxyMap, ProxyState, Server, DEFAULT_PORT};
48#[cfg(feature = "ssr")]
49pub use mesofact::{
50    revalidate, ssr, tenants, ResiliencePolicy, RetryPolicy, SsrChild, SsrSlot, SsrSpawnOptions,
51    DEFAULT_RESILIENCE_TIMEOUT_MS,
52};
53
54#[cfg(test)]
55mod tests {
56    //! Cross-boundary smoke: the dev S3 surface driven through the facade's
57    //! `Server`. This test is the reason it lives here rather than in the
58    //! facade — it needs BOTH the engine (facade) and `DevS3` (this crate), and
59    //! the dependency only points one way.
60
61    use super::*;
62    use axum::body::Body;
63    use axum::http::{Request, StatusCode};
64    use mesofact_publisher::ObjectStore;
65    use std::sync::Arc;
66    use tempfile::tempdir;
67    use tower::ServiceExt;
68
69    async fn body_string(response: axum::response::Response) -> String {
70        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
71            .await
72            .unwrap();
73        String::from_utf8(bytes.to_vec()).unwrap()
74    }
75
76    fn deferred_workload() -> tempfile::TempDir {
77        let dir = tempdir().unwrap();
78        let dist = dir.path().join("dist");
79        std::fs::create_dir_all(dist.join("html")).unwrap();
80        std::fs::write(
81            dist.join("manifest.json"),
82            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}}]}"#,
83        )
84        .unwrap();
85        dir
86    }
87
88    async fn flip_instance(store: &Arc<dyn ObjectStore>, key: &str, content_root: &str) {
89        use mesofact_publisher::{ObjectPointerStore, Pointer, PointerStore};
90        ObjectPointerStore::new(store.clone())
91            .flip(
92                key,
93                Pointer { content_root: content_root.into(), source_root: None, published_at: None },
94            )
95            .await
96            .unwrap();
97    }
98
99    async fn put_bytes(store: &Arc<dyn ObjectStore>, key: &str, body: &'static [u8]) {
100        use mesofact_publisher::PutOpts;
101        store
102            .put(
103                key,
104                axum::body::Bytes::from_static(body),
105                PutOpts { content_type: "text/html".into(), content_hash: "h".into(), cache_control: None },
106            )
107            .await
108            .unwrap();
109    }
110
111    /// Real-path smoke (W270 §9): resolve a deferred route through an
112    /// `mesofact_publisher::S3Store` pointed at the live dev-S3 surface — the
113    /// exact wiring `main.rs` uses. Proves the SigV4-signed requests are
114    /// accepted by the anonymous `s3s-fs` surface, so the local
115    /// `publish → view` loop resolves over real HTTP, not just the
116    /// InMemoryStore the facade's own tests use.
117    #[cfg(feature = "ssr")]
118    #[tokio::test]
119    async fn deferred_route_resolves_through_dev_s3_store() {
120        use mesofact_publisher::S3Store;
121
122        let dir = deferred_workload();
123        let dev = DevS3::start(dir.path().join("s3-surface"), DEV_S3_BUCKET)
124            .await
125            .unwrap();
126        let store: Arc<dyn ObjectStore> = Arc::new(
127            S3Store::new(dev.endpoint.clone(), dev.bucket.clone(), "auto", "dev", "dev").unwrap(),
128        );
129
130        // Publisher-side: flip the pointer + write the render-root bytes, both
131        // through the S3Store (the same store the server resolves against).
132        flip_instance(&store, "c/xyz", "content/xyz.html").await;
133        put_bytes(&store, "content/xyz.html", b"<h1>via dev s3</h1>").await;
134
135        let app = Server::from_workload(dir.path())
136            .unwrap()
137            .with_instance_store(store)
138            .router();
139        let response = app
140            .oneshot(Request::builder().uri("/c/xyz").body(Body::empty()).unwrap())
141            .await
142            .unwrap();
143        assert_eq!(response.status(), StatusCode::OK);
144        assert_eq!(
145            response.headers().get("cache-control").unwrap(),
146            "public, max-age=31536000, immutable"
147        );
148        assert!(body_string(response).await.contains("via dev s3"));
149    }
150
151    /// R444 end-to-end: a `mode:"ssr"` render handler that imports `r2` from
152    /// `@mesofact/runtime` and calls `.fetch(key)` resolves against the dev S3
153    /// surface from *inside the in-process V8 isolate* — the exact wiring
154    /// `main.rs` does (`DevS3::start` → `SsrSpawnOptions::with_env` →
155    /// `ssr::spawn`), proving the env + `[sources.r2]` plumbing reaches a real
156    /// request, not just a unit-level `resolve_r2_sources` call.
157    #[cfg(feature = "ssr")]
158    #[tokio::test]
159    async fn ssr_route_resolves_r2_source_against_dev_s3() {
160        let dir = tempdir().unwrap();
161        let dev = DevS3::start(dir.path().join("s3-surface"), DEV_S3_BUCKET)
162            .await
163            .unwrap();
164
165        // Seed the bucket directly over HTTP — the same anonymous PUT path
166        // s3.rs's own round-trip test exercises.
167        let put_url = format!("{}/{}/greeting.txt", dev.endpoint, dev.bucket);
168        let put = reqwest::Client::new()
169            .put(&put_url)
170            .body("hello from dev r2")
171            .send()
172            .await
173            .unwrap();
174        assert!(put.status().is_success(), "seed PUT status: {}", put.status());
175
176        std::fs::write(
177            dir.path().join("mesofact.config.toml"),
178            "[sources.assets]\nkind = \"r2\"\nbucket = \"dev\"\nendpoint_env = \"R2_ENDPOINT\"\naccess_key_id_env = \"R2_ACCESS_KEY_ID\"\nsecret_access_key_env = \"R2_SECRET_ACCESS_KEY\"\n",
179        )
180        .unwrap();
181
182        let server_dir = dir.path().join("dist/server");
183        std::fs::create_dir_all(&server_dir).unwrap();
184        std::fs::write(
185            server_dir.join("greet.js"),
186            "import { r2 } from \"@mesofact/runtime\";\n\
187             export default async function () {\n\
188               const bytes = await r2('assets').fetch('greeting.txt');\n\
189               const text = bytes ? new TextDecoder().decode(bytes) : null;\n\
190               return new Response(text ?? 'MISSING', { status: 200 });\n\
191             }\n",
192        )
193        .unwrap();
194        std::fs::write(
195            dir.path().join("dist/manifest.json"),
196            r#"{"routes": [{"route": "/greet", "mode": "ssr", "render_entrypoint": "dist/server/greet.js"}]}"#,
197        )
198        .unwrap();
199
200        let opts = ssr::SpawnOptions::new(
201            dir.path().to_path_buf(),
202            dir.path().join("dist"),
203            dir.path().join(".mesofact-dev"),
204        )
205        .with_env(dev.env_vars());
206        let child = ssr::spawn(opts).await.unwrap().expect("ssr present");
207
208        let resp = child
209            .dispatch(
210                "/greet",
211                mesofact::ssr_runtime::DispatchRequest {
212                    method: "GET".into(),
213                    url: "http://dev/greet".into(),
214                    headers: vec![],
215                    body: None,
216                },
217            )
218            .await
219            .unwrap();
220        assert_eq!(resp.status, 200);
221        assert_eq!(String::from_utf8(resp.body).unwrap(), "hello from dev r2");
222    }
223}