use std::collections::HashMap;
use std::future::Future;
#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct EdgeRequest {
pub method: String,
pub path: String,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl EdgeRequest {
pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
Self {
method: method.into(),
path: path.into(),
headers: HashMap::new(),
body: Vec::new(),
}
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
pub fn with_body(mut self, body: Vec<u8>) -> Self {
self.body = body;
self
}
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct EdgeResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl EdgeResponse {
pub fn new(status: u16) -> Self {
Self {
status,
headers: HashMap::new(),
body: Vec::new(),
}
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
pub fn with_body(mut self, body: Vec<u8>) -> Self {
self.body = body;
self
}
}
#[cfg_attr(mutants, mutants::skip)]
pub fn spawn<F>(future: F)
where
F: Future<Output = ()> + Send + 'static,
{
#[cfg(target_arch = "wasm32")]
{
wasm_bindgen_futures::spawn_local(future);
}
#[cfg(not(target_arch = "wasm32"))]
{
tokio::spawn(future);
}
}
#[non_exhaustive]
pub struct EdgeServer<F> {
pub handler: F,
pub port: u16,
}
impl<F, Fut> EdgeServer<F>
where
F: Fn(EdgeRequest) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = EdgeResponse> + Send + 'static,
{
pub fn new(handler: F) -> Self {
Self {
handler,
port: 3000,
}
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg_attr(mutants, mutants::skip)]
pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
let host = emulator_host(crate::server::builder::read_optional_environment_variable)?;
let listener = tokio::net::TcpListener::bind((host.as_str(), self.port)).await?;
println!(
"🚀 Edge local emulator running on http://{}",
listener.local_addr()?
);
axum::serve(listener, emulator_router(self.handler)).await?;
Ok(())
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(mutants, mutants::skip)]
pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
web_sys::console::log_1(&"🚀 Rullst Edge Runtime serving on WASM target".into());
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
const MAX_EDGE_BODY_BYTES: usize = 2 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
fn emulator_host(
environment: impl Fn(&str) -> Result<Option<String>, crate::server::ServerError>,
) -> Result<String, crate::server::ServerError> {
Ok(environment("HOST")?
.or(environment("RULLST_HOST")?)
.unwrap_or_else(|| "127.0.0.1".to_string()))
}
#[cfg(not(target_arch = "wasm32"))]
fn emulator_router<F, Fut>(handler: F) -> axum::Router
where
F: Fn(EdgeRequest) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = EdgeResponse> + Send + 'static,
{
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderMap, Method, StatusCode, Uri};
use axum::routing::any;
let service = any(
move |method: Method, uri: Uri, header_map: HeaderMap, body: axum::body::Bytes| {
let handler = handler.clone();
async move {
let mut headers = HashMap::new();
for (k, v) in header_map.iter() {
if let Ok(val) = v.to_str() {
headers.insert(k.as_str().to_string(), val.to_string());
}
}
let edge_req = EdgeRequest {
method: method.to_string(),
path: uri.path().to_string(),
headers,
body: body.to_vec(),
};
let edge_resp = handler(edge_req).await;
let mut res_builder = axum::http::Response::builder().status(edge_resp.status);
for (k, v) in edge_resp.headers.iter() {
res_builder = res_builder.header(k, v);
}
match res_builder.body(axum::body::Body::from(edge_resp.body)) {
Ok(res) => res,
Err(_) => {
let mut err_res = axum::response::Response::new(axum::body::Body::empty());
*err_res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
err_res
}
}
}
},
);
axum::Router::new()
.route("/", service.clone())
.route("/{*path}", service)
.layer(DefaultBodyLimit::max(MAX_EDGE_BODY_BYTES))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_edge_request_builder() {
let req = EdgeRequest::new("POST", "/test")
.with_header("X-Foo", "bar")
.with_body(vec![1, 2, 3]);
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/test");
assert_eq!(req.headers.get("X-Foo").map(|s| s.as_str()), Some("bar"));
assert_eq!(req.body, vec![1, 2, 3]);
}
#[test]
fn test_edge_response_builder() {
let res = EdgeResponse::new(201)
.with_header("Content-Type", "application/json")
.with_body(vec![123, 125]);
assert_eq!(res.status, 201);
assert_eq!(
res.headers.get("Content-Type").map(|s| s.as_str()),
Some("application/json")
);
assert_eq!(res.body, vec![123, 125]);
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
#[allow(clippy::unwrap_used)]
async fn emulator_rejects_unreadable_bodies_and_serves_the_root() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
let router = emulator_router(move |req: EdgeRequest| {
let seen = seen.clone();
async move {
seen.fetch_add(1, Ordering::SeqCst);
EdgeResponse::new(200).with_body(format!("{} {}", req.path, req.body.len()).into())
}
});
let send = |uri: &str, body: Vec<u8>| {
router.clone().oneshot(
axum::http::Request::post(uri)
.body(axum::body::Body::from(body))
.unwrap(),
)
};
let response = send("/documents/1", vec![b'x'; MAX_EDGE_BODY_BYTES + 1])
.await
.unwrap();
assert_eq!(response.status(), 413);
assert_eq!(calls.load(Ordering::SeqCst), 0, "handler must not run");
let response = send("/", b"ok".to_vec()).await.unwrap();
assert_eq!(response.status(), 200);
let body = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
assert_eq!(&body[..], b"/ 2");
let response = send("/documents/1", vec![b'x'; MAX_EDGE_BODY_BYTES])
.await
.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
#[allow(clippy::unwrap_used)]
fn emulator_binds_loopback_unless_a_host_is_configured() {
assert_eq!(emulator_host(|_| Ok(None)).unwrap(), "127.0.0.1");
let configured = |name: &str| Ok((name == "RULLST_HOST").then(|| "0.0.0.0".to_string()));
assert_eq!(emulator_host(configured).unwrap(), "0.0.0.0");
let both = |name: &str| Ok(Some(name.to_ascii_lowercase()));
assert_eq!(emulator_host(both).unwrap(), "host");
}
}