patx/relay-lang

Add static file serving, middleware hooks, and global template interpolation

Commit 2e2c90f · Harrison Erd · 2026-02-10T13:41:23-05:00

Changeset
2e2c90fc878b4fe1a31087bcf20f3f5e94186435
Parents
40c2bfe98b6c18c523e6538410b7eb480620ea48

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 2f8cd70..c4c9093 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,8 @@ Relay is designed for building high-performance web services, APIs, and network
 - **Implicit Async**: No `await` keyword needed—async operations resolve automatically when values are accessed
 - **Python-like Syntax**: Clean, indentation-based syntax that's easy to read and write
 - **Built-in Web Server**: Flask/FastAPI-style decorators with automatic routing
+- **Static File Serving**: Mount directories directly in your web app (`app.static("/assets", "./public")`)
+- **Middleware Hooks**: Run request middleware before handlers (`app.use(auth_middleware)`)
 - **HTTP Client**: Simple, async HTTP requests built-in
 - **MongoDB Integration**: Native async MongoDB support
 - **Session Management**: Built-in session handling with HttpOnly cookies
@@ -95,9 +97,16 @@ Notice how "hello" prints immediately while "world" waits 2 seconds—all withou
 app = WebApp()
 server = WebServer()
 
+fn auth()
+    if (request.path == "/private" && session["user"] == None)
+        return app.redirect("/login")
+
+app.use(auth)
+app.static("/assets", "./public")
+
 @app.get("/")
 fn index()
