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