1use 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
31pub 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
304async 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 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
347pub 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 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 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 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 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
670fn infer_preload_as(path: &str) -> Option<&'static str> {
677 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 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 other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
820 }
821}
822
823fn 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 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 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}