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