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