1use 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
30pub 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
319async 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 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
362pub 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 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 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 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 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
691fn infer_preload_as(path: &str) -> Option<&'static str> {
698 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 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 other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
853 }
854}
855
856fn 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 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 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}