Skip to main content

tatara_lisp_script/stdlib/
http_server.rs

1//! HTTP server.
2//!
3//! Lets a `.tlisp` script spin up an HTTP listener with a static
4//! routing table. Suitable for hello-world demos, smoke-test
5//! services, and the most basic "deploy a tatara-lisp program live"
6//! shape.
7//!
8//! ```text
9//! ;; Static routes — fixed responses per path.
10//! (http-serve-static
11//!   8080
12//!   '(("/healthz"   200 "{\"status\":\"ok\"}")
13//!     ("/"          200 "{\"message\":\"Hello, world!\"}")
14//!     ("/hello"     200 "{\"message\":\"Hello, world!\"}")))
15//! ```
16//!
17//! Blocks the calling thread until SIGTERM/SIGINT.
18//!
19//! Production HTTP services (with dynamic handlers, path params,
20//! middleware) ship through wasm-operator per
21//! [theory/WASM-STACK.md §IV](../../../../theory/WASM-STACK.md). This
22//! stdlib primitive is for the smallest demo case where a script
23//! "serves something" without coordinating a cluster.
24
25use std::sync::Arc;
26
27use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
28
29use crate::script_ctx::ScriptCtx;
30
31pub fn install(interp: &mut Interpreter<ScriptCtx>) {
32    interp.register_fn(
33        "http-serve-static",
34        Arity::Exact(2),
35        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
36            let port: u16 = match &args[0] {
37                Value::Int(n) => u16::try_from(*n).map_err(|_| {
38                    EvalError::native_fn(
39                        "http-serve-static",
40                        format!("http-serve-static: port {n} out of range"),
41                        sp,
42                    )
43                })?,
44                v => {
45                    return Err(EvalError::native_fn(
46                        "http-serve-static",
47                        format!("http-serve-static: port must be int, got {v:?}"),
48                        sp,
49                    ))
50                }
51            };
52
53            let routes = parse_routes(&args[1], sp)?;
54            let addr = format!("0.0.0.0:{port}");
55            let server = tiny_http::Server::http(&addr).map_err(|e| {
56                EvalError::native_fn(
57                    "http-serve-static",
58                    format!("http-serve-static bind {addr} failed: {e}"),
59                    sp,
60                )
61            })?;
62
63            eprintln!("[http-serve-static] listening on http://{addr}");
64            for path_route in &routes {
65                eprintln!(
66                    "[http-serve-static]   {} → {}",
67                    path_route.path, path_route.status
68                );
69            }
70
71            for request in server.incoming_requests() {
72                let path = request.url();
73                let path_only = path.split('?').next().unwrap_or(path).to_string();
74
75                let chosen: Option<&Route> = routes.iter().find(|r| r.path == path_only);
76
77                let response = match chosen {
78                    Some(r) => {
79                        eprintln!(
80                            "[http-serve-static] {} {} → {}",
81                            request.method(),
82                            path,
83                            r.status
84                        );
85                        tiny_http::Response::from_string(r.body.clone())
86                            .with_status_code(r.status)
87                            .with_header(json_header())
88                    }
89                    None => {
90                        eprintln!("[http-serve-static] {} {} → 404", request.method(), path);
91                        tiny_http::Response::from_string(r#"{"error":"not_found"}"#.to_string())
92                            .with_status_code(404)
93                            .with_header(json_header())
94                    }
95                };
96                let _ = request.respond(response);
97            }
98            Ok(Value::Nil)
99        },
100    );
101}
102
103fn json_header() -> tiny_http::Header {
104    tiny_http::Header::from_bytes(b"Content-Type".as_ref(), b"application/json".as_ref())
105        .expect("static header")
106}
107
108#[derive(Debug)]
109struct Route {
110    path: String,
111    status: i32,
112    body: String,
113}
114
115fn parse_routes(value: &Value, sp: tatara_lisp::Span) -> Result<Vec<Route>, EvalError> {
116    let outer = match value {
117        Value::List(l) => l.as_ref(),
118        v => {
119            return Err(EvalError::native_fn(
120                "http-serve-static",
121                format!("http-serve-static: routes must be a list, got {v:?}"),
122                sp,
123            ))
124        }
125    };
126
127    let mut routes = Vec::with_capacity(outer.len());
128    for entry in outer {
129        let triple = match entry {
130            Value::List(l) => l.as_ref(),
131            v => {
132                return Err(EvalError::native_fn(
133                    "http-serve-static",
134                    format!("http-serve-static: each route must be a 3-list, got {v:?}"),
135                    sp,
136                ))
137            }
138        };
139        if triple.len() != 3 {
140            return Err(EvalError::native_fn(
141                "http-serve-static",
142                format!(
143                    "http-serve-static: route needs (path status body), got {} elements",
144                    triple.len()
145                ),
146                sp,
147            ));
148        }
149
150        let path = match &triple[0] {
151            Value::Str(s) => s.to_string(),
152            v => {
153                return Err(EvalError::native_fn(
154                    "http-serve-static",
155                    format!("http-serve-static: path must be string, got {v:?}"),
156                    sp,
157                ))
158            }
159        };
160        let status: i32 = match &triple[1] {
161            Value::Int(n) => i32::try_from(*n).map_err(|_| {
162                EvalError::native_fn(
163                    "http-serve-static",
164                    format!("http-serve-static: status {n} out of range"),
165                    sp,
166                )
167            })?,
168            v => {
169                return Err(EvalError::native_fn(
170                    "http-serve-static",
171                    format!("http-serve-static: status must be int, got {v:?}"),
172                    sp,
173                ))
174            }
175        };
176        let body = match &triple[2] {
177            Value::Str(s) => s.to_string(),
178            v => {
179                return Err(EvalError::native_fn(
180                    "http-serve-static",
181                    format!("http-serve-static: body must be string, got {v:?}"),
182                    sp,
183                ))
184            }
185        };
186        routes.push(Route { path, status, body });
187    }
188    Ok(routes)
189}
190
191// Silence the unused-Arc-import lint for a clean module.
192#[allow(dead_code)]
193fn _unused() -> Arc<()> {
194    Arc::new(())
195}