1use std::collections::HashMap;
7use std::future::Future;
8
9#[non_exhaustive]
11#[derive(Clone, Debug)]
12pub struct EdgeRequest {
13 pub method: String,
15 pub path: String,
17 pub headers: HashMap<String, String>,
19 pub body: Vec<u8>,
21}
22
23impl EdgeRequest {
24 pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
26 Self {
27 method: method.into(),
28 path: path.into(),
29 headers: HashMap::new(),
30 body: Vec::new(),
31 }
32 }
33
34 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
36 self.headers.insert(key.into(), value.into());
37 self
38 }
39
40 pub fn with_body(mut self, body: Vec<u8>) -> Self {
42 self.body = body;
43 self
44 }
45}
46
47#[non_exhaustive]
49#[derive(Clone, Debug)]
50pub struct EdgeResponse {
51 pub status: u16,
53 pub headers: HashMap<String, String>,
55 pub body: Vec<u8>,
57}
58
59impl EdgeResponse {
60 pub fn new(status: u16) -> Self {
62 Self {
63 status,
64 headers: HashMap::new(),
65 body: Vec::new(),
66 }
67 }
68
69 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
71 self.headers.insert(key.into(), value.into());
72 self
73 }
74
75 pub fn with_body(mut self, body: Vec<u8>) -> Self {
77 self.body = body;
78 self
79 }
80}
81
82#[cfg_attr(mutants, mutants::skip)]
84pub fn spawn<F>(future: F)
85where
86 F: Future<Output = ()> + Send + 'static,
87{
88 #[cfg(target_arch = "wasm32")]
89 {
90 wasm_bindgen_futures::spawn_local(future);
91 }
92 #[cfg(not(target_arch = "wasm32"))]
93 {
94 tokio::spawn(future);
95 }
96}
97
98#[non_exhaustive]
100pub struct EdgeServer<F> {
101 pub handler: F,
103 pub port: u16,
105}
106
107impl<F, Fut> EdgeServer<F>
108where
109 F: Fn(EdgeRequest) -> Fut + Clone + Send + Sync + 'static,
110 Fut: Future<Output = EdgeResponse> + Send + 'static,
111{
112 pub fn new(handler: F) -> Self {
114 Self {
115 handler,
116 port: 3000,
117 }
118 }
119
120 pub fn with_port(mut self, port: u16) -> Self {
122 self.port = port;
123 self
124 }
125
126 #[cfg(not(target_arch = "wasm32"))]
134 #[cfg_attr(mutants, mutants::skip)]
135 pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
136 let host = emulator_host(crate::server::builder::read_optional_environment_variable)?;
137 let listener = tokio::net::TcpListener::bind((host.as_str(), self.port)).await?;
138 println!(
139 "🚀 Edge local emulator running on http://{}",
140 listener.local_addr()?
141 );
142
143 axum::serve(listener, emulator_router(self.handler)).await?;
144 Ok(())
145 }
146
147 #[cfg(target_arch = "wasm32")]
149 #[cfg_attr(mutants, mutants::skip)]
150 pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
151 web_sys::console::log_1(&"🚀 Rullst Edge Runtime serving on WASM target".into());
155 Ok(())
156 }
157}
158
159#[cfg(not(target_arch = "wasm32"))]
161const MAX_EDGE_BODY_BYTES: usize = 2 * 1024 * 1024;
162
163#[cfg(not(target_arch = "wasm32"))]
164fn emulator_host(
165 environment: impl Fn(&str) -> Result<Option<String>, crate::server::ServerError>,
166) -> Result<String, crate::server::ServerError> {
167 Ok(environment("HOST")?
168 .or(environment("RULLST_HOST")?)
169 .unwrap_or_else(|| "127.0.0.1".to_string()))
170}
171
172#[cfg(not(target_arch = "wasm32"))]
173fn emulator_router<F, Fut>(handler: F) -> axum::Router
174where
175 F: Fn(EdgeRequest) -> Fut + Clone + Send + Sync + 'static,
176 Fut: Future<Output = EdgeResponse> + Send + 'static,
177{
178 use axum::extract::DefaultBodyLimit;
179 use axum::http::{HeaderMap, Method, StatusCode, Uri};
180 use axum::routing::any;
181
182 let service = any(
185 move |method: Method, uri: Uri, header_map: HeaderMap, body: axum::body::Bytes| {
186 let handler = handler.clone();
187 async move {
188 let mut headers = HashMap::new();
189 for (k, v) in header_map.iter() {
190 if let Ok(val) = v.to_str() {
191 headers.insert(k.as_str().to_string(), val.to_string());
192 }
193 }
194
195 let edge_req = EdgeRequest {
196 method: method.to_string(),
197 path: uri.path().to_string(),
198 headers,
199 body: body.to_vec(),
200 };
201
202 let edge_resp = handler(edge_req).await;
203
204 let mut res_builder = axum::http::Response::builder().status(edge_resp.status);
205 for (k, v) in edge_resp.headers.iter() {
206 res_builder = res_builder.header(k, v);
207 }
208 match res_builder.body(axum::body::Body::from(edge_resp.body)) {
209 Ok(res) => res,
210 Err(_) => {
211 let mut err_res = axum::response::Response::new(axum::body::Body::empty());
212 *err_res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
213 err_res
214 }
215 }
216 }
217 },
218 );
219
220 axum::Router::new()
221 .route("/", service.clone())
222 .route("/{*path}", service)
223 .layer(DefaultBodyLimit::max(MAX_EDGE_BODY_BYTES))
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_edge_request_builder() {
232 let req = EdgeRequest::new("POST", "/test")
233 .with_header("X-Foo", "bar")
234 .with_body(vec![1, 2, 3]);
235 assert_eq!(req.method, "POST");
236 assert_eq!(req.path, "/test");
237 assert_eq!(req.headers.get("X-Foo").map(|s| s.as_str()), Some("bar"));
238 assert_eq!(req.body, vec![1, 2, 3]);
239 }
240
241 #[test]
242 fn test_edge_response_builder() {
243 let res = EdgeResponse::new(201)
244 .with_header("Content-Type", "application/json")
245 .with_body(vec![123, 125]);
246 assert_eq!(res.status, 201);
247 assert_eq!(
248 res.headers.get("Content-Type").map(|s| s.as_str()),
249 Some("application/json")
250 );
251 assert_eq!(res.body, vec![123, 125]);
252 }
253
254 #[cfg(not(target_arch = "wasm32"))]
255 #[tokio::test]
256 #[allow(clippy::unwrap_used)]
257 async fn emulator_rejects_unreadable_bodies_and_serves_the_root() {
258 use std::sync::Arc;
259 use std::sync::atomic::{AtomicUsize, Ordering};
260 use tower::ServiceExt;
261
262 let calls = Arc::new(AtomicUsize::new(0));
263 let seen = calls.clone();
264 let router = emulator_router(move |req: EdgeRequest| {
265 let seen = seen.clone();
266 async move {
267 seen.fetch_add(1, Ordering::SeqCst);
268 EdgeResponse::new(200).with_body(format!("{} {}", req.path, req.body.len()).into())
269 }
270 });
271 let send = |uri: &str, body: Vec<u8>| {
272 router.clone().oneshot(
273 axum::http::Request::post(uri)
274 .body(axum::body::Body::from(body))
275 .unwrap(),
276 )
277 };
278
279 let response = send("/documents/1", vec![b'x'; MAX_EDGE_BODY_BYTES + 1])
280 .await
281 .unwrap();
282 assert_eq!(response.status(), 413);
283 assert_eq!(calls.load(Ordering::SeqCst), 0, "handler must not run");
284
285 let response = send("/", b"ok".to_vec()).await.unwrap();
286 assert_eq!(response.status(), 200);
287 let body = axum::body::to_bytes(response.into_body(), 1024)
288 .await
289 .unwrap();
290 assert_eq!(&body[..], b"/ 2");
291
292 let response = send("/documents/1", vec![b'x'; MAX_EDGE_BODY_BYTES])
293 .await
294 .unwrap();
295 assert_eq!(response.status(), 200);
296 assert_eq!(calls.load(Ordering::SeqCst), 2);
297 }
298
299 #[cfg(not(target_arch = "wasm32"))]
300 #[test]
301 #[allow(clippy::unwrap_used)]
302 fn emulator_binds_loopback_unless_a_host_is_configured() {
303 assert_eq!(emulator_host(|_| Ok(None)).unwrap(), "127.0.0.1");
304 let configured = |name: &str| Ok((name == "RULLST_HOST").then(|| "0.0.0.0".to_string()));
305 assert_eq!(emulator_host(configured).unwrap(), "0.0.0.0");
306 let both = |name: &str| Ok(Some(name.to_ascii_lowercase()));
307 assert_eq!(emulator_host(both).unwrap(), "host");
308 }
309}