1use crate::proxy::{ProxyMap, ProxyState};
103use crate::cache_headers::{apply_cache_policy, CachePolicyTable};
104use crate::route_headers::{apply_route_headers, RouteHeaderTable};
105#[cfg(feature = "ssr")]
106use crate::ssr::{ResiliencePolicy, SsrChild, SsrSlot};
107
108#[cfg(test)]
113use crate::proxy;
114#[cfg(all(test, feature = "ssr"))]
115use crate::{ssr, ssr::RetryPolicy};
116
117use std::{
118 net::{IpAddr, Ipv4Addr, SocketAddr},
119 path::{Path, PathBuf},
120 sync::{
121 atomic::{AtomicI64, AtomicU64, Ordering},
122 Arc, RwLock,
123 },
124 time::{Duration, SystemTime, UNIX_EPOCH},
125};
126
127#[cfg(feature = "ssr")]
128use std::time::Instant;
129
130#[cfg(feature = "ssr")]
131use axum::body::Body;
132use axum::{
133 extract::{Request, State},
134 http::{header, StatusCode},
135 middleware::Next,
136 response::{IntoResponse, Response},
137 routing::{any, get},
138 Router,
139};
140#[cfg(feature = "ssr")]
141use futures::StreamExt;
142#[cfg(feature = "ssr")]
143use mesofact_publisher::ObjectStore;
144#[cfg(feature = "ssr")]
145use mesofact_ssr::{DispatchRequest, DispatchResponse};
146use tower_http::trace::TraceLayer;
147use tracing::{info, warn};
148use yah_mesofact_bundle::BundleManifest;
149
150pub const DEFAULT_PORT: u16 = 4321;
152
153#[cfg(feature = "ssr")]
157const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
158
159#[derive(Clone)]
162pub struct DistPointer {
163 inner: Arc<RwLock<PathBuf>>,
164}
165
166impl DistPointer {
167 pub fn new(initial: PathBuf) -> Self {
168 Self {
169 inner: Arc::new(RwLock::new(initial)),
170 }
171 }
172
173 pub fn current(&self) -> PathBuf {
176 self.inner.read().expect("dist pointer poisoned").clone()
177 }
178
179 pub fn set(&self, path: PathBuf) {
183 *self.inner.write().expect("dist pointer poisoned") = path;
184 }
185}
186
187#[derive(Clone, serde::Serialize)]
194pub struct Identity {
195 pub service: String,
196 pub component: String,
197}
198
199pub struct Server {
201 workload: PathBuf,
202 pointer: DistPointer,
203 #[cfg(feature = "ssr")]
204 ssr: SsrSlot,
205 proxy: Option<ProxyState>,
206 config_json: Option<Arc<Vec<u8>>>,
207 identity: Option<Identity>,
213 #[cfg(feature = "ssr")]
219 instance_store: Option<Arc<dyn ObjectStore>>,
220 health: Arc<crate::Health>,
224 #[cfg(feature = "ssr")]
231 expects_ssr: bool,
232 standard_probes: bool,
235 route_headers: Arc<RouteHeaderTable>,
241 cache_policy: Arc<CachePolicyTable>,
246}
247
248#[derive(Clone)]
249struct ServerState {
250 pointer: DistPointer,
251 #[cfg(feature = "ssr")]
252 ssr: SsrSlot,
253 proxy: Option<ProxyState>,
254 config_json: Option<Arc<Vec<u8>>>,
255 identity: Option<Arc<Identity>>,
256 #[cfg(feature = "ssr")]
257 instance_store: Option<Arc<dyn ObjectStore>>,
258}
259
260impl Server {
261 pub fn from_workload(workload: impl Into<PathBuf>) -> anyhow::Result<Self> {
264 let workload = workload.into();
265 if !workload.is_dir() {
266 anyhow::bail!("workload directory not found: {}", workload.display());
267 }
268 let pointer = DistPointer::new(workload.join("dist").join("html"));
269 Ok(Self {
270 workload,
271 pointer,
272 #[cfg(feature = "ssr")]
273 ssr: SsrSlot::new(),
274 proxy: None,
275 config_json: None,
276 identity: None,
277 #[cfg(feature = "ssr")]
278 instance_store: None,
279 health: crate::Health::new(),
280 #[cfg(feature = "ssr")]
281 expects_ssr: false,
282 standard_probes: true,
283 route_headers: Arc::new(RouteHeaderTable::default()),
284 cache_policy: Arc::new(CachePolicyTable::default()),
285 })
286 }
287
288 pub fn from_bundle(bundle: impl Into<PathBuf>) -> anyhow::Result<Self> {
308 let bundle = bundle.into();
309 let manifest_path = bundle.join("manifest.toml");
310 let raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
311 anyhow::anyhow!(
312 "not a mesofact bundle — reading {}: {e}",
313 manifest_path.display()
314 )
315 })?;
316 let manifest = BundleManifest::from_toml_str(&raw)
317 .map_err(|e| anyhow::anyhow!("invalid bundle manifest {}: {e}", manifest_path.display()))?;
318 if manifest.runtime.is_self_contained() {
319 warn!(
320 bundle = %manifest.name,
321 "bundle declares runtime=\"self\" (carries bins/<triple>/serve) — the stock \
322 `mesofact serve` serves its static tree but does not execute its custom runtime",
323 );
324 }
325 let app = bundle.join("app");
326 let server = Self::from_workload(&app).map_err(|e| {
327 anyhow::anyhow!("bundle {} has no servable app tree: {e}", manifest.name)
328 })?;
329 info!(
330 bundle = %manifest.name,
331 runtime = %manifest.runtime.as_wire(),
332 app = %app.display(),
333 "serving mesofact bundle (static v0)",
334 );
335 Ok(server)
336 }
337
338 pub fn with_identity(mut self, service: impl Into<String>, component: impl Into<String>) -> Self {
341 self.identity = Some(Identity {
342 service: service.into(),
343 component: component.into(),
344 });
345 self
346 }
347
348 pub fn with_route_headers(mut self, table: RouteHeaderTable) -> Self {
359 self.route_headers = Arc::new(table);
360 self
361 }
362
363 pub fn with_cache_policy(mut self, table: CachePolicyTable) -> Self {
371 self.cache_policy = Arc::new(table);
372 self
373 }
374
375 pub fn workload(&self) -> &Path {
376 &self.workload
377 }
378
379 pub fn pointer(&self) -> DistPointer {
382 self.pointer.clone()
383 }
384
385 pub fn dist_dir(&self) -> PathBuf {
388 self.pointer.current()
389 }
390
391 #[cfg(feature = "ssr")]
399 pub fn with_ssr(mut self, ssr: SsrChild) -> Self {
400 self.ssr.set(Some(Arc::new(ssr)));
401 self.expects_ssr = true;
402 self
403 }
404
405 pub fn with_proxy(mut self, map: ProxyMap) -> Self {
411 if !map.is_empty() {
412 self.proxy = Some(ProxyState::new(map));
413 }
414 self
415 }
416
417 #[cfg(feature = "ssr")]
424 pub fn with_instance_store(mut self, store: Arc<dyn ObjectStore>) -> Self {
425 self.instance_store = Some(store);
426 self
427 }
428
429 pub fn with_config_json(mut self, bytes: Vec<u8>) -> Self {
436 self.config_json = Some(Arc::new(bytes));
437 self
438 }
439
440 #[cfg(feature = "ssr")]
444 pub fn ssr_slot(&self) -> SsrSlot {
445 self.ssr.clone()
446 }
447
448 pub fn health(&self) -> Arc<crate::Health> {
451 self.health.clone()
452 }
453
454 fn install_gates(&self) {
470 let mut checks: Vec<Arc<dyn crate::health::ReadyCheck>> = Vec::new();
471
472 #[cfg(feature = "ssr")]
473 if self.expects_ssr {
474 let ssr = self.ssr.clone();
475 checks.push(Arc::new(crate::health::Gate::new("ssr", move || {
476 ssr.current().is_some()
477 })));
478 checks.push(Arc::new(AppReadyCheck {
479 ssr: self.ssr.clone(),
480 }));
481 }
482 if checks.is_empty() {
483 let pointer = self.pointer.clone();
484 checks.push(Arc::new(crate::health::Gate::new("dist", move || {
485 pointer.current().exists()
486 })));
487 }
488 self.health.set_checks(checks);
489 }
490
491 pub fn without_standard_probes(mut self) -> Self {
503 self.standard_probes = false;
504 self
505 }
506
507 pub fn router(&self) -> Router {
510 self.install_gates();
511 let state = ServerState {
512 pointer: self.pointer.clone(),
513 #[cfg(feature = "ssr")]
514 ssr: self.ssr.clone(),
515 proxy: self.proxy.clone(),
516 config_json: self.config_json.clone(),
517 identity: self.identity.clone().map(Arc::new),
518 #[cfg(feature = "ssr")]
519 instance_store: self.instance_store.clone(),
520 };
521 let mut router = Router::new()
522 .route("/__mesofact/info", get(serve_info));
528 if state.config_json.is_some() {
533 router = router.route("/config.json", get(serve_config_json));
534 }
535 let router = router
536 .route("/", any(serve_dynamic))
537 .route("/{*path}", any(serve_dynamic))
538 .with_state(state);
539
540 let router = if self.standard_probes {
560 router.merge(crate::health::probe_routes(self.health.clone()))
561 } else {
562 router
563 };
564 router
578 .layer(axum::middleware::from_fn_with_state(
579 self.cache_policy.clone(),
580 apply_cache_policy,
581 ))
582 .layer(TraceLayer::new_for_http())
583 .layer(axum::middleware::from_fn_with_state(
584 self.route_headers.clone(),
585 apply_route_headers,
586 ))
587 }
588
589 pub async fn serve(self, port: u16) -> anyhow::Result<()> {
593 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
594 self.serve_on(addr).await
595 }
596
597 pub async fn serve_on(self, addr: SocketAddr) -> anyhow::Result<()> {
602 let dist = self.pointer.current();
603 if !dist.exists() {
604 warn!(
605 dist = %dist.display(),
606 "served dir missing — run `bun run build` or start a watcher; 404s until it appears",
607 );
608 }
609 let listener = tokio::net::TcpListener::bind(addr).await?;
610 self.serve_on_listener(listener, None).await
611 }
612
613 pub async fn serve_on_listener(
631 self,
632 listener: tokio::net::TcpListener,
633 idle_ttl: Option<Duration>,
634 ) -> anyhow::Result<()> {
635 let local = listener.local_addr()?;
636 let idle = Arc::new(IdleTracker::default());
637 idle.touch();
638
639 let mut app = self.router();
640 if idle_ttl.is_some() {
641 app = app.layer(axum::middleware::from_fn_with_state(
644 idle.clone(),
645 track_activity,
646 ));
647 }
648 info!(
649 addr = %local,
650 workload = %self.workload.display(),
651 idle_ttl_s = idle_ttl.map(|d| d.as_secs_f64()),
652 "mesofact-dev listening",
653 );
654
655 self.health.mark_started();
657
658 let shutdown = {
659 let idle = idle.clone();
660 let health = self.health.clone();
661 async move {
662 match idle_ttl {
663 Some(ttl) => {
664 tokio::select! {
665 _ = crate::shutdown_signal_for(health.clone()) => {}
666 _ = idle_reaper(idle, ttl) => {
673 health.begin_drain();
674 info!(idle_ttl_s = ttl.as_secs_f64(), "idle TTL elapsed — self-reaping (JIT)");
675 }
676 }
677 }
678 None => crate::shutdown_signal_for(health).await,
679 }
680 }
681 };
682
683 axum::serve(listener, app)
684 .with_graceful_shutdown(shutdown)
685 .await?;
686 Ok(())
687 }
688}
689
690#[cfg(feature = "ssr")]
745struct AppReadyCheck {
746 ssr: SsrSlot,
747}
748
749#[cfg(feature = "ssr")]
752const READYZ_HOOK: &str = "readyz";
753
754#[cfg(feature = "ssr")]
755impl crate::health::ReadyCheck for AppReadyCheck {
756 fn name(&self) -> &'static str {
757 "app"
758 }
759
760 fn ready(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
761 let slot = self.ssr.clone();
762 Box::pin(async move {
763 let Some(child) = slot.current() else {
764 return true;
768 };
769 if !child.has_hook(READYZ_HOOK) {
770 return true;
771 }
772 let input = serde_json::json!({
778 "method": "GET",
779 "url": format!("http://localhost{}", crate::READY_PATH),
780 });
781 match child.invoke_hook(READYZ_HOOK, input).await {
782 Ok(verdict) => verdict
783 .get("status")
784 .and_then(|s| s.as_u64())
785 .is_some_and(|status| (200..300).contains(&status)),
786 Err(err) => {
789 warn!(?err, "app /readyz handler failed — reporting not ready");
790 false
791 }
792 }
793 })
794 }
795}
796
797#[derive(Default)]
802struct IdleTracker {
803 inflight: AtomicI64,
804 last_active_ms: AtomicU64,
805}
806
807impl IdleTracker {
808 fn touch(&self) {
809 self.last_active_ms.store(now_ms(), Ordering::Relaxed);
810 }
811
812 fn enter(&self) {
813 self.inflight.fetch_add(1, Ordering::Relaxed);
814 self.touch();
815 }
816
817 fn leave(&self) {
818 self.inflight.fetch_sub(1, Ordering::Relaxed);
819 self.touch();
820 }
821
822 fn idle_for(&self) -> Option<Duration> {
825 if self.inflight.load(Ordering::Relaxed) > 0 {
826 return None;
827 }
828 let last = self.last_active_ms.load(Ordering::Relaxed);
829 Some(Duration::from_millis(now_ms().saturating_sub(last)))
830 }
831}
832
833fn now_ms() -> u64 {
834 SystemTime::now()
835 .duration_since(UNIX_EPOCH)
836 .unwrap_or_default()
837 .as_millis() as u64
838}
839
840async fn track_activity(
843 State(idle): State<Arc<IdleTracker>>,
844 req: Request,
845 next: Next,
846) -> Response {
847 idle.enter();
848 let resp = next.run(req).await;
849 idle.leave();
850 resp
851}
852
853async fn idle_reaper(idle: Arc<IdleTracker>, ttl: Duration) {
857 let tick = (ttl / 4).max(Duration::from_millis(200));
858 loop {
859 tokio::time::sleep(tick).await;
860 if idle.idle_for().is_some_and(|d| d >= ttl) {
861 return;
862 }
863 }
864}
865
866async fn serve_info(State(state): State<ServerState>) -> Response {
871 match state.identity {
872 Some(identity) => (
873 StatusCode::OK,
874 [(header::CONTENT_TYPE, "application/json")],
875 serde_json::to_vec(&*identity).unwrap_or_default(),
876 )
877 .into_response(),
878 None => StatusCode::NOT_FOUND.into_response(),
879 }
880}
881
882async fn serve_config_json(State(state): State<ServerState>) -> Response {
886 match state.config_json {
887 Some(bytes) => (
888 StatusCode::OK,
889 [(header::CONTENT_TYPE, "application/json")],
890 bytes.to_vec(),
891 )
892 .into_response(),
893 None => StatusCode::NOT_FOUND.into_response(),
894 }
895}
896
897async fn serve_dynamic(State(state): State<ServerState>, req: Request) -> Response {
898 let uri_path = req.uri().path().to_string();
899 #[cfg(feature = "ssr")]
900 if let Some(ssr) = state.ssr.current() {
901 if ssr.matches(&uri_path) {
902 let policy = ssr.policy_for(&uri_path);
903 return dispatch_to_ssr(ssr, policy, req).await;
904 }
905 }
906 if let Some(proxy) = &state.proxy {
911 if let Some(base) = proxy.map().match_base(&uri_path) {
912 let base = base.to_string();
913 return proxy.forward(&base, req).await;
914 }
915 }
916 let dist = state.pointer.current();
917
918 if let Some(resp) = serve_static(&dist, &uri_path).await {
921 return resp;
922 }
923
924 #[cfg(feature = "ssr")]
930 if let Some(store) = &state.instance_store {
931 if let Some(resp) = serve_instance(&dist, &uri_path, store.clone()).await {
932 return resp;
933 }
934 }
935
936 serve_error_page(&dist, StatusCode::NOT_FOUND).await
937}
938
939#[cfg(feature = "ssr")]
944async fn dispatch_to_ssr(
945 ssr: Arc<SsrChild>,
946 policy: Option<ResiliencePolicy>,
947 req: Request,
948) -> Response {
949 let (parts, body) = req.into_parts();
950 let route_path = parts.uri.path().to_string();
951 let path_and_query = parts
952 .uri
953 .path_and_query()
954 .map(|p| p.as_str())
955 .unwrap_or(parts.uri.path())
956 .to_string();
957 let method = parts.method.as_str().to_uppercase();
958
959 let headers: Vec<(String, String)> = parts
960 .headers
961 .iter()
962 .filter_map(|(k, v)| {
963 let name = k.as_str().to_ascii_lowercase();
967 if matches!(
968 name.as_str(),
969 "connection"
970 | "keep-alive"
971 | "proxy-authenticate"
972 | "proxy-authorization"
973 | "te"
974 | "trailer"
975 | "transfer-encoding"
976 | "upgrade"
977 | "host"
978 | "content-length"
979 ) {
980 return None;
981 }
982 v.to_str().ok().map(|s| (k.as_str().to_string(), s.to_string()))
983 })
984 .collect();
985
986 let body_bytes = if matches!(method.as_str(), "GET" | "HEAD") {
987 None
988 } else {
989 match collect_body(body.into_data_stream()).await {
990 Ok(b) if b.is_empty() => None,
991 Ok(b) => Some(b),
992 Err(e) => {
993 warn!(error = %e, "failed to buffer SSR request body");
994 return (StatusCode::BAD_GATEWAY, "request buffer failed").into_response();
995 }
996 }
997 };
998
999 let dispatch_url = format!("http://dev{path_and_query}");
1003
1004 let retry = policy.as_ref().and_then(|p| p.retry.as_ref());
1005 let attempts = retry.map(|r| r.attempts.max(1)).unwrap_or(1);
1006 let backoff_ms = retry.map(|r| r.backoff_ms.clone()).unwrap_or_default();
1007 let retry_on: String = retry
1008 .and_then(|r| r.retry_on.clone())
1009 .unwrap_or_else(|| "connection".to_string());
1010 let budget_ms = retry.and_then(|r| r.budget_ms);
1011 let timeout_ms = policy.as_ref().and_then(|p| p.timeout_ms);
1012 let start = Instant::now();
1013
1014 let mut last_resp: Option<DispatchResponse> = None;
1015 let mut last_err: Option<anyhow::Error> = None;
1016
1017 for attempt in 0..attempts {
1018 if attempt > 0 {
1019 let gap = backoff_ms.get((attempt - 1) as usize).copied().unwrap_or(0);
1020 if gap > 0 {
1021 tokio::time::sleep(Duration::from_millis(gap)).await;
1022 }
1023 if let Some(budget) = budget_ms {
1024 if start.elapsed() >= Duration::from_millis(budget) {
1025 break;
1026 }
1027 }
1028 }
1029 let req = DispatchRequest {
1030 method: method.clone(),
1031 url: dispatch_url.clone(),
1032 headers: headers.clone(),
1033 body: body_bytes.clone(),
1034 };
1035 let call = ssr.dispatch(&route_path, req);
1036 let outcome = match timeout_ms {
1037 Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), call).await {
1038 Ok(r) => r,
1039 Err(_) => Err(anyhow::anyhow!("ssr dispatch timed out after {ms}ms")),
1040 },
1041 None => call.await,
1042 };
1043 match outcome {
1044 Ok(r) => {
1045 if should_retry_status(r.status, &retry_on) && attempt + 1 < attempts {
1046 last_resp = Some(r);
1047 continue;
1048 }
1049 emit_telemetry(&route_path, attempt + 1, "ok", start.elapsed());
1050 return forward_response(r);
1051 }
1052 Err(e) => {
1053 warn!(error = %e, attempt = attempt + 1, "ssr dispatch attempt failed");
1054 last_err = Some(e);
1055 }
1056 }
1057 }
1058
1059 let latency = start.elapsed();
1060 if let Some(r) = last_resp {
1061 emit_telemetry(&route_path, attempts, "exhausted_5xx", latency);
1062 return forward_response(r);
1063 }
1064 emit_telemetry(&route_path, attempts, "exhausted_connection", latency);
1065 let msg = last_err
1066 .map(|e| format!("ssr dispatch failed: {e}"))
1067 .unwrap_or_else(|| "ssr dispatch failed".to_string());
1068 (StatusCode::BAD_GATEWAY, msg).into_response()
1069}
1070
1071#[cfg(feature = "ssr")]
1072fn should_retry_status(status: u16, retry_on: &str) -> bool {
1073 match retry_on {
1074 "any" => status >= 400,
1075 "5xx" => status >= 500,
1076 _ => false,
1077 }
1078}
1079
1080#[cfg(feature = "ssr")]
1081async fn collect_body(mut stream: axum::body::BodyDataStream) -> Result<Vec<u8>, axum::Error> {
1082 let mut buf = Vec::new();
1083 while let Some(chunk) = stream.next().await {
1084 let bytes = chunk?;
1085 buf.extend_from_slice(&bytes);
1086 }
1087 Ok(buf)
1088}
1089
1090#[cfg(feature = "ssr")]
1091fn emit_telemetry(route: &str, attempts: u32, outcome: &str, latency: Duration) {
1092 info!(
1093 target: "mesofact_dev::resilience",
1094 route = route,
1095 attempts = attempts,
1096 outcome = outcome,
1097 latency_ms = latency.as_millis() as u64,
1098 "ssr dispatch outcome",
1099 );
1100}
1101
1102#[cfg(feature = "ssr")]
1103fn forward_response(resp: DispatchResponse) -> Response {
1104 let status = StatusCode::from_u16(resp.status).unwrap_or(StatusCode::BAD_GATEWAY);
1105 let mut builder = Response::builder().status(status);
1106 for (k, v) in resp.headers {
1107 let name = k.to_ascii_lowercase();
1108 if matches!(
1109 name.as_str(),
1110 "connection"
1111 | "keep-alive"
1112 | "proxy-authenticate"
1113 | "proxy-authorization"
1114 | "te"
1115 | "trailer"
1116 | "transfer-encoding"
1117 | "upgrade"
1118 ) {
1119 continue;
1120 }
1121 builder = builder.header(k, v);
1122 }
1123 builder
1124 .body(Body::from(resp.body))
1125 .unwrap_or_else(|_| (StatusCode::BAD_GATEWAY, "response build failed").into_response())
1126}
1127
1128async fn serve_static(dist: &Path, uri_path: &str) -> Option<Response> {
1133 let Some(rel) = sanitize(uri_path) else {
1134 return Some((StatusCode::BAD_REQUEST, "invalid path").into_response());
1135 };
1136
1137 if let Some(hydrate_rel) = hydrate_suffix(&rel) {
1141 let hydrate_dir = dist.parent().unwrap_or(dist).join("hydrate");
1142 let target = hydrate_dir.join(&hydrate_rel);
1143 if let Ok(bytes) = tokio::fs::read(&target).await {
1144 let mime = mime_for(&target);
1145 return Some(([(header::CONTENT_TYPE, mime)], bytes).into_response());
1146 }
1147 return Some(serve_error_page(dist, StatusCode::NOT_FOUND).await);
1150 }
1151
1152 let base = if rel.as_os_str().is_empty() {
1164 dist.join("index.html")
1165 } else {
1166 dist.join(&rel)
1167 };
1168 let mut candidates = vec![base.clone()];
1169 if base.extension().is_none() {
1170 candidates.push(base.with_extension("html"));
1171 candidates.push(base.join("index.html"));
1172 }
1173 for target in &candidates {
1174 if let Ok(bytes) = tokio::fs::read(target).await {
1175 let mime = mime_for(target);
1176 return Some(([(header::CONTENT_TYPE, mime)], bytes).into_response());
1177 }
1178 }
1179
1180 None
1181}
1182
1183#[cfg(feature = "ssr")]
1192async fn serve_instance(
1193 dist: &Path,
1194 uri_path: &str,
1195 store: Arc<dyn ObjectStore>,
1196) -> Option<Response> {
1197 use mesofact_publisher::{ObjectPointerStore, PointerError, PointerState, PointerStore};
1198
1199 if !matches_deferred_route(dist, uri_path).await {
1200 return None;
1201 }
1202
1203 let key = uri_path.trim_start_matches('/');
1204 let pointers = ObjectPointerStore::new(store.clone());
1205 let resp = match pointers.resolve(key).await {
1206 Ok(PointerState::Present(ptr)) => match store.get(&ptr.content_root).await {
1207 Ok(Some(bytes)) => (
1208 StatusCode::OK,
1209 [
1210 (header::CONTENT_TYPE, mime_for(Path::new(&ptr.content_root))),
1211 (header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL),
1212 ],
1213 bytes.to_vec(),
1214 )
1215 .into_response(),
1216 Ok(None) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1218 Err(_) => serve_error_page(dist, StatusCode::INTERNAL_SERVER_ERROR).await,
1219 },
1220 Ok(PointerState::Deleted) => serve_error_page(dist, StatusCode::GONE).await,
1222 Ok(PointerState::Absent) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1223 Err(PointerError::InvalidKey(..)) => serve_error_page(dist, StatusCode::NOT_FOUND).await,
1226 Err(e) => {
1228 warn!(key, error = %e, "instance pointer resolve failed");
1229 serve_error_page(dist, StatusCode::INTERNAL_SERVER_ERROR).await
1230 }
1231 };
1232 Some(resp)
1233}
1234
1235#[cfg(feature = "ssr")]
1240async fn matches_deferred_route(dist: &Path, uri_path: &str) -> bool {
1241 #[derive(serde::Deserialize)]
1242 struct RoutesSlice {
1243 #[serde(default)]
1244 routes: Vec<RouteSlice>,
1245 }
1246 #[derive(serde::Deserialize)]
1247 struct RouteSlice {
1248 route: String,
1249 #[serde(default)]
1250 prerender: Option<PrerenderSlice>,
1251 }
1252 #[derive(serde::Deserialize)]
1253 struct PrerenderSlice {
1254 #[serde(default)]
1255 deferred: Option<bool>,
1256 }
1257
1258 let Some(dir) = dist.parent() else {
1259 return false;
1260 };
1261 let Ok(bytes) = tokio::fs::read(dir.join("manifest.json")).await else {
1262 return false;
1263 };
1264 let Ok(manifest) = serde_json::from_slice::<RoutesSlice>(&bytes) else {
1265 return false;
1266 };
1267 manifest.routes.iter().any(|r| {
1268 r.prerender.as_ref().and_then(|p| p.deferred).unwrap_or(false)
1269 && match_route_pattern(&r.route, uri_path)
1270 })
1271}
1272
1273#[cfg(feature = "ssr")]
1281use crate::cache_headers::match_route_pattern;
1282
1283async fn serve_error_page(dist: &Path, status: StatusCode) -> Response {
1293 let is_server_error = status.is_server_error();
1294 let mut candidates: Vec<PathBuf> = Vec::new();
1295 if let Some(route) = read_error_route(dist, is_server_error).await {
1296 let rel = route.trim_start_matches('/');
1297 if rel.is_empty() {
1298 candidates.push(PathBuf::from("index.html"));
1299 } else if rel.rsplit('/').next().is_some_and(|s| s.contains('.')) {
1300 candidates.push(PathBuf::from(rel));
1301 } else {
1302 candidates.push(PathBuf::from(format!("{rel}.html")));
1303 candidates.push(PathBuf::from(rel).join("index.html"));
1304 }
1305 }
1306 if !is_server_error {
1307 candidates.push(PathBuf::from("404.html"));
1311 }
1312
1313 for cand in &candidates {
1314 if let Ok(bytes) = tokio::fs::read(dist.join(cand)).await {
1315 return (
1316 status,
1317 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1318 bytes,
1319 )
1320 .into_response();
1321 }
1322 }
1323 (status, default_status_text(status)).into_response()
1324}
1325
1326fn default_status_text(status: StatusCode) -> &'static str {
1329 match status {
1330 StatusCode::GONE => "Gone",
1331 s if s.is_server_error() => "Internal Server Error",
1332 _ => "Not Found",
1333 }
1334}
1335
1336async fn read_error_route(dist: &Path, server_error: bool) -> Option<String> {
1342 #[derive(serde::Deserialize)]
1343 struct ManifestSlice {
1344 error_routes: Option<ErrorRoutesSlice>,
1345 }
1346 #[derive(serde::Deserialize)]
1347 struct ErrorRoutesSlice {
1348 #[serde(rename = "404")]
1349 not_found: Option<String>,
1350 #[serde(rename = "5xx")]
1351 server_error: Option<String>,
1352 }
1353
1354 let manifest_path = dist.parent()?.join("manifest.json");
1355 let bytes = tokio::fs::read(&manifest_path).await.ok()?;
1356 let manifest: ManifestSlice = serde_json::from_slice(&bytes).ok()?;
1357 let routes = manifest.error_routes?;
1358 if server_error {
1359 routes.server_error
1360 } else {
1361 routes.not_found
1362 }
1363}
1364
1365pub fn read_manifest_bytes(workload: &Path) -> std::io::Result<Option<Vec<u8>>> {
1372 let manifest_path = workload.join("dist").join("manifest.json");
1373 match std::fs::read(&manifest_path) {
1374 Ok(b) => Ok(Some(b)),
1375 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1376 Err(e) => Err(e),
1377 }
1378}
1379
1380pub fn declared_cache_policy(workload: &Path) -> std::io::Result<CachePolicyTable> {
1384 let Some(bytes) = read_manifest_bytes(workload)? else {
1385 return Ok(CachePolicyTable::default());
1386 };
1387 CachePolicyTable::from_manifest_json(&bytes).map_err(|e| {
1388 std::io::Error::new(
1389 std::io::ErrorKind::InvalidData,
1390 format!(
1391 "parsing {}: {e}",
1392 workload.join("dist").join("manifest.json").display()
1393 ),
1394 )
1395 })
1396}
1397
1398pub fn routes_requiring_user(workload: &Path) -> std::io::Result<Vec<String>> {
1418 #[derive(serde::Deserialize)]
1419 struct ManifestSlice {
1420 #[serde(default)]
1421 routes: Vec<RouteSlice>,
1422 }
1423 #[derive(serde::Deserialize)]
1424 struct RouteSlice {
1425 route: String,
1426 #[serde(default)]
1427 requires: Option<Vec<String>>,
1428 }
1429
1430 let manifest_path = workload.join("dist").join("manifest.json");
1431 let bytes = match std::fs::read(&manifest_path) {
1432 Ok(b) => b,
1433 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1434 Err(e) => return Err(e),
1435 };
1436 let manifest: ManifestSlice = serde_json::from_slice(&bytes).map_err(|e| {
1437 std::io::Error::new(
1438 std::io::ErrorKind::InvalidData,
1439 format!("parsing {}: {e}", manifest_path.display()),
1440 )
1441 })?;
1442 let mut gated: Vec<String> = manifest
1443 .routes
1444 .into_iter()
1445 .filter(|r| {
1446 r.requires
1447 .as_ref()
1448 .is_some_and(|req| req.iter().any(|s| s == "user"))
1449 })
1450 .map(|r| r.route)
1451 .collect();
1452 gated.sort();
1453 gated.dedup();
1454 Ok(gated)
1455}
1456
1457pub fn routes_declaring_ssr(workload: &Path) -> std::io::Result<Vec<String>> {
1466 #[derive(serde::Deserialize)]
1467 struct ManifestSlice {
1468 #[serde(default)]
1469 routes: Vec<RouteSlice>,
1470 }
1471 #[derive(serde::Deserialize)]
1472 struct RouteSlice {
1473 route: String,
1474 #[serde(default)]
1475 mode: String,
1476 }
1477
1478 let manifest_path = workload.join("dist").join("manifest.json");
1479 let bytes = match std::fs::read(&manifest_path) {
1480 Ok(b) => b,
1481 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1482 Err(e) => return Err(e),
1483 };
1484 let manifest: ManifestSlice = serde_json::from_slice(&bytes).map_err(|e| {
1485 std::io::Error::new(
1486 std::io::ErrorKind::InvalidData,
1487 format!("parsing {}: {e}", manifest_path.display()),
1488 )
1489 })?;
1490 let mut ssr_routes: Vec<String> = manifest
1491 .routes
1492 .into_iter()
1493 .filter(|r| r.mode == "ssr")
1494 .map(|r| r.route)
1495 .collect();
1496 ssr_routes.sort();
1497 ssr_routes.dedup();
1498 Ok(ssr_routes)
1499}
1500
1501fn sanitize(uri_path: &str) -> Option<PathBuf> {
1506 let mut out = PathBuf::new();
1507 for seg in uri_path.split('/') {
1508 if seg.is_empty() || seg == "." {
1509 continue;
1510 }
1511 if seg == ".." || seg.contains('\0') {
1512 return None;
1513 }
1514 out.push(seg);
1515 }
1516 Some(out)
1517}
1518
1519fn hydrate_suffix(rel: &Path) -> Option<PathBuf> {
1523 let mut components = rel.components();
1524 let first = match components.next() {
1525 Some(std::path::Component::Normal(s)) => s,
1526 _ => return None,
1527 };
1528 if first == "hydrate" {
1529 Some(components.as_path().to_path_buf())
1530 } else {
1531 match components.next() {
1532 Some(std::path::Component::Normal(s)) if s == "hydrate" => {
1533 Some(components.as_path().to_path_buf())
1534 }
1535 _ => None,
1536 }
1537 }
1538}
1539
1540fn mime_for(path: &Path) -> &'static str {
1541 match path.extension().and_then(|e| e.to_str()) {
1542 Some("html") | Some("htm") => "text/html; charset=utf-8",
1543 Some("css") => "text/css; charset=utf-8",
1544 Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
1545 Some("json") => "application/json; charset=utf-8",
1546 Some("svg") => "image/svg+xml",
1547 Some("png") => "image/png",
1548 Some("jpg") | Some("jpeg") => "image/jpeg",
1549 Some("webp") => "image/webp",
1550 Some("avif") => "image/avif",
1551 Some("ico") => "image/x-icon",
1552 Some("woff2") => "font/woff2",
1553 Some("woff") => "font/woff",
1554 Some("ttf") => "font/ttf",
1555 Some("xml") => "application/xml; charset=utf-8",
1556 Some("txt") | Some("md") => "text/plain; charset=utf-8",
1557 Some("wasm") => "application/wasm",
1564 _ => "application/octet-stream",
1565 }
1566}
1567
1568
1569#[cfg(test)]
1570mod tests {
1571 use super::*;
1572 use axum::body::{to_bytes, Body};
1573 use axum::http::{Request, StatusCode};
1574 use tempfile::tempdir;
1575 use tower::ServiceExt;
1576
1577 async fn body_string(response: axum::response::Response) -> String {
1578 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1579 String::from_utf8(bytes.to_vec()).unwrap()
1580 }
1581
1582 fn workload_with_manifest(json: &str) -> tempfile::TempDir {
1586 let dir = tempdir().unwrap();
1587 let dist = dir.path().join("dist");
1588 std::fs::create_dir_all(&dist).unwrap();
1589 std::fs::write(dist.join("manifest.json"), json).unwrap();
1590 dir
1591 }
1592
1593 #[test]
1594 fn routes_requiring_user_finds_the_declared_gate() {
1595 let dir = workload_with_manifest(
1596 r#"{"routes":[
1597 {"route":"/","mode":"ssr","requires":["user"]},
1598 {"route":"/health","mode":"static"},
1599 {"route":"/admin","mode":"ssr","requires":["user"]}
1600 ]}"#,
1601 );
1602 assert_eq!(
1603 routes_requiring_user(dir.path()).unwrap(),
1604 vec!["/".to_string(), "/admin".to_string()],
1605 "sorted, so the refusal message a caller renders is stable",
1606 );
1607 }
1608
1609 #[test]
1610 fn routes_requiring_user_ignores_routes_without_the_gate() {
1611 let dir = workload_with_manifest(
1612 r#"{"routes":[
1613 {"route":"/","mode":"static"},
1614 {"route":"/feed","mode":"ssr","requires":[]}
1615 ]}"#,
1616 );
1617 assert!(routes_requiring_user(dir.path()).unwrap().is_empty());
1618 }
1619
1620 #[test]
1623 fn routes_requiring_user_treats_an_absent_manifest_as_no_routes() {
1624 let dir = tempdir().unwrap();
1625 assert!(routes_requiring_user(dir.path()).unwrap().is_empty());
1626 }
1627
1628 #[test]
1632 fn routes_requiring_user_refuses_an_unparseable_manifest() {
1633 let dir = workload_with_manifest("{ this is not json");
1634 let err = routes_requiring_user(dir.path()).unwrap_err();
1635 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1636 }
1637
1638 #[test]
1643 fn routes_requiring_user_matches_the_user_scope_specifically() {
1644 let dir = workload_with_manifest(
1645 r#"{"routes":[{"route":"/x","mode":"ssr","requires":["admin"]}]}"#,
1646 );
1647 assert!(routes_requiring_user(dir.path()).unwrap().is_empty());
1648 }
1649
1650 fn workload_with(files: &[(&str, &str)]) -> tempfile::TempDir {
1651 let dir = tempdir().unwrap();
1652 let dist = dir.path().join("dist").join("html");
1653 std::fs::create_dir_all(&dist).unwrap();
1654 for (name, body) in files {
1655 let path = dist.join(name);
1656 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1658 std::fs::write(path, body).unwrap();
1659 }
1660 dir
1661 }
1662
1663 #[tokio::test]
1664 async fn serves_index_at_root() {
1665 let workload = workload_with(&[("index.html", "<h1>hello</h1>")]);
1666 let app = Server::from_workload(workload.path()).unwrap().router();
1667 let response = app
1668 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
1669 .await
1670 .unwrap();
1671 assert_eq!(response.status(), StatusCode::OK);
1672 assert!(body_string(response).await.contains("hello"));
1673 }
1674
1675 #[tokio::test]
1676 async fn health_endpoint_returns_200() {
1677 let workload = workload_with(&[]);
1680 let app = Server::from_workload(workload.path()).unwrap().router();
1681 let response = app
1682 .oneshot(
1683 Request::builder()
1684 .uri("/__mesofact/health")
1685 .body(Body::empty())
1686 .unwrap(),
1687 )
1688 .await
1689 .unwrap();
1690 assert_eq!(response.status(), StatusCode::OK);
1691 assert_eq!(body_string(response).await, "ok");
1692 }
1693
1694 async fn probe_status(app: Router, path: &str) -> StatusCode {
1695 app.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
1696 .await
1697 .unwrap()
1698 .status()
1699 }
1700
1701 #[tokio::test]
1702 async fn readyz_tracks_the_served_tree_for_a_static_workload() {
1703 let workload = tempdir().unwrap();
1707 let server = Server::from_workload(workload.path()).unwrap();
1708 server.health().mark_started();
1709
1710 assert_eq!(
1711 probe_status(server.router(), crate::READY_PATH).await,
1712 StatusCode::SERVICE_UNAVAILABLE,
1713 );
1714 assert_eq!(
1715 probe_status(server.router(), crate::LIVE_PATH).await,
1716 StatusCode::OK,
1717 "no restart can produce a dist tree, so liveness must not gate on it",
1718 );
1719
1720 std::fs::create_dir_all(workload.path().join("dist").join("html")).unwrap();
1721 assert_eq!(
1722 probe_status(server.router(), crate::READY_PATH).await,
1723 StatusCode::OK,
1724 );
1725 }
1726
1727 #[cfg(feature = "ssr")]
1730 #[tokio::test]
1731 async fn readyz_tracks_the_isolate_for_an_ssr_workload() {
1732 let workload = tempdir().unwrap();
1733 let ssr = ssr::detached_for_test_with_policies(
1734 vec!["/api/x".to_string()],
1735 vec![],
1736 mock_dispatch_resp(200, "ok"),
1737 );
1738 let server = Server::from_workload(workload.path())
1739 .unwrap()
1740 .with_ssr(ssr);
1741 server.health().mark_started();
1742
1743 assert_eq!(
1745 probe_status(server.router(), crate::READY_PATH).await,
1746 StatusCode::OK,
1747 );
1748
1749 server.ssr_slot().set(None);
1751 assert_eq!(
1752 probe_status(server.router(), crate::READY_PATH).await,
1753 StatusCode::SERVICE_UNAVAILABLE,
1754 );
1755 assert_eq!(
1756 probe_status(server.router(), crate::LIVE_PATH).await,
1757 StatusCode::OK,
1758 );
1759 }
1760
1761 #[cfg(feature = "ssr")]
1764 fn server_with_app_readyz(workload: &tempfile::TempDir, status: u16) -> Server {
1765 let ssr = ssr::detached_for_test_with_policies(
1766 vec![crate::READY_PATH.to_string()],
1767 vec![],
1768 mock_dispatch_resp(status, "app"),
1769 );
1770 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
1771 server.health().mark_started();
1772 server
1773 }
1774
1775 #[cfg(feature = "ssr")]
1776 #[tokio::test]
1777 async fn an_app_declared_readyz_contributes_its_verdict() {
1778 let workload = tempdir().unwrap();
1779 let server = server_with_app_readyz(&workload, 503);
1780 let response = server
1781 .router()
1782 .oneshot(
1783 Request::builder()
1784 .uri(format!("{}?verbose", crate::READY_PATH))
1785 .body(Body::empty())
1786 .unwrap(),
1787 )
1788 .await
1789 .unwrap();
1790
1791 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1792 let body = body_string(response).await;
1793 assert!(body.contains("[+]ssr ok"), "{body}");
1794 assert!(body.contains("[-]app failed"), "{body}");
1795 }
1796
1797 #[cfg(feature = "ssr")]
1798 #[tokio::test]
1799 async fn an_app_readyz_returning_200_is_ready() {
1800 let workload = tempdir().unwrap();
1801 let server = server_with_app_readyz(&workload, 200);
1802 assert_eq!(
1803 probe_status(server.router(), crate::READY_PATH).await,
1804 StatusCode::OK,
1805 );
1806 }
1807
1808 #[cfg(feature = "ssr")]
1810 #[tokio::test]
1811 async fn an_app_readyz_cannot_overrule_the_engine() {
1812 let workload = tempdir().unwrap();
1813 let server = server_with_app_readyz(&workload, 200);
1814 server.health().begin_drain();
1815 assert_eq!(
1816 probe_status(server.router(), crate::READY_PATH).await,
1817 StatusCode::SERVICE_UNAVAILABLE,
1818 "a 200 from app code must not un-drain a terminating process",
1819 );
1820
1821 let workload = tempdir().unwrap();
1824 let server = server_with_app_readyz(&workload, 200);
1825 server.ssr_slot().set(None);
1826 let response = server
1827 .router()
1828 .oneshot(
1829 Request::builder()
1830 .uri(format!("{}?verbose", crate::READY_PATH))
1831 .body(Body::empty())
1832 .unwrap(),
1833 )
1834 .await
1835 .unwrap();
1836 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1837 let body = body_string(response).await;
1838 assert!(body.contains("[-]ssr failed"), "{body}");
1839 assert!(body.contains("[+]app ok"), "no double-reporting: {body}");
1840 }
1841
1842 #[cfg(feature = "ssr")]
1843 #[tokio::test]
1844 async fn an_ssr_workload_without_an_app_readyz_still_passes() {
1845 let workload = tempdir().unwrap();
1847 let ssr = ssr::detached_for_test_with_policies(
1848 vec!["/api/x".to_string()],
1849 vec![],
1850 mock_dispatch_resp(200, "x"),
1851 );
1852 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
1853 server.health().mark_started();
1854 assert_eq!(
1855 probe_status(server.router(), crate::READY_PATH).await,
1856 StatusCode::OK,
1857 );
1858 }
1859
1860 #[tokio::test]
1862 async fn without_standard_probes_leaves_the_paths_free() {
1863 let workload = workload_with(&[("index.html", "<h1>hello</h1>")]);
1864 let app = Server::from_workload(workload.path())
1865 .unwrap()
1866 .without_standard_probes()
1867 .router()
1868 .route(crate::READY_PATH, get(|| async { "mine" }));
1870 let response = app
1871 .oneshot(
1872 Request::builder()
1873 .uri(crate::READY_PATH)
1874 .body(Body::empty())
1875 .unwrap(),
1876 )
1877 .await
1878 .unwrap();
1879
1880 assert_eq!(response.status(), StatusCode::OK);
1881 assert_eq!(body_string(response).await, "mine");
1882 }
1883
1884 #[tokio::test]
1885 async fn probe_paths_win_over_the_catch_all_route() {
1886 let workload = workload_with(&[("index.html", "<h1>hello</h1>")]);
1889 let server = Server::from_workload(workload.path()).unwrap();
1890 server.health().mark_started();
1891 let response = server
1892 .router()
1893 .oneshot(
1894 Request::builder()
1895 .uri(format!("{}?verbose", crate::READY_PATH))
1896 .body(Body::empty())
1897 .unwrap(),
1898 )
1899 .await
1900 .unwrap();
1901
1902 assert_eq!(response.status(), StatusCode::OK);
1903 let body = body_string(response).await;
1904 assert!(body.starts_with("[+]started ok"), "served the SPA shell: {body}");
1905 }
1906
1907 #[tokio::test]
1908 async fn info_endpoint_returns_identity_when_stamped() {
1909 let workload = workload_with(&[]);
1912 let app = Server::from_workload(workload.path())
1913 .unwrap()
1914 .with_identity("scrabcake", "site")
1915 .router();
1916 let response = app
1917 .oneshot(
1918 Request::builder()
1919 .uri("/__mesofact/info")
1920 .body(Body::empty())
1921 .unwrap(),
1922 )
1923 .await
1924 .unwrap();
1925 assert_eq!(response.status(), StatusCode::OK);
1926 let body: serde_json::Value =
1927 serde_json::from_str(&body_string(response).await).unwrap();
1928 assert_eq!(body["service"], "scrabcake");
1929 assert_eq!(body["component"], "site");
1930 }
1931
1932 #[tokio::test]
1933 async fn info_endpoint_404s_without_identity() {
1934 let workload = workload_with(&[]);
1937 let app = Server::from_workload(workload.path()).unwrap().router();
1938 let response = app
1939 .oneshot(
1940 Request::builder()
1941 .uri("/__mesofact/info")
1942 .body(Body::empty())
1943 .unwrap(),
1944 )
1945 .await
1946 .unwrap();
1947 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1948 }
1949
1950 #[tokio::test]
1951 async fn serves_named_file() {
1952 let workload = workload_with(&[("404.html", "<h1>oops</h1>")]);
1953 let app = Server::from_workload(workload.path()).unwrap().router();
1954 let response = app
1955 .oneshot(
1956 Request::builder()
1957 .uri("/404.html")
1958 .body(Body::empty())
1959 .unwrap(),
1960 )
1961 .await
1962 .unwrap();
1963 assert_eq!(response.status(), StatusCode::OK);
1964 assert!(body_string(response).await.contains("oops"));
1965 }
1966
1967 #[tokio::test]
1968 async fn serves_clean_url_via_html_fallback() {
1969 let workload = workload_with(&[("releases.html", "<h1>releases</h1>")]);
1973 let app = Server::from_workload(workload.path()).unwrap().router();
1974 let response = app
1975 .oneshot(
1976 Request::builder()
1977 .uri("/releases")
1978 .body(Body::empty())
1979 .unwrap(),
1980 )
1981 .await
1982 .unwrap();
1983 assert_eq!(response.status(), StatusCode::OK);
1984 assert!(body_string(response).await.contains("releases"));
1985 }
1986
1987 #[tokio::test]
1994 async fn serves_parametric_instance_at_its_public_path() {
1995 let workload = workload_with(&[
1996 ("issues.html", "<h1>issue list</h1>"),
1997 ("issues/01KZVGVT0DV61ZGGNVHAWQW2CS.html", "<h1>issue detail</h1>"),
1998 ]);
1999 let app = Server::from_workload(workload.path()).unwrap().router();
2000 let detail = app
2001 .clone()
2002 .oneshot(
2003 Request::builder()
2004 .uri("/issues/01KZVGVT0DV61ZGGNVHAWQW2CS")
2005 .body(Body::empty())
2006 .unwrap(),
2007 )
2008 .await
2009 .unwrap();
2010 assert_eq!(detail.status(), StatusCode::OK);
2011 assert!(body_string(detail).await.contains("issue detail"));
2012
2013 let list = app
2015 .oneshot(Request::builder().uri("/issues").body(Body::empty()).unwrap())
2016 .await
2017 .unwrap();
2018 assert_eq!(list.status(), StatusCode::OK);
2019 assert!(body_string(list).await.contains("issue list"));
2020 }
2021
2022 #[tokio::test]
2023 async fn clean_url_fallback_skips_paths_with_extension() {
2024 let workload = workload_with(&[
2027 ("404.html", "<h1>oops</h1>"),
2028 ("style.css.html", "this should not be served"),
2029 ]);
2030 let app = Server::from_workload(workload.path()).unwrap().router();
2031 let response = app
2032 .oneshot(
2033 Request::builder()
2034 .uri("/style.css")
2035 .body(Body::empty())
2036 .unwrap(),
2037 )
2038 .await
2039 .unwrap();
2040 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2041 assert!(body_string(response).await.contains("oops"));
2042 }
2043
2044 #[tokio::test]
2045 async fn missing_path_falls_back_to_404_html() {
2046 let workload = workload_with(&[("404.html", "<h1>oops</h1>")]);
2047 let app = Server::from_workload(workload.path()).unwrap().router();
2048 let response = app
2049 .oneshot(
2050 Request::builder()
2051 .uri("/does-not-exist")
2052 .body(Body::empty())
2053 .unwrap(),
2054 )
2055 .await
2056 .unwrap();
2057 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2058 assert!(body_string(response).await.contains("oops"));
2059 }
2060
2061 #[tokio::test]
2062 async fn missing_path_without_404_file_returns_plain_404() {
2063 let workload = workload_with(&[]);
2064 let app = Server::from_workload(workload.path()).unwrap().router();
2065 let response = app
2066 .oneshot(
2067 Request::builder()
2068 .uri("/missing")
2069 .body(Body::empty())
2070 .unwrap(),
2071 )
2072 .await
2073 .unwrap();
2074 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2075 assert!(body_string(response).await.contains("Not Found"));
2076 }
2077
2078 #[tokio::test]
2082 async fn missing_path_uses_error_routes_from_manifest() {
2083 let dir = tempdir().unwrap();
2084 let dist = dir.path().join("dist");
2085 let html = dist.join("html");
2086 std::fs::create_dir_all(&html).unwrap();
2087 std::fs::write(html.join("custom-nf.html"), "<h1>custom nf</h1>").unwrap();
2088 std::fs::write(html.join("404.html"), "<h1>default 404</h1>").unwrap();
2089 std::fs::write(
2090 dist.join("manifest.json"),
2091 r#"{"version":"1","build_id":"b","routes":[],"error_routes":{"404":"/custom-nf"}}"#,
2092 )
2093 .unwrap();
2094
2095 let app = Server::from_workload(dir.path()).unwrap().router();
2096 let response = app
2097 .oneshot(
2098 Request::builder()
2099 .uri("/does-not-exist")
2100 .body(Body::empty())
2101 .unwrap(),
2102 )
2103 .await
2104 .unwrap();
2105 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2106 assert!(body_string(response).await.contains("custom nf"));
2107 }
2108
2109 fn bundle_with(runtime: &str, html: &[(&str, &str)]) -> tempfile::TempDir {
2120 let dir = tempdir().unwrap();
2121 let html_dir = dir.path().join("app").join("dist").join("html");
2122 std::fs::create_dir_all(&html_dir).unwrap();
2123 for (name, body) in html {
2124 std::fs::write(html_dir.join(name), body).unwrap();
2125 }
2126 std::fs::write(
2127 dir.path().join("manifest.toml"),
2128 format!("schema_version = 1\nname = \"test-bundle\"\nruntime = \"{runtime}\"\n"),
2129 )
2130 .unwrap();
2131 dir
2132 }
2133
2134 #[tokio::test]
2135 async fn serves_bundle_index_and_clean_url() {
2136 let bundle = bundle_with(
2137 "mesofact/0.8.20",
2138 &[("index.html", "<h1>home</h1>"), ("releases.html", "<h1>rel</h1>")],
2139 );
2140 let app = Server::from_bundle(bundle.path()).unwrap().router();
2141
2142 let root = app
2143 .clone()
2144 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2145 .await
2146 .unwrap();
2147 assert_eq!(root.status(), StatusCode::OK);
2148 assert!(body_string(root).await.contains("home"));
2149
2150 let clean = app
2152 .oneshot(Request::builder().uri("/releases").body(Body::empty()).unwrap())
2153 .await
2154 .unwrap();
2155 assert_eq!(clean.status(), StatusCode::OK);
2156 assert!(body_string(clean).await.contains("rel"));
2157 }
2158
2159 #[tokio::test]
2160 async fn bundle_miss_serves_404_page() {
2161 let bundle = bundle_with("mesofact/0.8.20", &[("404.html", "<h1>nope</h1>")]);
2162 let app = Server::from_bundle(bundle.path()).unwrap().router();
2163 let response = app
2164 .oneshot(Request::builder().uri("/absent").body(Body::empty()).unwrap())
2165 .await
2166 .unwrap();
2167 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2168 assert!(body_string(response).await.contains("nope"));
2169 }
2170
2171 #[tokio::test]
2172 async fn self_runtime_bundle_still_serves_static() {
2173 let bundle = bundle_with("self", &[("index.html", "<h1>custom</h1>")]);
2176 let app = Server::from_bundle(bundle.path()).unwrap().router();
2177 let response = app
2178 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2179 .await
2180 .unwrap();
2181 assert_eq!(response.status(), StatusCode::OK);
2182 assert!(body_string(response).await.contains("custom"));
2183 }
2184
2185 #[tokio::test]
2196 async fn serves_the_publish_beacon_from_a_dot_well_known_path() {
2197 let bundle = bundle_with("self", &[("index.html", "<h1>home</h1>")]);
2198 let beacon_dir = bundle.path().join("app/dist/html/.well-known");
2199 std::fs::create_dir_all(&beacon_dir).unwrap();
2200 let body = r#"{"prefix":"bundle/yah-marketing","published_at":null,"digest":"ab","files":2}"#;
2201 std::fs::write(beacon_dir.join("yah-publish.json"), body).unwrap();
2202
2203 let response = Server::from_bundle(bundle.path())
2204 .unwrap()
2205 .router()
2206 .oneshot(
2207 Request::builder()
2208 .uri("/.well-known/yah-publish.json")
2209 .body(Body::empty())
2210 .unwrap(),
2211 )
2212 .await
2213 .unwrap();
2214
2215 assert_eq!(response.status(), StatusCode::OK);
2216 assert_eq!(
2217 response
2218 .headers()
2219 .get(header::CONTENT_TYPE)
2220 .and_then(|v| v.to_str().ok()),
2221 Some("application/json; charset=utf-8"),
2222 );
2223 assert!(body_string(response).await.contains("bundle/yah-marketing"));
2224 }
2225
2226 const ISOLATION_TABLE: &str = r#"[
2235 {"path":"/app/*","headers":{"Cross-Origin-Opener-Policy":"same-origin","Cross-Origin-Embedder-Policy":"require-corp"}},
2236 {"path":"/*","headers":{"X-Tier":"marketing"}}
2237 ]"#;
2238
2239 fn isolation_table() -> RouteHeaderTable {
2240 RouteHeaderTable::parse(ISOLATION_TABLE).unwrap()
2241 }
2242
2243 fn header_of(response: &axum::response::Response, name: &str) -> Option<String> {
2244 response
2245 .headers()
2246 .get(name)
2247 .and_then(|v| v.to_str().ok())
2248 .map(str::to_owned)
2249 }
2250
2251 #[tokio::test]
2255 async fn the_branded_404_carries_the_route_headers() {
2256 let bundle = bundle_with("mesofact/0.8.20", &[("404.html", "<h1>nope</h1>")]);
2257 let app = Server::from_bundle(bundle.path())
2258 .unwrap()
2259 .with_route_headers(isolation_table())
2260 .router();
2261 let response = app
2262 .oneshot(
2263 Request::builder()
2264 .uri("/app/missing.js")
2265 .body(Body::empty())
2266 .unwrap(),
2267 )
2268 .await
2269 .unwrap();
2270 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2271 assert_eq!(
2272 header_of(&response, "cross-origin-opener-policy").as_deref(),
2273 Some("same-origin"),
2274 );
2275 assert_eq!(
2276 header_of(&response, "cross-origin-embedder-policy").as_deref(),
2277 Some("require-corp"),
2278 );
2279 assert!(body_string(response).await.contains("nope"));
2280 }
2281
2282 #[tokio::test]
2288 async fn a_declared_cache_policy_reaches_a_served_response() {
2289 use crate::cache_headers::CachePolicyTable;
2290 use mesofact_core::manifest::{CachePolicy, Route, RouteMode};
2291
2292 let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>home</h1>")]);
2293 let table = CachePolicyTable::from_routes(&[Route {
2294 route: "/".into(),
2295 mode: RouteMode::Static,
2296 render_entrypoint: "e.js".into(),
2297 requires: None,
2298 source_reads: None,
2299 data_inputs: None,
2300 cache_policy: CachePolicy {
2301 ttl: 3600,
2302 swr: Some(86_400),
2303 negative_ttl: None,
2304 vary: None,
2305 },
2306 concurrency: None,
2307 hydration: None,
2308 prerender: None,
2309 placement: None,
2310 resilience: None,
2311 }]);
2312 let app = Server::from_bundle(bundle.path())
2313 .unwrap()
2314 .with_cache_policy(table)
2315 .router();
2316 let response = app
2317 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2318 .await
2319 .unwrap();
2320 assert_eq!(response.status(), StatusCode::OK);
2321 assert_eq!(
2322 header_of(&response, "cache-control").as_deref(),
2323 Some("public, max-age=3600, stale-while-revalidate=86400"),
2324 );
2325 }
2326
2327 #[tokio::test]
2333 async fn a_domain_declared_cache_control_wins_over_the_route_policy() {
2334 use crate::cache_headers::CachePolicyTable;
2335 use mesofact_core::manifest::{CachePolicy, Route, RouteMode};
2336
2337 let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>home</h1>")]);
2338 let table = CachePolicyTable::from_routes(&[Route {
2339 route: "/".into(),
2340 mode: RouteMode::Static,
2341 render_entrypoint: "e.js".into(),
2342 requires: None,
2343 source_reads: None,
2344 data_inputs: None,
2345 cache_policy: CachePolicy {
2346 ttl: 3600,
2347 swr: None,
2348 negative_ttl: None,
2349 vary: None,
2350 },
2351 concurrency: None,
2352 hydration: None,
2353 prerender: None,
2354 placement: None,
2355 resilience: None,
2356 }]);
2357 let app = Server::from_bundle(bundle.path())
2358 .unwrap()
2359 .with_cache_policy(table)
2360 .with_route_headers(
2361 RouteHeaderTable::parse(
2362 r#"[{"path":"/*","headers":{"Cache-Control":"no-store"}}]"#,
2363 )
2364 .unwrap(),
2365 )
2366 .router();
2367 let response = app
2368 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2369 .await
2370 .unwrap();
2371 assert_eq!(
2372 header_of(&response, "cache-control").as_deref(),
2373 Some("no-store"),
2374 );
2375 }
2376
2377 #[tokio::test]
2379 async fn the_plaintext_404_fallback_carries_them_too() {
2380 let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>home</h1>")]);
2381 let app = Server::from_bundle(bundle.path())
2382 .unwrap()
2383 .with_route_headers(isolation_table())
2384 .router();
2385 let response = app
2386 .oneshot(
2387 Request::builder()
2388 .uri("/app/missing")
2389 .body(Body::empty())
2390 .unwrap(),
2391 )
2392 .await
2393 .unwrap();
2394 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2395 assert_eq!(
2396 header_of(&response, "cross-origin-opener-policy").as_deref(),
2397 Some("same-origin"),
2398 );
2399 }
2400
2401 #[tokio::test]
2405 async fn even_the_probe_routes_go_through_the_layer() {
2406 let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>home</h1>")]);
2407 let app = Server::from_bundle(bundle.path())
2408 .unwrap()
2409 .with_route_headers(isolation_table())
2410 .router();
2411 let response = app
2412 .oneshot(
2413 Request::builder()
2414 .uri(crate::LIVE_PATH)
2415 .body(Body::empty())
2416 .unwrap(),
2417 )
2418 .await
2419 .unwrap();
2420 assert_eq!(header_of(&response, "x-tier").as_deref(), Some("marketing"));
2421 }
2422
2423 #[cfg(feature = "ssr")]
2427 #[tokio::test]
2428 async fn the_410_tombstone_page_carries_the_route_headers() {
2429 use mesofact_publisher::{ObjectPointerStore, PointerStore};
2430 let dir = deferred_workload(&[("404.html", "<h1>gone-page</h1>")], "");
2431 let store = mem_store();
2432 flip_instance(&store, "c/abc", "content/abc.html").await;
2433 ObjectPointerStore::new(store.clone())
2434 .delete("c/abc", Some("2026-07-14T00:00:00Z".into()))
2435 .await
2436 .unwrap();
2437
2438 let app = Server::from_workload(dir.path())
2439 .unwrap()
2440 .with_instance_store(store)
2441 .with_route_headers(
2442 RouteHeaderTable::parse(
2443 r#"[{"path":"/*","headers":{"Cross-Origin-Opener-Policy":"same-origin"}}]"#,
2444 )
2445 .unwrap(),
2446 )
2447 .router();
2448 let response = app
2449 .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2450 .await
2451 .unwrap();
2452 assert_eq!(response.status(), StatusCode::GONE);
2453 assert_eq!(
2454 header_of(&response, "cross-origin-opener-policy").as_deref(),
2455 Some("same-origin"),
2456 );
2457 }
2458
2459 #[tokio::test]
2465 async fn the_layer_stamps_any_status_including_5xx() {
2466 use crate::route_headers::apply_route_headers;
2467 for status in [
2468 StatusCode::OK,
2469 StatusCode::NOT_FOUND,
2470 StatusCode::GONE,
2471 StatusCode::INTERNAL_SERVER_ERROR,
2472 StatusCode::BAD_GATEWAY,
2473 ] {
2474 let app = Router::new()
2475 .route(
2476 "/app/{*rest}",
2477 any(move || async move { (status, "body").into_response() }),
2478 )
2479 .layer(axum::middleware::from_fn_with_state(
2480 Arc::new(isolation_table()),
2481 apply_route_headers,
2482 ));
2483 let response = app
2484 .oneshot(
2485 Request::builder()
2486 .uri("/app/x")
2487 .body(Body::empty())
2488 .unwrap(),
2489 )
2490 .await
2491 .unwrap();
2492 assert_eq!(response.status(), status);
2493 assert_eq!(
2494 header_of(&response, "cross-origin-opener-policy").as_deref(),
2495 Some("same-origin"),
2496 "status {status}",
2497 );
2498 }
2499 }
2500
2501 #[tokio::test]
2514 async fn serves_wasm_with_the_mime_instantiate_streaming_accepts() {
2515 let bundle = bundle_with("self", &[("index.html", "<h1>home</h1>")]);
2516 let wasm_dir = bundle.path().join("app/dist/html/wasm");
2517 std::fs::create_dir_all(&wasm_dir).unwrap();
2518 std::fs::write(wasm_dir.join("demo_bg.wasm"), b"\0asm\x01\0\0\0").unwrap();
2519
2520 let response = Server::from_bundle(bundle.path())
2521 .unwrap()
2522 .router()
2523 .oneshot(
2524 Request::builder()
2525 .uri("/wasm/demo_bg.wasm")
2526 .body(Body::empty())
2527 .unwrap(),
2528 )
2529 .await
2530 .unwrap();
2531
2532 assert_eq!(response.status(), StatusCode::OK);
2533 assert_eq!(
2534 response
2535 .headers()
2536 .get(header::CONTENT_TYPE)
2537 .and_then(|v| v.to_str().ok()),
2538 Some("application/wasm"),
2539 );
2540 }
2541
2542 #[tokio::test]
2547 async fn an_unstamped_bundle_does_not_answer_the_beacon_url_with_200() {
2548 let bundle = bundle_with("self", &[("index.html", "<h1>home</h1>")]);
2549 let response = Server::from_bundle(bundle.path())
2550 .unwrap()
2551 .router()
2552 .oneshot(
2553 Request::builder()
2554 .uri("/.well-known/yah-publish.json")
2555 .body(Body::empty())
2556 .unwrap(),
2557 )
2558 .await
2559 .unwrap();
2560 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2561 }
2562
2563 #[test]
2564 fn from_bundle_rejects_missing_manifest() {
2565 let dir = tempdir().unwrap();
2567 std::fs::create_dir_all(dir.path().join("app")).unwrap();
2568 let err = Server::from_bundle(dir.path()).err().unwrap().to_string();
2569 assert!(err.contains("not a mesofact bundle"), "got: {err}");
2570 }
2571
2572 #[test]
2573 fn from_bundle_rejects_unknown_schema() {
2574 let dir = tempdir().unwrap();
2575 std::fs::create_dir_all(dir.path().join("app")).unwrap();
2576 std::fs::write(
2577 dir.path().join("manifest.toml"),
2578 "schema_version = 99\nname = \"x\"\nruntime = \"mesofact/0.8.20\"\n",
2579 )
2580 .unwrap();
2581 let err = Server::from_bundle(dir.path()).err().unwrap().to_string();
2582 assert!(err.contains("invalid bundle manifest"), "got: {err}");
2583 }
2584
2585 #[test]
2586 fn from_bundle_rejects_missing_app_tree() {
2587 let dir = tempdir().unwrap();
2590 std::fs::write(
2591 dir.path().join("manifest.toml"),
2592 "schema_version = 1\nname = \"x\"\nruntime = \"mesofact/0.8.20\"\n",
2593 )
2594 .unwrap();
2595 let err = Server::from_bundle(dir.path()).err().unwrap().to_string();
2596 assert!(err.contains("no servable app tree"), "got: {err}");
2597 }
2598
2599 #[tokio::test]
2608 async fn serve_on_listener_adopts_the_given_socket() {
2609 let std_l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2612 let port = std_l.local_addr().unwrap().port();
2613 std_l.set_nonblocking(true).unwrap();
2614 let listener = tokio::net::TcpListener::from_std(std_l).unwrap();
2615
2616 let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>adopted</h1>")]);
2617 let server = Server::from_bundle(bundle.path()).unwrap();
2618 let serve = tokio::spawn(async move { server.serve_on_listener(listener, None).await });
2619
2620 let resp = reqwest::get(format!("http://127.0.0.1:{port}/")).await.unwrap();
2621 assert_eq!(resp.status(), 200);
2622 assert!(resp.text().await.unwrap().contains("adopted"));
2623 serve.abort();
2624 }
2625
2626 #[tokio::test]
2627 async fn jit_idle_ttl_self_reaps_after_last_request() {
2628 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2632 let port = listener.local_addr().unwrap().port();
2633
2634 let bundle = bundle_with("mesofact/0.8.20", &[("index.html", "<h1>hi</h1>")]);
2635 let server = Server::from_bundle(bundle.path()).unwrap();
2636 let serve = tokio::spawn(async move {
2637 server
2638 .serve_on_listener(listener, Some(Duration::from_millis(300)))
2639 .await
2640 });
2641
2642 let resp = reqwest::get(format!("http://127.0.0.1:{port}/")).await.unwrap();
2644 assert_eq!(resp.status(), 200);
2645
2646 let out = tokio::time::timeout(Duration::from_secs(3), serve)
2648 .await
2649 .expect("server should self-reap within the timeout")
2650 .expect("serve task panicked");
2651 out.expect("serve returned an error");
2652 }
2653
2654 #[cfg(feature = "ssr")]
2663 fn deferred_workload(extra_html: &[(&str, &str)], error_routes_json: &str) -> tempfile::TempDir {
2664 let dir = tempdir().unwrap();
2665 let dist = dir.path().join("dist");
2666 let html = dist.join("html");
2667 std::fs::create_dir_all(&html).unwrap();
2668 for (name, body) in extra_html {
2669 std::fs::write(html.join(name), body).unwrap();
2670 }
2671 std::fs::write(
2672 dist.join("manifest.json"),
2673 format!(
2674 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}}}"#
2675 ),
2676 )
2677 .unwrap();
2678 dir
2679 }
2680
2681 #[cfg(feature = "ssr")]
2682 fn mem_store() -> std::sync::Arc<dyn ObjectStore> {
2683 std::sync::Arc::new(mesofact_publisher::InMemoryStore::new())
2684 }
2685
2686 #[cfg(feature = "ssr")]
2687 async fn flip_instance(store: &std::sync::Arc<dyn ObjectStore>, key: &str, content_root: &str) {
2688 use mesofact_publisher::{ObjectPointerStore, Pointer, PointerStore};
2689 ObjectPointerStore::new(store.clone())
2690 .flip(
2691 key,
2692 Pointer { content_root: content_root.into(), source_root: None, published_at: None },
2693 )
2694 .await
2695 .unwrap();
2696 }
2697
2698 #[cfg(feature = "ssr")]
2699 async fn put_bytes(store: &std::sync::Arc<dyn ObjectStore>, key: &str, body: &'static [u8]) {
2700 use mesofact_publisher::PutOpts;
2701 store
2702 .put(
2703 key,
2704 axum::body::Bytes::from_static(body),
2705 PutOpts { content_type: "text/html".into(), content_hash: "h".into(), cache_control: None },
2706 )
2707 .await
2708 .unwrap();
2709 }
2710
2711 #[cfg(feature = "ssr")]
2713 #[tokio::test]
2714 async fn deferred_route_present_serves_instance_bytes() {
2715 let dir = deferred_workload(&[], "");
2716 let store = mem_store();
2717 flip_instance(&store, "c/abc", "content/abc.html").await;
2718 put_bytes(&store, "content/abc.html", b"<h1>chat abc</h1>").await;
2719
2720 let app = Server::from_workload(dir.path())
2721 .unwrap()
2722 .with_instance_store(store)
2723 .router();
2724 let response = app
2725 .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2726 .await
2727 .unwrap();
2728 assert_eq!(response.status(), StatusCode::OK);
2729 assert_eq!(
2730 response.headers().get("cache-control").unwrap(),
2731 "public, max-age=31536000, immutable"
2732 );
2733 assert!(response
2734 .headers()
2735 .get("content-type")
2736 .unwrap()
2737 .to_str()
2738 .unwrap()
2739 .contains("text/html"));
2740 assert!(body_string(response).await.contains("chat abc"));
2741 }
2742
2743 #[cfg(feature = "ssr")]
2746 #[tokio::test]
2747 async fn deferred_route_deleted_returns_410() {
2748 use mesofact_publisher::{ObjectPointerStore, PointerStore};
2749 let dir = deferred_workload(&[("404.html", "<h1>gone-page</h1>")], "");
2750 let store = mem_store();
2751 flip_instance(&store, "c/abc", "content/abc.html").await;
2752 ObjectPointerStore::new(store.clone())
2753 .delete("c/abc", Some("2026-07-14T00:00:00Z".into()))
2754 .await
2755 .unwrap();
2756
2757 let app = Server::from_workload(dir.path())
2758 .unwrap()
2759 .with_instance_store(store)
2760 .router();
2761 let response = app
2762 .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2763 .await
2764 .unwrap();
2765 assert_eq!(response.status(), StatusCode::GONE);
2766 assert!(body_string(response).await.contains("gone-page"));
2767 }
2768
2769 #[cfg(feature = "ssr")]
2771 #[tokio::test]
2772 async fn deferred_route_absent_returns_404() {
2773 let dir = deferred_workload(&[("404.html", "<h1>nf</h1>")], "");
2774 let store = mem_store();
2775 let app = Server::from_workload(dir.path())
2776 .unwrap()
2777 .with_instance_store(store)
2778 .router();
2779 let response = app
2780 .oneshot(Request::builder().uri("/c/never").body(Body::empty()).unwrap())
2781 .await
2782 .unwrap();
2783 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2784 assert!(body_string(response).await.contains("nf"));
2785 }
2786
2787 #[cfg(feature = "ssr")]
2791 #[tokio::test]
2792 async fn non_deferred_path_not_routed_through_pointer_store() {
2793 let dir = deferred_workload(&[("404.html", "<h1>nf</h1>")], "");
2794 let store = mem_store();
2795 flip_instance(&store, "c/a/b", "content/ab.html").await;
2798 put_bytes(&store, "content/ab.html", b"<h1>should not serve</h1>").await;
2799
2800 let app = Server::from_workload(dir.path())
2801 .unwrap()
2802 .with_instance_store(store)
2803 .router();
2804 let response = app
2805 .oneshot(Request::builder().uri("/c/a/b").body(Body::empty()).unwrap())
2806 .await
2807 .unwrap();
2808 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2809 assert!(body_string(response).await.contains("nf"));
2810 }
2811
2812 #[cfg(feature = "ssr")]
2815 #[tokio::test]
2816 async fn deferred_route_malformed_record_returns_5xx() {
2817 let dir = deferred_workload(
2818 &[("5xx.html", "<h1>boom</h1>"), ("404.html", "<h1>nf</h1>")],
2819 r#","error_routes":{"5xx":"/5xx"}"#,
2820 );
2821 let store = mem_store();
2822 put_bytes(
2824 &store,
2825 "p/c/bad",
2826 br#"{"v":99,"pointer":{"content_root":"x"}}"#,
2827 )
2828 .await;
2829
2830 let app = Server::from_workload(dir.path())
2831 .unwrap()
2832 .with_instance_store(store)
2833 .router();
2834 let response = app
2835 .oneshot(Request::builder().uri("/c/bad").body(Body::empty()).unwrap())
2836 .await
2837 .unwrap();
2838 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
2839 assert!(body_string(response).await.contains("boom"));
2840 }
2841
2842 #[cfg(feature = "ssr")]
2846 #[tokio::test]
2847 async fn deferred_route_without_store_is_404() {
2848 let dir = deferred_workload(&[("404.html", "<h1>nf</h1>")], "");
2849 let app = Server::from_workload(dir.path()).unwrap().router();
2850 let response = app
2851 .oneshot(Request::builder().uri("/c/abc").body(Body::empty()).unwrap())
2852 .await
2853 .unwrap();
2854 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2855 assert!(body_string(response).await.contains("nf"));
2856 }
2857
2858 #[cfg(feature = "ssr")]
2859 #[test]
2860 fn match_route_pattern_is_segment_aware() {
2861 assert!(match_route_pattern("/c/:slug", "/c/abc"));
2862 assert!(match_route_pattern("/a/:x/b/:y", "/a/1/b/2"));
2863 assert!(match_route_pattern("/about", "/about"));
2864 assert!(match_route_pattern("/", "/"));
2865 assert!(!match_route_pattern("/c/:slug", "/c/abc/def"));
2867 assert!(!match_route_pattern("/c/:slug", "/c"));
2869 assert!(!match_route_pattern("/about", "/abou"));
2871 }
2872
2873
2874 #[tokio::test]
2875 async fn from_workload_rejects_missing_directory() {
2876 let result = Server::from_workload(tempdir().unwrap().path().join("nope"));
2877 assert!(result.is_err());
2878 }
2879
2880 #[tokio::test]
2881 async fn dist_dir_resolves_under_workload() {
2882 let workload = tempdir().unwrap();
2883 let server = Server::from_workload(workload.path()).unwrap();
2884 assert_eq!(server.dist_dir(), workload.path().join("dist").join("html"));
2885 }
2886
2887 #[tokio::test]
2888 async fn pointer_swap_changes_served_content() {
2889 let workload_a = tempdir().unwrap();
2890 let dist_a = workload_a.path().join("dist").join("html");
2891 std::fs::create_dir_all(&dist_a).unwrap();
2892 std::fs::write(dist_a.join("index.html"), "<h1>A</h1>").unwrap();
2893
2894 let dir_b = tempdir().unwrap();
2895 let dist_b = dir_b.path().join("html");
2896 std::fs::create_dir_all(&dist_b).unwrap();
2897 std::fs::write(dist_b.join("index.html"), "<h1>B</h1>").unwrap();
2898
2899 let server = Server::from_workload(workload_a.path()).unwrap();
2900 let pointer = server.pointer();
2901
2902 let response = server
2904 .router()
2905 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2906 .await
2907 .unwrap();
2908 assert!(body_string(response).await.contains("A"));
2909
2910 pointer.set(dist_b);
2912
2913 let response = server
2915 .router()
2916 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2917 .await
2918 .unwrap();
2919 assert!(body_string(response).await.contains("B"));
2920 }
2921
2922 #[tokio::test]
2923 async fn sanitize_rejects_dot_dot() {
2924 assert!(sanitize("/../etc/passwd").is_none());
2925 assert!(sanitize("/foo/../bar").is_none());
2926 }
2927
2928 #[tokio::test]
2929 async fn sanitize_accepts_normal_paths() {
2930 assert_eq!(sanitize("/"), Some(PathBuf::new()));
2931 assert_eq!(sanitize("/index.html"), Some(PathBuf::from("index.html")));
2932 assert_eq!(sanitize("/a/b/c"), Some(PathBuf::from("a/b/c")));
2933 }
2934
2935 #[cfg(feature = "ssr")]
2942 use mesofact_ssr::DispatchResponse;
2943
2944 #[cfg(feature = "ssr")]
2945 fn mock_dispatch_resp(
2946 status: u16,
2947 body: &str,
2948 ) -> impl Fn(DispatchRequest) -> Result<DispatchResponse, anyhow::Error> + Send + Sync + 'static
2949 {
2950 let body = body.to_owned();
2951 move |_req| {
2952 Ok(DispatchResponse {
2953 status,
2954 headers: vec![("content-type".into(), "text/plain".into())],
2955 body: body.as_bytes().to_vec(),
2956 })
2957 }
2958 }
2959
2960 #[cfg(feature = "ssr")]
2963 #[tokio::test]
2964 async fn ssr_proxied_path_returns_handler_response() {
2965 let workload = workload_with(&[("index.html", "<h1>static</h1>")]);
2966 let ssr = ssr::detached_for_test_with_policies(
2967 vec!["/api/health".to_string()],
2968 vec![],
2969 mock_dispatch_resp(200, "healthy"),
2970 );
2971
2972 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2973 let response = server
2974 .router()
2975 .oneshot(
2976 Request::builder()
2977 .uri("/api/health")
2978 .body(Body::empty())
2979 .unwrap(),
2980 )
2981 .await
2982 .unwrap();
2983 assert_eq!(response.status(), StatusCode::OK);
2984 assert_eq!(body_string(response).await, "healthy");
2985 }
2986
2987 #[cfg(feature = "ssr")]
2989 #[tokio::test]
2990 async fn ssr_does_not_swallow_static_routes() {
2991 let workload = workload_with(&[("index.html", "<h1>static</h1>")]);
2992 let ssr = ssr::detached_for_test_with_policies(
2993 vec!["/api/health".to_string()],
2994 vec![],
2995 mock_dispatch_resp(200, "healthy"),
2996 );
2997
2998 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
2999 let response = server
3000 .router()
3001 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
3002 .await
3003 .unwrap();
3004 assert_eq!(response.status(), StatusCode::OK);
3005 assert!(body_string(response).await.contains("static"));
3006 }
3007
3008 #[cfg(feature = "ssr")]
3012 #[tokio::test]
3013 async fn ssr_segment_boundary_not_naive_starts_with() {
3014 let workload = workload_with(&[("404.html", "static-404")]);
3015 let ssr = ssr::detached_for_test_with_policies(
3016 vec!["/api/health".to_string()],
3017 vec![],
3018 mock_dispatch_resp(200, "healthy"),
3019 );
3020
3021 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
3022 let router = server.router();
3023
3024 let r1 = router
3026 .clone()
3027 .oneshot(
3028 Request::builder()
3029 .uri("/api/health")
3030 .body(Body::empty())
3031 .unwrap(),
3032 )
3033 .await
3034 .unwrap();
3035 assert_eq!(r1.status(), StatusCode::OK);
3036 assert_eq!(body_string(r1).await, "healthy");
3037
3038 let r2 = router
3041 .oneshot(
3042 Request::builder()
3043 .uri("/api/healthcheck")
3044 .body(Body::empty())
3045 .unwrap(),
3046 )
3047 .await
3048 .unwrap();
3049 assert_eq!(r2.status(), StatusCode::NOT_FOUND);
3050 assert_eq!(body_string(r2).await, "static-404");
3051 }
3052
3053 fn workload_with_hydrate(
3056 html_files: &[(&str, &str)],
3057 hydrate_files: &[(&str, &str)],
3058 ) -> tempfile::TempDir {
3059 let dir = tempdir().unwrap();
3060 let html_dir = dir.path().join("dist").join("html");
3061 let hydrate_dir = dir.path().join("dist").join("hydrate");
3062 std::fs::create_dir_all(&html_dir).unwrap();
3063 std::fs::create_dir_all(&hydrate_dir).unwrap();
3064 for (name, body) in html_files {
3065 std::fs::write(html_dir.join(name), body).unwrap();
3066 }
3067 for (name, body) in hydrate_files {
3068 std::fs::write(hydrate_dir.join(name), body).unwrap();
3069 }
3070 dir
3071 }
3072
3073 #[tokio::test]
3075 async fn serves_hydrate_bundle_with_build_id_prefix() {
3076 let workload = workload_with_hydrate(
3077 &[],
3078 &[("issues.abc123.js", "console.log('hydrate')")],
3079 );
3080 let app = Server::from_workload(workload.path()).unwrap().router();
3081 let response = app
3082 .oneshot(
3083 Request::builder()
3084 .uri("/gen-1/hydrate/issues.abc123.js")
3085 .body(Body::empty())
3086 .unwrap(),
3087 )
3088 .await
3089 .unwrap();
3090 assert_eq!(response.status(), StatusCode::OK);
3091 let ct = response
3092 .headers()
3093 .get("content-type")
3094 .unwrap()
3095 .to_str()
3096 .unwrap();
3097 assert!(ct.contains("application/javascript"), "wrong mime: {ct}");
3098 assert!(body_string(response).await.contains("hydrate"));
3099 }
3100
3101 #[tokio::test]
3104 async fn serves_hydrate_bundle_build_id_opaque() {
3105 let workload = workload_with_hydrate(
3106 &[],
3107 &[("app.xyz.js", "export default 1")],
3108 );
3109 let app = Server::from_workload(workload.path()).unwrap().router();
3110 for prefix in &["no-such-build-id", "gen-99", "abc123"] {
3111 let response = app
3112 .clone()
3113 .oneshot(
3114 Request::builder()
3115 .uri(format!("/{prefix}/hydrate/app.xyz.js"))
3116 .body(Body::empty())
3117 .unwrap(),
3118 )
3119 .await
3120 .unwrap();
3121 assert_eq!(
3122 response.status(),
3123 StatusCode::OK,
3124 "build_id '{prefix}' should be opaque"
3125 );
3126 }
3127 }
3128
3129 #[tokio::test]
3131 async fn serves_hydrate_bundle_no_build_id_prefix() {
3132 let workload =
3133 workload_with_hydrate(&[], &[("app.js", "export default 1")]);
3134 let app = Server::from_workload(workload.path()).unwrap().router();
3135 let response = app
3136 .oneshot(
3137 Request::builder()
3138 .uri("/hydrate/app.js")
3139 .body(Body::empty())
3140 .unwrap(),
3141 )
3142 .await
3143 .unwrap();
3144 assert_eq!(response.status(), StatusCode::OK);
3145 }
3146
3147 #[tokio::test]
3149 async fn hydrate_path_traversal_rejected() {
3150 let workload = workload_with_hydrate(&[], &[]);
3151 let app = Server::from_workload(workload.path()).unwrap().router();
3152 let response = app
3153 .oneshot(
3154 Request::builder()
3155 .uri("/gen-1/hydrate/../../etc/passwd")
3156 .body(Body::empty())
3157 .unwrap(),
3158 )
3159 .await
3160 .unwrap();
3161 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3162 }
3163
3164 #[cfg(feature = "ssr")]
3167 #[tokio::test]
3168 async fn ssr_parametric_prefix_forwards_full_path() {
3169 let workload = workload_with(&[]);
3170 let ssr = ssr::detached_for_test_with_policies(
3171 vec!["/api/users/".to_string()],
3172 vec![],
3173 |req| {
3174 let id = req
3175 .url
3176 .rsplit_once('/')
3177 .map(|(_, t)| t.to_string())
3178 .unwrap_or_default();
3179 Ok(DispatchResponse {
3180 status: 200,
3181 headers: vec![("content-type".into(), "text/plain".into())],
3182 body: format!("user {id}").into_bytes(),
3183 })
3184 },
3185 );
3186 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
3187 let response = server
3188 .router()
3189 .oneshot(
3190 Request::builder()
3191 .uri("/api/users/42")
3192 .body(Body::empty())
3193 .unwrap(),
3194 )
3195 .await
3196 .unwrap();
3197 assert_eq!(response.status(), StatusCode::OK);
3198 assert_eq!(body_string(response).await, "user 42");
3199 }
3200
3201 #[cfg(feature = "ssr")]
3204 fn retry_policy(attempts: u32, backoff_ms: Vec<u64>, retry_on: &str) -> ResiliencePolicy {
3205 ResiliencePolicy {
3206 retry: Some(RetryPolicy {
3207 attempts,
3208 backoff_ms,
3209 retry_on: Some(retry_on.to_string()),
3210 budget_ms: None,
3211 }),
3212 queue: None,
3213 timeout_ms: None,
3214 }
3215 }
3216
3217 #[cfg(feature = "ssr")]
3221 fn flaky_dispatch(
3222 ok_after: usize,
3223 ) -> (
3224 impl Fn(DispatchRequest) -> Result<DispatchResponse, anyhow::Error>
3225 + Send
3226 + Sync
3227 + 'static,
3228 Arc<std::sync::atomic::AtomicUsize>,
3229 ) {
3230 use std::sync::atomic::{AtomicUsize, Ordering};
3231 let counter = Arc::new(AtomicUsize::new(0));
3232 let c = counter.clone();
3233 let f = move |_req: DispatchRequest| {
3234 let n = c.fetch_add(1, Ordering::SeqCst);
3235 if n < ok_after {
3236 Ok(DispatchResponse {
3237 status: 500,
3238 headers: vec![("content-type".into(), "text/plain".into())],
3239 body: b"down".to_vec(),
3240 })
3241 } else {
3242 Ok(DispatchResponse {
3243 status: 201,
3244 headers: vec![("content-type".into(), "text/plain".into())],
3245 body: format!("ok after {n}").into_bytes(),
3246 })
3247 }
3248 };
3249 (f, counter)
3250 }
3251
3252 #[cfg(feature = "ssr")]
3254 #[tokio::test]
3255 async fn resilience_retry_on_5xx_succeeds_on_third_attempt() {
3256 let workload = workload_with(&[]);
3257 let (dispatch, counter) = flaky_dispatch(2);
3258 let policy = retry_policy(3, vec![10, 10], "5xx");
3259 let ssr = ssr::detached_for_test_with_policies(
3260 vec!["/api/issues".to_string()],
3261 vec![("/api/issues".to_string(), policy)],
3262 dispatch,
3263 );
3264 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
3265 let resp = server
3266 .router()
3267 .oneshot(
3268 Request::builder()
3269 .method("POST")
3270 .uri("/api/issues")
3271 .body(Body::from("{\"title\":\"x\"}"))
3272 .unwrap(),
3273 )
3274 .await
3275 .unwrap();
3276 assert_eq!(resp.status(), StatusCode::CREATED);
3277 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 3);
3278 }
3279
3280 #[cfg(feature = "ssr")]
3283 #[tokio::test]
3284 async fn resilience_no_retry_on_5xx_when_retry_on_connection() {
3285 let workload = workload_with(&[]);
3286 let (dispatch, counter) = flaky_dispatch(usize::MAX);
3287 let policy = retry_policy(3, vec![10, 10], "connection");
3288 let ssr = ssr::detached_for_test_with_policies(
3289 vec!["/api/issues".to_string()],
3290 vec![("/api/issues".to_string(), policy)],
3291 dispatch,
3292 );
3293 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
3294 let resp = server
3295 .router()
3296 .oneshot(
3297 Request::builder()
3298 .method("POST")
3299 .uri("/api/issues")
3300 .body(Body::from("{\"title\":\"x\"}"))
3301 .unwrap(),
3302 )
3303 .await
3304 .unwrap();
3305 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3306 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
3307 }
3308
3309 #[cfg(feature = "ssr")]
3312 #[tokio::test]
3313 async fn resilience_per_attempt_timeout_aborts_slow_dispatch() {
3314 let workload = workload_with(&[]);
3315 let _ = workload;
3324 }
3327
3328 #[cfg(feature = "ssr")]
3330 #[tokio::test]
3331 async fn resilience_absent_falls_back_to_single_attempt() {
3332 let workload = workload_with(&[]);
3333 let (dispatch, counter) = flaky_dispatch(usize::MAX);
3334 let ssr = ssr::detached_for_test_with_policies(
3335 vec!["/api/issues".to_string()],
3336 vec![],
3337 dispatch,
3338 );
3339 let server = Server::from_workload(workload.path()).unwrap().with_ssr(ssr);
3340 let resp = server
3341 .router()
3342 .oneshot(
3343 Request::builder()
3344 .method("POST")
3345 .uri("/api/issues")
3346 .body(Body::from("{\"title\":\"x\"}"))
3347 .unwrap(),
3348 )
3349 .await
3350 .unwrap();
3351 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3352 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
3353 }
3354
3355 async fn spawn_echo_backend() -> String {
3360 use axum::routing::any;
3361 let app = Router::new().route(
3362 "/{*rest}",
3363 any(|req: axum::extract::Request| async move {
3364 let method = req.method().to_string();
3365 let path = req.uri().path().to_string();
3366 let body = to_bytes(req.into_body(), usize::MAX).await.unwrap();
3367 format!("backend {method} {path} body={}", String::from_utf8_lossy(&body))
3368 }),
3369 );
3370 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3371 let addr = listener.local_addr().unwrap();
3372 tokio::spawn(async move {
3373 axum::serve(listener, app).await.unwrap();
3374 });
3375 format!("http://{addr}")
3376 }
3377
3378 #[tokio::test]
3379 async fn proxy_forwards_matching_prefix_path_preserving() {
3380 let backend = spawn_echo_backend().await;
3381 let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3382 let app = Server::from_workload(workload.path())
3383 .unwrap()
3384 .with_proxy(proxy::ProxyMap::new([("/auth".to_string(), backend.clone())]))
3385 .router();
3386 let resp = app
3387 .oneshot(
3388 Request::builder()
3389 .method("POST")
3390 .uri("/auth/magic-link/request")
3391 .body(Body::from("{\"email\":\"cecil@yah.dev\"}"))
3392 .unwrap(),
3393 )
3394 .await
3395 .unwrap();
3396 assert_eq!(resp.status(), StatusCode::OK);
3397 let body = body_string(resp).await;
3398 assert!(
3400 body.contains("backend POST /auth/magic-link/request"),
3401 "proxy must preserve method + path: {body}",
3402 );
3403 assert!(body.contains("cecil@yah.dev"), "proxy must forward the body: {body}");
3404 }
3405
3406 #[tokio::test]
3407 async fn proxy_falls_through_to_spa_for_unmapped_paths() {
3408 let backend = spawn_echo_backend().await;
3409 let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3410 let app = Server::from_workload(workload.path())
3411 .unwrap()
3412 .with_proxy(proxy::ProxyMap::new([("/auth".to_string(), backend)]))
3413 .router();
3414 let resp = app
3416 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
3417 .await
3418 .unwrap();
3419 assert_eq!(resp.status(), StatusCode::OK);
3420 assert!(body_string(resp).await.contains("spa"));
3421 }
3422
3423 #[tokio::test]
3424 async fn config_json_served_when_injected() {
3425 let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3426 let app = Server::from_workload(workload.path())
3427 .unwrap()
3428 .with_config_json(br#"{"env":"ci","authBaseUrl":"/auth"}"#.to_vec())
3429 .router();
3430 let resp = app
3431 .oneshot(Request::builder().uri("/config.json").body(Body::empty()).unwrap())
3432 .await
3433 .unwrap();
3434 assert_eq!(resp.status(), StatusCode::OK);
3435 let body = body_string(resp).await;
3436 assert!(body.contains("\"env\":\"ci\""), "serves injected config: {body}");
3437 }
3438
3439 #[tokio::test]
3440 async fn config_json_falls_through_to_static_when_not_injected() {
3441 let workload = workload_with(&[("config.json", r#"{"env":"static-file"}"#)]);
3445 let app = Server::from_workload(workload.path()).unwrap().router();
3446 let resp = app
3447 .oneshot(Request::builder().uri("/config.json").body(Body::empty()).unwrap())
3448 .await
3449 .unwrap();
3450 assert_eq!(resp.status(), StatusCode::OK);
3454 assert!(body_string(resp).await.contains("static-file"));
3455 }
3456
3457 #[tokio::test]
3458 async fn proxy_returns_502_on_dead_backend() {
3459 let workload = workload_with(&[("index.html", "<h1>spa</h1>")]);
3460 let app = Server::from_workload(workload.path())
3462 .unwrap()
3463 .with_proxy(proxy::ProxyMap::new([(
3464 "/auth".to_string(),
3465 "http://127.0.0.1:1".to_string(),
3466 )]))
3467 .router();
3468 let resp = app
3469 .oneshot(
3470 Request::builder()
3471 .uri("/auth/health")
3472 .body(Body::empty())
3473 .unwrap(),
3474 )
3475 .await
3476 .unwrap();
3477 assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
3478 }
3479}