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