-    return {"message": "Hello, Relay!"}
+    return "Hello, {{ user }}"
 
 server.run(app)
 ```
diff --git a/src/main.rs b/src/main.rs
index de857f7..ef87a15 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,6 +13,7 @@
 use std::{
     collections::{HashMap, HashSet},
     future::Future,
+    path::Component,
     path::PathBuf,
     pin::Pin,
     sync::{Arc, Mutex},
@@ -1434,6 +1435,16 @@ impl Env {
     fn snapshot_top(&self) -> HashMap<String, Value> {
         self.scopes.last().unwrap().clone()
     }
+
+    fn snapshot_merged(&self) -> HashMap<String, Value> {
+        let mut merged = HashMap::new();
+        for scope in &self.scopes {
+            for (k, v) in scope {
+                merged.insert(k.clone(), v.clone());
+            }
+        }
+        merged
+    }
 }
 
 // ========================= Template strings =========================
@@ -1803,7 +1814,17 @@ impl Evaluator {
             Expr::Float(f) => Ok(Value::Float(*f)),
             Expr::Bool(b) => Ok(Value::Bool(*b)),
             Expr::None => Ok(Value::None),
-            Expr::Str(s) => Ok(Value::Str(s.clone())),
+            Expr::Str(s) => {
+                if s.contains("{{") && s.contains("}}") {
+                    let locals = {
+                        let env = self.env.lock().await;
+                        env.snapshot_merged()
+                    };
+                    Ok(Value::Str(render_template(s, &locals)))
+                } else {
+                    Ok(Value::Str(s.clone()))
+                }
+            }
             Expr::Ident(s) => self.env.lock().await.get(s),
 
             Expr::List(items) => {
@@ -2749,6 +2770,8 @@ struct WebServerHandle;
 #[derive(Default)]
 struct AppState {
     routes: Vec<RouteSpec>,
+    middlewares: Vec<String>,
+    static_mounts: Vec<StaticMount>,
     sessions: HashMap<String, IndexMap<String, Value>>,
 }
 #[derive(Clone)]
@@ -2758,6 +2781,12 @@ struct RouteSpec {
     fn_name: String,
 }
 
+#[derive(Clone)]
+struct StaticMount {
+    route_prefix: String,
+    dir: String,
+}
+
 impl WebServerHandle {
     fn new() -> Self {
         Self
@@ -2785,6 +2814,21 @@ impl WebAppHandle {
         });
     }
 
+    fn use_middleware(&self, fn_name: String) {
+        let mut st = self.inner.lock().unwrap();
+        st.middlewares.push(fn_name);
+    }
+
+    fn mount_static(&self, route_prefix: String, dir: String) {
+        let mut st = self.inner.lock().unwrap();
+        st.static_mounts.push(StaticMount { route_prefix, dir });
+    }
+
+    fn middleware_names(&self) -> Vec<String> {
+        let st = self.inner.lock().unwrap();
+        st.middlewares.clone()
+    }
+
     fn get_session(&self, sid: &str) -> IndexMap<String, Value> {
         let st = self.inner.lock().unwrap();
         st.sessions.get(sid).cloned().unwrap_or_default()
@@ -2853,9 +2897,44 @@ fn get_global_callback() -> Option<HandlerCb> {
 }
 
 async fn run_app(app: WebAppHandle) -> RResult<()> {
-    let specs = app.inner.lock().unwrap().routes.clone();
+    let (specs, static_mounts) = {
+        let state = app.inner.lock().unwrap();
+        (state.routes.clone(), state.static_mounts.clone())
+    };
     let mut router = Router::new();
 
+    for mount in static_mounts {
+        let route = normalize_static_route_prefix(&mount.route_prefix);
+        let path_pattern = format!("{}/*file", route.trim_end_matches('/'));
+        let dir = mount.dir.clone();
+
+        let dir_for_index = dir.clone();
+        let route_for_index = route.clone();
+        let index_handler = move || {
+            let dir = dir_for_index.clone();
+            let route_prefix = route_for_index.clone();
+            async move {
+                let response = serve_static_file(&route_prefix, &dir, "index.html").await;
+                Ok::<_, (StatusCode, String)>(value_to_axum_response(Value::Response(response)).await.into_response())
+            }
+        };
+
+        let dir_for_files = dir.clone();
+        let route_for_files = route.clone();
+        let static_handler = move |Path(file): Path<String>| {
+            let dir = dir_for_files.clone();
+            let route_prefix = route_for_files.clone();
+            async move {
+                let response = serve_static_file(&route_prefix, &dir, &file).await;
+                Ok::<_, (StatusCode, String)>(value_to_axum_response(Value::Response(response)).await.into_response())
+            }
+        };
+
+        router = router
+            .route(&route, get(index_handler))
+            .route(&path_pattern, get(static_handler));
+    }
+
     for r in specs {
         let method = r.method.to_lowercase();
         let path = relay_path_to_axum(&r.path);
@@ -2979,6 +3058,90 @@ fn relay_path_to_axum(p: &str) -> String {
     out
 }
 
+fn normalize_static_route_prefix(prefix: &str) -> String {
+    if prefix.is_empty() || prefix == "/" {
+        "/".to_string()
+    } else {
+        let mut normalized = prefix.to_string();
+        if !normalized.starts_with('/') {
+            normalized.insert(0, '/');
+        }
+        while normalized.len() > 1 && normalized.ends_with('/') {
+            normalized.pop();
+        }
+        normalized
+    }
+}
+
+fn safe_static_join(base: &str, rel_file: &str) -> Option<PathBuf> {
+    let rel = PathBuf::from(rel_file);
+    if rel.is_absolute() {
+        return None;
+    }
+    if rel
+        .components()
+        .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
+    {
+        return None;
+    }
+    Some(PathBuf::from(base).join(rel))
+}
+
+fn guess_content_type(path: &str) -> &'static str {
+    if let Some(ext) = path.rsplit('.').next() {
+        match ext.to_ascii_lowercase().as_str() {
+            "html" | "htm" => "text/html; charset=utf-8",
+            "css" => "text/css; charset=utf-8",
+            "js" | "mjs" => "application/javascript; charset=utf-8",
+            "json" => "application/json",
+            "svg" => "image/svg+xml",
+            "png" => "image/png",
+            "jpg" | "jpeg" => "image/jpeg",
+            "gif" => "image/gif",
+            "webp" => "image/webp",
+            "txt" => "text/plain; charset=utf-8",
+            _ => "application/octet-stream",
+        }
+    } else {
+        "application/octet-stream"
+    }
+}
+
+async fn serve_static_file(route_prefix: &str, dir: &str, file: &str) -> Response {
+    let target = if file.is_empty() {
+        "index.html".to_string()
+    } else {
+        file.to_string()
+    };
+
+    let Some(path) = safe_static_join(dir, &target) else {
+        return Response {
+            status: 400,
+            content_type: "text/plain; charset=utf-8".into(),
+            body: format!("Bad static file path under {route_prefix}").into_bytes(),
+            headers: HashMap::new(),
+            set_cookies: vec![],
+        };
+    };
+
+    match tokio::fs::read(&path).await {
+        Ok(bytes) => Response {
+            status: 200,
+            content_type: guess_content_type(&target).into(),
+            body: bytes,
+            headers: HashMap::new(),
+            set_cookies: vec![],
+        },
+        Err(_) => Response {
+            status: 404,
+            content_type: "text/plain; charset=utf-8".into(),
+            body: b"Not Found".to_vec(),
+            headers: HashMap::new(),
+            set_cookies: vec![],
+        },
+    }
+}
+
 async fn value_to_axum_response(v: Value) -> axum::response::Response {
     let r = match v {
         Value::Response(r) => r,
@@ -3492,6 +3655,39 @@ impl Object {
                         }))
                     })
                 })),
+                "use" => {
+                    let a = a.clone();
+                    Value::Builtin(Arc::new(move |args, _| {
+                        let a = a.clone();
+                        Box::pin(async move {
+                            let middleware_fn = args.get(0).cloned().ok_or_else(|| {
+                                RelayError::Type("app.use(fn) expects a function".into())
+                            })?;
+                            let fn_name = match middleware_fn {
+                                Value::Function(f) => f.name.clone(),
+                                _ => {
+                                    return Err(RelayError::Type(
+                                        "app.use(fn) expects a function".into(),
+                                    ))
+                                }
+                            };
+                            a.use_middleware(fn_name);
+                            Ok(Value::None)
+                        })
+                    }))
+                }
+                "static" => {
+                    let a = a.clone();
+                    Value::Builtin(Arc::new(move |args, _| {
+                        let a = a.clone();
+                        Box::pin(async move {
+                            let route_prefix = expect_str(&args, 0, "app.static(route, dir)")?;
+                            let dir = expect_str(&args, 1, "app.static(route, dir)")?;
+                            a.mount_static(route_prefix, dir);
+                            Ok(Value::None)
+                        })
+                    }))
+                }
                 _ => Value::None,
             },
             Object::Route(_r) => Value::None, // decorators handle route registration
@@ -3684,26 +3880,51 @@ impl Evaluator {
             env.set("session", Value::Dict(existing_session.clone()));
         }
 
+        for mw_name in app.middleware_names() {
+            let mw_value = self.env.lock().await.get(&mw_name)?;
+            let mw_func = match mw_value {
+                Value::Function(f) => f,
+                _ => {
+                    return Err(RelayError::Type(format!(
+                        "Middleware '{mw_name}' is not a function"
+                    )))
+                }
+            };
+
+            let result = self.eval_block(&mw_func.body).await?;
+            if let Flow::Return(v) = result {
+                if !matches!(v, Value::None) {
+                    let final_session = {
+                        let env = self.env.lock().await;
+                        let locals = env.snapshot_top();
+                        match locals.get("session") {
+                            Some(Value::Dict(m)) => m.clone(),
+                            _ => existing_session.clone(),
+                        }
+                    };
+
+                    {
+                        let mut env = self.env.lock().await;
+                        env.pop();
+                    }
+
+                    return Ok(apply_session_and_cookies(app, session_id, final_session, v));
+                }
+            }
+        }
+
         let out = match self.eval_block(&func.body).await? {
             Flow::None => Value::None,
             Flow::Return(v) => v,
         };
 
-        // template strings: render with locals if string contains {{ }}
-        let (rendered, final_session) = {
+        let final_session = {
             let env = self.env.lock().await;
             let locals = env.snapshot_top();
-            let session = match locals.get("session") {
+            match locals.get("session") {
                 Some(Value::Dict(m)) => m.clone(),
                 _ => existing_session,
-            };
-            let rendered = match out {
-                Value::Str(s) if s.contains("{{") && s.contains("}}") => {
-                    Value::Str(render_template(&s, &locals))
-                }
-                other => other,
-            };
-            (rendered, session)
+            }
         };
 
         {
@@ -3711,7 +3932,7 @@ impl Evaluator {
             env.pop();
         }
 
-        let mut response = match rendered {
+        let mut response = match out {
             Value::Response(r) => r,
             Value::Json(j) => Response {
                 status: 200,
@@ -3788,6 +4009,88 @@ impl Evaluator {
     }
 }
 
+fn apply_session_and_cookies(
+    app: WebAppHandle,
+    session_id: Option<String>,
+    final_session: IndexMap<String, Value>,
+    out: Value,
+) -> Value {
+    let mut response = match out {
+        Value::Response(r) => r,
+        Value::Json(j) => Response {
+            status: 200,
+            content_type: "application/json".into(),
+            body: j.to_string().into_bytes(),
+            headers: HashMap::new(),
+            set_cookies: vec![],
+        },
+        Value::Dict(m) => {
+            let mut obj = serde_json::Map::new();
+            for (k, vv) in m {
+                obj.insert(k, J::String(vv.repr()));
+            }
+            Response {
+                status: 200,
+                content_type: "application/json".into(),
+                body: J::Object(obj).to_string().into_bytes(),
+                headers: HashMap::new(),
+                set_cookies: vec![],
+            }
+        }
+        Value::List(vs) => {
+            let arr = vs
+                .into_iter()
+                .map(|x| J::String(x.repr()))
+                .collect::<Vec<_>>();
+            Response {
+                status: 200,
+                content_type: "application/json".into(),
+                body: J::Array(arr).to_string().into_bytes(),
+                headers: HashMap::new(),
+                set_cookies: vec![],
+            }
+        }
+        Value::Bytes(b) => Response {
+            status: 200,
+            content_type: "application/octet-stream".into(),
+            body: b,
+            headers: HashMap::new(),
+            set_cookies: vec![],
+        },
+        Value::Str(s) => {
+            let ct = if s.contains("<") || s.contains("{{") {
+                "text/html; charset=utf-8"
+            } else {
+                "text/plain; charset=utf-8"
+            };
+            Response {
+                status: 200,
+                content_type: ct.into(),
+                body: s.into_bytes(),
+                headers: HashMap::new(),
+                set_cookies: vec![],
+            }
+        }
+        other => Response {
+            status: 200,
+            content_type: "text/plain; charset=utf-8".into(),
+            body: other.repr().into_bytes(),
+            headers: HashMap::new(),
+            set_cookies: vec![],
+        },
+    };
+
+    if !final_session.is_empty() {
+        let sid = session_id.unwrap_or_else(new_session_id);
+        app.put_session(sid.clone(), final_session);
+        response
+            .set_cookies
+            .push(format!("relay_sid={sid}; Path=/; HttpOnly; SameSite=Lax"));
+    }
+
+    Value::Response(response)
+}
+
 // ========================= Main =========================
 
 #[tokio::main]