Skip to main content

harn_vm/http/
mod.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, HashMap};
3use std::rc::Rc;
4
5use crate::value::{VmClosure, VmError, VmValue};
6use crate::vm::Vm;
7
8mod client;
9mod mock;
10mod streaming;
11#[cfg(test)]
12mod tests;
13
14use mock::{
15    clear_http_mocks, http_mock_calls_value, parse_mock_responses, register_http_mock,
16    reset_http_mocks,
17};
18pub use mock::{http_mock_calls_snapshot, push_http_mock, HttpMockCallSnapshot, HttpMockResponse};
19#[cfg(test)]
20use mock::{mock_call_headers_value, redact_mock_call_url};
21
22#[derive(Clone)]
23struct HttpServerRoute {
24    method: String,
25    template: String,
26    handler: Rc<VmClosure>,
27    max_body_bytes: Option<usize>,
28    retain_raw_body: Option<bool>,
29}
30
31#[derive(Clone)]
32struct HttpServer {
33    routes: Vec<HttpServerRoute>,
34    before: Vec<Rc<VmClosure>>,
35    after: Vec<Rc<VmClosure>>,
36    ready: bool,
37    readiness: Option<Rc<VmClosure>>,
38    shutdown_hooks: Vec<Rc<VmClosure>>,
39    shutdown: bool,
40    max_body_bytes: usize,
41    retain_raw_body: bool,
42}
43
44pub(super) const DEFAULT_TIMEOUT_MS: u64 = 30_000;
45pub(super) const DEFAULT_BACKOFF_MS: u64 = 1_000;
46pub(super) const MAX_RETRY_DELAY_MS: u64 = 60_000;
47pub(super) const DEFAULT_RETRYABLE_STATUSES: [u16; 6] = [408, 429, 500, 502, 503, 504];
48pub(super) const DEFAULT_RETRYABLE_METHODS: [&str; 5] = ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"];
49pub(super) const DEFAULT_TRANSPORT_RECEIVE_TIMEOUT_MS: u64 = 30_000;
50pub(super) const DEFAULT_MAX_STREAM_EVENTS: usize = 10_000;
51pub(super) const DEFAULT_MAX_MESSAGE_BYTES: usize = 1024 * 1024;
52pub(super) const DEFAULT_SERVER_MAX_BODY_BYTES: usize = 1024 * 1024;
53pub(super) const DEFAULT_WEBSOCKET_SERVER_IDLE_TIMEOUT_MS: u64 = 30_000;
54pub(super) const MAX_HTTP_SESSIONS: usize = 64;
55pub(super) const MAX_HTTP_STREAMS: usize = 64;
56pub(super) const MAX_SSE_STREAMS: usize = 64;
57pub(super) const MAX_SSE_SERVER_STREAMS: usize = 64;
58pub(super) const MAX_WEBSOCKETS: usize = 64;
59pub(super) const MULTIPART_MOCK_BOUNDARY: &str = "harn-boundary";
60pub(super) const MAX_HTTP_SERVERS: usize = 128;
61pub(super) const MAX_WEBSOCKET_SERVERS: usize = 16;
62
63thread_local! {
64    static TRANSPORT_HANDLE_COUNTER: RefCell<u64> = const { RefCell::new(0) };
65    static HTTP_SERVERS: RefCell<HashMap<String, HttpServer>> = RefCell::new(HashMap::new());
66}
67
68/// Reset thread-local HTTP mock state. Call between test runs.
69pub fn reset_http_state() {
70    reset_http_mocks();
71    client::reset_client_state();
72    streaming::reset_streaming_state();
73    TRANSPORT_HANDLE_COUNTER.with(|counter| *counter.borrow_mut() = 0);
74    HTTP_SERVERS.with(|servers| servers.borrow_mut().clear());
75}
76
77pub(super) fn vm_error(message: impl Into<String>) -> VmError {
78    VmError::Thrown(VmValue::String(Rc::from(message.into())))
79}
80
81pub(super) fn next_transport_handle(prefix: &str) -> String {
82    TRANSPORT_HANDLE_COUNTER.with(|counter| {
83        let mut counter = counter.borrow_mut();
84        *counter += 1;
85        format!("{prefix}-{}", *counter)
86    })
87}
88
89pub(super) fn handle_from_value(value: &VmValue, builtin: &str) -> Result<String, VmError> {
90    match value {
91        VmValue::String(handle) => Ok(handle.to_string()),
92        VmValue::Dict(dict) => dict
93            .get("id")
94            .map(|id| id.display())
95            .filter(|id| !id.is_empty())
96            .ok_or_else(|| vm_error(format!("{builtin}: handle dict must contain id"))),
97        _ => Err(vm_error(format!(
98            "{builtin}: first argument must be a handle string or dict"
99        ))),
100    }
101}
102
103pub(super) fn get_options_arg(args: &[VmValue], index: usize) -> BTreeMap<String, VmValue> {
104    args.get(index)
105        .and_then(|value| value.as_dict())
106        .cloned()
107        .unwrap_or_default()
108}
109
110fn vm_string(value: impl AsRef<str>) -> VmValue {
111    VmValue::String(Rc::from(value.as_ref()))
112}
113
114fn dict_value(entries: BTreeMap<String, VmValue>) -> VmValue {
115    VmValue::Dict(Rc::new(entries))
116}
117
118fn get_bool_option(options: &BTreeMap<String, VmValue>, key: &str, default: bool) -> bool {
119    match options.get(key) {
120        Some(VmValue::Bool(value)) => *value,
121        _ => default,
122    }
123}
124
125fn get_usize_option(
126    options: &BTreeMap<String, VmValue>,
127    key: &str,
128    default: usize,
129) -> Result<usize, VmError> {
130    match options.get(key).and_then(VmValue::as_int) {
131        Some(value) if value >= 0 => Ok(value as usize),
132        Some(_) => Err(vm_error(format!("http_server: {key} must be non-negative"))),
133        None => Ok(default),
134    }
135}
136
137fn get_optional_usize_option(
138    options: &BTreeMap<String, VmValue>,
139    key: &str,
140) -> Result<Option<usize>, VmError> {
141    match options.get(key).and_then(VmValue::as_int) {
142        Some(value) if value >= 0 => Ok(Some(value as usize)),
143        Some(_) => Err(vm_error(format!(
144            "http_server_route: {key} must be non-negative"
145        ))),
146        None => Ok(None),
147    }
148}
149
150fn server_from_value(value: &VmValue, builtin: &str) -> Result<String, VmError> {
151    handle_from_value(value, builtin)
152}
153
154fn closure_arg(args: &[VmValue], index: usize, builtin: &str) -> Result<Rc<VmClosure>, VmError> {
155    match args.get(index) {
156        Some(VmValue::Closure(closure)) => Ok(closure.clone()),
157        Some(other) => Err(vm_error(format!(
158            "{builtin}: argument {} must be a closure, got {}",
159            index + 1,
160            other.type_name()
161        ))),
162        None => Err(vm_error(format!(
163            "{builtin}: missing closure argument {}",
164            index + 1
165        ))),
166    }
167}
168
169fn http_server_handle_value(id: &str) -> VmValue {
170    let mut dict = BTreeMap::new();
171    dict.insert("id".to_string(), vm_string(id));
172    dict.insert("kind".to_string(), vm_string("http_server"));
173    dict_value(dict)
174}
175
176fn header_lookup_value(headers: &BTreeMap<String, VmValue>, name: &str) -> VmValue {
177    headers
178        .iter()
179        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
180        .map(|(_, value)| value.clone())
181        .unwrap_or(VmValue::Nil)
182}
183
184fn headers_from_value(value: &VmValue) -> BTreeMap<String, VmValue> {
185    match value {
186        VmValue::Dict(dict) => dict
187            .get("headers")
188            .and_then(VmValue::as_dict)
189            .map(|headers| {
190                headers
191                    .iter()
192                    .map(|(key, value)| (key.to_ascii_lowercase(), vm_string(value.display())))
193                    .collect()
194            })
195            .unwrap_or_else(|| {
196                dict.iter()
197                    .map(|(key, value)| (key.to_ascii_lowercase(), vm_string(value.display())))
198                    .collect()
199            }),
200        _ => BTreeMap::new(),
201    }
202}
203
204fn normalize_headers(value: Option<&VmValue>) -> BTreeMap<String, VmValue> {
205    match value.and_then(VmValue::as_dict) {
206        Some(headers) => headers
207            .iter()
208            .map(|(key, value)| (key.to_ascii_lowercase(), vm_string(value.display())))
209            .collect(),
210        None => BTreeMap::new(),
211    }
212}
213
214fn percent_decode(input: &str) -> String {
215    let bytes = input.as_bytes();
216    let mut out = Vec::with_capacity(bytes.len());
217    let mut i = 0;
218    while i < bytes.len() {
219        if bytes[i] == b'+' {
220            out.push(b' ');
221            i += 1;
222            continue;
223        }
224        if bytes[i] == b'%' && i + 2 < bytes.len() {
225            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
226                out.push((hi << 4) | lo);
227                i += 3;
228                continue;
229            }
230        }
231        out.push(bytes[i]);
232        i += 1;
233    }
234    String::from_utf8_lossy(&out).into_owned()
235}
236
237fn hex_val(byte: u8) -> Option<u8> {
238    match byte {
239        b'0'..=b'9' => Some(byte - b'0'),
240        b'a'..=b'f' => Some(byte - b'a' + 10),
241        b'A'..=b'F' => Some(byte - b'A' + 10),
242        _ => None,
243    }
244}
245
246fn split_path_and_query(raw_path: &str) -> (String, BTreeMap<String, VmValue>) {
247    let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, ""));
248    let mut query_map = BTreeMap::new();
249    for pair in query.split('&').filter(|part| !part.is_empty()) {
250        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
251        query_map.insert(percent_decode(key), vm_string(percent_decode(value)));
252    }
253    (
254        if path.is_empty() { "/" } else { path }.to_string(),
255        query_map,
256    )
257}
258
259fn request_body_bytes(input: &BTreeMap<String, VmValue>) -> Vec<u8> {
260    match input.get("raw_body").or_else(|| input.get("body")) {
261        Some(VmValue::Bytes(bytes)) => bytes.as_ref().clone(),
262        Some(value) => value.display().into_bytes(),
263        None => Vec::new(),
264    }
265}
266
267fn request_value(
268    method: &str,
269    path: &str,
270    path_params: BTreeMap<String, VmValue>,
271    mut query: BTreeMap<String, VmValue>,
272    input: &BTreeMap<String, VmValue>,
273    body_bytes: &[u8],
274    retain_raw_body: bool,
275) -> VmValue {
276    if let Some(explicit_query) = input.get("query").and_then(VmValue::as_dict) {
277        query.extend(
278            explicit_query
279                .iter()
280                .map(|(key, value)| (key.clone(), value.clone())),
281        );
282    }
283
284    let headers = normalize_headers(input.get("headers"));
285    let body = String::from_utf8_lossy(body_bytes).into_owned();
286    let mut request = BTreeMap::new();
287    request.insert("method".to_string(), vm_string(method));
288    request.insert("path".to_string(), vm_string(path));
289    let path_params = dict_value(path_params);
290    request.insert("path_params".to_string(), path_params.clone());
291    request.insert("params".to_string(), path_params);
292    request.insert("query".to_string(), dict_value(query));
293    request.insert("headers".to_string(), dict_value(headers));
294    request.insert("body".to_string(), vm_string(body));
295    request.insert(
296        "raw_body".to_string(),
297        if retain_raw_body {
298            VmValue::Bytes(Rc::new(body_bytes.to_vec()))
299        } else {
300            VmValue::Nil
301        },
302    );
303    request.insert(
304        "body_bytes".to_string(),
305        VmValue::Int(body_bytes.len() as i64),
306    );
307    request.insert(
308        "remote_addr".to_string(),
309        input
310            .get("remote_addr")
311            .or_else(|| input.get("remote"))
312            .map(|value| vm_string(value.display()))
313            .unwrap_or(VmValue::Nil),
314    );
315    request.insert(
316        "client_ip".to_string(),
317        input
318            .get("client_ip")
319            .or_else(|| input.get("remote_ip"))
320            .or_else(|| input.get("ip"))
321            .map(|value| vm_string(value.display()))
322            .unwrap_or(VmValue::Nil),
323    );
324    dict_value(request)
325}
326
327fn normalize_status(status: i64) -> i64 {
328    if (100..=999).contains(&status) {
329        status
330    } else {
331        500
332    }
333}
334
335fn response_with_kind(
336    status: i64,
337    mut headers: BTreeMap<String, VmValue>,
338    body: VmValue,
339    body_kind: &str,
340) -> VmValue {
341    let status = normalize_status(status);
342    let mut response = BTreeMap::new();
343    if body_kind == "json" && matches!(header_lookup_value(&headers, "content-type"), VmValue::Nil)
344    {
345        headers.insert(
346            "content-type".to_string(),
347            vm_string("application/json; charset=utf-8"),
348        );
349    } else if body_kind == "text"
350        && matches!(header_lookup_value(&headers, "content-type"), VmValue::Nil)
351    {
352        headers.insert(
353            "content-type".to_string(),
354            vm_string("text/plain; charset=utf-8"),
355        );
356    }
357    response.insert("status".to_string(), VmValue::Int(status));
358    response.insert("headers".to_string(), dict_value(headers));
359    response.insert(
360        "ok".to_string(),
361        VmValue::Bool((200..300).contains(&status)),
362    );
363    response.insert("body_kind".to_string(), vm_string(body_kind));
364    match body {
365        VmValue::Bytes(bytes) => {
366            response.insert(
367                "body".to_string(),
368                vm_string(String::from_utf8_lossy(&bytes)),
369            );
370            response.insert("raw_body".to_string(), VmValue::Bytes(bytes));
371        }
372        other => {
373            response.insert("body".to_string(), vm_string(other.display()));
374            response.insert(
375                "raw_body".to_string(),
376                VmValue::Bytes(Rc::new(other.display().into_bytes())),
377            );
378        }
379    }
380    dict_value(response)
381}
382
383fn normalize_response(value: VmValue) -> VmValue {
384    match value {
385        VmValue::Dict(dict) if dict.contains_key("status") => {
386            let status = dict.get("status").and_then(VmValue::as_int).unwrap_or(200);
387            let headers = dict
388                .get("headers")
389                .and_then(VmValue::as_dict)
390                .cloned()
391                .unwrap_or_default();
392            let body_kind = dict
393                .get("body_kind")
394                .or_else(|| dict.get("kind"))
395                .map(|value| value.display())
396                .unwrap_or_else(|| "text".to_string());
397            let body = dict
398                .get("raw_body")
399                .filter(|value| matches!(value, VmValue::Bytes(_)))
400                .or_else(|| dict.get("body"))
401                .cloned()
402                .unwrap_or(VmValue::Nil);
403            response_with_kind(status, headers, body, &body_kind)
404        }
405        VmValue::Nil => response_with_kind(204, BTreeMap::new(), VmValue::Nil, "text"),
406        other => response_with_kind(200, BTreeMap::new(), other, "text"),
407    }
408}
409
410fn body_limit_response(limit: usize, actual: usize) -> VmValue {
411    let mut headers = BTreeMap::new();
412    headers.insert(
413        "content-type".to_string(),
414        vm_string("text/plain; charset=utf-8"),
415    );
416    headers.insert("connection".to_string(), vm_string("close"));
417    headers.insert(
418        "x-harn-body-limit".to_string(),
419        vm_string(limit.to_string()),
420    );
421    response_with_kind(
422        413,
423        headers,
424        vm_string(format!("request body too large: {actual} > {limit} bytes")),
425        "text",
426    )
427}
428
429fn not_found_response(method: &str, path: &str) -> VmValue {
430    response_with_kind(
431        404,
432        BTreeMap::new(),
433        vm_string(format!("no route for {method} {path}")),
434        "text",
435    )
436}
437
438fn unavailable_response(message: &str) -> VmValue {
439    response_with_kind(503, BTreeMap::new(), vm_string(message), "text")
440}
441
442fn route_template_match(template: &str, path: &str) -> Option<BTreeMap<String, VmValue>> {
443    let template_segments: Vec<&str> = template.trim_matches('/').split('/').collect();
444    let path_segments: Vec<&str> = path.trim_matches('/').split('/').collect();
445    if template == "/" && path == "/" {
446        return Some(BTreeMap::new());
447    }
448    if template_segments.len() != path_segments.len() {
449        return None;
450    }
451    let mut params = BTreeMap::new();
452    for (tmpl, actual) in template_segments.iter().zip(path_segments.iter()) {
453        if tmpl.starts_with('{') && tmpl.ends_with('}') && tmpl.len() > 2 {
454            params.insert(
455                tmpl[1..tmpl.len() - 1].to_string(),
456                vm_string(percent_decode(actual)),
457            );
458        } else if tmpl.starts_with(':') && tmpl.len() > 1 {
459            params.insert(tmpl[1..].to_string(), vm_string(percent_decode(actual)));
460        } else if tmpl != actual {
461            return None;
462        }
463    }
464    Some(params)
465}
466
467fn matching_route(
468    server: &HttpServer,
469    method: &str,
470    path: &str,
471) -> Option<(HttpServerRoute, BTreeMap<String, VmValue>)> {
472    server.routes.iter().find_map(|route| {
473        if route.method != "*" && !route.method.eq_ignore_ascii_case(method) {
474            return None;
475        }
476        route_template_match(&route.template, path).map(|params| (route.clone(), params))
477    })
478}
479
480async fn call_server_closure(
481    closure: &Rc<VmClosure>,
482    args: &[VmValue],
483    builtin: &str,
484) -> Result<VmValue, VmError> {
485    let mut vm = crate::vm::clone_async_builtin_child_vm()
486        .ok_or_else(|| vm_error(format!("{builtin}: requires an async builtin VM context")))?;
487    vm.call_closure_pub(closure, args).await
488}
489
490fn value_is_response(value: &VmValue) -> bool {
491    matches!(value, VmValue::Dict(dict) if dict.contains_key("status"))
492}
493
494async fn run_http_server_request(server_id: &str, request: VmValue) -> Result<VmValue, VmError> {
495    let server = HTTP_SERVERS.with(|servers| servers.borrow().get(server_id).cloned());
496    let Some(server) = server else {
497        return Err(vm_error(format!(
498            "http_server_request: unknown server handle '{server_id}'"
499        )));
500    };
501    if server.shutdown {
502        return Ok(unavailable_response("server is shut down"));
503    }
504    if !server.ready {
505        return Ok(unavailable_response("server is not ready"));
506    }
507    if let Some(readiness) = &server.readiness {
508        let ready = call_server_closure(
509            readiness,
510            &[http_server_handle_value(server_id)],
511            "http_server_request",
512        )
513        .await?;
514        if !ready.is_truthy() {
515            return Ok(unavailable_response("server is not ready"));
516        }
517    }
518
519    let input = request.as_dict().cloned().unwrap_or_default();
520    let method = input
521        .get("method")
522        .map(|value| value.display())
523        .filter(|value| !value.is_empty())
524        .unwrap_or_else(|| "GET".to_string())
525        .to_ascii_uppercase();
526    let raw_path = input
527        .get("path")
528        .map(|value| value.display())
529        .filter(|value| !value.is_empty())
530        .unwrap_or_else(|| "/".to_string());
531    let (path, query) = split_path_and_query(&raw_path);
532    let body_bytes = request_body_bytes(&input);
533
534    let Some((route, path_params)) = matching_route(&server, &method, &path) else {
535        return Ok(not_found_response(&method, &path));
536    };
537
538    let limit = route.max_body_bytes.unwrap_or(server.max_body_bytes);
539    if body_bytes.len() > limit {
540        return Ok(body_limit_response(limit, body_bytes.len()));
541    }
542    let retain_raw_body = route.retain_raw_body.unwrap_or(server.retain_raw_body);
543    let mut req = request_value(
544        &method,
545        &path,
546        path_params,
547        query,
548        &input,
549        &body_bytes,
550        retain_raw_body,
551    );
552
553    for before in &server.before {
554        let result = call_server_closure(before, &[req.clone()], "http_server_request").await?;
555        if value_is_response(&result) {
556            return Ok(normalize_response(result));
557        }
558        if !matches!(result, VmValue::Nil) {
559            req = result;
560        }
561    }
562
563    let handler_result =
564        call_server_closure(&route.handler, &[req.clone()], "http_server_request").await?;
565    let mut response = normalize_response(handler_result);
566
567    for after in &server.after {
568        let result = call_server_closure(
569            after,
570            &[response.clone(), req.clone()],
571            "http_server_request",
572        )
573        .await?;
574        if !matches!(result, VmValue::Nil) {
575            response = normalize_response(result);
576        }
577    }
578
579    Ok(response)
580}
581
582/// Register HTTP builtins on a VM.
583pub fn register_http_builtins(vm: &mut Vm) {
584    register_http_tls_builtins(vm);
585    client::register_http_verb_builtins(vm);
586    register_http_server_builtins(vm);
587    register_http_mock_builtins(vm);
588    client::register_http_client_builtins(vm);
589    streaming::register_http_streaming_builtins(vm);
590}
591
592fn register_http_tls_builtins(vm: &mut Vm) {
593    vm.register_builtin("http_server_tls_plain", |_args, _out| {
594        Ok(http_server_tls_config_value(
595            "plain",
596            false,
597            "http",
598            false,
599            BTreeMap::new(),
600        ))
601    });
602    vm.register_builtin("http_server_tls_edge", |args, _out| {
603        let options = get_options_arg(args, 0);
604        Ok(http_server_tls_config_value(
605            "edge",
606            false,
607            "https",
608            vm_get_bool_option(&options, "hsts", true),
609            hsts_options(&options),
610        ))
611    });
612    vm.register_builtin("http_server_tls_pem", |args, _out| {
613        if args.len() < 2 {
614            return Err(vm_error(
615                "http_server_tls_pem: requires cert path and key path",
616            ));
617        }
618        let cert_path = args[0].display();
619        let key_path = args[1].display();
620        if !std::path::Path::new(&cert_path).is_file() {
621            return Err(vm_error(format!(
622                "http_server_tls_pem: certificate not found: {cert_path}"
623            )));
624        }
625        if !std::path::Path::new(&key_path).is_file() {
626            return Err(vm_error(format!(
627                "http_server_tls_pem: private key not found: {key_path}"
628            )));
629        }
630        let mut extra = BTreeMap::new();
631        extra.insert(
632            "cert_path".to_string(),
633            VmValue::String(Rc::from(cert_path)),
634        );
635        extra.insert("key_path".to_string(), VmValue::String(Rc::from(key_path)));
636        Ok(http_server_tls_config_value(
637            "pem", true, "https", true, extra,
638        ))
639    });
640    vm.register_builtin("http_server_tls_self_signed_dev", |args, _out| {
641        let hosts = tls_hosts_arg(args.first())?;
642        let cert = rcgen::generate_simple_self_signed(hosts.clone()).map_err(|error| {
643            vm_error(format!(
644                "http_server_tls_self_signed_dev: failed to generate certificate: {error}"
645            ))
646        })?;
647        let mut extra = BTreeMap::new();
648        extra.insert(
649            "hosts".to_string(),
650            VmValue::List(Rc::new(
651                hosts
652                    .into_iter()
653                    .map(|host| VmValue::String(Rc::from(host)))
654                    .collect(),
655            )),
656        );
657        extra.insert(
658            "cert_pem".to_string(),
659            VmValue::String(Rc::from(cert.cert.pem())),
660        );
661        extra.insert(
662            "key_pem".to_string(),
663            VmValue::String(Rc::from(cert.signing_key.serialize_pem())),
664        );
665        Ok(http_server_tls_config_value(
666            "self_signed_dev",
667            true,
668            "https",
669            false,
670            extra,
671        ))
672    });
673    vm.register_builtin("http_server_security_headers", |args, _out| {
674        let Some(VmValue::Dict(config)) = args.first() else {
675            return Err(vm_error(
676                "http_server_security_headers: requires a TLS config dict",
677            ));
678        };
679        Ok(VmValue::Dict(Rc::new(http_server_security_headers(config))))
680    });
681}
682
683fn register_http_server_builtins(vm: &mut Vm) {
684    // --- Inbound HTTP server primitives ---
685
686    vm.register_builtin("http_server", |args, _out| {
687        let options = get_options_arg(args, 0);
688        let server = HttpServer {
689            routes: Vec::new(),
690            before: Vec::new(),
691            after: Vec::new(),
692            ready: get_bool_option(&options, "ready", true),
693            readiness: None,
694            shutdown_hooks: Vec::new(),
695            shutdown: false,
696            max_body_bytes: get_usize_option(
697                &options,
698                "max_body_bytes",
699                DEFAULT_SERVER_MAX_BODY_BYTES,
700            )?,
701            retain_raw_body: get_bool_option(&options, "retain_raw_body", true),
702        };
703        let id = next_transport_handle("http-server");
704        HTTP_SERVERS.with(|servers| {
705            let mut servers = servers.borrow_mut();
706            if servers.len() >= MAX_HTTP_SERVERS {
707                return Err(vm_error(format!(
708                    "http_server: maximum open servers ({MAX_HTTP_SERVERS}) reached"
709                )));
710            }
711            servers.insert(id.clone(), server);
712            Ok(())
713        })?;
714        Ok(http_server_handle_value(&id))
715    });
716
717    vm.register_builtin("http_server_route", |args, _out| {
718        if args.len() < 4 {
719            return Err(vm_error(
720                "http_server_route: requires server, method, path template, and handler",
721            ));
722        }
723        let server_id = server_from_value(&args[0], "http_server_route")?;
724        let method = args[1].display().to_ascii_uppercase();
725        if method.is_empty() {
726            return Err(vm_error("http_server_route: method is required"));
727        }
728        let template = args[2].display();
729        if !template.starts_with('/') {
730            return Err(vm_error(
731                "http_server_route: path template must start with '/'",
732            ));
733        }
734        let handler = closure_arg(args, 3, "http_server_route")?;
735        let options = get_options_arg(args, 4);
736        let route = HttpServerRoute {
737            method,
738            template,
739            handler,
740            max_body_bytes: get_optional_usize_option(&options, "max_body_bytes")?,
741            retain_raw_body: match options.get("retain_raw_body") {
742                Some(VmValue::Bool(value)) => Some(*value),
743                _ => None,
744            },
745        };
746        HTTP_SERVERS.with(|servers| {
747            let mut servers = servers.borrow_mut();
748            let server = servers.get_mut(&server_id).ok_or_else(|| {
749                vm_error(format!("http_server_route: unknown server '{server_id}'"))
750            })?;
751            server.routes.push(route);
752            Ok::<_, VmError>(())
753        })?;
754        Ok(http_server_handle_value(&server_id))
755    });
756
757    vm.register_builtin("http_server_before", |args, _out| {
758        if args.len() < 2 {
759            return Err(vm_error("http_server_before: requires server and handler"));
760        }
761        let server_id = server_from_value(&args[0], "http_server_before")?;
762        let handler = closure_arg(args, 1, "http_server_before")?;
763        HTTP_SERVERS.with(|servers| {
764            let mut servers = servers.borrow_mut();
765            let server = servers.get_mut(&server_id).ok_or_else(|| {
766                vm_error(format!("http_server_before: unknown server '{server_id}'"))
767            })?;
768            server.before.push(handler);
769            Ok::<_, VmError>(())
770        })?;
771        Ok(http_server_handle_value(&server_id))
772    });
773
774    vm.register_builtin("http_server_after", |args, _out| {
775        if args.len() < 2 {
776            return Err(vm_error("http_server_after: requires server and handler"));
777        }
778        let server_id = server_from_value(&args[0], "http_server_after")?;
779        let handler = closure_arg(args, 1, "http_server_after")?;
780        HTTP_SERVERS.with(|servers| {
781            let mut servers = servers.borrow_mut();
782            let server = servers.get_mut(&server_id).ok_or_else(|| {
783                vm_error(format!("http_server_after: unknown server '{server_id}'"))
784            })?;
785            server.after.push(handler);
786            Ok::<_, VmError>(())
787        })?;
788        Ok(http_server_handle_value(&server_id))
789    });
790
791    vm.register_async_builtin("http_server_request", |args| async move {
792        if args.len() < 2 {
793            return Err(vm_error("http_server_request: requires server and request"));
794        }
795        let server_id = server_from_value(&args[0], "http_server_request")?;
796        run_http_server_request(&server_id, args[1].clone()).await
797    });
798
799    vm.register_async_builtin("http_server_test", |args| async move {
800        if args.len() < 2 {
801            return Err(vm_error("http_server_test: requires server and request"));
802        }
803        let server_id = server_from_value(&args[0], "http_server_test")?;
804        run_http_server_request(&server_id, args[1].clone()).await
805    });
806
807    vm.register_builtin("http_server_set_ready", |args, _out| {
808        if args.len() < 2 {
809            return Err(vm_error(
810                "http_server_set_ready: requires server and ready bool",
811            ));
812        }
813        let server_id = server_from_value(&args[0], "http_server_set_ready")?;
814        let ready = matches!(args[1], VmValue::Bool(true));
815        HTTP_SERVERS.with(|servers| {
816            let mut servers = servers.borrow_mut();
817            let server = servers.get_mut(&server_id).ok_or_else(|| {
818                vm_error(format!(
819                    "http_server_set_ready: unknown server '{server_id}'"
820                ))
821            })?;
822            server.ready = ready;
823            Ok::<_, VmError>(())
824        })?;
825        Ok(VmValue::Bool(ready))
826    });
827
828    vm.register_builtin("http_server_readiness", |args, _out| {
829        if args.len() < 2 {
830            return Err(vm_error(
831                "http_server_readiness: requires server and readiness closure",
832            ));
833        }
834        let server_id = server_from_value(&args[0], "http_server_readiness")?;
835        let handler = closure_arg(args, 1, "http_server_readiness")?;
836        HTTP_SERVERS.with(|servers| {
837            let mut servers = servers.borrow_mut();
838            let server = servers.get_mut(&server_id).ok_or_else(|| {
839                vm_error(format!(
840                    "http_server_readiness: unknown server '{server_id}'"
841                ))
842            })?;
843            server.readiness = Some(handler);
844            Ok::<_, VmError>(())
845        })?;
846        Ok(http_server_handle_value(&server_id))
847    });
848
849    vm.register_async_builtin("http_server_ready", |args| async move {
850        let Some(server_arg) = args.first() else {
851            return Err(vm_error("http_server_ready: requires server"));
852        };
853        let server_id = server_from_value(server_arg, "http_server_ready")?;
854        let server = HTTP_SERVERS.with(|servers| servers.borrow().get(&server_id).cloned());
855        let Some(server) = server else {
856            return Err(vm_error(format!(
857                "http_server_ready: unknown server '{server_id}'"
858            )));
859        };
860        if server.shutdown {
861            return Ok(VmValue::Bool(false));
862        }
863        let Some(readiness) = server.readiness else {
864            return Ok(VmValue::Bool(server.ready));
865        };
866        let result = call_server_closure(
867            &readiness,
868            &[http_server_handle_value(&server_id)],
869            "http_server_ready",
870        )
871        .await?;
872        Ok(VmValue::Bool(result.is_truthy()))
873    });
874
875    vm.register_builtin("http_server_on_shutdown", |args, _out| {
876        if args.len() < 2 {
877            return Err(vm_error(
878                "http_server_on_shutdown: requires server and handler",
879            ));
880        }
881        let server_id = server_from_value(&args[0], "http_server_on_shutdown")?;
882        let handler = closure_arg(args, 1, "http_server_on_shutdown")?;
883        HTTP_SERVERS.with(|servers| {
884            let mut servers = servers.borrow_mut();
885            let server = servers.get_mut(&server_id).ok_or_else(|| {
886                vm_error(format!(
887                    "http_server_on_shutdown: unknown server '{server_id}'"
888                ))
889            })?;
890            server.shutdown_hooks.push(handler);
891            Ok::<_, VmError>(())
892        })?;
893        Ok(http_server_handle_value(&server_id))
894    });
895
896    vm.register_async_builtin("http_server_shutdown", |args| async move {
897        let Some(server_arg) = args.first() else {
898            return Err(vm_error("http_server_shutdown: requires server"));
899        };
900        let server_id = server_from_value(server_arg, "http_server_shutdown")?;
901        let hooks = HTTP_SERVERS.with(|servers| {
902            let mut servers = servers.borrow_mut();
903            let server = servers.get_mut(&server_id).ok_or_else(|| {
904                vm_error(format!(
905                    "http_server_shutdown: unknown server '{server_id}'"
906                ))
907            })?;
908            server.shutdown = true;
909            Ok::<_, VmError>(server.shutdown_hooks.clone())
910        })?;
911        for hook in hooks {
912            let _ = call_server_closure(
913                &hook,
914                &[http_server_handle_value(&server_id)],
915                "http_server_shutdown",
916            )
917            .await?;
918        }
919        Ok(VmValue::Bool(true))
920    });
921
922    vm.register_builtin("http_response", |args, _out| {
923        let status = args.first().and_then(VmValue::as_int).unwrap_or(200);
924        let body = args.get(1).cloned().unwrap_or(VmValue::Nil);
925        let headers = args
926            .get(2)
927            .and_then(VmValue::as_dict)
928            .cloned()
929            .unwrap_or_default();
930        Ok(response_with_kind(status, headers, body, "text"))
931    });
932
933    vm.register_builtin("http_response_text", |args, _out| {
934        let body = args.first().cloned().unwrap_or(VmValue::Nil);
935        let options = get_options_arg(args, 1);
936        let status = options
937            .get("status")
938            .and_then(VmValue::as_int)
939            .unwrap_or(200);
940        let headers = options
941            .get("headers")
942            .and_then(VmValue::as_dict)
943            .cloned()
944            .unwrap_or_default();
945        Ok(response_with_kind(status, headers, body, "text"))
946    });
947
948    vm.register_builtin("http_response_json", |args, _out| {
949        let body = args
950            .first()
951            .map(crate::stdlib::json::vm_value_to_json)
952            .map(vm_string)
953            .unwrap_or_else(|| vm_string("null"));
954        let options = get_options_arg(args, 1);
955        let status = options
956            .get("status")
957            .and_then(VmValue::as_int)
958            .unwrap_or(200);
959        let headers = options
960            .get("headers")
961            .and_then(VmValue::as_dict)
962            .cloned()
963            .unwrap_or_default();
964        Ok(response_with_kind(status, headers, body, "json"))
965    });
966
967    vm.register_builtin("http_response_bytes", |args, _out| {
968        let body = match args.first() {
969            Some(VmValue::Bytes(bytes)) => VmValue::Bytes(bytes.clone()),
970            Some(value) => VmValue::Bytes(Rc::new(value.display().into_bytes())),
971            None => VmValue::Bytes(Rc::new(Vec::new())),
972        };
973        let options = get_options_arg(args, 1);
974        let status = options
975            .get("status")
976            .and_then(VmValue::as_int)
977            .unwrap_or(200);
978        let headers = options
979            .get("headers")
980            .and_then(VmValue::as_dict)
981            .cloned()
982            .unwrap_or_default();
983        Ok(response_with_kind(status, headers, body, "bytes"))
984    });
985
986    vm.register_builtin("http_header", |args, _out| {
987        if args.len() < 2 {
988            return Err(vm_error(
989                "http_header: requires headers/request/response and name",
990            ));
991        }
992        let headers = headers_from_value(&args[0]);
993        Ok(header_lookup_value(&headers, &args[1].display()))
994    });
995}
996
997fn register_http_mock_builtins(vm: &mut Vm) {
998    // --- Mock HTTP builtins ---
999
1000    // http_mock(method, url_pattern, response) -> nil
1001    //
1002    // Calling http_mock again with the same (method, url_pattern) tuple
1003    // *replaces* the prior mock for that target — tests can override a
1004    // per-case response without first calling http_mock_clear().
1005    vm.register_builtin("http_mock", |args, _out| {
1006        let method = args.first().map(|a| a.display()).unwrap_or_default();
1007        let url_pattern = args.get(1).map(|a| a.display()).unwrap_or_default();
1008        let response = args
1009            .get(2)
1010            .and_then(|a| a.as_dict())
1011            .cloned()
1012            .unwrap_or_default();
1013        let responses = parse_mock_responses(&response);
1014
1015        register_http_mock(method, url_pattern, responses);
1016        Ok(VmValue::Nil)
1017    });
1018
1019    // http_mock_clear() -> nil
1020    vm.register_builtin("http_mock_clear", |_args, _out| {
1021        clear_http_mocks();
1022        client::clear_http_streams();
1023        Ok(VmValue::Nil)
1024    });
1025
1026    // http_mock_calls(options?) -> list of {method, url, headers, body}
1027    vm.register_builtin("http_mock_calls", |args, _out| {
1028        let options = get_options_arg(args, 0);
1029        let include_sensitive = get_bool_option(&options, "include_sensitive", false)
1030            || get_bool_option(&options, "include_sensitive_headers", false);
1031        let redact_sensitive = get_bool_option(
1032            &options,
1033            "redact_sensitive",
1034            get_bool_option(&options, "redact_headers", true),
1035        ) && !include_sensitive;
1036        Ok(VmValue::List(Rc::new(http_mock_calls_value(
1037            redact_sensitive,
1038        ))))
1039    });
1040}
1041
1042fn http_server_tls_config_value(
1043    mode: &str,
1044    terminate_tls: bool,
1045    scheme: &str,
1046    hsts: bool,
1047    extra: BTreeMap<String, VmValue>,
1048) -> VmValue {
1049    let mut dict = BTreeMap::new();
1050    dict.insert("mode".to_string(), VmValue::String(Rc::from(mode)));
1051    dict.insert("terminate_tls".to_string(), VmValue::Bool(terminate_tls));
1052    dict.insert("scheme".to_string(), VmValue::String(Rc::from(scheme)));
1053    dict.insert("hsts".to_string(), VmValue::Bool(hsts));
1054    for (key, value) in extra {
1055        dict.insert(key, value);
1056    }
1057    VmValue::Dict(Rc::new(dict))
1058}
1059
1060fn hsts_options(options: &BTreeMap<String, VmValue>) -> BTreeMap<String, VmValue> {
1061    let mut hsts = BTreeMap::new();
1062    hsts.insert(
1063        "hsts_max_age_seconds".to_string(),
1064        VmValue::Int(vm_get_int_option(
1065            options,
1066            "hsts_max_age_seconds",
1067            31_536_000,
1068        )),
1069    );
1070    hsts.insert(
1071        "hsts_include_subdomains".to_string(),
1072        VmValue::Bool(vm_get_bool_option(
1073            options,
1074            "hsts_include_subdomains",
1075            false,
1076        )),
1077    );
1078    hsts.insert(
1079        "hsts_preload".to_string(),
1080        VmValue::Bool(vm_get_bool_option(options, "hsts_preload", false)),
1081    );
1082    hsts
1083}
1084
1085fn http_server_security_headers(config: &BTreeMap<String, VmValue>) -> BTreeMap<String, VmValue> {
1086    let hsts_enabled = vm_get_bool_option(config, "hsts", false);
1087    if !hsts_enabled {
1088        return BTreeMap::new();
1089    }
1090    let mut value = format!(
1091        "max-age={}",
1092        vm_get_int_option(config, "hsts_max_age_seconds", 31_536_000).max(0)
1093    );
1094    if vm_get_bool_option(config, "hsts_include_subdomains", false) {
1095        value.push_str("; includeSubDomains");
1096    }
1097    if vm_get_bool_option(config, "hsts_preload", false) {
1098        value.push_str("; preload");
1099    }
1100    BTreeMap::from([(
1101        "strict-transport-security".to_string(),
1102        VmValue::String(Rc::from(value)),
1103    )])
1104}
1105
1106fn tls_hosts_arg(value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
1107    match value {
1108        None | Some(VmValue::Nil) => Ok(vec!["localhost".to_string(), "127.0.0.1".to_string()]),
1109        Some(VmValue::List(hosts)) => {
1110            let mut parsed = Vec::new();
1111            for host in hosts.iter() {
1112                let host = host.display();
1113                if host.is_empty() {
1114                    return Err(vm_error(
1115                        "http_server_tls_self_signed_dev: host names must be non-empty",
1116                    ));
1117                }
1118                parsed.push(host);
1119            }
1120            if parsed.is_empty() {
1121                return Err(vm_error(
1122                    "http_server_tls_self_signed_dev: host list must not be empty",
1123                ));
1124            }
1125            Ok(parsed)
1126        }
1127        Some(other) => {
1128            let host = other.display();
1129            if host.is_empty() {
1130                return Err(vm_error(
1131                    "http_server_tls_self_signed_dev: host name must be non-empty",
1132                ));
1133            }
1134            Ok(vec![host])
1135        }
1136    }
1137}
1138
1139pub(super) fn vm_get_int_option(
1140    options: &BTreeMap<String, VmValue>,
1141    key: &str,
1142    default: i64,
1143) -> i64 {
1144    options.get(key).and_then(|v| v.as_int()).unwrap_or(default)
1145}
1146
1147pub(super) fn vm_get_bool_option(
1148    options: &BTreeMap<String, VmValue>,
1149    key: &str,
1150    default: bool,
1151) -> bool {
1152    match options.get(key) {
1153        Some(VmValue::Bool(b)) => *b,
1154        _ => default,
1155    }
1156}
1157
1158pub(super) fn vm_get_int_option_prefer(
1159    options: &BTreeMap<String, VmValue>,
1160    canonical: &str,
1161    alias: &str,
1162    default: i64,
1163) -> i64 {
1164    options
1165        .get(canonical)
1166        .and_then(|value| value.as_int())
1167        .or_else(|| options.get(alias).and_then(|value| value.as_int()))
1168        .unwrap_or(default)
1169}
1170
1171pub(super) fn vm_get_optional_int_option(
1172    options: &BTreeMap<String, VmValue>,
1173    key: &str,
1174) -> Option<u64> {
1175    options
1176        .get(key)
1177        .and_then(|value| value.as_int())
1178        .map(|value| value.max(0) as u64)
1179}
1180
1181pub(super) fn string_option(options: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
1182    options
1183        .get(key)
1184        .map(|value| value.display())
1185        .filter(|value| !value.is_empty())
1186}