Skip to main content

harn_vm/stdlib/
http_response.rs

1//! HTTP response codec primitives for `.harn` HTTP handlers.
2//!
3//! Handlers hosted on `harn-serve` build their replies with these
4//! builtins instead of bare JSON dicts. The codec on the server side
5//! (`harn_serve::http_codec`) recognises the tagged record, lifts
6//! status/headers/body out of it, and renders it as a proper HTTP
7//! response — JSON, no-content, error envelope, buffered stream, or
8//! Server-Sent Events.
9//!
10//! The tag is the literal string `"v1"` under the
11//! `__http_response__` key. Plain dicts that happen to define
12//! `status`/`headers`/`body` are not picked up by the codec; only the
13//! tagged record is.
14//!
15//! Channel-bearing bodies (`http_stream(channel)`, `http_sse(channel)`)
16//! are drained inside the builtin before the handler returns. This
17//! keeps the v1 codec wire-format JSON-only — the channel is
18//! materialised into a list of chunks/events. Authors write the same
19//! code that true streaming will accept; only the on-the-wire timing
20//! differs.
21
22use crate::value::VmDictExt;
23use std::collections::BTreeMap;
24
25use sha2::{Digest, Sha256};
26
27use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
28use crate::value::{VmError, VmValue};
29use crate::vm::Vm;
30
31/// Tag key + version that the harn-serve codec keys off of.
32pub const HTTP_RESPONSE_TAG_KEY: &str = "__http_response__";
33pub const HTTP_RESPONSE_TAG_VERSION: &str = "v1";
34
35const BODY_KIND_JSON: &str = "json";
36const BODY_KIND_NONE: &str = "none";
37const BODY_KIND_BYTES: &str = "bytes";
38const BODY_KIND_STREAM: &str = "stream";
39const BODY_KIND_SSE: &str = "sse";
40
41pub(crate) fn register_http_response_builtins(vm: &mut Vm) {
42    for def in MODULE_BUILTINS {
43        vm.register_builtin_def(def);
44    }
45}
46
47#[harn_builtin(sig = "http_ok(body: any?) -> dict", category = "http_response")]
48fn http_ok_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
49    let body = args.first().cloned().unwrap_or(VmValue::Nil);
50    Ok(envelope(
51        200,
52        body,
53        BODY_KIND_JSON,
54        crate::value::DictMap::new(),
55    ))
56}
57
58#[harn_builtin(
59    sig = "http_created(body: any?, location?: string?) -> dict",
60    category = "http_response"
61)]
62fn http_created_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
63    let body = args.first().cloned().unwrap_or(VmValue::Nil);
64    let mut headers = crate::value::DictMap::new();
65    if let Some(location) = args.get(1).and_then(string_or_nil) {
66        headers.put_str("Location", location);
67    }
68    Ok(envelope(201, body, BODY_KIND_JSON, headers))
69}
70
71#[harn_builtin(sig = "http_no_content() -> dict", category = "http_response")]
72fn http_no_content_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
73    Ok(envelope(
74        204,
75        VmValue::Nil,
76        BODY_KIND_NONE,
77        crate::value::DictMap::new(),
78    ))
79}
80
81#[harn_builtin(
82    sig = "http_error(status: int, code: string, message: string, details?: any) -> dict",
83    category = "http_response"
84)]
85fn http_error_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
86    let status = require_status(args.first(), "http_error")?;
87    if !(400..=599).contains(&status) {
88        return Err(thrown_err(format!(
89            "http_error: status must be 4xx or 5xx (got {status})"
90        )));
91    }
92    let code = require_nonempty_string(args.get(1), "http_error", "code")?;
93    let message = require_nonempty_string(args.get(2), "http_error", "message")?;
94    let details = args.get(3).cloned().unwrap_or(VmValue::Nil);
95
96    let mut body = crate::value::DictMap::new();
97    body.put_str("code", code);
98    body.put_str("message", message);
99    if !matches!(details, VmValue::Nil) {
100        body.insert("details".to_string(), details);
101    }
102    let mut env = envelope_map(
103        status,
104        VmValue::dict(body),
105        BODY_KIND_JSON,
106        crate::value::DictMap::new(),
107    );
108    env.insert("is_error".to_string(), VmValue::Bool(true));
109    Ok(VmValue::dict(env))
110}
111
112#[harn_builtin(
113    sig = "http_reply(status: int, body?: any, headers?: dict) -> dict",
114    category = "http_response"
115)]
116fn http_reply_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
117    let status = require_status(args.first(), "http_reply")?;
118    let body = args.get(1).cloned().unwrap_or(VmValue::Nil);
119    let headers = parse_headers(args.get(2), "http_reply")?;
120    let body_kind = if status == 204 || status == 304 || matches!(body, VmValue::Nil) {
121        BODY_KIND_NONE
122    } else if matches!(body, VmValue::Bytes(_)) {
123        BODY_KIND_BYTES
124    } else {
125        BODY_KIND_JSON
126    };
127    let body_for_envelope = if body_kind == BODY_KIND_NONE {
128        VmValue::Nil
129    } else {
130        body
131    };
132    Ok(envelope(status, body_for_envelope, body_kind, headers))
133}
134
135#[harn_builtin(
136    sig = "http_stream(source: any, content_type?: string?) -> dict",
137    kind = "async",
138    category = "http_response"
139)]
140async fn http_stream_impl(
141    _ctx: crate::vm::AsyncBuiltinCtx,
142    args: Vec<VmValue>,
143) -> Result<VmValue, VmError> {
144    let source = args
145        .first()
146        .cloned()
147        .ok_or_else(|| thrown_err("http_stream: source is required"))?;
148    let content_type = args
149        .get(1)
150        .and_then(string_or_nil)
151        .unwrap_or_else(|| "application/octet-stream".to_string());
152
153    let chunks = drain_to_list(source, "http_stream").await?;
154    let mut headers = crate::value::DictMap::new();
155    headers.put_str("Content-Type", content_type);
156    Ok(envelope(
157        200,
158        VmValue::List(std::sync::Arc::new(chunks)),
159        BODY_KIND_STREAM,
160        headers,
161    ))
162}
163
164#[harn_builtin(
165    sig = "http_sse(source: any, retry_ms?: int?) -> dict",
166    kind = "async",
167    category = "http_response"
168)]
169async fn http_sse_impl(
170    _ctx: crate::vm::AsyncBuiltinCtx,
171    args: Vec<VmValue>,
172) -> Result<VmValue, VmError> {
173    let source = args
174        .first()
175        .cloned()
176        .ok_or_else(|| thrown_err("http_sse: source is required"))?;
177    let retry_ms = match args.get(1) {
178        None | Some(VmValue::Nil) => None,
179        Some(VmValue::Int(value)) => {
180            if *value < 0 {
181                return Err(thrown_err(format!(
182                    "http_sse: retry_ms must be non-negative (got {value})"
183                )));
184            }
185            Some(*value)
186        }
187        Some(other) => {
188            return Err(thrown_err(format!(
189                "http_sse: retry_ms must be an integer (got {})",
190                other.type_name()
191            )));
192        }
193    };
194
195    let events = drain_to_list(source, "http_sse").await?;
196    let mut headers = crate::value::DictMap::new();
197    headers.put_str("Content-Type", "text/event-stream");
198    headers.put_str("Cache-Control", "no-cache");
199    let mut env = envelope_map(
200        200,
201        VmValue::List(std::sync::Arc::new(events)),
202        BODY_KIND_SSE,
203        headers,
204    );
205    if let Some(retry_ms) = retry_ms {
206        env.insert("retry_ms".to_string(), VmValue::Int(retry_ms));
207    }
208    Ok(VmValue::dict(env))
209}
210
211fn envelope(
212    status: i64,
213    body: VmValue,
214    body_kind: &str,
215    headers: crate::value::DictMap,
216) -> VmValue {
217    VmValue::dict(envelope_map(status, body, body_kind, headers))
218}
219
220fn envelope_map(
221    status: i64,
222    body: VmValue,
223    body_kind: &str,
224    headers: crate::value::DictMap,
225) -> crate::value::DictMap {
226    let mut map = crate::value::DictMap::new();
227    map.insert(
228        HTTP_RESPONSE_TAG_KEY.to_string(),
229        VmValue::String(std::sync::Arc::from(HTTP_RESPONSE_TAG_VERSION)),
230    );
231    map.insert("status".to_string(), VmValue::Int(status));
232    map.put_str("body_kind", body_kind);
233    map.insert("headers".to_string(), VmValue::dict(headers));
234    if !matches!(body, VmValue::Nil) {
235        map.insert("body".to_string(), body);
236    }
237    map
238}
239
240fn require_status(value: Option<&VmValue>, fn_name: &str) -> Result<i64, VmError> {
241    let status = match value {
242        Some(VmValue::Int(value)) => *value,
243        Some(other) => {
244            return Err(thrown_err(format!(
245                "{fn_name}: status must be an integer (got {})",
246                other.type_name()
247            )));
248        }
249        None => {
250            return Err(thrown_err(format!("{fn_name}: status is required")));
251        }
252    };
253    if !(100..=599).contains(&status) {
254        return Err(thrown_err(format!(
255            "{fn_name}: status {status} is out of range (100-599)"
256        )));
257    }
258    Ok(status)
259}
260
261fn require_nonempty_string(
262    value: Option<&VmValue>,
263    fn_name: &str,
264    arg_name: &str,
265) -> Result<String, VmError> {
266    let text = match value {
267        Some(VmValue::String(text)) => text.to_string(),
268        Some(other) => {
269            return Err(thrown_err(format!(
270                "{fn_name}: {arg_name} must be a string (got {})",
271                other.type_name()
272            )));
273        }
274        None => {
275            return Err(thrown_err(format!("{fn_name}: {arg_name} is required")));
276        }
277    };
278    if text.is_empty() {
279        return Err(thrown_err(format!(
280            "{fn_name}: {arg_name} must be non-empty"
281        )));
282    }
283    Ok(text)
284}
285
286fn string_or_nil(value: &VmValue) -> Option<String> {
287    match value {
288        VmValue::String(text) if !text.is_empty() => Some(text.to_string()),
289        _ => None,
290    }
291}
292
293fn parse_headers(value: Option<&VmValue>, fn_name: &str) -> Result<crate::value::DictMap, VmError> {
294    match value {
295        None | Some(VmValue::Nil) => Ok(crate::value::DictMap::new()),
296        Some(VmValue::Dict(dict)) => Ok((**dict).clone()),
297        Some(other) => Err(thrown_err(format!(
298            "{fn_name}: headers must be a dict (got {})",
299            other.type_name()
300        ))),
301    }
302}
303
304/// Drain a channel into a `Vec<VmValue>`, or pass a list through verbatim.
305///
306/// The drain stops when the channel is closed and all queued values have been
307/// consumed. Channel handles can close through a flag/signal pair without
308/// dropping every sender clone, so the drain polls with `try_recv` and observes
309/// the closed state; when both report empty, the drain terminates.
310async fn drain_to_list(value: VmValue, fn_name: &str) -> Result<Vec<VmValue>, VmError> {
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.is_closed() {
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(std::sync::Arc::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(std::sync::Arc::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(std::sync::Arc::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.put_str("ETag", 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(std::sync::Arc::from(link))),
646    );
647
648    headers.insert(
649        "Link".to_string(),
650        VmValue::List(std::sync::Arc::new(combined)),
651    );
652    envelope_map.insert("headers".to_string(), VmValue::dict(headers));
653    Ok(VmValue::dict(envelope_map))
654}
655
656fn is_http_response_envelope(map: &crate::value::DictMap) -> bool {
657    matches!(
658        map.get(HTTP_RESPONSE_TAG_KEY),
659        Some(VmValue::String(tag)) if tag.as_ref() == HTTP_RESPONSE_TAG_VERSION,
660    )
661}
662
663fn format_link_header(path: &str) -> String {
664    match infer_preload_as(path) {
665        Some(kind) => format!("<{path}>; rel=preload; as={kind}"),
666        None => format!("<{path}>; rel=preload"),
667    }
668}
669
670/// Map the asset extension to its `as=` attribute per the HTML living
671/// standard's [destination table]. Unknown extensions emit a bare
672/// `rel=preload` and let the browser decline the hint rather than
673/// guessing — `as=` mismatch silently invalidates the preload.
674///
675/// [destination table]: https://html.spec.whatwg.org/multipage/links.html#link-type-preload
676fn infer_preload_as(path: &str) -> Option<&'static str> {
677    // Strip any query string / fragment before extension lookup. Paths
678    // like `/main.js?v=42` should still infer `script`.
679    let pre_query = path.split(['?', '#']).next().unwrap_or(path);
680    let dot = pre_query.rfind('.')?;
681    let ext = &pre_query[dot + 1..];
682    Some(match ext.to_ascii_lowercase().as_str() {
683        "css" => "style",
684        "js" | "mjs" => "script",
685        "json" => "fetch",
686        "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "ico" => "image",
687        "woff" | "woff2" | "ttf" | "otf" => "font",
688        _ => return None,
689    })
690}
691
692#[harn_builtin(
693    sig = "http_upgrade_ws(req: dict, options?: dict) -> dict",
694    category = "http_response"
695)]
696fn http_upgrade_ws_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
697    let req = args
698        .first()
699        .and_then(VmValue::as_dict)
700        .ok_or_else(|| thrown_err("http_upgrade_ws: req must be a dict"))?;
701    let options = args.get(1).and_then(VmValue::as_dict);
702
703    let request_subprotocols = req
704        .get("headers")
705        .and_then(VmValue::as_dict)
706        .and_then(|headers| header_lookup(headers, "sec-websocket-protocol"))
707        .map(|raw| {
708            raw.split(',')
709                .map(|s| s.trim().to_string())
710                .filter(|s| !s.is_empty())
711                .collect::<Vec<_>>()
712        })
713        .unwrap_or_default();
714    let offered_subprotocols = options
715        .and_then(|opts| opts.get("subprotocols"))
716        .and_then(|value| match value {
717            VmValue::List(items) => Some(
718                items
719                    .iter()
720                    .filter_map(|v| match v {
721                        VmValue::String(s) => Some(s.to_string()),
722                        _ => None,
723                    })
724                    .collect::<Vec<_>>(),
725            ),
726            _ => None,
727        })
728        .unwrap_or_default();
729
730    // Pick the first *client-preferred* subprotocol the server can
731    // serve. This must match the convention in
732    // `harn_serve::ws::negotiate_subprotocol` — if the two
733    // disagreed, the builtin's envelope would carry one subprotocol
734    // while the actual upgrade handshake echoed back another.
735    let negotiated = request_subprotocols
736        .iter()
737        .find(|client| offered_subprotocols.iter().any(|name| name == *client))
738        .cloned();
739
740    let mut headers = crate::value::DictMap::new();
741    headers.put_str("Upgrade", "websocket");
742    headers.put_str("Connection", "Upgrade");
743    if let Some(name) = &negotiated {
744        headers.put_str("Sec-WebSocket-Protocol", name.clone());
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({
764            let mut map = crate::value::DictMap::new();
765            map.insert(
766                "subprotocol".to_string(),
767                match &negotiated {
768                    Some(name) => VmValue::String(std::sync::Arc::from(name.clone())),
769                    None => VmValue::Nil,
770                },
771            );
772            map.insert(
773                "offered".to_string(),
774                VmValue::List(std::sync::Arc::new(
775                    offered_subprotocols
776                        .iter()
777                        .map(|s| VmValue::String(std::sync::Arc::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.put_str("on_message", handler.clone());
789            }
790            map
791        }),
792    );
793    Ok(VmValue::dict(env_map))
794}
795
796fn header_lookup(headers: &crate::value::DictMap, name: &str) -> Option<String> {
797    let needle = name.to_ascii_lowercase();
798    headers
799        .iter()
800        .find(|(key, _)| key.to_ascii_lowercase() == needle)
801        .and_then(|(_, value)| match value {
802            VmValue::String(text) => Some(text.to_string()),
803            _ => None,
804        })
805}
806
807fn value_as_bytes(value: &VmValue) -> Vec<u8> {
808    match value {
809        VmValue::Bytes(bytes) => bytes.as_ref().clone(),
810        VmValue::String(text) => text.as_bytes().to_vec(),
811        VmValue::Nil => Vec::new(),
812        // For dicts / lists / structs, fall through to the stdlib JSON
813        // encoder so the ETag derives from a stable canonical form
814        // (dict keys sorted, bytes base64-tagged) rather than the
815        // less-stable `display()` representation. We reuse
816        // `stdlib::json` directly instead of reaching for the
817        // llm-helpers encoder so the abstraction boundary stays
818        // sibling-module, not cross-subsystem.
819        other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
820    }
821}
822
823/// Parse an HTTP `Accept` header and return the best matching offer.
824///
825/// Standard Q-value scoring per RFC 9110 §12.5.1: each media-range
826/// gets a `q` (1.0 by default); each offer is scored by its
827/// best-matching range, with ties broken by offer order. Wildcard
828/// matches (`type/*`, `*/*`) score below exact-type matches.
829fn negotiate_accept(header: &str, offers: &[String]) -> Option<String> {
830    let ranges: Vec<MediaRange> = header
831        .split(',')
832        .filter_map(MediaRange::parse)
833        .filter(|range| range.q > 0.0)
834        .collect();
835    if ranges.is_empty() {
836        return None;
837    }
838
839    let mut best: Option<(usize, f32, u8)> = None;
840    for (index, offer) in offers.iter().enumerate() {
841        let (offer_type, offer_subtype) = split_media(offer)?;
842        for range in &ranges {
843            let score = range.match_score(offer_type, offer_subtype);
844            let Some(score) = score else { continue };
845            let q = range.q;
846            let candidate = (index, q, score);
847            best = Some(match best {
848                None => candidate,
849                Some(current) => {
850                    // Prefer higher q first; if equal, higher specificity;
851                    // if equal, earlier offer wins.
852                    if q > current.1
853                        || (q == current.1 && score > current.2)
854                        || (q == current.1 && score == current.2 && index < current.0)
855                    {
856                        candidate
857                    } else {
858                        current
859                    }
860                }
861            });
862        }
863    }
864    best.map(|(index, _, _)| offers[index].clone())
865}
866
867struct MediaRange<'a> {
868    type_: &'a str,
869    subtype: &'a str,
870    q: f32,
871}
872
873impl<'a> MediaRange<'a> {
874    fn parse(raw: &'a str) -> Option<Self> {
875        let trimmed = raw.trim();
876        let mut parts = trimmed.split(';');
877        let media = parts.next()?.trim();
878        let (type_, subtype) = split_media(media)?;
879        let mut q = 1.0;
880        for param in parts {
881            let param = param.trim();
882            if let Some(value) = param
883                .strip_prefix("q=")
884                .or_else(|| param.strip_prefix("Q="))
885            {
886                if let Ok(parsed) = value.trim().parse::<f32>() {
887                    if (0.0..=1.0).contains(&parsed) {
888                        q = parsed;
889                    }
890                }
891            }
892        }
893        Some(Self { type_, subtype, q })
894    }
895
896    fn match_score(&self, offer_type: &str, offer_subtype: &str) -> Option<u8> {
897        let type_match = self.type_ == "*" || self.type_.eq_ignore_ascii_case(offer_type);
898        let subtype_match = self.subtype == "*" || self.subtype.eq_ignore_ascii_case(offer_subtype);
899        if !type_match || !subtype_match {
900            return None;
901        }
902        Some(match (self.type_, self.subtype) {
903            ("*", _) => 1,
904            (_, "*") => 2,
905            _ => 3,
906        })
907    }
908}
909
910fn split_media(value: &str) -> Option<(&str, &str)> {
911    let mut iter = value.splitn(2, '/');
912    let type_ = iter.next()?.trim();
913    let subtype = iter.next()?.trim();
914    if type_.is_empty() || subtype.is_empty() {
915        return None;
916    }
917    Some((type_, subtype))
918}
919
920pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
921    &HTTP_OK_IMPL_DEF,
922    &HTTP_CREATED_IMPL_DEF,
923    &HTTP_NO_CONTENT_IMPL_DEF,
924    &HTTP_ERROR_IMPL_DEF,
925    &HTTP_REPLY_IMPL_DEF,
926    &HTTP_STREAM_IMPL_DEF,
927    &HTTP_SSE_IMPL_DEF,
928    &HTTP_ETAG_IMPL_DEF,
929    &HTTP_CHOOSE_IMPL_DEF,
930    &HTTP_NOT_MODIFIED_IMPL_DEF,
931    &HTTP_PUSH_HINTS_IMPL_DEF,
932    &HTTP_UPGRADE_WS_IMPL_DEF,
933];
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use crate::llm::helpers::vm_value_to_json;
939
940    fn dict(value: &VmValue) -> &crate::value::DictMap {
941        value.as_dict().expect("envelope is a dict")
942    }
943
944    fn run_sync<F, Fut>(future: F) -> Fut::Output
945    where
946        F: FnOnce() -> Fut,
947        Fut: std::future::Future,
948    {
949        tokio::runtime::Builder::new_current_thread()
950            .enable_all()
951            .build()
952            .expect("rt")
953            .block_on(future())
954    }
955
956    #[test]
957    fn http_ok_produces_tagged_envelope() {
958        let body = VmValue::String(std::sync::Arc::from("hello"));
959        let response = http_ok_impl(&[body], &mut String::new()).expect("ok");
960        let map = dict(&response);
961        assert_eq!(
962            map.get(HTTP_RESPONSE_TAG_KEY).and_then(|v| match v {
963                VmValue::String(s) => Some(s.as_ref()),
964                _ => None,
965            }),
966            Some(HTTP_RESPONSE_TAG_VERSION)
967        );
968        assert!(matches!(map.get("status"), Some(VmValue::Int(200))));
969        assert_eq!(
970            map.get("body").map(|v| v.display()).as_deref(),
971            Some("hello")
972        );
973    }
974
975    #[test]
976    fn http_created_sets_location_header() {
977        let body = VmValue::dict(crate::value::DictMap::from_iter([(
978            "id".to_string(),
979            VmValue::String(std::sync::Arc::from("sess_1")),
980        )]));
981        let location = VmValue::String(std::sync::Arc::from("/v1/sessions/sess_1"));
982        let response = http_created_impl(&[body, location], &mut String::new()).expect("created");
983        let map = dict(&response);
984        assert!(matches!(map.get("status"), Some(VmValue::Int(201))));
985        let headers = map
986            .get("headers")
987            .and_then(VmValue::as_dict)
988            .expect("headers");
989        assert_eq!(
990            headers.get("Location").map(|v| v.display()).as_deref(),
991            Some("/v1/sessions/sess_1")
992        );
993    }
994
995    #[test]
996    fn http_no_content_omits_body_marker() {
997        let response = http_no_content_impl(&[], &mut String::new()).expect("no_content");
998        let map = dict(&response);
999        assert!(matches!(map.get("status"), Some(VmValue::Int(204))));
1000        assert!(map.get("body").is_none());
1001        assert_eq!(
1002            map.get("body_kind").and_then(|v| match v {
1003                VmValue::String(s) => Some(s.as_ref()),
1004                _ => None,
1005            }),
1006            Some(BODY_KIND_NONE)
1007        );
1008    }
1009
1010    #[test]
1011    fn http_error_carries_code_message_and_marker() {
1012        let response = http_error_impl(
1013            &[
1014                VmValue::Int(422),
1015                VmValue::String(std::sync::Arc::from("invalid_input")),
1016                VmValue::String(std::sync::Arc::from("bad payload")),
1017                VmValue::Nil,
1018            ],
1019            &mut String::new(),
1020        )
1021        .expect("error");
1022        let map = dict(&response);
1023        assert!(matches!(map.get("status"), Some(VmValue::Int(422))));
1024        assert!(matches!(map.get("is_error"), Some(VmValue::Bool(true))));
1025        let body = map
1026            .get("body")
1027            .and_then(VmValue::as_dict)
1028            .expect("body dict");
1029        assert_eq!(
1030            body.get("code").map(|v| v.display()).as_deref(),
1031            Some("invalid_input")
1032        );
1033        assert_eq!(
1034            body.get("message").map(|v| v.display()).as_deref(),
1035            Some("bad payload")
1036        );
1037    }
1038
1039    #[test]
1040    fn http_error_rejects_2xx_status() {
1041        let err = http_error_impl(
1042            &[
1043                VmValue::Int(200),
1044                VmValue::String(std::sync::Arc::from("x")),
1045                VmValue::String(std::sync::Arc::from("y")),
1046            ],
1047            &mut String::new(),
1048        )
1049        .expect_err("expected reject");
1050        match err {
1051            VmError::Thrown(VmValue::String(text)) => {
1052                assert!(text.contains("4xx or 5xx"), "got: {text}");
1053            }
1054            other => panic!("unexpected error: {other:?}"),
1055        }
1056    }
1057
1058    #[test]
1059    fn http_reply_rejects_out_of_range_status() {
1060        let err =
1061            http_reply_impl(&[VmValue::Int(999)], &mut String::new()).expect_err("out of range");
1062        match err {
1063            VmError::Thrown(VmValue::String(text)) => {
1064                assert!(text.contains("100-599"), "got: {text}");
1065            }
1066            other => panic!("unexpected error: {other:?}"),
1067        }
1068    }
1069
1070    #[test]
1071    fn http_reply_bytes_uses_bytes_body_kind() {
1072        let bytes = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1073        let headers = VmValue::dict(crate::value::DictMap::from_iter([(
1074            "Content-Type".to_string(),
1075            VmValue::String(std::sync::Arc::from("application/octet-stream")),
1076        )]));
1077        let response =
1078            http_reply_impl(&[VmValue::Int(200), bytes, headers], &mut String::new()).unwrap();
1079        let map = dict(&response);
1080        assert_eq!(
1081            map.get("body_kind").and_then(|v| match v {
1082                VmValue::String(s) => Some(s.as_ref()),
1083                _ => None,
1084            }),
1085            Some(BODY_KIND_BYTES)
1086        );
1087        assert!(matches!(map.get("body"), Some(VmValue::Bytes(_))));
1088    }
1089
1090    #[test]
1091    fn http_stream_buffers_list_source() {
1092        let items = vec![
1093            VmValue::String(std::sync::Arc::from("a")),
1094            VmValue::String(std::sync::Arc::from("b")),
1095        ];
1096        let response = run_sync(|| {
1097            http_stream_impl(
1098                crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1099                vec![
1100                    VmValue::List(std::sync::Arc::new(items.clone())),
1101                    VmValue::String(std::sync::Arc::from("text/plain")),
1102                ],
1103            )
1104        })
1105        .expect("stream");
1106        let map = dict(&response);
1107        assert_eq!(
1108            map.get("body_kind").and_then(|v| match v {
1109                VmValue::String(s) => Some(s.as_ref()),
1110                _ => None,
1111            }),
1112            Some(BODY_KIND_STREAM)
1113        );
1114        let body = map.get("body").expect("body");
1115        match body {
1116            VmValue::List(values) => {
1117                assert_eq!(values.len(), 2);
1118            }
1119            other => panic!("expected list body, got {other:?}"),
1120        }
1121        let headers = map
1122            .get("headers")
1123            .and_then(VmValue::as_dict)
1124            .expect("headers");
1125        assert_eq!(
1126            headers.get("Content-Type").map(|v| v.display()).as_deref(),
1127            Some("text/plain")
1128        );
1129    }
1130
1131    #[test]
1132    fn http_sse_sets_event_stream_headers_and_optional_retry() {
1133        let events = vec![VmValue::dict(crate::value::DictMap::from_iter([(
1134            "data".to_string(),
1135            VmValue::String(std::sync::Arc::from("ping")),
1136        )]))];
1137        let response = run_sync(|| {
1138            http_sse_impl(
1139                crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1140                vec![
1141                    VmValue::List(std::sync::Arc::new(events.clone())),
1142                    VmValue::Int(2500),
1143                ],
1144            )
1145        })
1146        .expect("sse");
1147        let map = dict(&response);
1148        let headers = map
1149            .get("headers")
1150            .and_then(VmValue::as_dict)
1151            .expect("headers");
1152        assert_eq!(
1153            headers.get("Content-Type").map(|v| v.display()).as_deref(),
1154            Some("text/event-stream")
1155        );
1156        assert_eq!(
1157            headers.get("Cache-Control").map(|v| v.display()).as_deref(),
1158            Some("no-cache")
1159        );
1160        assert!(matches!(map.get("retry_ms"), Some(VmValue::Int(2500))));
1161    }
1162
1163    #[test]
1164    fn parse_envelope_round_trip_through_json() {
1165        let response = http_error_impl(
1166            &[
1167                VmValue::Int(404),
1168                VmValue::String(std::sync::Arc::from("not_found")),
1169                VmValue::String(std::sync::Arc::from("missing")),
1170                VmValue::dict(crate::value::DictMap::from_iter([(
1171                    "id".to_string(),
1172                    VmValue::String(std::sync::Arc::from("sess_404")),
1173                )])),
1174            ],
1175            &mut String::new(),
1176        )
1177        .expect("error");
1178        let json = vm_value_to_json(&response);
1179        let envelope = parse_envelope(&json).expect("envelope parses");
1180        assert_eq!(envelope.status, 404);
1181        assert!(envelope.is_error);
1182        let body = envelope.body.expect("body");
1183        assert_eq!(body["code"], "not_found");
1184        assert_eq!(body["details"]["id"], "sess_404");
1185    }
1186
1187    #[test]
1188    fn parse_envelope_ignores_untagged_dicts() {
1189        let plain = serde_json::json!({"status": 200, "body": {}});
1190        assert!(parse_envelope(&plain).is_none());
1191    }
1192
1193    #[test]
1194    fn http_etag_is_quoted_hex_sha256_of_payload() {
1195        let value = VmValue::String(std::sync::Arc::from("hello"));
1196        let etag = http_etag_impl(&[value], &mut String::new()).expect("etag");
1197        match etag {
1198            VmValue::String(text) => {
1199                assert_eq!(
1200                    text.as_ref(),
1201                    "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\""
1202                );
1203            }
1204            other => panic!("expected string, got {other:?}"),
1205        }
1206    }
1207
1208    #[test]
1209    fn http_etag_stable_across_string_and_bytes_for_same_payload() {
1210        let from_string = http_etag_impl(
1211            &[VmValue::String(std::sync::Arc::from("hello"))],
1212            &mut String::new(),
1213        )
1214        .unwrap();
1215        let from_bytes = http_etag_impl(
1216            &[VmValue::Bytes(std::sync::Arc::new(b"hello".to_vec()))],
1217            &mut String::new(),
1218        )
1219        .unwrap();
1220        assert_eq!(from_string.display(), from_bytes.display());
1221    }
1222
1223    #[test]
1224    fn http_choose_returns_best_q_match() {
1225        let accept = VmValue::String(std::sync::Arc::from(
1226            "application/xml;q=0.5, application/json;q=0.9",
1227        ));
1228        let offers = VmValue::List(std::sync::Arc::new(vec![
1229            VmValue::String(std::sync::Arc::from("application/xml")),
1230            VmValue::String(std::sync::Arc::from("application/json")),
1231        ]));
1232        let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1233        assert_eq!(chosen.display(), "application/json");
1234    }
1235
1236    #[test]
1237    fn http_choose_prefers_specific_over_wildcard() {
1238        let accept = VmValue::String(std::sync::Arc::from("text/*;q=0.5, application/json"));
1239        let offers = VmValue::List(std::sync::Arc::new(vec![
1240            VmValue::String(std::sync::Arc::from("text/plain")),
1241            VmValue::String(std::sync::Arc::from("application/json")),
1242        ]));
1243        let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1244        assert_eq!(chosen.display(), "application/json");
1245    }
1246
1247    #[test]
1248    fn http_choose_returns_default_for_no_accept() {
1249        let offers = VmValue::List(std::sync::Arc::new(vec![
1250            VmValue::String(std::sync::Arc::from("text/plain")),
1251            VmValue::String(std::sync::Arc::from("application/json")),
1252        ]));
1253        let chosen = http_choose_impl(&[VmValue::Nil, offers], &mut String::new()).unwrap();
1254        assert_eq!(chosen.display(), "text/plain");
1255    }
1256
1257    #[test]
1258    fn http_choose_overrides_default_with_explicit() {
1259        let offers = VmValue::List(std::sync::Arc::new(vec![
1260            VmValue::String(std::sync::Arc::from("text/plain")),
1261            VmValue::String(std::sync::Arc::from("application/json")),
1262        ]));
1263        let chosen = http_choose_impl(
1264            &[
1265                VmValue::Nil,
1266                offers,
1267                VmValue::String(std::sync::Arc::from("application/json")),
1268            ],
1269            &mut String::new(),
1270        )
1271        .unwrap();
1272        assert_eq!(chosen.display(), "application/json");
1273    }
1274
1275    #[test]
1276    fn http_choose_wildcard_accept_yields_default() {
1277        let offers = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1278            std::sync::Arc::from("application/json"),
1279        )]));
1280        let chosen = http_choose_impl(
1281            &[VmValue::String(std::sync::Arc::from("*/*")), offers],
1282            &mut String::new(),
1283        )
1284        .unwrap();
1285        assert_eq!(chosen.display(), "application/json");
1286    }
1287
1288    #[test]
1289    fn http_not_modified_envelope_carries_etag() {
1290        let etag = VmValue::String(std::sync::Arc::from("\"abc\""));
1291        let response = http_not_modified_impl(&[etag, VmValue::Nil], &mut String::new()).unwrap();
1292        let map = dict(&response);
1293        assert!(matches!(map.get("status"), Some(VmValue::Int(304))));
1294        let headers = map
1295            .get("headers")
1296            .and_then(VmValue::as_dict)
1297            .expect("headers");
1298        assert_eq!(
1299            headers.get("ETag").map(|v| v.display()).as_deref(),
1300            Some("\"abc\"")
1301        );
1302    }
1303
1304    #[test]
1305    fn http_push_hints_appends_link_headers_with_inferred_as() {
1306        let envelope = http_ok_impl(
1307            &[VmValue::dict(crate::value::DictMap::new())],
1308            &mut String::new(),
1309        )
1310        .unwrap();
1311        let paths = VmValue::List(std::sync::Arc::new(vec![
1312            VmValue::String(std::sync::Arc::from("/main.css")),
1313            VmValue::String(std::sync::Arc::from("/app.js")),
1314            VmValue::String(std::sync::Arc::from("/hero.webp")),
1315            VmValue::String(std::sync::Arc::from("/inter.woff2")),
1316            VmValue::String(std::sync::Arc::from("/manifest.json")),
1317            VmValue::String(std::sync::Arc::from("/unknown.xyz")),
1318        ]));
1319        let response =
1320            http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1321        let map = dict(&response);
1322        let headers = map
1323            .get("headers")
1324            .and_then(VmValue::as_dict)
1325            .expect("headers");
1326        let links = match headers.get("Link") {
1327            Some(VmValue::List(items)) => items.clone(),
1328            other => panic!("Link should be a list, got {other:?}"),
1329        };
1330        let rendered: Vec<String> = links
1331            .iter()
1332            .map(|v| match v {
1333                VmValue::String(s) => s.to_string(),
1334                other => panic!("Link entry is not a string: {other:?}"),
1335            })
1336            .collect();
1337        assert_eq!(
1338            rendered,
1339            vec![
1340                "</main.css>; rel=preload; as=style",
1341                "</app.js>; rel=preload; as=script",
1342                "</hero.webp>; rel=preload; as=image",
1343                "</inter.woff2>; rel=preload; as=font",
1344                "</manifest.json>; rel=preload; as=fetch",
1345                "</unknown.xyz>; rel=preload",
1346            ]
1347        );
1348    }
1349
1350    #[test]
1351    fn http_push_hints_handles_querystring_in_path() {
1352        let envelope = http_ok_impl(&[VmValue::Nil], &mut String::new()).unwrap();
1353        let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1354            std::sync::Arc::from("/static/app.js?v=42"),
1355        )]));
1356        let response =
1357            http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1358        let map = dict(&response);
1359        let headers = map
1360            .get("headers")
1361            .and_then(VmValue::as_dict)
1362            .expect("headers");
1363        let links = match headers.get("Link") {
1364            Some(VmValue::List(items)) => items.clone(),
1365            other => panic!("Link should be a list, got {other:?}"),
1366        };
1367        assert_eq!(
1368            links[0].display(),
1369            "</static/app.js?v=42>; rel=preload; as=script"
1370        );
1371    }
1372
1373    #[test]
1374    fn http_push_hints_rejects_untagged_envelope() {
1375        let plain = VmValue::dict(crate::value::DictMap::from_iter([(
1376            "status".to_string(),
1377            VmValue::Int(200),
1378        )]));
1379        let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1380            std::sync::Arc::from("/main.css"),
1381        )]));
1382        let result = http_push_hints_impl(&[plain, paths], &mut String::new());
1383        assert!(
1384            matches!(result, Err(VmError::Thrown(_))),
1385            "untagged dict should be rejected, got {result:?}"
1386        );
1387    }
1388
1389    #[test]
1390    fn http_push_hints_preserves_existing_link_header() {
1391        let envelope = http_reply_impl(
1392            &[
1393                VmValue::Int(200),
1394                VmValue::dict(crate::value::DictMap::new()),
1395                VmValue::dict(crate::value::DictMap::from_iter([(
1396                    "Link".to_string(),
1397                    VmValue::String(std::sync::Arc::from("</legacy.css>; rel=preload; as=style")),
1398                )])),
1399            ],
1400            &mut String::new(),
1401        )
1402        .unwrap();
1403        let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1404            std::sync::Arc::from("/app.js"),
1405        )]));
1406        let response = http_push_hints_impl(&[envelope, paths], &mut String::new()).unwrap();
1407        let map = dict(&response);
1408        let headers = map
1409            .get("headers")
1410            .and_then(VmValue::as_dict)
1411            .expect("headers");
1412        let links = match headers.get("Link") {
1413            Some(VmValue::List(items)) => items.clone(),
1414            other => panic!("Link should be a list once preloads are added, got {other:?}"),
1415        };
1416        assert_eq!(links.len(), 2);
1417        assert_eq!(links[0].display(), "</legacy.css>; rel=preload; as=style");
1418        assert_eq!(links[1].display(), "</app.js>; rel=preload; as=script");
1419    }
1420
1421    #[test]
1422    fn http_upgrade_ws_envelope_negotiates_subprotocol() {
1423        let req = VmValue::dict(crate::value::DictMap::from_iter([(
1424            "headers".to_string(),
1425            VmValue::dict(crate::value::DictMap::from_iter([(
1426                "Sec-WebSocket-Protocol".to_string(),
1427                VmValue::String(std::sync::Arc::from("v0.harn, v1.harn")),
1428            )])),
1429        )]));
1430        let options = VmValue::dict(crate::value::DictMap::from_iter([(
1431            "subprotocols".to_string(),
1432            VmValue::List(std::sync::Arc::new(vec![
1433                VmValue::String(std::sync::Arc::from("v1.harn")),
1434                VmValue::String(std::sync::Arc::from("v2.harn")),
1435            ])),
1436        )]));
1437        let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1438        let map = dict(&response);
1439        assert!(matches!(map.get("status"), Some(VmValue::Int(101))));
1440        let upgrade = map
1441            .get("ws_upgrade")
1442            .and_then(VmValue::as_dict)
1443            .expect("ws_upgrade");
1444        assert_eq!(
1445            upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1446            Some("v1.harn")
1447        );
1448        let headers = map
1449            .get("headers")
1450            .and_then(VmValue::as_dict)
1451            .expect("headers");
1452        assert_eq!(
1453            headers.get("Upgrade").map(|v| v.display()).as_deref(),
1454            Some("websocket")
1455        );
1456        assert_eq!(
1457            headers
1458                .get("Sec-WebSocket-Protocol")
1459                .map(|v| v.display())
1460                .as_deref(),
1461            Some("v1.harn")
1462        );
1463    }
1464
1465    #[test]
1466    fn http_upgrade_ws_picks_client_preferred_when_both_overlap() {
1467        // Regression for the divergence between
1468        // `http_upgrade_ws_impl`'s envelope-side negotiation and
1469        // `harn_serve::ws::negotiate_subprotocol`'s wire-side
1470        // negotiation. With client "v2.harn, v1.harn" and server
1471        // ["v1.harn", "v2.harn"] the two implementations used to
1472        // disagree (server-order picked v1; client-order picks v2).
1473        // The envelope MUST match what the upgrade handshake echoes
1474        // back, so we honour client preference everywhere.
1475        let req = VmValue::dict(crate::value::DictMap::from_iter([(
1476            "headers".to_string(),
1477            VmValue::dict(crate::value::DictMap::from_iter([(
1478                "Sec-WebSocket-Protocol".to_string(),
1479                VmValue::String(std::sync::Arc::from("v2.harn, v1.harn")),
1480            )])),
1481        )]));
1482        let options = VmValue::dict(crate::value::DictMap::from_iter([(
1483            "subprotocols".to_string(),
1484            VmValue::List(std::sync::Arc::new(vec![
1485                VmValue::String(std::sync::Arc::from("v1.harn")),
1486                VmValue::String(std::sync::Arc::from("v2.harn")),
1487            ])),
1488        )]));
1489        let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1490        let upgrade = dict(&response)
1491            .get("ws_upgrade")
1492            .and_then(VmValue::as_dict)
1493            .expect("ws_upgrade");
1494        assert_eq!(
1495            upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1496            Some("v2.harn")
1497        );
1498    }
1499
1500    #[test]
1501    fn parse_envelope_round_trips_ws_upgrade_marker() {
1502        let req = VmValue::dict(crate::value::DictMap::from_iter([(
1503            "headers".to_string(),
1504            VmValue::dict(crate::value::DictMap::from_iter([(
1505                "Sec-WebSocket-Protocol".to_string(),
1506                VmValue::String(std::sync::Arc::from("v1.harn")),
1507            )])),
1508        )]));
1509        let options = VmValue::dict(crate::value::DictMap::from_iter([
1510            (
1511                "subprotocols".to_string(),
1512                VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1513                    std::sync::Arc::from("v1.harn"),
1514                )])),
1515            ),
1516            ("idle_ping_ms".to_string(), VmValue::Int(15_000)),
1517        ]));
1518        let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1519        let json = vm_value_to_json(&response);
1520        let envelope = parse_envelope(&json).expect("envelope parses");
1521        let ws = envelope.ws_upgrade.expect("ws_upgrade present");
1522        assert_eq!(ws.subprotocol.as_deref(), Some("v1.harn"));
1523        assert_eq!(ws.offered, vec!["v1.harn"]);
1524        assert_eq!(ws.idle_ping_ms, Some(15_000));
1525        assert_eq!(envelope.status, 101);
1526    }
1527
1528    #[test]
1529    fn http_upgrade_ws_falls_through_when_no_subprotocols_offered() {
1530        let req = VmValue::dict(crate::value::DictMap::new());
1531        let response = http_upgrade_ws_impl(&[req], &mut String::new()).unwrap();
1532        let map = dict(&response);
1533        let upgrade = map
1534            .get("ws_upgrade")
1535            .and_then(VmValue::as_dict)
1536            .expect("ws_upgrade");
1537        assert!(matches!(upgrade.get("subprotocol"), Some(VmValue::Nil)));
1538    }
1539}