1use crate::proxy::{ProxyMap, ProxyState};
103#[cfg(feature = "ssr")]
104use crate::ssr::{ResiliencePolicy, SsrChild, SsrSlot};
105
106#[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
148pub const DEFAULT_PORT: u16 = 4321;
150
151#[cfg(feature = "ssr")]
155const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
156
157#[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 pub fn current(&self) -> PathBuf {
174 self.inner.read().expect("dist pointer poisoned").clone()
175 }
176
177 pub fn set(&self, path: PathBuf) {
181 *self.inner.write().expect("dist pointer poisoned") = path;
182 }
183}
184
185#[derive(Clone, serde::Serialize)]
192pub struct Identity {
193 pub service: String,
194 pub component: String,
195}
196
197pub 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 identity: Option<Identity>,
211 #[cfg(feature = "ssr")]
217 instance_store: Option<Arc<dyn ObjectStore>>,
218 health: Arc<crate::Health>,
222 #[cfg(feature = "ssr")]
229 expects_ssr: bool,
230 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 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 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 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 pub fn pointer(&self) -> DistPointer {
340 self.pointer.clone()
341 }
342
343 pub fn dist_dir(&self) -> PathBuf {
346 self.pointer.current()
347 }
348
349 #[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 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 #[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 pub fn with_config_json(mut self, bytes: Vec<u8>) -> Self {
394 self.config_json = Some(Arc::new(bytes));
395 self
396 }
397
398 #[cfg(feature = "ssr")]
402 pub fn ssr_slot(&self) -> SsrSlot {
403 self.ssr.clone()
404 }
405
406 pub fn health(&self) -> Arc<crate::Health> {
409 self.health.clone()
410 }
411
412 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 pub fn without_standard_probes(mut self) -> Self {
461 self.standard_probes = false;
462 self
463 }
464
465 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 .route("/__mesofact/info", get(serve_info));
486 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 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 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 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 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 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 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 _ = 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#[cfg(feature = "ssr")]
681struct AppReadyCheck {
682 ssr: SsrSlot,
683}
684
685#[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 return true;
704 };
705 if !child.has_hook(READYZ_HOOK) {
706 return true;
707 }
708 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 Err(err) => {
725 warn!(?err, "app /readyz handler failed — reporting not ready");
726 false
727 }
728 }
729 })
730 }
731}
732
733#[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 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
776async 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
789async 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
802async 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
818async 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 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 if let Some(resp) = serve_static(&dist, &uri_path).await {
857 return resp;
858 }
859
860 #[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#[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 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 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
1064async 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 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 return Some(serve_error_page(dist, StatusCode::NOT_FOUND).await);
1086 }
1087
1088 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#[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 Ok(None) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1154 Err(_) => serve_error_page(dist, StatusCode::INTERNAL_SERVER_ERROR).await,
1155 },
1156 Ok(PointerState::Deleted) => serve_error_page(dist, StatusCode::GONE).await,
1158 Ok(PointerState::Absent) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1159 Err(PointerError::InvalidKey(..)) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1162 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#[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#[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
1229async 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 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
1272fn 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
1282async 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
1311pub 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
1370pub 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
1414fn 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
1432fn 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 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 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 #[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 #[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 #[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 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 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 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 #[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 assert_eq!(
1658 probe_status(server.router(), crate::READY_PATH).await,
1659 StatusCode::OK,
1660 );
1661
1662 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 #[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 #[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 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 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 #[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 .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 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 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 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 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 #[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 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 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 #[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 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 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 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 #[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 #[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 #[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 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 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 #[tokio::test]
2246 async fn serve_on_listener_adopts_the_given_socket() {
2247 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 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 let resp = reqwest::get(format!("http://127.0.0.1:{port}/")).await.unwrap();
2282 assert_eq!(resp.status(), 200);
2283
2284 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 assert!(!match_route_pattern("/c/:slug", "/c/abc/def"));
2505 assert!(!match_route_pattern("/c/:slug", "/c"));
2507 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 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 pointer.set(dist_b);
2550
2551 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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[cfg(feature = "ssr")]
2950 #[tokio::test]
2951 async fn resilience_per_attempt_timeout_aborts_slow_dispatch() {
2952 let workload = workload_with(&[]);
2953 let _ = workload;
2962 }
2965
2966 #[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 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 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 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 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 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 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}