Skip to main content

harn_vm/http/
mod.rs

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