Skip to main content

harn_vm/stdlib/
http_response.rs

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