Skip to main content

rullst_core/
edge.rs

1//! Rullst Edge Runtime (`rullst::edge`)
2//!
3//! Native support for compiling and running Rullst apps on WebAssembly edge infrastructure
4//! (Cloudflare Workers, Fastly Compute, AWS Lambda@Edge) abstracting Tokio/WASI differences.
5
6use std::collections::HashMap;
7use std::future::Future;
8
9/// Environment-agnostic HTTP request payload designed for maximum compatibility.
10#[non_exhaustive]
11#[derive(Clone, Debug)]
12pub struct EdgeRequest {
13    /// HTTP method (e.g., "GET", "POST").
14    pub method: String,
15    /// Request URL path (e.g., "/users").
16    pub path: String,
17    /// Collection of request headers.
18    pub headers: HashMap<String, String>,
19    /// Raw request body in bytes.
20    pub body: Vec<u8>,
21}
22
23impl EdgeRequest {
24    /// Creates a new `EdgeRequest` using constructor and builder pattern for backwards compatibility.
25    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    /// Appends a header key-value pair to the request.
35    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    /// Sets the raw request body.
41    pub fn with_body(mut self, body: Vec<u8>) -> Self {
42        self.body = body;
43        self
44    }
45}
46
47/// Environment-agnostic HTTP response payload designed for maximum compatibility.
48#[non_exhaustive]
49#[derive(Clone, Debug)]
50pub struct EdgeResponse {
51    /// HTTP status code (e.g., 200, 404).
52    pub status: u16,
53    /// Collection of response headers.
54    pub headers: HashMap<String, String>,
55    /// Raw response body in bytes.
56    pub body: Vec<u8>,
57}
58
59impl EdgeResponse {
60    /// Creates a new `EdgeResponse` using constructor and builder pattern for backwards compatibility.
61    pub fn new(status: u16) -> Self {
62        Self {
63            status,
64            headers: HashMap::new(),
65            body: Vec::new(),
66        }
67    }
68
69    /// Appends a header key-value pair to the response.
70    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    /// Sets the raw response body.
76    pub fn with_body(mut self, body: Vec<u8>) -> Self {
77        self.body = body;
78        self
79    }
80}
81
82/// Environment-agnostic task spawner mapping to native Tokio or WASM local execution environments.
83#[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/// Portable Edge server running a local Axum emulator on native, and a direct executor on WASM.
99#[non_exhaustive]
100pub struct EdgeServer<F> {
101    /// The edge HTTP request handler function.
102    pub handler: F,
103    /// Optional: Local port to bind the emulation server.
104    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    /// Creates a new `EdgeServer` with the specified request handler.
113    pub fn new(handler: F) -> Self {
114        Self {
115            handler,
116            port: 3000,
117        }
118    }
119
120    /// Sets the local TCP port of the emulation server.
121    pub fn with_port(mut self, port: u16) -> Self {
122        self.port = port;
123        self
124    }
125
126    /// Serves request handling either natively as an emulator or natively in WASM edge runtimes.
127    ///
128    /// The native emulator binds `127.0.0.1` unless `HOST` or `RULLST_HOST` is
129    /// set (the same variables, in the same order, as [`crate::Server`]). It
130    /// answers every path including `/`. A request body larger than
131    /// 2 MiB is rejected with `413`, and a body that cannot be
132    /// read with `400`, without calling the handler.
133    #[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    /// Serves request handling natively inside WASM WASI edge loops.
148    #[cfg(target_arch = "wasm32")]
149    #[cfg_attr(mutants, mutants::skip)]
150    pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
151        // In actual Cloudflare Workers or WASM Edge targets,
152        // the global handler is registered statically.
153        // We log execution readiness for testing.
154        web_sys::console::log_1(&"🚀 Rullst Edge Runtime serving on WASM target".into());
155        Ok(())
156    }
157}
158
159/// Largest request body the native emulator buffers for an [`EdgeRequest`].
160#[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    // `Bytes` rejects an oversized body with 413 and an unreadable one with 400
183    // before the handler runs, instead of substituting an empty body.
184    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}