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