Skip to main content

harn_vm/stdlib/
http_response.rs

1//! HTTP response codec primitives for `.harn` HTTP handlers.
2//!
3//! Handlers hosted on `harn-serve` build their replies with these
4//! builtins instead of bare JSON dicts. The codec on the server side
5//! (`harn_serve::http_codec`) recognises the tagged record, lifts
6//! status/headers/body out of it, and renders it as a proper HTTP
7//! response — JSON, no-content, error envelope, buffered stream, or
8//! Server-Sent Events.
9//!
10//! The tag is the literal string `"v1"` under the
11//! `__http_response__` key. Plain dicts that happen to define
12//! `status`/`headers`/`body` are not picked up by the codec; only the
13//! tagged record is.
14//!
15//! Channel-bearing bodies (`http_stream(channel)`, `http_sse(channel)`)
16//! are drained inside the builtin before the handler returns. This
17//! keeps the v1 codec wire-format JSON-only — the channel is
18//! materialised into a list of chunks/events. Authors write the same
19//! code that true streaming will accept; only the on-the-wire timing
20//! differs.
21
22use crate::value::VmDictExt;
23use std::collections::BTreeMap;
24
25use sha2::{Digest, Sha256};
26
27use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
28use crate::value::{VmError, VmValue};
29use crate::vm::Vm;
30
31/// Tag key + version that the harn-serve codec keys off of.
32pub const HTTP_RESPONSE_TAG_KEY: &str = "__http_response__";
33pub const HTTP_RESPONSE_TAG_VERSION: &str = "v1";
34
35const BODY_KIND_JSON: &str = "json";
36const BODY_KIND_NONE: &str = "none";
37const BODY_KIND_BYTES: &str = "bytes";
38const BODY_KIND_STREAM: &str = "stream";
39const BODY_KIND_SSE: &str = "sse";
40
41pub(crate) fn register_http_response_builtins(vm: &mut Vm) {
42    for def in MODULE_BUILTINS {
43        vm.register_builtin_def(def);
44    }
45}
46
47#[harn_builtin(
48    exposure = "pure",
49    effects = [],
50    sig = "http_ok(body: any?) -> dict", category = "http_response"
51)]
52fn http_ok_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
53    let body = args.first().cloned().unwrap_or(VmValue::Nil);
54    Ok(envelope(
55        200,
56        body,
57        BODY_KIND_JSON,
58        crate::value::DictMap::new(),
59    ))
60}
61
62#[harn_builtin(
63    exposure = "pure",
64    effects = [],
65    sig = "http_created(body: any?, location?: string?) -> dict",
66    category = "http_response"
67)]
68fn http_created_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
69    let body = args.first().cloned().unwrap_or(VmValue::Nil);
70    let mut headers = crate::value::DictMap::new();
71    if let Some(location) = args.get(1).and_then(string_or_nil) {
72        headers.put_str("Location", location);
73    }
74    Ok(envelope(201, body, BODY_KIND_JSON, headers))
75}
76
77#[harn_builtin(
78    exposure = "pure",
79    effects = [],
80    sig = "http_no_content() -> dict", category = "http_response"
81)]
82fn http_no_content_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
83    Ok(envelope(
84        204,
85        VmValue::Nil,
86        BODY_KIND_NONE,
87        crate::value::DictMap::new(),
88    ))
89}
90
91#[harn_builtin(
92    exposure = "pure",
93    effects = [],
94    sig = "http_error(status: int, code: string, message: string, details?: any) -> dict",
95    category = "http_response"
96)]
97fn http_error_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
98    let status = require_status(args.first(), "http_error")?;
99    if !(400..=599).contains(&status) {
100        return Err(thrown_err(format!(
101            "http_error: status must be 4xx or 5xx (got {status})"
102        )));
103    }
104    let code = require_nonempty_string(args.get(1), "http_error", "code")?;
105    let message = require_nonempty_string(args.get(2), "http_error", "message")?;
106    let details = args.get(3).cloned().unwrap_or(VmValue::Nil);
107
108    let mut body = crate::value::DictMap::new();
109    body.put_str("code", code);
110    body.put_str("message", message);
111    if !matches!(details, VmValue::Nil) {
112        body.insert(crate::value::intern_key("details"), details);
113    }
114    let mut env = envelope_map(
115        status,
116        VmValue::dict(body),
117        BODY_KIND_JSON,
118        crate::value::DictMap::new(),
119    );
120    env.insert(crate::value::intern_key("is_error"), VmValue::Bool(true));
121    Ok(VmValue::dict(env))
122}
123
124#[harn_builtin(
125    exposure = "pure",
126    effects = [],
127    sig = "http_reply(status: int, body?: any, headers?: dict) -> dict",
128    category = "http_response"
129)]
130fn http_reply_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
131    let status = require_status(args.first(), "http_reply")?;
132    let body = args.get(1).cloned().unwrap_or(VmValue::Nil);
133    let headers = parse_headers(args.get(2), "http_reply")?;
134    let body_kind = if status == 204 || status == 304 || matches!(body, VmValue::Nil) {
135        BODY_KIND_NONE
136    } else if matches!(body, VmValue::Bytes(_)) {
137        BODY_KIND_BYTES
138    } else {
139        BODY_KIND_JSON
140    };
141    let body_for_envelope = if body_kind == BODY_KIND_NONE {
142        VmValue::Nil
143    } else {
144        body
145    };
146    Ok(envelope(status, body_for_envelope, body_kind, headers))
147}
148
149/// Convert a host/adapter response record with `status`, `body`, `headers`,
150/// and optional `body_kind` into a tagged HTTP response envelope.
151#[harn_builtin(
152    exposure = "pure",
153    effects = [],
154    sig = "http_reply_from(result: dict) -> dict",
155    category = "http_response"
156)]
157fn http_reply_from_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
158    let result = args
159        .first()
160        .and_then(VmValue::as_dict)
161        .ok_or_else(|| thrown_err("http_reply_from: result must be a dict"))?;
162
163    if is_http_response_envelope(result) {
164        return Ok(VmValue::Dict(result.clone().into()));
165    }
166
167    let status = require_status(result.get("status"), "http_reply_from")?;
168    let headers = parse_headers(result.get("headers"), "http_reply_from")?;
169    let body_kind = match result.get("body_kind") {
170        None | Some(VmValue::Nil) => None,
171        Some(VmValue::String(kind)) => Some(kind.as_str()),
172        Some(other) => {
173            return Err(thrown_err(format!(
174                "http_reply_from: body_kind must be a string (got {})",
175                other.type_name()
176            )));
177        }
178    };
179
180    match body_kind {
181        Some(BODY_KIND_NONE) => Ok(envelope(status, VmValue::Nil, BODY_KIND_NONE, headers)),
182        Some(BODY_KIND_BYTES) => {
183            let body = result
184                .get("raw_body")
185                .filter(|value| matches!(value, VmValue::Bytes(_)))
186                .or_else(|| result.get("body"))
187                .cloned()
188                .unwrap_or(VmValue::Nil);
189            match body {
190                VmValue::Bytes(_) | VmValue::Nil => {
191                    Ok(envelope(status, body, BODY_KIND_BYTES, headers))
192                }
193                other => Err(thrown_err(format!(
194                    "http_reply_from: body_kind bytes requires bytes `raw_body` or `body` (got {})",
195                    other.type_name()
196                ))),
197            }
198        }
199        Some(BODY_KIND_STREAM) => {
200            let body = result
201                .get("body")
202                .cloned()
203                .map(stream_body_chunks)
204                .unwrap_or_else(empty_list);
205            Ok(envelope(status, body, BODY_KIND_STREAM, headers))
206        }
207        Some(BODY_KIND_SSE) => {
208            let body = result
209                .get("body")
210                .cloned()
211                .map(stream_body_chunks)
212                .unwrap_or_else(empty_list);
213            Ok(envelope(status, body, BODY_KIND_SSE, headers))
214        }
215        Some(BODY_KIND_JSON) => {
216            let body = result.get("body").cloned().unwrap_or(VmValue::Nil);
217            Ok(envelope(status, body, BODY_KIND_JSON, headers))
218        }
219        _ => {
220            let body = result.get("body").cloned().unwrap_or(VmValue::Nil);
221            let args = [VmValue::Int(status), body, VmValue::dict(headers)];
222            http_reply_impl(&args, &mut String::new())
223        }
224    }
225}
226
227fn empty_list() -> VmValue {
228    VmValue::List(std::sync::Arc::new(Vec::new()))
229}
230
231fn stream_body_chunks(body: VmValue) -> VmValue {
232    match body {
233        VmValue::List(_) => body,
234        VmValue::Nil => empty_list(),
235        other => VmValue::List(std::sync::Arc::new(vec![other])),
236    }
237}
238
239#[harn_builtin(
240    exposure = "capability_arg:0",
241    effects = ["state.mutate@arg0"],
242    sig = "http_stream(source: any, content_type?: string?) -> dict",
243    kind = "async",
244    category = "http_response"
245)]
246async fn http_stream_impl(
247    _ctx: crate::vm::AsyncBuiltinCtx,
248    args: Vec<VmValue>,
249) -> Result<VmValue, VmError> {
250    let source = args
251        .first()
252        .cloned()
253        .ok_or_else(|| thrown_err("http_stream: source is required"))?;
254    let content_type = args
255        .get(1)
256        .and_then(string_or_nil)
257        .unwrap_or_else(|| "application/octet-stream".to_string());
258
259    let chunks = drain_to_list(source, "http_stream").await?;
260    let mut headers = crate::value::DictMap::new();
261    headers.put_str("Content-Type", content_type);
262    Ok(envelope(
263        200,
264        VmValue::List(std::sync::Arc::new(chunks)),
265        BODY_KIND_STREAM,
266        headers,
267    ))
268}
269
270#[harn_builtin(
271    exposure = "capability_arg:0",
272    effects = ["state.mutate@arg0"],
273    sig = "http_sse(source: any, retry_ms?: int?) -> dict",
274    kind = "async",
275    category = "http_response"
276)]
277async fn http_sse_impl(
278    _ctx: crate::vm::AsyncBuiltinCtx,
279    args: Vec<VmValue>,
280) -> Result<VmValue, VmError> {
281    let source = args
282        .first()
283        .cloned()
284        .ok_or_else(|| thrown_err("http_sse: source is required"))?;
285    let retry_ms = match args.get(1) {
286        None | Some(VmValue::Nil) => None,
287        Some(VmValue::Int(value)) => {
288            if *value < 0 {
289                return Err(thrown_err(format!(
290                    "http_sse: retry_ms must be non-negative (got {value})"
291                )));
292            }
293            Some(*value)
294        }
295        Some(other) => {
296            return Err(thrown_err(format!(
297                "http_sse: retry_ms must be an integer (got {})",
298                other.type_name()
299            )));
300        }
301    };
302
303    let events = drain_to_list(source, "http_sse").await?;
304    let mut headers = crate::value::DictMap::new();
305    headers.put_str("Content-Type", "text/event-stream");
306    headers.put_str("Cache-Control", "no-cache");
307    let mut env = envelope_map(
308        200,
309        VmValue::List(std::sync::Arc::new(events)),
310        BODY_KIND_SSE,
311        headers,
312    );
313    if let Some(retry_ms) = retry_ms {
314        env.insert(crate::value::intern_key("retry_ms"), VmValue::Int(retry_ms));
315    }
316    Ok(VmValue::dict(env))
317}
318
319fn envelope(
320    status: i64,
321    body: VmValue,
322    body_kind: &str,
323    headers: crate::value::DictMap,
324) -> VmValue {
325    VmValue::dict(envelope_map(status, body, body_kind, headers))
326}
327
328fn envelope_map(
329    status: i64,
330    body: VmValue,
331    body_kind: &str,
332    headers: crate::value::DictMap,
333) -> crate::value::DictMap {
334    let mut map = crate::value::DictMap::new();
335    map.insert(
336        crate::value::intern_key(HTTP_RESPONSE_TAG_KEY),
337        VmValue::String(arcstr::ArcStr::from(HTTP_RESPONSE_TAG_VERSION)),
338    );
339    map.insert(crate::value::intern_key("status"), VmValue::Int(status));
340    map.put_str("body_kind", body_kind);
341    map.insert(crate::value::intern_key("headers"), VmValue::dict(headers));
342    if !matches!(body, VmValue::Nil) {
343        map.insert(crate::value::intern_key("body"), body);
344    }
345    map
346}
347
348fn require_status(value: Option<&VmValue>, fn_name: &str) -> Result<i64, VmError> {
349    let status = match value {
350        Some(VmValue::Int(value)) => *value,
351        Some(other) => {
352            return Err(thrown_err(format!(
353                "{fn_name}: status must be an integer (got {})",
354                other.type_name()
355            )));
356        }
357        None => {
358            return Err(thrown_err(format!("{fn_name}: status is required")));
359        }
360    };
361    if !(100..=599).contains(&status) {
362        return Err(thrown_err(format!(
363            "{fn_name}: status {status} is out of range (100-599)"
364        )));
365    }
366    Ok(status)
367}
368
369fn require_nonempty_string(
370    value: Option<&VmValue>,
371    fn_name: &str,
372    arg_name: &str,
373) -> Result<String, VmError> {
374    let text = match value {
375        Some(VmValue::String(text)) => text.to_string(),
376        Some(other) => {
377            return Err(thrown_err(format!(
378                "{fn_name}: {arg_name} must be a string (got {})",
379                other.type_name()
380            )));
381        }
382        None => {
383            return Err(thrown_err(format!("{fn_name}: {arg_name} is required")));
384        }
385    };
386    if text.is_empty() {
387        return Err(thrown_err(format!(
388            "{fn_name}: {arg_name} must be non-empty"
389        )));
390    }
391    Ok(text)
392}
393
394fn string_or_nil(value: &VmValue) -> Option<String> {
395    match value {
396        VmValue::String(text) if !text.is_empty() => Some(text.to_string()),
397        _ => None,
398    }
399}
400
401fn parse_headers(value: Option<&VmValue>, fn_name: &str) -> Result<crate::value::DictMap, VmError> {
402    match value {
403        None | Some(VmValue::Nil) => Ok(crate::value::DictMap::new()),
404        Some(VmValue::Dict(dict)) => Ok((**dict).clone()),
405        Some(other) => Err(thrown_err(format!(
406            "{fn_name}: headers must be a dict (got {})",
407            other.type_name()
408        ))),
409    }
410}
411
412/// Drain a channel into a `Vec<VmValue>`, or pass a list through verbatim.
413///
414/// The drain stops when the channel is closed and all queued values have been
415/// consumed. Channel handles can close through a flag/signal pair without
416/// dropping every sender clone, so the drain polls with `try_recv` and observes
417/// the closed state; when both report empty, the drain terminates.
418async fn drain_to_list(value: VmValue, fn_name: &str) -> Result<Vec<VmValue>, VmError> {
419    use tokio::sync::mpsc::error::TryRecvError;
420
421    match value {
422        VmValue::List(items) => Ok(items.iter().cloned().collect()),
423        VmValue::Channel(handle) => {
424            let mut items = Vec::new();
425            let mut rx = handle.receiver.lock().await;
426            loop {
427                match rx.try_recv() {
428                    Ok(value) => items.push(value),
429                    Err(TryRecvError::Empty) => {
430                        if handle.is_closed() {
431                            break;
432                        }
433                        // Yield so the producer can deliver the next
434                        // value; if the channel is still empty after
435                        // the producer has done its work, the next
436                        // pass will see it closed.
437                        tokio::task::yield_now().await;
438                    }
439                    Err(TryRecvError::Disconnected) => break,
440                }
441            }
442            Ok(items)
443        }
444        other => Err(thrown_err(format!(
445            "{fn_name}: source must be a list or channel (got {})",
446            other.type_name()
447        ))),
448    }
449}
450
451fn thrown_err(message: impl Into<String>) -> VmError {
452    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
453}
454
455/// Return `Some(envelope)` if the JSON value is a tagged HTTP response.
456///
457/// Used by the harn-serve HTTP codec to detect that a `.harn` handler
458/// opted into structured HTTP semantics rather than the default `200
459/// {result}` shape.
460pub fn parse_envelope(value: &serde_json::Value) -> Option<HttpEnvelope> {
461    let obj = value.as_object()?;
462    let tag = obj.get(HTTP_RESPONSE_TAG_KEY)?.as_str()?;
463    if tag != HTTP_RESPONSE_TAG_VERSION {
464        return None;
465    }
466    let status = obj.get("status")?.as_u64()? as u16;
467    let body_kind = obj
468        .get("body_kind")
469        .and_then(|v| v.as_str())
470        .unwrap_or(BODY_KIND_JSON)
471        .to_string();
472    let headers = obj
473        .get("headers")
474        .and_then(|v| v.as_object())
475        .map(|map| {
476            map.iter()
477                .map(|(key, value)| {
478                    let header = match value {
479                        serde_json::Value::String(s) => HttpHeaderValue::Single(s.clone()),
480                        serde_json::Value::Array(values) => HttpHeaderValue::Multi(
481                            values
482                                .iter()
483                                .filter_map(|v| v.as_str().map(str::to_string))
484                                .collect(),
485                        ),
486                        other => HttpHeaderValue::Single(other.to_string()),
487                    };
488                    (key.clone(), header)
489                })
490                .collect::<BTreeMap<_, _>>()
491        })
492        .unwrap_or_default();
493    let body = obj.get("body").cloned();
494    let retry_ms = obj.get("retry_ms").and_then(|v| v.as_u64());
495    let is_error = obj
496        .get("is_error")
497        .and_then(|v| v.as_bool())
498        .unwrap_or(false);
499    let ws_upgrade = obj
500        .get("ws_upgrade")
501        .and_then(|v| v.as_object())
502        .map(|map| {
503            let subprotocol = map
504                .get("subprotocol")
505                .and_then(|v| v.as_str())
506                .map(str::to_string);
507            let offered = map
508                .get("offered")
509                .and_then(|v| v.as_array())
510                .map(|values| {
511                    values
512                        .iter()
513                        .filter_map(|v| v.as_str().map(str::to_string))
514                        .collect()
515                })
516                .unwrap_or_default();
517            let idle_ping_ms = map.get("idle_ping_ms").and_then(|v| v.as_u64());
518            let max_message_bytes = map.get("max_message_bytes").and_then(|v| v.as_u64());
519            let on_message = map
520                .get("on_message")
521                .and_then(|v| v.as_str())
522                .map(str::to_string);
523            WsUpgradeSpec {
524                subprotocol,
525                offered,
526                idle_ping_ms,
527                max_message_bytes,
528                on_message,
529            }
530        });
531    Some(HttpEnvelope {
532        status,
533        body_kind,
534        headers,
535        body,
536        retry_ms,
537        is_error,
538        ws_upgrade,
539    })
540}
541
542#[derive(Debug, Clone)]
543pub struct HttpEnvelope {
544    pub status: u16,
545    pub body_kind: String,
546    pub headers: BTreeMap<String, HttpHeaderValue>,
547    pub body: Option<serde_json::Value>,
548    pub retry_ms: Option<u64>,
549    pub is_error: bool,
550    /// Populated when the handler returned an `http_upgrade_ws(...)`
551    /// envelope. The hosting adapter detects this and routes the
552    /// upgrade through `harn_serve::ws_route` instead of rendering the
553    /// 101 response as plain HTTP.
554    pub ws_upgrade: Option<WsUpgradeSpec>,
555}
556
557#[derive(Debug, Clone, Default)]
558pub struct WsUpgradeSpec {
559    pub subprotocol: Option<String>,
560    pub offered: Vec<String>,
561    pub idle_ping_ms: Option<u64>,
562    pub max_message_bytes: Option<u64>,
563    /// Exported `.harn` function the hosting adapter dispatches once per
564    /// inbound WebSocket frame. The function receives a `{type, data}`
565    /// message dict and its return value is rendered back to the client
566    /// (a string is sent verbatim; any other value is JSON-encoded; `nil`
567    /// sends nothing). `None` leaves the socket inbound-only — the adapter
568    /// drains and discards frames, which suits server-push handlers that
569    /// never read from the client.
570    pub on_message: Option<String>,
571}
572
573#[derive(Debug, Clone)]
574pub enum HttpHeaderValue {
575    Single(String),
576    Multi(Vec<String>),
577}
578
579impl HttpHeaderValue {
580    pub fn values(&self) -> Box<dyn Iterator<Item = &str> + '_> {
581        match self {
582            Self::Single(value) => Box::new(std::iter::once(value.as_str())),
583            Self::Multi(values) => Box::new(values.iter().map(String::as_str)),
584        }
585    }
586}
587
588#[harn_builtin(
589    exposure = "pure",
590    effects = [],
591    sig = "http_etag(body: any) -> string", category = "http_response"
592)]
593fn http_etag_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
594    let body = args
595        .first()
596        .ok_or_else(|| thrown_err("http_etag: body is required"))?;
597    let bytes = value_as_bytes(body);
598    let mut hasher = Sha256::new();
599    hasher.update(&bytes);
600    let digest = hasher.finalize();
601    Ok(VmValue::String(arcstr::ArcStr::from(format!(
602        "\"{}\"",
603        hex::encode(digest)
604    ))))
605}
606
607#[harn_builtin(
608    exposure = "pure",
609    effects = [],
610    sig = "http_choose(accept: string?, offers: list, default?: string?) -> string",
611    category = "http_response"
612)]
613fn http_choose_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
614    let accept = optional_string_arg(args.first(), "http_choose", "accept")?;
615    let offers_value = args
616        .get(1)
617        .ok_or_else(|| thrown_err("http_choose: offers is required"))?;
618    let offers = expect_string_list(offers_value, "http_choose", "offers")?;
619    if offers.is_empty() {
620        return Err(thrown_err("http_choose: offers must be non-empty"));
621    }
622    let default = optional_string_arg(args.get(2), "http_choose", "default")?
623        .unwrap_or_else(|| offers[0].clone());
624
625    let chosen = match accept.as_deref() {
626        None | Some("") | Some("*/*") => default,
627        Some(header) => negotiate_accept(header, &offers).unwrap_or(default),
628    };
629    Ok(VmValue::String(arcstr::ArcStr::from(chosen)))
630}
631
632fn optional_string_arg(
633    value: Option<&VmValue>,
634    builtin: &str,
635    arg_name: &str,
636) -> Result<Option<String>, VmError> {
637    match value {
638        None | Some(VmValue::Nil) => Ok(None),
639        Some(VmValue::String(text)) => Ok(Some(text.to_string())),
640        Some(other) => Err(thrown_err(format!(
641            "{builtin}: {arg_name} must be a string or nil (got {})",
642            other.type_name()
643        ))),
644    }
645}
646
647fn expect_string_list(
648    value: &VmValue,
649    builtin: &str,
650    arg_name: &str,
651) -> Result<Vec<String>, VmError> {
652    let items = match value {
653        VmValue::List(items) => items,
654        other => {
655            return Err(thrown_err(format!(
656                "{builtin}: {arg_name} must be a list (got {})",
657                other.type_name()
658            )));
659        }
660    };
661    items
662        .iter()
663        .map(|value| match value {
664            VmValue::String(text) => Ok(text.to_string()),
665            other => Err(thrown_err(format!(
666                "{builtin}: {arg_name} must contain strings (got {})",
667                other.type_name()
668            ))),
669        })
670        .collect()
671}
672
673#[harn_builtin(
674    exposure = "pure",
675    effects = [],
676    sig = "http_not_modified(etag?: string?, headers?: dict) -> dict",
677    category = "http_response"
678)]
679fn http_not_modified_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
680    let mut headers = parse_headers(args.get(1), "http_not_modified")?;
681    if let Some(etag) = args.first().and_then(string_or_nil) {
682        headers.put_str("ETag", etag);
683    }
684    Ok(envelope(304, VmValue::Nil, BODY_KIND_NONE, headers))
685}
686
687#[harn_builtin(
688    exposure = "pure",
689    effects = [],
690    sig = "http_push_hints(envelope: dict, paths: list) -> dict",
691    category = "http_response"
692)]
693fn http_push_hints_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
694    // Pull the inbound envelope. We require it to be a tagged
695    // `http_response` envelope so the codec on the other side actually
696    // applies the Link headers — wrapping a plain dict would silently
697    // no-op once it reached the wire.
698    let envelope = args
699        .first()
700        .and_then(VmValue::as_dict)
701        .ok_or_else(|| thrown_err("http_push_hints: envelope must be a dict"))?;
702    if !is_http_response_envelope(envelope) {
703        return Err(thrown_err(
704            "http_push_hints: envelope must be an http_response envelope \
705             (use http_ok, http_reply, etc. before calling this)",
706        ));
707    }
708
709    let paths = match args.get(1) {
710        Some(VmValue::List(items)) => items.clone(),
711        Some(other) => {
712            return Err(thrown_err(format!(
713                "http_push_hints: paths must be a list (got {})",
714                other.type_name()
715            )));
716        }
717        None => {
718            return Err(thrown_err("http_push_hints: paths is required"));
719        }
720    };
721
722    let mut new_links: Vec<String> = Vec::with_capacity(paths.len());
723    for item in paths.iter() {
724        match item {
725            VmValue::String(text) => {
726                let path = text.as_str();
727                if path.is_empty() {
728                    return Err(thrown_err(
729                        "http_push_hints: paths must not contain empty strings",
730                    ));
731                }
732                new_links.push(format_link_header(path));
733            }
734            other => {
735                return Err(thrown_err(format!(
736                    "http_push_hints: paths must contain strings (got {})",
737                    other.type_name()
738                )));
739            }
740        }
741    }
742
743    if new_links.is_empty() {
744        return Ok(VmValue::Dict(envelope.clone().into()));
745    }
746
747    let mut envelope_map = (*envelope).clone();
748    let mut headers = envelope_map
749        .get("headers")
750        .and_then(VmValue::as_dict)
751        .cloned()
752        .unwrap_or_default();
753
754    // Preserve any pre-existing Link header(s) the handler already set.
755    let mut combined: Vec<VmValue> = match headers.get("Link") {
756        Some(VmValue::String(existing)) => vec![VmValue::String(existing.clone())],
757        Some(VmValue::List(items)) => items.iter().cloned().collect(),
758        _ => Vec::new(),
759    };
760    combined.extend(
761        new_links
762            .into_iter()
763            .map(|link| VmValue::String(arcstr::ArcStr::from(link))),
764    );
765
766    headers.insert(
767        crate::value::intern_key("Link"),
768        VmValue::List(std::sync::Arc::new(combined)),
769    );
770    envelope_map.insert(crate::value::intern_key("headers"), VmValue::dict(headers));
771    Ok(VmValue::dict(envelope_map))
772}
773
774fn is_http_response_envelope(map: &crate::value::DictMap) -> bool {
775    matches!(
776        map.get(HTTP_RESPONSE_TAG_KEY),
777        Some(VmValue::String(tag)) if tag.as_str() == HTTP_RESPONSE_TAG_VERSION,
778    )
779}
780
781fn format_link_header(path: &str) -> String {
782    match infer_preload_as(path) {
783        Some(kind) => format!("<{path}>; rel=preload; as={kind}"),
784        None => format!("<{path}>; rel=preload"),
785    }
786}
787
788/// Map the asset extension to its `as=` attribute per the HTML living
789/// standard's [destination table]. Unknown extensions emit a bare
790/// `rel=preload` and let the browser decline the hint rather than
791/// guessing — `as=` mismatch silently invalidates the preload.
792///
793/// [destination table]: https://html.spec.whatwg.org/multipage/links.html#link-type-preload
794fn infer_preload_as(path: &str) -> Option<&'static str> {
795    // Strip any query string / fragment before extension lookup. Paths
796    // like `/main.js?v=42` should still infer `script`.
797    let pre_query = path.split(['?', '#']).next().unwrap_or(path);
798    let dot = pre_query.rfind('.')?;
799    let ext = &pre_query[dot + 1..];
800    Some(match ext.to_ascii_lowercase().as_str() {
801        "css" => "style",
802        "js" | "mjs" => "script",
803        "json" => "fetch",
804        "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "ico" => "image",
805        "woff" | "woff2" | "ttf" | "otf" => "font",
806        _ => return None,
807    })
808}
809
810#[harn_builtin(
811    exposure = "pure",
812    effects = [],
813    sig = "http_upgrade_ws(req: dict, options?: dict) -> dict",
814    category = "http_response"
815)]
816fn http_upgrade_ws_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
817    let req = args
818        .first()
819        .and_then(VmValue::as_dict)
820        .ok_or_else(|| thrown_err("http_upgrade_ws: req must be a dict"))?;
821    let options = args.get(1).and_then(VmValue::as_dict);
822
823    let request_subprotocols = req
824        .get("headers")
825        .and_then(VmValue::as_dict)
826        .and_then(|headers| header_lookup(headers, "sec-websocket-protocol"))
827        .map(|raw| {
828            raw.split(',')
829                .map(|s| s.trim().to_string())
830                .filter(|s| !s.is_empty())
831                .collect::<Vec<_>>()
832        })
833        .unwrap_or_default();
834    let offered_subprotocols = options
835        .and_then(|opts| opts.get("subprotocols"))
836        .and_then(|value| match value {
837            VmValue::List(items) => Some(
838                items
839                    .iter()
840                    .filter_map(|v| match v {
841                        VmValue::String(s) => Some(s.to_string()),
842                        _ => None,
843                    })
844                    .collect::<Vec<_>>(),
845            ),
846            _ => None,
847        })
848        .unwrap_or_default();
849
850    // Pick the first *client-preferred* subprotocol the server can
851    // serve. This must match the convention in
852    // `harn_serve::ws::negotiate_subprotocol` — if the two
853    // disagreed, the builtin's envelope would carry one subprotocol
854    // while the actual upgrade handshake echoed back another.
855    let negotiated = request_subprotocols
856        .iter()
857        .find(|client| offered_subprotocols.iter().any(|name| name == *client))
858        .cloned();
859
860    let mut headers = crate::value::DictMap::new();
861    headers.put_str("Upgrade", "websocket");
862    headers.put_str("Connection", "Upgrade");
863    if let Some(name) = &negotiated {
864        headers.put_str("Sec-WebSocket-Protocol", name.clone());
865    }
866
867    let idle_ping_ms = options
868        .and_then(|opts| opts.get("idle_ping_ms"))
869        .and_then(|v| v.as_int());
870    let max_message_bytes = options
871        .and_then(|opts| opts.get("max_message_bytes"))
872        .and_then(|v| v.as_int());
873    let on_message = options
874        .and_then(|opts| opts.get("on_message"))
875        .and_then(|v| match v {
876            VmValue::String(name) => Some(name.to_string()),
877            _ => None,
878        });
879
880    let mut env_map = envelope_map(101, VmValue::Nil, BODY_KIND_NONE, headers);
881    env_map.insert(
882        crate::value::intern_key("ws_upgrade"),
883        VmValue::dict({
884            let mut map = crate::value::DictMap::new();
885            map.insert(
886                crate::value::intern_key("subprotocol"),
887                match &negotiated {
888                    Some(name) => VmValue::String(arcstr::ArcStr::from(name.clone())),
889                    None => VmValue::Nil,
890                },
891            );
892            map.insert(
893                crate::value::intern_key("offered"),
894                VmValue::List(std::sync::Arc::new(
895                    offered_subprotocols
896                        .iter()
897                        .map(|s| VmValue::String(arcstr::ArcStr::from(s.clone())))
898                        .collect(),
899                )),
900            );
901            if let Some(ms) = idle_ping_ms {
902                map.insert(crate::value::intern_key("idle_ping_ms"), VmValue::Int(ms));
903            }
904            if let Some(bytes) = max_message_bytes {
905                map.insert(
906                    crate::value::intern_key("max_message_bytes"),
907                    VmValue::Int(bytes),
908                );
909            }
910            if let Some(handler) = &on_message {
911                map.put_str("on_message", handler.clone());
912            }
913            map
914        }),
915    );
916    Ok(VmValue::dict(env_map))
917}
918
919fn header_lookup(headers: &crate::value::DictMap, name: &str) -> Option<String> {
920    let needle = name.to_ascii_lowercase();
921    headers
922        .iter()
923        .find(|(key, _)| key.to_ascii_lowercase() == needle)
924        .and_then(|(_, value)| match value {
925            VmValue::String(text) => Some(text.to_string()),
926            _ => None,
927        })
928}
929
930fn value_as_bytes(value: &VmValue) -> Vec<u8> {
931    match value {
932        VmValue::Bytes(bytes) => bytes.as_ref().clone(),
933        VmValue::String(text) => text.as_bytes().to_vec(),
934        VmValue::Nil => Vec::new(),
935        // For dicts / lists / structs, fall through to the stdlib JSON
936        // encoder so the ETag derives from a stable canonical form
937        // (dict keys sorted, bytes base64-tagged) rather than the
938        // less-stable `display()` representation. We reuse
939        // `stdlib::json` directly instead of reaching for the
940        // llm-helpers encoder so the abstraction boundary stays
941        // sibling-module, not cross-subsystem.
942        other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
943    }
944}
945
946/// Parse an HTTP `Accept` header and return the best matching offer.
947///
948/// Standard Q-value scoring per RFC 9110 §12.5.1: each media-range
949/// gets a `q` (1.0 by default); each offer is scored by its
950/// best-matching range, with ties broken by offer order. Wildcard
951/// matches (`type/*`, `*/*`) score below exact-type matches.
952fn negotiate_accept(header: &str, offers: &[String]) -> Option<String> {
953    let ranges: Vec<MediaRange> = header
954        .split(',')
955        .filter_map(MediaRange::parse)
956        .filter(|range| range.q > 0.0)
957        .collect();
958    if ranges.is_empty() {
959        return None;
960    }
961
962    let mut best: Option<(usize, f32, u8)> = None;
963    for (index, offer) in offers.iter().enumerate() {
964        let (offer_type, offer_subtype) = split_media(offer)?;
965        for range in &ranges {
966            let score = range.match_score(offer_type, offer_subtype);
967            let Some(score) = score else { continue };
968            let q = range.q;
969            let candidate = (index, q, score);
970            best = Some(match best {
971                None => candidate,
972                Some(current) => {
973                    // Prefer higher q first; if equal, higher specificity;
974                    // if equal, earlier offer wins.
975                    if q > current.1
976                        || (q == current.1 && score > current.2)
977                        || (q == current.1 && score == current.2 && index < current.0)
978                    {
979                        candidate
980                    } else {
981                        current
982                    }
983                }
984            });
985        }
986    }
987    best.map(|(index, _, _)| offers[index].clone())
988}
989
990struct MediaRange<'a> {
991    type_: &'a str,
992    subtype: &'a str,
993    q: f32,
994}
995
996impl<'a> MediaRange<'a> {
997    fn parse(raw: &'a str) -> Option<Self> {
998        let trimmed = raw.trim();
999        let mut parts = trimmed.split(';');
1000        let media = parts.next()?.trim();
1001        let (type_, subtype) = split_media(media)?;
1002        let mut q = 1.0;
1003        for param in parts {
1004            let param = param.trim();
1005            if let Some(value) = param
1006                .strip_prefix("q=")
1007                .or_else(|| param.strip_prefix("Q="))
1008            {
1009                if let Ok(parsed) = value.trim().parse::<f32>() {
1010                    if (0.0..=1.0).contains(&parsed) {
1011                        q = parsed;
1012                    }
1013                }
1014            }
1015        }
1016        Some(Self { type_, subtype, q })
1017    }
1018
1019    fn match_score(&self, offer_type: &str, offer_subtype: &str) -> Option<u8> {
1020        let type_match = self.type_ == "*" || self.type_.eq_ignore_ascii_case(offer_type);
1021        let subtype_match = self.subtype == "*" || self.subtype.eq_ignore_ascii_case(offer_subtype);
1022        if !type_match || !subtype_match {
1023            return None;
1024        }
1025        Some(match (self.type_, self.subtype) {
1026            ("*", _) => 1,
1027            (_, "*") => 2,
1028            _ => 3,
1029        })
1030    }
1031}
1032
1033fn split_media(value: &str) -> Option<(&str, &str)> {
1034    let mut iter = value.splitn(2, '/');
1035    let type_ = iter.next()?.trim();
1036    let subtype = iter.next()?.trim();
1037    if type_.is_empty() || subtype.is_empty() {
1038        return None;
1039    }
1040    Some((type_, subtype))
1041}
1042
1043pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
1044    &HTTP_OK_IMPL_DEF,
1045    &HTTP_CREATED_IMPL_DEF,
1046    &HTTP_NO_CONTENT_IMPL_DEF,
1047    &HTTP_ERROR_IMPL_DEF,
1048    &HTTP_REPLY_IMPL_DEF,
1049    &HTTP_REPLY_FROM_IMPL_DEF,
1050    &HTTP_STREAM_IMPL_DEF,
1051    &HTTP_SSE_IMPL_DEF,
1052    &HTTP_ETAG_IMPL_DEF,
1053    &HTTP_CHOOSE_IMPL_DEF,
1054    &HTTP_NOT_MODIFIED_IMPL_DEF,
1055    &HTTP_PUSH_HINTS_IMPL_DEF,
1056    &HTTP_UPGRADE_WS_IMPL_DEF,
1057];
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062    use crate::llm::helpers::vm_value_to_json;
1063
1064    fn dict(value: &VmValue) -> &crate::value::DictMap {
1065        value.as_dict().expect("envelope is a dict")
1066    }
1067
1068    fn run_sync<F, Fut>(future: F) -> Fut::Output
1069    where
1070        F: FnOnce() -> Fut,
1071        Fut: std::future::Future,
1072    {
1073        tokio::runtime::Builder::new_current_thread()
1074            .enable_all()
1075            .build()
1076            .expect("rt")
1077            .block_on(future())
1078    }
1079
1080    #[test]
1081    fn http_ok_produces_tagged_envelope() {
1082        let body = VmValue::String(arcstr::ArcStr::from("hello"));
1083        let response = http_ok_impl(&[body], &mut String::new()).expect("ok");
1084        let map = dict(&response);
1085        assert_eq!(
1086            map.get(HTTP_RESPONSE_TAG_KEY).and_then(|v| match v {
1087                VmValue::String(s) => Some(s.as_str()),
1088                _ => None,
1089            }),
1090            Some(HTTP_RESPONSE_TAG_VERSION)
1091        );
1092        assert!(matches!(map.get("status"), Some(VmValue::Int(200))));
1093        assert_eq!(
1094            map.get("body").map(|v| v.display()).as_deref(),
1095            Some("hello")
1096        );
1097    }
1098
1099    #[test]
1100    fn http_created_sets_location_header() {
1101        let body = VmValue::dict(crate::value::DictMap::from_iter([(
1102            crate::value::intern_key("id"),
1103            VmValue::String(arcstr::ArcStr::from("sess_1")),
1104        )]));
1105        let location = VmValue::String(arcstr::ArcStr::from("/v1/sessions/sess_1"));
1106        let response = http_created_impl(&[body, location], &mut String::new()).expect("created");
1107        let map = dict(&response);
1108        assert!(matches!(map.get("status"), Some(VmValue::Int(201))));
1109        let headers = map
1110            .get("headers")
1111            .and_then(VmValue::as_dict)
1112            .expect("headers");
1113        assert_eq!(
1114            headers.get("Location").map(|v| v.display()).as_deref(),
1115            Some("/v1/sessions/sess_1")
1116        );
1117    }
1118
1119    #[test]
1120    fn http_no_content_omits_body_marker() {
1121        let response = http_no_content_impl(&[], &mut String::new()).expect("no_content");
1122        let map = dict(&response);
1123        assert!(matches!(map.get("status"), Some(VmValue::Int(204))));
1124        assert!(map.get("body").is_none());
1125        assert_eq!(
1126            map.get("body_kind").and_then(|v| match v {
1127                VmValue::String(s) => Some(s.as_str()),
1128                _ => None,
1129            }),
1130            Some(BODY_KIND_NONE)
1131        );
1132    }
1133
1134    #[test]
1135    fn http_error_carries_code_message_and_marker() {
1136        let response = http_error_impl(
1137            &[
1138                VmValue::Int(422),
1139                VmValue::String(arcstr::ArcStr::from("invalid_input")),
1140                VmValue::String(arcstr::ArcStr::from("bad payload")),
1141                VmValue::Nil,
1142            ],
1143            &mut String::new(),
1144        )
1145        .expect("error");
1146        let map = dict(&response);
1147        assert!(matches!(map.get("status"), Some(VmValue::Int(422))));
1148        assert!(matches!(map.get("is_error"), Some(VmValue::Bool(true))));
1149        let body = map
1150            .get("body")
1151            .and_then(VmValue::as_dict)
1152            .expect("body dict");
1153        assert_eq!(
1154            body.get("code").map(|v| v.display()).as_deref(),
1155            Some("invalid_input")
1156        );
1157        assert_eq!(
1158            body.get("message").map(|v| v.display()).as_deref(),
1159            Some("bad payload")
1160        );
1161    }
1162
1163    #[test]
1164    fn http_error_rejects_2xx_status() {
1165        let err = http_error_impl(
1166            &[
1167                VmValue::Int(200),
1168                VmValue::String(arcstr::ArcStr::from("x")),
1169                VmValue::String(arcstr::ArcStr::from("y")),
1170            ],
1171            &mut String::new(),
1172        )
1173        .expect_err("expected reject");
1174        match err {
1175            VmError::Thrown(VmValue::String(text)) => {
1176                assert!(text.contains("4xx or 5xx"), "got: {text}");
1177            }
1178            other => panic!("unexpected error: {other:?}"),
1179        }
1180    }
1181
1182    #[test]
1183    fn http_reply_rejects_out_of_range_status() {
1184        let err =
1185            http_reply_impl(&[VmValue::Int(999)], &mut String::new()).expect_err("out of range");
1186        match err {
1187            VmError::Thrown(VmValue::String(text)) => {
1188                assert!(text.contains("100-599"), "got: {text}");
1189            }
1190            other => panic!("unexpected error: {other:?}"),
1191        }
1192    }
1193
1194    #[test]
1195    fn http_reply_bytes_uses_bytes_body_kind() {
1196        let bytes = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1197        let headers = VmValue::dict(crate::value::DictMap::from_iter([(
1198            crate::value::intern_key("Content-Type"),
1199            VmValue::String(arcstr::ArcStr::from("application/octet-stream")),
1200        )]));
1201        let response =
1202            http_reply_impl(&[VmValue::Int(200), bytes, headers], &mut String::new()).unwrap();
1203        let map = dict(&response);
1204        assert_eq!(
1205            map.get("body_kind").and_then(|v| match v {
1206                VmValue::String(s) => Some(s.as_str()),
1207                _ => None,
1208            }),
1209            Some(BODY_KIND_BYTES)
1210        );
1211        assert!(matches!(map.get("body"), Some(VmValue::Bytes(_))));
1212    }
1213
1214    #[test]
1215    fn http_reply_from_wraps_stream_body_as_chunk_list() {
1216        let result = VmValue::dict(crate::value::DictMap::from_iter([
1217            (crate::value::intern_key("status"), VmValue::Int(202)),
1218            (
1219                crate::value::intern_key("body_kind"),
1220                VmValue::string("stream"),
1221            ),
1222            (
1223                crate::value::intern_key("headers"),
1224                VmValue::dict(crate::value::DictMap::from_iter([(
1225                    crate::value::intern_key("Content-Type"),
1226                    VmValue::string("text/plain"),
1227                )])),
1228            ),
1229            (crate::value::intern_key("body"), VmValue::string("queued")),
1230        ]));
1231
1232        let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1233        let map = dict(&response);
1234        assert!(matches!(map.get("status"), Some(VmValue::Int(202))));
1235        assert_eq!(
1236            map.get("body_kind").and_then(|v| match v {
1237                VmValue::String(s) => Some(s.as_str()),
1238                _ => None,
1239            }),
1240            Some(BODY_KIND_STREAM)
1241        );
1242        let body = match map.get("body") {
1243            Some(VmValue::List(items)) => items,
1244            other => panic!("expected stream body chunk list, got {other:?}"),
1245        };
1246        assert_eq!(body.len(), 1);
1247        assert_eq!(body[0].display(), "queued");
1248    }
1249
1250    #[test]
1251    fn http_reply_from_preserves_existing_stream_chunks() {
1252        let chunks = VmValue::List(std::sync::Arc::new(vec![
1253            VmValue::string("alpha"),
1254            VmValue::string("bravo"),
1255        ]));
1256        let result = VmValue::dict(crate::value::DictMap::from_iter([
1257            (crate::value::intern_key("status"), VmValue::Int(200)),
1258            (
1259                crate::value::intern_key("body_kind"),
1260                VmValue::string("stream"),
1261            ),
1262            (crate::value::intern_key("body"), chunks),
1263        ]));
1264
1265        let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1266        let map = dict(&response);
1267        let body = match map.get("body") {
1268            Some(VmValue::List(items)) => items,
1269            other => panic!("expected stream body chunk list, got {other:?}"),
1270        };
1271        assert_eq!(body.len(), 2);
1272        assert_eq!(body[0].display(), "alpha");
1273        assert_eq!(body[1].display(), "bravo");
1274    }
1275
1276    #[test]
1277    fn http_reply_from_preserves_raw_body_for_bytes_kind() {
1278        let raw = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1279        let result = VmValue::dict(crate::value::DictMap::from_iter([
1280            (crate::value::intern_key("status"), VmValue::Int(200)),
1281            (
1282                crate::value::intern_key("body_kind"),
1283                VmValue::string("bytes"),
1284            ),
1285            (crate::value::intern_key("body"), VmValue::string("<lossy>")),
1286            (crate::value::intern_key("raw_body"), raw),
1287        ]));
1288
1289        let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1290        let map = dict(&response);
1291        assert_eq!(
1292            map.get("body_kind").and_then(|v| match v {
1293                VmValue::String(s) => Some(s.as_str()),
1294                _ => None,
1295            }),
1296            Some(BODY_KIND_BYTES)
1297        );
1298        match map.get("body") {
1299            Some(VmValue::Bytes(bytes)) => assert_eq!(bytes.as_ref(), &[0x00, 0xff, 0xfe, 0x80]),
1300            other => panic!("expected bytes body, got {other:?}"),
1301        }
1302    }
1303
1304    #[test]
1305    fn http_reply_from_rejects_non_bytes_for_bytes_kind() {
1306        let result = VmValue::dict(crate::value::DictMap::from_iter([
1307            (crate::value::intern_key("status"), VmValue::Int(200)),
1308            (
1309                crate::value::intern_key("body_kind"),
1310                VmValue::string("bytes"),
1311            ),
1312            (
1313                crate::value::intern_key("body"),
1314                VmValue::string("not bytes"),
1315            ),
1316        ]));
1317
1318        let err =
1319            http_reply_from_impl(&[result], &mut String::new()).expect_err("expected bytes error");
1320        match err {
1321            VmError::Thrown(VmValue::String(text)) => {
1322                assert!(text.contains("requires bytes"), "unexpected error: {text}");
1323            }
1324            other => panic!("unexpected error: {other:?}"),
1325        }
1326    }
1327
1328    #[test]
1329    fn http_reply_from_falls_back_to_http_reply_for_text_kind() {
1330        let result = VmValue::dict(crate::value::DictMap::from_iter([
1331            (crate::value::intern_key("status"), VmValue::Int(200)),
1332            (
1333                crate::value::intern_key("body_kind"),
1334                VmValue::string("text"),
1335            ),
1336            (crate::value::intern_key("body"), VmValue::string("hello")),
1337        ]));
1338
1339        let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1340        let map = dict(&response);
1341        assert_eq!(
1342            map.get("body_kind").and_then(|v| match v {
1343                VmValue::String(s) => Some(s.as_str()),
1344                _ => None,
1345            }),
1346            Some(BODY_KIND_JSON)
1347        );
1348        assert_eq!(
1349            map.get("body").map(VmValue::display).as_deref(),
1350            Some("hello")
1351        );
1352    }
1353
1354    #[test]
1355    fn http_reply_from_rejects_non_dict_result() {
1356        let err = http_reply_from_impl(&[VmValue::string("nope")], &mut String::new())
1357            .expect_err("expected result type error");
1358        match err {
1359            VmError::Thrown(VmValue::String(text)) => {
1360                assert!(
1361                    text.contains("result must be a dict"),
1362                    "unexpected error: {text}"
1363                );
1364            }
1365            other => panic!("unexpected error: {other:?}"),
1366        }
1367    }
1368
1369    #[test]
1370    fn http_stream_buffers_list_source() {
1371        let items = vec![
1372            VmValue::String(arcstr::ArcStr::from("a")),
1373            VmValue::String(arcstr::ArcStr::from("b")),
1374        ];
1375        let response = run_sync(|| {
1376            http_stream_impl(
1377                crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1378                vec![
1379                    VmValue::List(std::sync::Arc::new(items.clone())),
1380                    VmValue::String(arcstr::ArcStr::from("text/plain")),
1381                ],
1382            )
1383        })
1384        .expect("stream");
1385        let map = dict(&response);
1386        assert_eq!(
1387            map.get("body_kind").and_then(|v| match v {
1388                VmValue::String(s) => Some(s.as_str()),
1389                _ => None,
1390            }),
1391            Some(BODY_KIND_STREAM)
1392        );
1393        let body = map.get("body").expect("body");
1394        match body {
1395            VmValue::List(values) => {
1396                assert_eq!(values.len(), 2);
1397            }
1398            other => panic!("expected list body, got {other:?}"),
1399        }
1400        let headers = map
1401            .get("headers")
1402            .and_then(VmValue::as_dict)
1403            .expect("headers");
1404        assert_eq!(
1405            headers.get("Content-Type").map(|v| v.display()).as_deref(),
1406            Some("text/plain")
1407        );
1408    }
1409
1410    #[test]
1411    fn http_sse_sets_event_stream_headers_and_optional_retry() {
1412        let events = vec![VmValue::dict(crate::value::DictMap::from_iter([(
1413            crate::value::intern_key("data"),
1414            VmValue::String(arcstr::ArcStr::from("ping")),
1415        )]))];
1416        let response = run_sync(|| {
1417            http_sse_impl(
1418                crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1419                vec![
1420                    VmValue::List(std::sync::Arc::new(events.clone())),
1421                    VmValue::Int(2500),
1422                ],
1423            )
1424        })
1425        .expect("sse");
1426        let map = dict(&response);
1427        let headers = map
1428            .get("headers")
1429            .and_then(VmValue::as_dict)
1430            .expect("headers");
1431        assert_eq!(
1432            headers.get("Content-Type").map(|v| v.display()).as_deref(),
1433            Some("text/event-stream")
1434        );
1435        assert_eq!(
1436            headers.get("Cache-Control").map(|v| v.display()).as_deref(),
1437            Some("no-cache")
1438        );
1439        assert!(matches!(map.get("retry_ms"), Some(VmValue::Int(2500))));
1440    }
1441
1442    #[test]
1443    fn parse_envelope_round_trip_through_json() {
1444        let response = http_error_impl(
1445            &[
1446                VmValue::Int(404),
1447                VmValue::String(arcstr::ArcStr::from("not_found")),
1448                VmValue::String(arcstr::ArcStr::from("missing")),
1449                VmValue::dict(crate::value::DictMap::from_iter([(
1450                    crate::value::intern_key("id"),
1451                    VmValue::String(arcstr::ArcStr::from("sess_404")),
1452                )])),
1453            ],
1454            &mut String::new(),
1455        )
1456        .expect("error");
1457        let json = vm_value_to_json(&response);
1458        let envelope = parse_envelope(&json).expect("envelope parses");
1459        assert_eq!(envelope.status, 404);
1460        assert!(envelope.is_error);
1461        let body = envelope.body.expect("body");
1462        assert_eq!(body["code"], "not_found");
1463        assert_eq!(body["details"]["id"], "sess_404");
1464    }
1465
1466    #[test]
1467    fn parse_envelope_ignores_untagged_dicts() {
1468        let plain = serde_json::json!({"status": 200, "body": {}});
1469        assert!(parse_envelope(&plain).is_none());
1470    }
1471
1472    #[test]
1473    fn http_etag_is_quoted_hex_sha256_of_payload() {
1474        let value = VmValue::String(arcstr::ArcStr::from("hello"));
1475        let etag = http_etag_impl(&[value], &mut String::new()).expect("etag");
1476        match etag {
1477            VmValue::String(text) => {
1478                assert_eq!(
1479                    text.as_str(),
1480                    "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\""
1481                );
1482            }
1483            other => panic!("expected string, got {other:?}"),
1484        }
1485    }
1486
1487    #[test]
1488    fn http_etag_stable_across_string_and_bytes_for_same_payload() {
1489        let from_string = http_etag_impl(
1490            &[VmValue::String(arcstr::ArcStr::from("hello"))],
1491            &mut String::new(),
1492        )
1493        .unwrap();
1494        let from_bytes = http_etag_impl(
1495            &[VmValue::Bytes(std::sync::Arc::new(b"hello".to_vec()))],
1496            &mut String::new(),
1497        )
1498        .unwrap();
1499        assert_eq!(from_string.display(), from_bytes.display());
1500    }
1501
1502    #[test]
1503    fn http_choose_returns_best_q_match() {
1504        let accept = VmValue::String(arcstr::ArcStr::from(
1505            "application/xml;q=0.5, application/json;q=0.9",
1506        ));
1507        let offers = VmValue::List(std::sync::Arc::new(vec![
1508            VmValue::String(arcstr::ArcStr::from("application/xml")),
1509            VmValue::String(arcstr::ArcStr::from("application/json")),
1510        ]));
1511        let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1512        assert_eq!(chosen.display(), "application/json");
1513    }
1514
1515    #[test]
1516    fn http_choose_prefers_specific_over_wildcard() {
1517        let accept = VmValue::String(arcstr::ArcStr::from("text/*;q=0.5, application/json"));
1518        let offers = VmValue::List(std::sync::Arc::new(vec![
1519            VmValue::String(arcstr::ArcStr::from("text/plain")),
1520            VmValue::String(arcstr::ArcStr::from("application/json")),
1521        ]));
1522        let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1523        assert_eq!(chosen.display(), "application/json");
1524    }
1525
1526    #[test]
1527    fn http_choose_returns_default_for_no_accept() {
1528        let offers = VmValue::List(std::sync::Arc::new(vec![
1529            VmValue::String(arcstr::ArcStr::from("text/plain")),
1530            VmValue::String(arcstr::ArcStr::from("application/json")),
1531        ]));
1532        let chosen = http_choose_impl(&[VmValue::Nil, offers], &mut String::new()).unwrap();
1533        assert_eq!(chosen.display(), "text/plain");
1534    }
1535
1536    #[test]
1537    fn http_choose_overrides_default_with_explicit() {
1538        let offers = VmValue::List(std::sync::Arc::new(vec![
1539            VmValue::String(arcstr::ArcStr::from("text/plain")),
1540            VmValue::String(arcstr::ArcStr::from("application/json")),
1541        ]));
1542        let chosen = http_choose_impl(
1543            &[
1544                VmValue::Nil,
1545                offers,
1546                VmValue::String(arcstr::ArcStr::from("application/json")),
1547            ],
1548            &mut String::new(),
1549        )
1550        .unwrap();
1551        assert_eq!(chosen.display(), "application/json");
1552    }
1553
1554    #[test]
1555    fn http_choose_wildcard_accept_yields_default() {
1556        let offers = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1557            arcstr::ArcStr::from("application/json"),
1558        )]));
1559        let chosen = http_choose_impl(
1560            &[VmValue::String(arcstr::ArcStr::from("*/*")), offers],
1561            &mut String::new(),
1562        )
1563        .unwrap();
1564        assert_eq!(chosen.display(), "application/json");
1565    }
1566
1567    #[test]
1568    fn http_not_modified_envelope_carries_etag() {
1569        let etag = VmValue::String(arcstr::ArcStr::from("\"abc\""));
1570        let response = http_not_modified_impl(&[etag, VmValue::Nil], &mut String::new()).unwrap();
1571        let map = dict(&response);
1572        assert!(matches!(map.get("status"), Some(VmValue::Int(304))));
1573        let headers = map
1574            .get("headers")
1575            .and_then(VmValue::as_dict)
1576            .expect("headers");
1577        assert_eq!(
1578            headers.get("ETag").map(|v| v.display()).as_deref(),
1579            Some("\"abc\"")
1580        );
1581    }
1582
1583    #[test]
1584    fn http_push_hints_appends_link_headers_with_inferred_as() {
1585        let envelope = http_ok_impl(
1586            &[VmValue::dict(crate::value::DictMap::new())],
1587            &mut String::new(),
1588        )
1589        .unwrap();
1590        let paths = VmValue::List(std::sync::Arc::new(vec![
1591            VmValue::String(arcstr::ArcStr::from("/main.css")),
1592            VmValue::String(arcstr::ArcStr::from("/app.js")),
1593            VmValue::String(arcstr::ArcStr::from("/hero.webp")),
1594            VmValue::String(arcstr::ArcStr::from("/inter.woff2")),
1595            VmValue::String(arcstr::ArcStr::from("/manifest.json")),
1596            VmValue::String(arcstr::ArcStr::from("/unknown.xyz")),
1597        ]));
1598        let response =
1599            http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1600        let map = dict(&response);
1601        let headers = map
1602            .get("headers")
1603            .and_then(VmValue::as_dict)
1604            .expect("headers");
1605        let links = match headers.get("Link") {
1606            Some(VmValue::List(items)) => items.clone(),
1607            other => panic!("Link should be a list, got {other:?}"),
1608        };
1609        let rendered: Vec<String> = links
1610            .iter()
1611            .map(|v| match v {
1612                VmValue::String(s) => s.to_string(),
1613                other => panic!("Link entry is not a string: {other:?}"),
1614            })
1615            .collect();
1616        assert_eq!(
1617            rendered,
1618            vec![
1619                "</main.css>; rel=preload; as=style",
1620                "</app.js>; rel=preload; as=script",
1621                "</hero.webp>; rel=preload; as=image",
1622                "</inter.woff2>; rel=preload; as=font",
1623                "</manifest.json>; rel=preload; as=fetch",
1624                "</unknown.xyz>; rel=preload",
1625            ]
1626        );
1627    }
1628
1629    #[test]
1630    fn http_push_hints_handles_querystring_in_path() {
1631        let envelope = http_ok_impl(&[VmValue::Nil], &mut String::new()).unwrap();
1632        let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1633            arcstr::ArcStr::from("/static/app.js?v=42"),
1634        )]));
1635        let response =
1636            http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1637        let map = dict(&response);
1638        let headers = map
1639            .get("headers")
1640            .and_then(VmValue::as_dict)
1641            .expect("headers");
1642        let links = match headers.get("Link") {
1643            Some(VmValue::List(items)) => items.clone(),
1644            other => panic!("Link should be a list, got {other:?}"),
1645        };
1646        assert_eq!(
1647            links[0].display(),
1648            "</static/app.js?v=42>; rel=preload; as=script"
1649        );
1650    }
1651
1652    #[test]
1653    fn http_push_hints_rejects_untagged_envelope() {
1654        let plain = VmValue::dict(crate::value::DictMap::from_iter([(
1655            crate::value::intern_key("status"),
1656            VmValue::Int(200),
1657        )]));
1658        let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1659            arcstr::ArcStr::from("/main.css"),
1660        )]));
1661        let result = http_push_hints_impl(&[plain, paths], &mut String::new());
1662        assert!(
1663            matches!(result, Err(VmError::Thrown(_))),
1664            "untagged dict should be rejected, got {result:?}"
1665        );
1666    }
1667
1668    #[test]
1669    fn http_push_hints_preserves_existing_link_header() {
1670        let envelope = http_reply_impl(
1671            &[
1672                VmValue::Int(200),
1673                VmValue::dict(crate::value::DictMap::new()),
1674                VmValue::dict(crate::value::DictMap::from_iter([(
1675                    crate::value::intern_key("Link"),
1676                    VmValue::String(arcstr::ArcStr::from("</legacy.css>; rel=preload; as=style")),
1677                )])),
1678            ],
1679            &mut String::new(),
1680        )
1681        .unwrap();
1682        let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1683            arcstr::ArcStr::from("/app.js"),
1684        )]));
1685        let response = http_push_hints_impl(&[envelope, paths], &mut String::new()).unwrap();
1686        let map = dict(&response);
1687        let headers = map
1688            .get("headers")
1689            .and_then(VmValue::as_dict)
1690            .expect("headers");
1691        let links = match headers.get("Link") {
1692            Some(VmValue::List(items)) => items.clone(),
1693            other => panic!("Link should be a list once preloads are added, got {other:?}"),
1694        };
1695        assert_eq!(links.len(), 2);
1696        assert_eq!(links[0].display(), "</legacy.css>; rel=preload; as=style");
1697        assert_eq!(links[1].display(), "</app.js>; rel=preload; as=script");
1698    }
1699
1700    #[test]
1701    fn http_upgrade_ws_envelope_negotiates_subprotocol() {
1702        let req = VmValue::dict(crate::value::DictMap::from_iter([(
1703            crate::value::intern_key("headers"),
1704            VmValue::dict(crate::value::DictMap::from_iter([(
1705                crate::value::intern_key("Sec-WebSocket-Protocol"),
1706                VmValue::String(arcstr::ArcStr::from("v0.harn, v1.harn")),
1707            )])),
1708        )]));
1709        let options = VmValue::dict(crate::value::DictMap::from_iter([(
1710            crate::value::intern_key("subprotocols"),
1711            VmValue::List(std::sync::Arc::new(vec![
1712                VmValue::String(arcstr::ArcStr::from("v1.harn")),
1713                VmValue::String(arcstr::ArcStr::from("v2.harn")),
1714            ])),
1715        )]));
1716        let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1717        let map = dict(&response);
1718        assert!(matches!(map.get("status"), Some(VmValue::Int(101))));
1719        let upgrade = map
1720            .get("ws_upgrade")
1721            .and_then(VmValue::as_dict)
1722            .expect("ws_upgrade");
1723        assert_eq!(
1724            upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1725            Some("v1.harn")
1726        );
1727        let headers = map
1728            .get("headers")
1729            .and_then(VmValue::as_dict)
1730            .expect("headers");
1731        assert_eq!(
1732            headers.get("Upgrade").map(|v| v.display()).as_deref(),
1733            Some("websocket")
1734        );
1735        assert_eq!(
1736            headers
1737                .get("Sec-WebSocket-Protocol")
1738                .map(|v| v.display())
1739                .as_deref(),
1740            Some("v1.harn")
1741        );
1742    }
1743
1744    #[test]
1745    fn http_upgrade_ws_picks_client_preferred_when_both_overlap() {
1746        // Regression for the divergence between
1747        // `http_upgrade_ws_impl`'s envelope-side negotiation and
1748        // `harn_serve::ws::negotiate_subprotocol`'s wire-side
1749        // negotiation. With client "v2.harn, v1.harn" and server
1750        // ["v1.harn", "v2.harn"] the two implementations used to
1751        // disagree (server-order picked v1; client-order picks v2).
1752        // The envelope MUST match what the upgrade handshake echoes
1753        // back, so we honour client preference everywhere.
1754        let req = VmValue::dict(crate::value::DictMap::from_iter([(
1755            crate::value::intern_key("headers"),
1756            VmValue::dict(crate::value::DictMap::from_iter([(
1757                crate::value::intern_key("Sec-WebSocket-Protocol"),
1758                VmValue::String(arcstr::ArcStr::from("v2.harn, v1.harn")),
1759            )])),
1760        )]));
1761        let options = VmValue::dict(crate::value::DictMap::from_iter([(
1762            crate::value::intern_key("subprotocols"),
1763            VmValue::List(std::sync::Arc::new(vec![
1764                VmValue::String(arcstr::ArcStr::from("v1.harn")),
1765                VmValue::String(arcstr::ArcStr::from("v2.harn")),
1766            ])),
1767        )]));
1768        let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1769        let upgrade = dict(&response)
1770            .get("ws_upgrade")
1771            .and_then(VmValue::as_dict)
1772            .expect("ws_upgrade");
1773        assert_eq!(
1774            upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1775            Some("v2.harn")
1776        );
1777    }
1778
1779    #[test]
1780    fn parse_envelope_round_trips_ws_upgrade_marker() {
1781        let req = VmValue::dict(crate::value::DictMap::from_iter([(
1782            crate::value::intern_key("headers"),
1783            VmValue::dict(crate::value::DictMap::from_iter([(
1784                crate::value::intern_key("Sec-WebSocket-Protocol"),
1785                VmValue::String(arcstr::ArcStr::from("v1.harn")),
1786            )])),
1787        )]));
1788        let options = VmValue::dict(crate::value::DictMap::from_iter([
1789            (
1790                crate::value::intern_key("subprotocols"),
1791                VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1792                    arcstr::ArcStr::from("v1.harn"),
1793                )])),
1794            ),
1795            (
1796                crate::value::intern_key("idle_ping_ms"),
1797                VmValue::Int(15_000),
1798            ),
1799        ]));
1800        let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1801        let json = vm_value_to_json(&response);
1802        let envelope = parse_envelope(&json).expect("envelope parses");
1803        let ws = envelope.ws_upgrade.expect("ws_upgrade present");
1804        assert_eq!(ws.subprotocol.as_deref(), Some("v1.harn"));
1805        assert_eq!(ws.offered, vec!["v1.harn"]);
1806        assert_eq!(ws.idle_ping_ms, Some(15_000));
1807        assert_eq!(envelope.status, 101);
1808    }
1809
1810    #[test]
1811    fn http_upgrade_ws_falls_through_when_no_subprotocols_offered() {
1812        let req = VmValue::dict(crate::value::DictMap::new());
1813        let response = http_upgrade_ws_impl(&[req], &mut String::new()).unwrap();
1814        let map = dict(&response);
1815        let upgrade = map
1816            .get("ws_upgrade")
1817            .and_then(VmValue::as_dict)
1818            .expect("ws_upgrade");
1819        assert!(matches!(upgrade.get("subprotocol"), Some(VmValue::Nil)));
1820    }
1821}