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