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(
128 _ctx: crate::vm::AsyncBuiltinCtx,
129 args: Vec<VmValue>,
130) -> Result<VmValue, VmError> {
131 let source = args
132 .first()
133 .cloned()
134 .ok_or_else(|| thrown_err("http_stream: source is required"))?;
135 let content_type = args
136 .get(1)
137 .and_then(string_or_nil)
138 .unwrap_or_else(|| "application/octet-stream".to_string());
139
140 let chunks = drain_to_list(source, "http_stream").await?;
141 let mut headers = BTreeMap::new();
142 headers.insert(
143 "Content-Type".to_string(),
144 VmValue::String(Rc::from(content_type)),
145 );
146 Ok(envelope(
147 200,
148 VmValue::List(Rc::new(chunks)),
149 BODY_KIND_STREAM,
150 headers,
151 ))
152}
153
154#[harn_builtin(
155 sig = "http_sse(source: any, retry_ms?: int?) -> dict",
156 kind = "async",
157 category = "http_response"
158)]
159async fn http_sse_impl(
160 _ctx: crate::vm::AsyncBuiltinCtx,
161 args: Vec<VmValue>,
162) -> Result<VmValue, VmError> {
163 let source = args
164 .first()
165 .cloned()
166 .ok_or_else(|| thrown_err("http_sse: source is required"))?;
167 let retry_ms = match args.get(1) {
168 None | Some(VmValue::Nil) => None,
169 Some(VmValue::Int(value)) => {
170 if *value < 0 {
171 return Err(thrown_err(format!(
172 "http_sse: retry_ms must be non-negative (got {value})"
173 )));
174 }
175 Some(*value)
176 }
177 Some(other) => {
178 return Err(thrown_err(format!(
179 "http_sse: retry_ms must be an integer (got {})",
180 other.type_name()
181 )));
182 }
183 };
184
185 let events = drain_to_list(source, "http_sse").await?;
186 let mut headers = BTreeMap::new();
187 headers.insert(
188 "Content-Type".to_string(),
189 VmValue::String(Rc::from("text/event-stream")),
190 );
191 headers.insert(
192 "Cache-Control".to_string(),
193 VmValue::String(Rc::from("no-cache")),
194 );
195 let mut env = envelope_map(200, VmValue::List(Rc::new(events)), BODY_KIND_SSE, headers);
196 if let Some(retry_ms) = retry_ms {
197 env.insert("retry_ms".to_string(), VmValue::Int(retry_ms));
198 }
199 Ok(VmValue::Dict(Rc::new(env)))
200}
201
202fn envelope(
203 status: i64,
204 body: VmValue,
205 body_kind: &str,
206 headers: BTreeMap<String, VmValue>,
207) -> VmValue {
208 VmValue::Dict(Rc::new(envelope_map(status, body, body_kind, headers)))
209}
210
211fn envelope_map(
212 status: i64,
213 body: VmValue,
214 body_kind: &str,
215 headers: BTreeMap<String, VmValue>,
216) -> BTreeMap<String, VmValue> {
217 let mut map = BTreeMap::new();
218 map.insert(
219 HTTP_RESPONSE_TAG_KEY.to_string(),
220 VmValue::String(Rc::from(HTTP_RESPONSE_TAG_VERSION)),
221 );
222 map.insert("status".to_string(), VmValue::Int(status));
223 map.insert(
224 "body_kind".to_string(),
225 VmValue::String(Rc::from(body_kind)),
226 );
227 map.insert("headers".to_string(), VmValue::Dict(Rc::new(headers)));
228 if !matches!(body, VmValue::Nil) {
229 map.insert("body".to_string(), body);
230 }
231 map
232}
233
234fn require_status(value: Option<&VmValue>, fn_name: &str) -> Result<i64, VmError> {
235 let status = match value {
236 Some(VmValue::Int(value)) => *value,
237 Some(other) => {
238 return Err(thrown_err(format!(
239 "{fn_name}: status must be an integer (got {})",
240 other.type_name()
241 )));
242 }
243 None => {
244 return Err(thrown_err(format!("{fn_name}: status is required")));
245 }
246 };
247 if !(100..=599).contains(&status) {
248 return Err(thrown_err(format!(
249 "{fn_name}: status {status} is out of range (100-599)"
250 )));
251 }
252 Ok(status)
253}
254
255fn require_nonempty_string(
256 value: Option<&VmValue>,
257 fn_name: &str,
258 arg_name: &str,
259) -> Result<String, VmError> {
260 let text = match value {
261 Some(VmValue::String(text)) => text.to_string(),
262 Some(other) => {
263 return Err(thrown_err(format!(
264 "{fn_name}: {arg_name} must be a string (got {})",
265 other.type_name()
266 )));
267 }
268 None => {
269 return Err(thrown_err(format!("{fn_name}: {arg_name} is required")));
270 }
271 };
272 if text.is_empty() {
273 return Err(thrown_err(format!(
274 "{fn_name}: {arg_name} must be non-empty"
275 )));
276 }
277 Ok(text)
278}
279
280fn string_or_nil(value: &VmValue) -> Option<String> {
281 match value {
282 VmValue::String(text) if !text.is_empty() => Some(text.to_string()),
283 _ => None,
284 }
285}
286
287fn parse_headers(
288 value: Option<&VmValue>,
289 fn_name: &str,
290) -> Result<BTreeMap<String, VmValue>, VmError> {
291 match value {
292 None | Some(VmValue::Nil) => Ok(BTreeMap::new()),
293 Some(VmValue::Dict(dict)) => Ok((**dict).clone()),
294 Some(other) => Err(thrown_err(format!(
295 "{fn_name}: headers must be a dict (got {})",
296 other.type_name()
297 ))),
298 }
299}
300
301async fn drain_to_list(value: VmValue, fn_name: &str) -> Result<Vec<VmValue>, VmError> {
310 use std::sync::atomic::Ordering;
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.closed.load(Ordering::SeqCst) {
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(Rc::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(Rc::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(Rc::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.insert("ETag".to_string(), VmValue::String(Rc::from(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(Rc::from(link))),
646 );
647
648 headers.insert("Link".to_string(), VmValue::List(Rc::new(combined)));
649 envelope_map.insert("headers".to_string(), VmValue::Dict(Rc::new(headers)));
650 Ok(VmValue::Dict(Rc::new(envelope_map)))
651}
652
653fn is_http_response_envelope(map: &BTreeMap<String, VmValue>) -> bool {
654 matches!(
655 map.get(HTTP_RESPONSE_TAG_KEY),
656 Some(VmValue::String(tag)) if tag.as_ref() == HTTP_RESPONSE_TAG_VERSION,
657 )
658}
659
660fn format_link_header(path: &str) -> String {
661 match infer_preload_as(path) {
662 Some(kind) => format!("<{path}>; rel=preload; as={kind}"),
663 None => format!("<{path}>; rel=preload"),
664 }
665}
666
667fn infer_preload_as(path: &str) -> Option<&'static str> {
674 let pre_query = path.split(['?', '#']).next().unwrap_or(path);
677 let dot = pre_query.rfind('.')?;
678 let ext = &pre_query[dot + 1..];
679 Some(match ext.to_ascii_lowercase().as_str() {
680 "css" => "style",
681 "js" | "mjs" => "script",
682 "json" => "fetch",
683 "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "ico" => "image",
684 "woff" | "woff2" | "ttf" | "otf" => "font",
685 _ => return None,
686 })
687}
688
689#[harn_builtin(
690 sig = "http_upgrade_ws(req: dict, options?: dict) -> dict",
691 category = "http_response"
692)]
693fn http_upgrade_ws_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
694 let req = args
695 .first()
696 .and_then(VmValue::as_dict)
697 .ok_or_else(|| thrown_err("http_upgrade_ws: req must be a dict"))?;
698 let options = args.get(1).and_then(VmValue::as_dict);
699
700 let request_subprotocols = req
701 .get("headers")
702 .and_then(VmValue::as_dict)
703 .and_then(|headers| header_lookup(headers, "sec-websocket-protocol"))
704 .map(|raw| {
705 raw.split(',')
706 .map(|s| s.trim().to_string())
707 .filter(|s| !s.is_empty())
708 .collect::<Vec<_>>()
709 })
710 .unwrap_or_default();
711 let offered_subprotocols = options
712 .and_then(|opts| opts.get("subprotocols"))
713 .and_then(|value| match value {
714 VmValue::List(items) => Some(
715 items
716 .iter()
717 .filter_map(|v| match v {
718 VmValue::String(s) => Some(s.to_string()),
719 _ => None,
720 })
721 .collect::<Vec<_>>(),
722 ),
723 _ => None,
724 })
725 .unwrap_or_default();
726
727 let negotiated = request_subprotocols
733 .iter()
734 .find(|client| offered_subprotocols.iter().any(|name| name == *client))
735 .cloned();
736
737 let mut headers = BTreeMap::new();
738 headers.insert(
739 "Upgrade".to_string(),
740 VmValue::String(Rc::from("websocket")),
741 );
742 headers.insert(
743 "Connection".to_string(),
744 VmValue::String(Rc::from("Upgrade")),
745 );
746 if let Some(name) = &negotiated {
747 headers.insert(
748 "Sec-WebSocket-Protocol".to_string(),
749 VmValue::String(Rc::from(name.clone())),
750 );
751 }
752
753 let idle_ping_ms = options
754 .and_then(|opts| opts.get("idle_ping_ms"))
755 .and_then(|v| v.as_int());
756 let max_message_bytes = options
757 .and_then(|opts| opts.get("max_message_bytes"))
758 .and_then(|v| v.as_int());
759 let on_message = options
760 .and_then(|opts| opts.get("on_message"))
761 .and_then(|v| match v {
762 VmValue::String(name) => Some(name.to_string()),
763 _ => None,
764 });
765
766 let mut env_map = envelope_map(101, VmValue::Nil, BODY_KIND_NONE, headers);
767 env_map.insert(
768 "ws_upgrade".to_string(),
769 VmValue::Dict(Rc::new({
770 let mut map = BTreeMap::new();
771 map.insert(
772 "subprotocol".to_string(),
773 match &negotiated {
774 Some(name) => VmValue::String(Rc::from(name.clone())),
775 None => VmValue::Nil,
776 },
777 );
778 map.insert(
779 "offered".to_string(),
780 VmValue::List(Rc::new(
781 offered_subprotocols
782 .iter()
783 .map(|s| VmValue::String(Rc::from(s.clone())))
784 .collect(),
785 )),
786 );
787 if let Some(ms) = idle_ping_ms {
788 map.insert("idle_ping_ms".to_string(), VmValue::Int(ms));
789 }
790 if let Some(bytes) = max_message_bytes {
791 map.insert("max_message_bytes".to_string(), VmValue::Int(bytes));
792 }
793 if let Some(handler) = &on_message {
794 map.insert(
795 "on_message".to_string(),
796 VmValue::String(Rc::from(handler.clone())),
797 );
798 }
799 map
800 })),
801 );
802 Ok(VmValue::Dict(Rc::new(env_map)))
803}
804
805fn header_lookup(headers: &BTreeMap<String, VmValue>, name: &str) -> Option<String> {
806 let needle = name.to_ascii_lowercase();
807 headers
808 .iter()
809 .find(|(key, _)| key.to_ascii_lowercase() == needle)
810 .and_then(|(_, value)| match value {
811 VmValue::String(text) => Some(text.to_string()),
812 _ => None,
813 })
814}
815
816fn value_as_bytes(value: &VmValue) -> Vec<u8> {
817 match value {
818 VmValue::Bytes(bytes) => bytes.as_ref().clone(),
819 VmValue::String(text) => text.as_bytes().to_vec(),
820 VmValue::Nil => Vec::new(),
821 other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
829 }
830}
831
832fn negotiate_accept(header: &str, offers: &[String]) -> Option<String> {
839 let ranges: Vec<MediaRange> = header
840 .split(',')
841 .filter_map(MediaRange::parse)
842 .filter(|range| range.q > 0.0)
843 .collect();
844 if ranges.is_empty() {
845 return None;
846 }
847
848 let mut best: Option<(usize, f32, u8)> = None;
849 for (index, offer) in offers.iter().enumerate() {
850 let (offer_type, offer_subtype) = split_media(offer)?;
851 for range in &ranges {
852 let score = range.match_score(offer_type, offer_subtype);
853 let Some(score) = score else { continue };
854 let q = range.q;
855 let candidate = (index, q, score);
856 best = Some(match best {
857 None => candidate,
858 Some(current) => {
859 if q > current.1
862 || (q == current.1 && score > current.2)
863 || (q == current.1 && score == current.2 && index < current.0)
864 {
865 candidate
866 } else {
867 current
868 }
869 }
870 });
871 }
872 }
873 best.map(|(index, _, _)| offers[index].clone())
874}
875
876struct MediaRange<'a> {
877 type_: &'a str,
878 subtype: &'a str,
879 q: f32,
880}
881
882impl<'a> MediaRange<'a> {
883 fn parse(raw: &'a str) -> Option<Self> {
884 let trimmed = raw.trim();
885 let mut parts = trimmed.split(';');
886 let media = parts.next()?.trim();
887 let (type_, subtype) = split_media(media)?;
888 let mut q = 1.0;
889 for param in parts {
890 let param = param.trim();
891 if let Some(value) = param
892 .strip_prefix("q=")
893 .or_else(|| param.strip_prefix("Q="))
894 {
895 if let Ok(parsed) = value.trim().parse::<f32>() {
896 if (0.0..=1.0).contains(&parsed) {
897 q = parsed;
898 }
899 }
900 }
901 }
902 Some(Self { type_, subtype, q })
903 }
904
905 fn match_score(&self, offer_type: &str, offer_subtype: &str) -> Option<u8> {
906 let type_match = self.type_ == "*" || self.type_.eq_ignore_ascii_case(offer_type);
907 let subtype_match = self.subtype == "*" || self.subtype.eq_ignore_ascii_case(offer_subtype);
908 if !type_match || !subtype_match {
909 return None;
910 }
911 Some(match (self.type_, self.subtype) {
912 ("*", _) => 1,
913 (_, "*") => 2,
914 _ => 3,
915 })
916 }
917}
918
919fn split_media(value: &str) -> Option<(&str, &str)> {
920 let mut iter = value.splitn(2, '/');
921 let type_ = iter.next()?.trim();
922 let subtype = iter.next()?.trim();
923 if type_.is_empty() || subtype.is_empty() {
924 return None;
925 }
926 Some((type_, subtype))
927}
928
929pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
930 &HTTP_OK_IMPL_DEF,
931 &HTTP_CREATED_IMPL_DEF,
932 &HTTP_NO_CONTENT_IMPL_DEF,
933 &HTTP_ERROR_IMPL_DEF,
934 &HTTP_REPLY_IMPL_DEF,
935 &HTTP_STREAM_IMPL_DEF,
936 &HTTP_SSE_IMPL_DEF,
937 &HTTP_ETAG_IMPL_DEF,
938 &HTTP_CHOOSE_IMPL_DEF,
939 &HTTP_NOT_MODIFIED_IMPL_DEF,
940 &HTTP_PUSH_HINTS_IMPL_DEF,
941 &HTTP_UPGRADE_WS_IMPL_DEF,
942];
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use crate::llm::helpers::vm_value_to_json;
948
949 fn dict(value: &VmValue) -> &BTreeMap<String, VmValue> {
950 value.as_dict().expect("envelope is a dict")
951 }
952
953 fn run_sync<F, Fut>(future: F) -> Fut::Output
954 where
955 F: FnOnce() -> Fut,
956 Fut: std::future::Future,
957 {
958 tokio::runtime::Builder::new_current_thread()
959 .enable_all()
960 .build()
961 .expect("rt")
962 .block_on(future())
963 }
964
965 #[test]
966 fn http_ok_produces_tagged_envelope() {
967 let body = VmValue::String(Rc::from("hello"));
968 let response = http_ok_impl(&[body], &mut String::new()).expect("ok");
969 let map = dict(&response);
970 assert_eq!(
971 map.get(HTTP_RESPONSE_TAG_KEY).and_then(|v| match v {
972 VmValue::String(s) => Some(s.as_ref()),
973 _ => None,
974 }),
975 Some(HTTP_RESPONSE_TAG_VERSION)
976 );
977 assert!(matches!(map.get("status"), Some(VmValue::Int(200))));
978 assert_eq!(
979 map.get("body").map(|v| v.display()).as_deref(),
980 Some("hello")
981 );
982 }
983
984 #[test]
985 fn http_created_sets_location_header() {
986 let body = VmValue::Dict(Rc::new(BTreeMap::from([(
987 "id".to_string(),
988 VmValue::String(Rc::from("sess_1")),
989 )])));
990 let location = VmValue::String(Rc::from("/v1/sessions/sess_1"));
991 let response = http_created_impl(&[body, location], &mut String::new()).expect("created");
992 let map = dict(&response);
993 assert!(matches!(map.get("status"), Some(VmValue::Int(201))));
994 let headers = map
995 .get("headers")
996 .and_then(VmValue::as_dict)
997 .expect("headers");
998 assert_eq!(
999 headers.get("Location").map(|v| v.display()).as_deref(),
1000 Some("/v1/sessions/sess_1")
1001 );
1002 }
1003
1004 #[test]
1005 fn http_no_content_omits_body_marker() {
1006 let response = http_no_content_impl(&[], &mut String::new()).expect("no_content");
1007 let map = dict(&response);
1008 assert!(matches!(map.get("status"), Some(VmValue::Int(204))));
1009 assert!(map.get("body").is_none());
1010 assert_eq!(
1011 map.get("body_kind").and_then(|v| match v {
1012 VmValue::String(s) => Some(s.as_ref()),
1013 _ => None,
1014 }),
1015 Some(BODY_KIND_NONE)
1016 );
1017 }
1018
1019 #[test]
1020 fn http_error_carries_code_message_and_marker() {
1021 let response = http_error_impl(
1022 &[
1023 VmValue::Int(422),
1024 VmValue::String(Rc::from("invalid_input")),
1025 VmValue::String(Rc::from("bad payload")),
1026 VmValue::Nil,
1027 ],
1028 &mut String::new(),
1029 )
1030 .expect("error");
1031 let map = dict(&response);
1032 assert!(matches!(map.get("status"), Some(VmValue::Int(422))));
1033 assert!(matches!(map.get("is_error"), Some(VmValue::Bool(true))));
1034 let body = map
1035 .get("body")
1036 .and_then(VmValue::as_dict)
1037 .expect("body dict");
1038 assert_eq!(
1039 body.get("code").map(|v| v.display()).as_deref(),
1040 Some("invalid_input")
1041 );
1042 assert_eq!(
1043 body.get("message").map(|v| v.display()).as_deref(),
1044 Some("bad payload")
1045 );
1046 }
1047
1048 #[test]
1049 fn http_error_rejects_2xx_status() {
1050 let err = http_error_impl(
1051 &[
1052 VmValue::Int(200),
1053 VmValue::String(Rc::from("x")),
1054 VmValue::String(Rc::from("y")),
1055 ],
1056 &mut String::new(),
1057 )
1058 .expect_err("expected reject");
1059 match err {
1060 VmError::Thrown(VmValue::String(text)) => {
1061 assert!(text.contains("4xx or 5xx"), "got: {text}");
1062 }
1063 other => panic!("unexpected error: {other:?}"),
1064 }
1065 }
1066
1067 #[test]
1068 fn http_reply_rejects_out_of_range_status() {
1069 let err =
1070 http_reply_impl(&[VmValue::Int(999)], &mut String::new()).expect_err("out of range");
1071 match err {
1072 VmError::Thrown(VmValue::String(text)) => {
1073 assert!(text.contains("100-599"), "got: {text}");
1074 }
1075 other => panic!("unexpected error: {other:?}"),
1076 }
1077 }
1078
1079 #[test]
1080 fn http_stream_buffers_list_source() {
1081 let items = vec![
1082 VmValue::String(Rc::from("a")),
1083 VmValue::String(Rc::from("b")),
1084 ];
1085 let response = run_sync(|| {
1086 http_stream_impl(
1087 crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1088 vec![
1089 VmValue::List(Rc::new(items.clone())),
1090 VmValue::String(Rc::from("text/plain")),
1091 ],
1092 )
1093 })
1094 .expect("stream");
1095 let map = dict(&response);
1096 assert_eq!(
1097 map.get("body_kind").and_then(|v| match v {
1098 VmValue::String(s) => Some(s.as_ref()),
1099 _ => None,
1100 }),
1101 Some(BODY_KIND_STREAM)
1102 );
1103 let body = map.get("body").expect("body");
1104 match body {
1105 VmValue::List(values) => {
1106 assert_eq!(values.len(), 2);
1107 }
1108 other => panic!("expected list body, got {other:?}"),
1109 }
1110 let headers = map
1111 .get("headers")
1112 .and_then(VmValue::as_dict)
1113 .expect("headers");
1114 assert_eq!(
1115 headers.get("Content-Type").map(|v| v.display()).as_deref(),
1116 Some("text/plain")
1117 );
1118 }
1119
1120 #[test]
1121 fn http_sse_sets_event_stream_headers_and_optional_retry() {
1122 let events = vec![VmValue::Dict(Rc::new(BTreeMap::from([(
1123 "data".to_string(),
1124 VmValue::String(Rc::from("ping")),
1125 )])))];
1126 let response = run_sync(|| {
1127 http_sse_impl(
1128 crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1129 vec![VmValue::List(Rc::new(events.clone())), VmValue::Int(2500)],
1130 )
1131 })
1132 .expect("sse");
1133 let map = dict(&response);
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/event-stream")
1141 );
1142 assert_eq!(
1143 headers.get("Cache-Control").map(|v| v.display()).as_deref(),
1144 Some("no-cache")
1145 );
1146 assert!(matches!(map.get("retry_ms"), Some(VmValue::Int(2500))));
1147 }
1148
1149 #[test]
1150 fn parse_envelope_round_trip_through_json() {
1151 let response = http_error_impl(
1152 &[
1153 VmValue::Int(404),
1154 VmValue::String(Rc::from("not_found")),
1155 VmValue::String(Rc::from("missing")),
1156 VmValue::Dict(Rc::new(BTreeMap::from([(
1157 "id".to_string(),
1158 VmValue::String(Rc::from("sess_404")),
1159 )]))),
1160 ],
1161 &mut String::new(),
1162 )
1163 .expect("error");
1164 let json = vm_value_to_json(&response);
1165 let envelope = parse_envelope(&json).expect("envelope parses");
1166 assert_eq!(envelope.status, 404);
1167 assert!(envelope.is_error);
1168 let body = envelope.body.expect("body");
1169 assert_eq!(body["code"], "not_found");
1170 assert_eq!(body["details"]["id"], "sess_404");
1171 }
1172
1173 #[test]
1174 fn parse_envelope_ignores_untagged_dicts() {
1175 let plain = serde_json::json!({"status": 200, "body": {}});
1176 assert!(parse_envelope(&plain).is_none());
1177 }
1178
1179 #[test]
1180 fn http_etag_is_quoted_hex_sha256_of_payload() {
1181 let value = VmValue::String(Rc::from("hello"));
1182 let etag = http_etag_impl(&[value], &mut String::new()).expect("etag");
1183 match etag {
1184 VmValue::String(text) => {
1185 assert_eq!(
1186 text.as_ref(),
1187 "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\""
1188 );
1189 }
1190 other => panic!("expected string, got {other:?}"),
1191 }
1192 }
1193
1194 #[test]
1195 fn http_etag_stable_across_string_and_bytes_for_same_payload() {
1196 let from_string =
1197 http_etag_impl(&[VmValue::String(Rc::from("hello"))], &mut String::new()).unwrap();
1198 let from_bytes = http_etag_impl(
1199 &[VmValue::Bytes(Rc::new(b"hello".to_vec()))],
1200 &mut String::new(),
1201 )
1202 .unwrap();
1203 assert_eq!(from_string.display(), from_bytes.display());
1204 }
1205
1206 #[test]
1207 fn http_choose_returns_best_q_match() {
1208 let accept = VmValue::String(Rc::from("application/xml;q=0.5, application/json;q=0.9"));
1209 let offers = VmValue::List(Rc::new(vec![
1210 VmValue::String(Rc::from("application/xml")),
1211 VmValue::String(Rc::from("application/json")),
1212 ]));
1213 let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1214 assert_eq!(chosen.display(), "application/json");
1215 }
1216
1217 #[test]
1218 fn http_choose_prefers_specific_over_wildcard() {
1219 let accept = VmValue::String(Rc::from("text/*;q=0.5, application/json"));
1220 let offers = VmValue::List(Rc::new(vec![
1221 VmValue::String(Rc::from("text/plain")),
1222 VmValue::String(Rc::from("application/json")),
1223 ]));
1224 let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1225 assert_eq!(chosen.display(), "application/json");
1226 }
1227
1228 #[test]
1229 fn http_choose_returns_default_for_no_accept() {
1230 let offers = VmValue::List(Rc::new(vec![
1231 VmValue::String(Rc::from("text/plain")),
1232 VmValue::String(Rc::from("application/json")),
1233 ]));
1234 let chosen = http_choose_impl(&[VmValue::Nil, offers], &mut String::new()).unwrap();
1235 assert_eq!(chosen.display(), "text/plain");
1236 }
1237
1238 #[test]
1239 fn http_choose_overrides_default_with_explicit() {
1240 let offers = VmValue::List(Rc::new(vec![
1241 VmValue::String(Rc::from("text/plain")),
1242 VmValue::String(Rc::from("application/json")),
1243 ]));
1244 let chosen = http_choose_impl(
1245 &[
1246 VmValue::Nil,
1247 offers,
1248 VmValue::String(Rc::from("application/json")),
1249 ],
1250 &mut String::new(),
1251 )
1252 .unwrap();
1253 assert_eq!(chosen.display(), "application/json");
1254 }
1255
1256 #[test]
1257 fn http_choose_wildcard_accept_yields_default() {
1258 let offers = VmValue::List(Rc::new(vec![VmValue::String(Rc::from("application/json"))]));
1259 let chosen = http_choose_impl(
1260 &[VmValue::String(Rc::from("*/*")), offers],
1261 &mut String::new(),
1262 )
1263 .unwrap();
1264 assert_eq!(chosen.display(), "application/json");
1265 }
1266
1267 #[test]
1268 fn http_not_modified_envelope_carries_etag() {
1269 let etag = VmValue::String(Rc::from("\"abc\""));
1270 let response = http_not_modified_impl(&[etag, VmValue::Nil], &mut String::new()).unwrap();
1271 let map = dict(&response);
1272 assert!(matches!(map.get("status"), Some(VmValue::Int(304))));
1273 let headers = map
1274 .get("headers")
1275 .and_then(VmValue::as_dict)
1276 .expect("headers");
1277 assert_eq!(
1278 headers.get("ETag").map(|v| v.display()).as_deref(),
1279 Some("\"abc\"")
1280 );
1281 }
1282
1283 #[test]
1284 fn http_push_hints_appends_link_headers_with_inferred_as() {
1285 let envelope = http_ok_impl(
1286 &[VmValue::Dict(Rc::new(BTreeMap::new()))],
1287 &mut String::new(),
1288 )
1289 .unwrap();
1290 let paths = VmValue::List(Rc::new(vec![
1291 VmValue::String(Rc::from("/main.css")),
1292 VmValue::String(Rc::from("/app.js")),
1293 VmValue::String(Rc::from("/hero.webp")),
1294 VmValue::String(Rc::from("/inter.woff2")),
1295 VmValue::String(Rc::from("/manifest.json")),
1296 VmValue::String(Rc::from("/unknown.xyz")),
1297 ]));
1298 let response =
1299 http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1300 let map = dict(&response);
1301 let headers = map
1302 .get("headers")
1303 .and_then(VmValue::as_dict)
1304 .expect("headers");
1305 let links = match headers.get("Link") {
1306 Some(VmValue::List(items)) => items.clone(),
1307 other => panic!("Link should be a list, got {other:?}"),
1308 };
1309 let rendered: Vec<String> = links
1310 .iter()
1311 .map(|v| match v {
1312 VmValue::String(s) => s.to_string(),
1313 other => panic!("Link entry is not a string: {other:?}"),
1314 })
1315 .collect();
1316 assert_eq!(
1317 rendered,
1318 vec![
1319 "</main.css>; rel=preload; as=style",
1320 "</app.js>; rel=preload; as=script",
1321 "</hero.webp>; rel=preload; as=image",
1322 "</inter.woff2>; rel=preload; as=font",
1323 "</manifest.json>; rel=preload; as=fetch",
1324 "</unknown.xyz>; rel=preload",
1325 ]
1326 );
1327 }
1328
1329 #[test]
1330 fn http_push_hints_handles_querystring_in_path() {
1331 let envelope = http_ok_impl(&[VmValue::Nil], &mut String::new()).unwrap();
1332 let paths = VmValue::List(Rc::new(vec![VmValue::String(Rc::from(
1333 "/static/app.js?v=42",
1334 ))]));
1335 let response =
1336 http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1337 let map = dict(&response);
1338 let headers = map
1339 .get("headers")
1340 .and_then(VmValue::as_dict)
1341 .expect("headers");
1342 let links = match headers.get("Link") {
1343 Some(VmValue::List(items)) => items.clone(),
1344 other => panic!("Link should be a list, got {other:?}"),
1345 };
1346 assert_eq!(
1347 links[0].display(),
1348 "</static/app.js?v=42>; rel=preload; as=script"
1349 );
1350 }
1351
1352 #[test]
1353 fn http_push_hints_rejects_untagged_envelope() {
1354 let plain = VmValue::Dict(Rc::new(BTreeMap::from([(
1355 "status".to_string(),
1356 VmValue::Int(200),
1357 )])));
1358 let paths = VmValue::List(Rc::new(vec![VmValue::String(Rc::from("/main.css"))]));
1359 let result = http_push_hints_impl(&[plain, paths], &mut String::new());
1360 assert!(
1361 matches!(result, Err(VmError::Thrown(_))),
1362 "untagged dict should be rejected, got {result:?}"
1363 );
1364 }
1365
1366 #[test]
1367 fn http_push_hints_preserves_existing_link_header() {
1368 let envelope = http_reply_impl(
1369 &[
1370 VmValue::Int(200),
1371 VmValue::Dict(Rc::new(BTreeMap::new())),
1372 VmValue::Dict(Rc::new(BTreeMap::from([(
1373 "Link".to_string(),
1374 VmValue::String(Rc::from("</legacy.css>; rel=preload; as=style")),
1375 )]))),
1376 ],
1377 &mut String::new(),
1378 )
1379 .unwrap();
1380 let paths = VmValue::List(Rc::new(vec![VmValue::String(Rc::from("/app.js"))]));
1381 let response = http_push_hints_impl(&[envelope, paths], &mut String::new()).unwrap();
1382 let map = dict(&response);
1383 let headers = map
1384 .get("headers")
1385 .and_then(VmValue::as_dict)
1386 .expect("headers");
1387 let links = match headers.get("Link") {
1388 Some(VmValue::List(items)) => items.clone(),
1389 other => panic!("Link should be a list once preloads are added, got {other:?}"),
1390 };
1391 assert_eq!(links.len(), 2);
1392 assert_eq!(links[0].display(), "</legacy.css>; rel=preload; as=style");
1393 assert_eq!(links[1].display(), "</app.js>; rel=preload; as=script");
1394 }
1395
1396 #[test]
1397 fn http_upgrade_ws_envelope_negotiates_subprotocol() {
1398 let req = VmValue::Dict(Rc::new(BTreeMap::from([(
1399 "headers".to_string(),
1400 VmValue::Dict(Rc::new(BTreeMap::from([(
1401 "Sec-WebSocket-Protocol".to_string(),
1402 VmValue::String(Rc::from("v0.harn, v1.harn")),
1403 )]))),
1404 )])));
1405 let options = VmValue::Dict(Rc::new(BTreeMap::from([(
1406 "subprotocols".to_string(),
1407 VmValue::List(Rc::new(vec![
1408 VmValue::String(Rc::from("v1.harn")),
1409 VmValue::String(Rc::from("v2.harn")),
1410 ])),
1411 )])));
1412 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1413 let map = dict(&response);
1414 assert!(matches!(map.get("status"), Some(VmValue::Int(101))));
1415 let upgrade = map
1416 .get("ws_upgrade")
1417 .and_then(VmValue::as_dict)
1418 .expect("ws_upgrade");
1419 assert_eq!(
1420 upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1421 Some("v1.harn")
1422 );
1423 let headers = map
1424 .get("headers")
1425 .and_then(VmValue::as_dict)
1426 .expect("headers");
1427 assert_eq!(
1428 headers.get("Upgrade").map(|v| v.display()).as_deref(),
1429 Some("websocket")
1430 );
1431 assert_eq!(
1432 headers
1433 .get("Sec-WebSocket-Protocol")
1434 .map(|v| v.display())
1435 .as_deref(),
1436 Some("v1.harn")
1437 );
1438 }
1439
1440 #[test]
1441 fn http_upgrade_ws_picks_client_preferred_when_both_overlap() {
1442 let req = VmValue::Dict(Rc::new(BTreeMap::from([(
1451 "headers".to_string(),
1452 VmValue::Dict(Rc::new(BTreeMap::from([(
1453 "Sec-WebSocket-Protocol".to_string(),
1454 VmValue::String(Rc::from("v2.harn, v1.harn")),
1455 )]))),
1456 )])));
1457 let options = VmValue::Dict(Rc::new(BTreeMap::from([(
1458 "subprotocols".to_string(),
1459 VmValue::List(Rc::new(vec![
1460 VmValue::String(Rc::from("v1.harn")),
1461 VmValue::String(Rc::from("v2.harn")),
1462 ])),
1463 )])));
1464 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1465 let upgrade = dict(&response)
1466 .get("ws_upgrade")
1467 .and_then(VmValue::as_dict)
1468 .expect("ws_upgrade");
1469 assert_eq!(
1470 upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1471 Some("v2.harn")
1472 );
1473 }
1474
1475 #[test]
1476 fn parse_envelope_round_trips_ws_upgrade_marker() {
1477 let req = VmValue::Dict(Rc::new(BTreeMap::from([(
1478 "headers".to_string(),
1479 VmValue::Dict(Rc::new(BTreeMap::from([(
1480 "Sec-WebSocket-Protocol".to_string(),
1481 VmValue::String(Rc::from("v1.harn")),
1482 )]))),
1483 )])));
1484 let options = VmValue::Dict(Rc::new(BTreeMap::from([
1485 (
1486 "subprotocols".to_string(),
1487 VmValue::List(Rc::new(vec![VmValue::String(Rc::from("v1.harn"))])),
1488 ),
1489 ("idle_ping_ms".to_string(), VmValue::Int(15_000)),
1490 ])));
1491 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1492 let json = vm_value_to_json(&response);
1493 let envelope = parse_envelope(&json).expect("envelope parses");
1494 let ws = envelope.ws_upgrade.expect("ws_upgrade present");
1495 assert_eq!(ws.subprotocol.as_deref(), Some("v1.harn"));
1496 assert_eq!(ws.offered, vec!["v1.harn"]);
1497 assert_eq!(ws.idle_ping_ms, Some(15_000));
1498 assert_eq!(envelope.status, 101);
1499 }
1500
1501 #[test]
1502 fn http_upgrade_ws_falls_through_when_no_subprotocols_offered() {
1503 let req = VmValue::Dict(Rc::new(BTreeMap::new()));
1504 let response = http_upgrade_ws_impl(&[req], &mut String::new()).unwrap();
1505 let map = dict(&response);
1506 let upgrade = map
1507 .get("ws_upgrade")
1508 .and_then(VmValue::as_dict)
1509 .expect("ws_upgrade");
1510 assert!(matches!(upgrade.get("subprotocol"), Some(VmValue::Nil)));
1511 }
1512}