1use crate::value::VmDictExt;
23use std::collections::BTreeMap;
24
25use sha2::{Digest, Sha256};
26
27use crate::stdlib::args::{ArgError, Args};
28use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
29use crate::value::{VmError, VmValue};
30use crate::vm::Vm;
31
32pub const HTTP_RESPONSE_TAG_KEY: &str = "__http_response__";
34pub const HTTP_RESPONSE_TAG_VERSION: &str = "v1";
35
36const BODY_KIND_JSON: &str = "json";
37const BODY_KIND_NONE: &str = "none";
38const BODY_KIND_BYTES: &str = "bytes";
39const BODY_KIND_STREAM: &str = "stream";
40const BODY_KIND_SSE: &str = "sse";
41
42pub(crate) fn register_http_response_builtins(vm: &mut Vm) {
43 for def in MODULE_BUILTINS {
44 vm.register_builtin_def(def);
45 }
46}
47
48#[harn_builtin(
49 exposure = "pure",
50 effects = [],
51 sig = "http_ok(body: any?) -> dict", category = "http_response"
52)]
53fn http_ok_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
54 let body = args.first().cloned().unwrap_or(VmValue::Nil);
55 Ok(envelope(
56 200,
57 body,
58 BODY_KIND_JSON,
59 crate::value::DictMap::new(),
60 ))
61}
62
63#[harn_builtin(
64 exposure = "pure",
65 effects = [],
66 sig = "http_created(body: any?, location?: string?) -> dict",
67 category = "http_response"
68)]
69fn http_created_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
70 let body = args.first().cloned().unwrap_or(VmValue::Nil);
71 let mut headers = crate::value::DictMap::new();
72 if let Some(location) = args.get(1).and_then(string_or_nil) {
73 headers.put_str("Location", location);
74 }
75 Ok(envelope(201, body, BODY_KIND_JSON, headers))
76}
77
78#[harn_builtin(
79 exposure = "pure",
80 effects = [],
81 sig = "http_no_content() -> dict", category = "http_response"
82)]
83fn http_no_content_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
84 Ok(envelope(
85 204,
86 VmValue::Nil,
87 BODY_KIND_NONE,
88 crate::value::DictMap::new(),
89 ))
90}
91
92#[harn_builtin(
93 exposure = "pure",
94 effects = [],
95 sig = "http_error(status: int, code: string, message: string, details?: any) -> dict",
96 category = "http_response"
97)]
98fn http_error_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
99 let status = require_status(args.first(), "http_error")?;
100 if !(400..=599).contains(&status) {
101 return Err(thrown_err(format!(
102 "http_error: status must be 4xx or 5xx (got {status})"
103 )));
104 }
105 let code = require_nonempty_string(args.get(1), "http_error", "code")?;
106 let message = require_nonempty_string(args.get(2), "http_error", "message")?;
107 let details = args.get(3).cloned().unwrap_or(VmValue::Nil);
108
109 let mut body = crate::value::DictMap::new();
110 body.put_str("code", code);
111 body.put_str("message", message);
112 if !matches!(details, VmValue::Nil) {
113 body.insert(crate::value::intern_key("details"), details);
114 }
115 let mut env = envelope_map(
116 status,
117 VmValue::dict(body),
118 BODY_KIND_JSON,
119 crate::value::DictMap::new(),
120 );
121 env.insert(crate::value::intern_key("is_error"), VmValue::Bool(true));
122 Ok(VmValue::dict(env))
123}
124
125#[harn_builtin(
126 exposure = "pure",
127 effects = [],
128 sig = "http_reply(status: int, body?: any, headers?: dict) -> dict",
129 category = "http_response"
130)]
131fn http_reply_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
132 let status = require_status(args.first(), "http_reply")?;
133 let body = args.get(1).cloned().unwrap_or(VmValue::Nil);
134 let headers = parse_headers(args.get(2), "http_reply")?;
135 let body_kind = if status == 204 || status == 304 || matches!(body, VmValue::Nil) {
136 BODY_KIND_NONE
137 } else if matches!(body, VmValue::Bytes(_)) {
138 BODY_KIND_BYTES
139 } else {
140 BODY_KIND_JSON
141 };
142 let body_for_envelope = if body_kind == BODY_KIND_NONE {
143 VmValue::Nil
144 } else {
145 body
146 };
147 Ok(envelope(status, body_for_envelope, body_kind, headers))
148}
149
150#[harn_builtin(
153 exposure = "pure",
154 effects = [],
155 sig = "http_reply_from(result: dict) -> dict",
156 category = "http_response"
157)]
158fn http_reply_from_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
159 let result = args
160 .first()
161 .and_then(VmValue::as_dict)
162 .ok_or_else(|| thrown_err("http_reply_from: result must be a dict"))?;
163
164 if is_http_response_envelope(result) {
165 return Ok(VmValue::Dict(result.clone().into()));
166 }
167
168 let status = require_status(result.get("status"), "http_reply_from")?;
169 let headers = parse_headers(result.get("headers"), "http_reply_from")?;
170 let body_kind = match result.get("body_kind") {
171 None | Some(VmValue::Nil) => None,
172 Some(VmValue::String(kind)) => Some(kind.as_str()),
173 Some(other) => {
174 return Err(thrown_err(format!(
175 "http_reply_from: body_kind must be a string (got {})",
176 other.type_name()
177 )));
178 }
179 };
180
181 match body_kind {
182 Some(BODY_KIND_NONE) => Ok(envelope(status, VmValue::Nil, BODY_KIND_NONE, headers)),
183 Some(BODY_KIND_BYTES) => {
184 let body = result
185 .get("raw_body")
186 .filter(|value| matches!(value, VmValue::Bytes(_)))
187 .or_else(|| result.get("body"))
188 .cloned()
189 .unwrap_or(VmValue::Nil);
190 match body {
191 VmValue::Bytes(_) | VmValue::Nil => {
192 Ok(envelope(status, body, BODY_KIND_BYTES, headers))
193 }
194 other => Err(thrown_err(format!(
195 "http_reply_from: body_kind bytes requires bytes `raw_body` or `body` (got {})",
196 other.type_name()
197 ))),
198 }
199 }
200 Some(BODY_KIND_STREAM) => {
201 let body = result
202 .get("body")
203 .cloned()
204 .map(stream_body_chunks)
205 .unwrap_or_else(empty_list);
206 Ok(envelope(status, body, BODY_KIND_STREAM, headers))
207 }
208 Some(BODY_KIND_SSE) => {
209 let body = result
210 .get("body")
211 .cloned()
212 .map(stream_body_chunks)
213 .unwrap_or_else(empty_list);
214 Ok(envelope(status, body, BODY_KIND_SSE, headers))
215 }
216 Some(BODY_KIND_JSON) => {
217 let body = result.get("body").cloned().unwrap_or(VmValue::Nil);
218 Ok(envelope(status, body, BODY_KIND_JSON, headers))
219 }
220 _ => {
221 let body = result.get("body").cloned().unwrap_or(VmValue::Nil);
222 let args = [VmValue::Int(status), body, VmValue::dict(headers)];
223 http_reply_impl(&args, &mut String::new())
224 }
225 }
226}
227
228fn empty_list() -> VmValue {
229 VmValue::List(std::sync::Arc::new(Vec::new()))
230}
231
232fn stream_body_chunks(body: VmValue) -> VmValue {
233 match body {
234 VmValue::List(_) => body,
235 VmValue::Nil => empty_list(),
236 other => VmValue::List(std::sync::Arc::new(vec![other])),
237 }
238}
239
240#[harn_builtin(
241 exposure = "capability_arg:0",
242 effects = ["state.mutate@arg0"],
243 sig = "http_stream(source: any, content_type?: string?) -> dict",
244 kind = "async",
245 category = "http_response"
246)]
247async fn http_stream_impl(
248 _ctx: crate::vm::AsyncBuiltinCtx,
249 args: Vec<VmValue>,
250) -> Result<VmValue, VmError> {
251 let source = args
252 .first()
253 .cloned()
254 .ok_or_else(|| thrown_err("http_stream: source is required"))?;
255 let content_type = args
256 .get(1)
257 .and_then(string_or_nil)
258 .unwrap_or_else(|| "application/octet-stream".to_string());
259
260 let chunks = drain_to_list(source, "http_stream").await?;
261 let mut headers = crate::value::DictMap::new();
262 headers.put_str("Content-Type", content_type);
263 Ok(envelope(
264 200,
265 VmValue::List(std::sync::Arc::new(chunks)),
266 BODY_KIND_STREAM,
267 headers,
268 ))
269}
270
271#[harn_builtin(
272 exposure = "capability_arg:0",
273 effects = ["state.mutate@arg0"],
274 sig = "http_sse(source: any, retry_ms?: int?) -> dict",
275 kind = "async",
276 category = "http_response"
277)]
278async fn http_sse_impl(
279 _ctx: crate::vm::AsyncBuiltinCtx,
280 args: Vec<VmValue>,
281) -> Result<VmValue, VmError> {
282 let source = args
283 .first()
284 .cloned()
285 .ok_or_else(|| thrown_err("http_sse: source is required"))?;
286 let retry_ms = match args.get(1) {
287 None | Some(VmValue::Nil) => None,
288 Some(VmValue::Int(value)) => {
289 if *value < 0 {
290 return Err(thrown_err(format!(
291 "http_sse: retry_ms must be non-negative (got {value})"
292 )));
293 }
294 Some(*value)
295 }
296 Some(other) => {
297 return Err(thrown_err(format!(
298 "http_sse: retry_ms must be an int, got {}",
299 other.type_name()
300 )));
301 }
302 };
303
304 let events = drain_to_list(source, "http_sse").await?;
305 let mut headers = crate::value::DictMap::new();
306 headers.put_str("Content-Type", "text/event-stream");
307 headers.put_str("Cache-Control", "no-cache");
308 let mut env = envelope_map(
309 200,
310 VmValue::List(std::sync::Arc::new(events)),
311 BODY_KIND_SSE,
312 headers,
313 );
314 if let Some(retry_ms) = retry_ms {
315 env.insert(crate::value::intern_key("retry_ms"), VmValue::Int(retry_ms));
316 }
317 Ok(VmValue::dict(env))
318}
319
320fn envelope(
321 status: i64,
322 body: VmValue,
323 body_kind: &str,
324 headers: crate::value::DictMap,
325) -> VmValue {
326 VmValue::dict(envelope_map(status, body, body_kind, headers))
327}
328
329fn envelope_map(
330 status: i64,
331 body: VmValue,
332 body_kind: &str,
333 headers: crate::value::DictMap,
334) -> crate::value::DictMap {
335 let mut map = crate::value::DictMap::new();
336 map.insert(
337 crate::value::intern_key(HTTP_RESPONSE_TAG_KEY),
338 VmValue::String(arcstr::ArcStr::from(HTTP_RESPONSE_TAG_VERSION)),
339 );
340 map.insert(crate::value::intern_key("status"), VmValue::Int(status));
341 map.put_str("body_kind", body_kind);
342 map.insert(crate::value::intern_key("headers"), VmValue::dict(headers));
343 if !matches!(body, VmValue::Nil) {
344 map.insert(crate::value::intern_key("body"), body);
345 }
346 map
347}
348
349fn require_status(value: Option<&VmValue>, fn_name: &str) -> Result<i64, VmError> {
350 let status = match value {
351 Some(VmValue::Int(value)) => *value,
352 Some(other) => {
353 return Err(thrown_err(format!(
354 "{fn_name}: status must be an int, got {}",
355 other.type_name()
356 )));
357 }
358 None => {
359 return Err(thrown_err(format!("{fn_name}: status is required")));
360 }
361 };
362 if !(100..=599).contains(&status) {
363 return Err(thrown_err(format!(
364 "{fn_name}: status {status} is out of range (100-599)"
365 )));
366 }
367 Ok(status)
368}
369
370fn require_nonempty_string(
371 value: Option<&VmValue>,
372 fn_name: &str,
373 arg_name: &str,
374) -> Result<String, VmError> {
375 let text = match value {
376 Some(VmValue::String(text)) => text.to_string(),
377 Some(other) => {
378 return Err(thrown_err(format!(
379 "{fn_name}: {arg_name} must be a string (got {})",
380 other.type_name()
381 )));
382 }
383 None => {
384 return Err(thrown_err(format!("{fn_name}: {arg_name} is required")));
385 }
386 };
387 if text.is_empty() {
388 return Err(thrown_err(format!(
389 "{fn_name}: {arg_name} must be non-empty"
390 )));
391 }
392 Ok(text)
393}
394
395fn string_or_nil(value: &VmValue) -> Option<String> {
396 match value {
397 VmValue::String(text) if !text.is_empty() => Some(text.to_string()),
398 _ => None,
399 }
400}
401
402fn parse_headers(value: Option<&VmValue>, fn_name: &str) -> Result<crate::value::DictMap, VmError> {
403 match value {
404 None | Some(VmValue::Nil) => Ok(crate::value::DictMap::new()),
405 Some(VmValue::Dict(dict)) => Ok((**dict).clone()),
406 Some(other) => Err(thrown_err(format!(
407 "{fn_name}: headers must be a dict (got {})",
408 other.type_name()
409 ))),
410 }
411}
412
413async fn drain_to_list(value: VmValue, fn_name: &str) -> Result<Vec<VmValue>, VmError> {
420 use tokio::sync::mpsc::error::TryRecvError;
421
422 match value {
423 VmValue::List(items) => Ok(items.iter().cloned().collect()),
424 VmValue::Channel(handle) => {
425 let mut items = Vec::new();
426 let mut rx = handle.receiver.lock().await;
427 loop {
428 match rx.try_recv() {
429 Ok(value) => items.push(value),
430 Err(TryRecvError::Empty) => {
431 if handle.is_closed() {
432 break;
433 }
434 tokio::task::yield_now().await;
439 }
440 Err(TryRecvError::Disconnected) => break,
441 }
442 }
443 Ok(items)
444 }
445 other => Err(thrown_err(format!(
446 "{fn_name}: source must be a list or channel (got {})",
447 other.type_name()
448 ))),
449 }
450}
451
452fn thrown_err(message: impl Into<String>) -> VmError {
453 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
454}
455
456pub fn parse_envelope(value: &serde_json::Value) -> Option<HttpEnvelope> {
462 let obj = value.as_object()?;
463 let tag = obj.get(HTTP_RESPONSE_TAG_KEY)?.as_str()?;
464 if tag != HTTP_RESPONSE_TAG_VERSION {
465 return None;
466 }
467 let status = obj.get("status")?.as_u64()? as u16;
468 let body_kind = obj
469 .get("body_kind")
470 .and_then(|v| v.as_str())
471 .unwrap_or(BODY_KIND_JSON)
472 .to_string();
473 let headers = obj
474 .get("headers")
475 .and_then(|v| v.as_object())
476 .map(|map| {
477 map.iter()
478 .map(|(key, value)| {
479 let header = match value {
480 serde_json::Value::String(s) => HttpHeaderValue::Single(s.clone()),
481 serde_json::Value::Array(values) => HttpHeaderValue::Multi(
482 values
483 .iter()
484 .filter_map(|v| v.as_str().map(str::to_string))
485 .collect(),
486 ),
487 other => HttpHeaderValue::Single(other.to_string()),
488 };
489 (key.clone(), header)
490 })
491 .collect::<BTreeMap<_, _>>()
492 })
493 .unwrap_or_default();
494 let body = obj.get("body").cloned();
495 let retry_ms = obj.get("retry_ms").and_then(|v| v.as_u64());
496 let is_error = obj
497 .get("is_error")
498 .and_then(|v| v.as_bool())
499 .unwrap_or(false);
500 let ws_upgrade = obj
501 .get("ws_upgrade")
502 .and_then(|v| v.as_object())
503 .map(|map| {
504 let subprotocol = map
505 .get("subprotocol")
506 .and_then(|v| v.as_str())
507 .map(str::to_string);
508 let offered = map
509 .get("offered")
510 .and_then(|v| v.as_array())
511 .map(|values| {
512 values
513 .iter()
514 .filter_map(|v| v.as_str().map(str::to_string))
515 .collect()
516 })
517 .unwrap_or_default();
518 let idle_ping_ms = map.get("idle_ping_ms").and_then(|v| v.as_u64());
519 let max_message_bytes = map.get("max_message_bytes").and_then(|v| v.as_u64());
520 let on_message = map
521 .get("on_message")
522 .and_then(|v| v.as_str())
523 .map(str::to_string);
524 WsUpgradeSpec {
525 subprotocol,
526 offered,
527 idle_ping_ms,
528 max_message_bytes,
529 on_message,
530 }
531 });
532 Some(HttpEnvelope {
533 status,
534 body_kind,
535 headers,
536 body,
537 retry_ms,
538 is_error,
539 ws_upgrade,
540 })
541}
542
543#[derive(Debug, Clone)]
544pub struct HttpEnvelope {
545 pub status: u16,
546 pub body_kind: String,
547 pub headers: BTreeMap<String, HttpHeaderValue>,
548 pub body: Option<serde_json::Value>,
549 pub retry_ms: Option<u64>,
550 pub is_error: bool,
551 pub ws_upgrade: Option<WsUpgradeSpec>,
556}
557
558#[derive(Debug, Clone, Default)]
559pub struct WsUpgradeSpec {
560 pub subprotocol: Option<String>,
561 pub offered: Vec<String>,
562 pub idle_ping_ms: Option<u64>,
563 pub max_message_bytes: Option<u64>,
564 pub on_message: Option<String>,
572}
573
574#[derive(Debug, Clone)]
575pub enum HttpHeaderValue {
576 Single(String),
577 Multi(Vec<String>),
578}
579
580impl HttpHeaderValue {
581 pub fn values(&self) -> Box<dyn Iterator<Item = &str> + '_> {
582 match self {
583 Self::Single(value) => Box::new(std::iter::once(value.as_str())),
584 Self::Multi(values) => Box::new(values.iter().map(String::as_str)),
585 }
586 }
587}
588
589#[harn_builtin(
590 exposure = "pure",
591 effects = [],
592 sig = "http_etag(body: any) -> string", category = "http_response"
593)]
594fn http_etag_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
595 let body = args
596 .first()
597 .ok_or_else(|| thrown_err("http_etag: body is required"))?;
598 let bytes = value_as_bytes(body);
599 let mut hasher = Sha256::new();
600 hasher.update(&bytes);
601 let digest = hasher.finalize();
602 Ok(VmValue::String(arcstr::ArcStr::from(format!(
603 "\"{}\"",
604 hex::encode(digest)
605 ))))
606}
607
608#[harn_builtin(
609 exposure = "pure",
610 effects = [],
611 sig = "http_choose(accept: string?, offers: list, default?: string?) -> string",
612 category = "http_response"
613)]
614fn http_choose_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
615 let choose = Args::thrown("http_choose", args);
616 let accept = choose.opt_string(0, "accept")?;
617 let offers = choose.string_list(1, "offers")?;
618 if offers.is_empty() {
619 return Err(ArgError::empty(choose.fn_name(), choose.kind(), "offers"));
620 }
621 let default = choose.opt_string(2, "default")?.unwrap_or(offers[0]);
622
623 let chosen = match accept {
624 None | Some("") | Some("*/*") => default,
625 Some(header) => negotiate_accept(header, &offers).unwrap_or(default),
626 };
627 Ok(VmValue::String(arcstr::ArcStr::from(chosen)))
628}
629
630#[harn_builtin(
631 exposure = "pure",
632 effects = [],
633 sig = "http_not_modified(etag?: string?, headers?: dict) -> dict",
634 category = "http_response"
635)]
636fn http_not_modified_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
637 let mut headers = parse_headers(args.get(1), "http_not_modified")?;
638 if let Some(etag) = args.first().and_then(string_or_nil) {
639 headers.put_str("ETag", etag);
640 }
641 Ok(envelope(304, VmValue::Nil, BODY_KIND_NONE, headers))
642}
643
644#[harn_builtin(
645 exposure = "pure",
646 effects = [],
647 sig = "http_push_hints(envelope: dict, paths: list) -> dict",
648 category = "http_response"
649)]
650fn http_push_hints_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
651 let envelope = args
656 .first()
657 .and_then(VmValue::as_dict)
658 .ok_or_else(|| thrown_err("http_push_hints: envelope must be a dict"))?;
659 if !is_http_response_envelope(envelope) {
660 return Err(thrown_err(
661 "http_push_hints: envelope must be an http_response envelope \
662 (use http_ok, http_reply, etc. before calling this)",
663 ));
664 }
665
666 let paths = match args.get(1) {
667 Some(VmValue::List(items)) => items.clone(),
668 Some(other) => {
669 return Err(thrown_err(format!(
670 "http_push_hints: paths must be a list (got {})",
671 other.type_name()
672 )));
673 }
674 None => {
675 return Err(thrown_err("http_push_hints: paths is required"));
676 }
677 };
678
679 let mut new_links: Vec<String> = Vec::with_capacity(paths.len());
680 for item in paths.iter() {
681 match item {
682 VmValue::String(text) => {
683 let path = text.as_str();
684 if path.is_empty() {
685 return Err(thrown_err(
686 "http_push_hints: paths must not contain empty strings",
687 ));
688 }
689 new_links.push(format_link_header(path));
690 }
691 other => {
692 return Err(thrown_err(format!(
693 "http_push_hints: paths must contain strings (got {})",
694 other.type_name()
695 )));
696 }
697 }
698 }
699
700 if new_links.is_empty() {
701 return Ok(VmValue::Dict(envelope.clone().into()));
702 }
703
704 let mut envelope_map = (*envelope).clone();
705 let mut headers = envelope_map
706 .get("headers")
707 .and_then(VmValue::as_dict)
708 .cloned()
709 .unwrap_or_default();
710
711 let mut combined: Vec<VmValue> = match headers.get("Link") {
713 Some(VmValue::String(existing)) => vec![VmValue::String(existing.clone())],
714 Some(VmValue::List(items)) => items.iter().cloned().collect(),
715 _ => Vec::new(),
716 };
717 combined.extend(
718 new_links
719 .into_iter()
720 .map(|link| VmValue::String(arcstr::ArcStr::from(link))),
721 );
722
723 headers.insert(
724 crate::value::intern_key("Link"),
725 VmValue::List(std::sync::Arc::new(combined)),
726 );
727 envelope_map.insert(crate::value::intern_key("headers"), VmValue::dict(headers));
728 Ok(VmValue::dict(envelope_map))
729}
730
731fn is_http_response_envelope(map: &crate::value::DictMap) -> bool {
732 matches!(
733 map.get(HTTP_RESPONSE_TAG_KEY),
734 Some(VmValue::String(tag)) if tag.as_str() == HTTP_RESPONSE_TAG_VERSION,
735 )
736}
737
738fn format_link_header(path: &str) -> String {
739 match infer_preload_as(path) {
740 Some(kind) => format!("<{path}>; rel=preload; as={kind}"),
741 None => format!("<{path}>; rel=preload"),
742 }
743}
744
745fn infer_preload_as(path: &str) -> Option<&'static str> {
752 let pre_query = path.split(['?', '#']).next().unwrap_or(path);
755 let (_, ext) = pre_query.rsplit_once('.')?;
756 Some(match ext.to_ascii_lowercase().as_str() {
757 "css" => "style",
758 "js" | "mjs" => "script",
759 "json" => "fetch",
760 "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "ico" => "image",
761 "woff" | "woff2" | "ttf" | "otf" => "font",
762 _ => return None,
763 })
764}
765
766#[harn_builtin(
767 exposure = "pure",
768 effects = [],
769 sig = "http_upgrade_ws(req: dict, options?: dict) -> dict",
770 category = "http_response"
771)]
772fn http_upgrade_ws_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
773 let req = args
774 .first()
775 .and_then(VmValue::as_dict)
776 .ok_or_else(|| thrown_err("http_upgrade_ws: req must be a dict"))?;
777 let options = args.get(1).and_then(VmValue::as_dict);
778
779 let request_subprotocols = req
780 .get("headers")
781 .and_then(VmValue::as_dict)
782 .and_then(|headers| header_lookup(headers, "sec-websocket-protocol"))
783 .map(|raw| {
784 raw.split(',')
785 .map(|s| s.trim().to_string())
786 .filter(|s| !s.is_empty())
787 .collect::<Vec<_>>()
788 })
789 .unwrap_or_default();
790 let offered_subprotocols = options
791 .and_then(|opts| opts.get("subprotocols"))
792 .and_then(|value| match value {
793 VmValue::List(items) => Some(
794 items
795 .iter()
796 .filter_map(|v| match v {
797 VmValue::String(s) => Some(s.to_string()),
798 _ => None,
799 })
800 .collect::<Vec<_>>(),
801 ),
802 _ => None,
803 })
804 .unwrap_or_default();
805
806 let negotiated = request_subprotocols
812 .iter()
813 .find(|client| offered_subprotocols.iter().any(|name| name == *client))
814 .cloned();
815
816 let mut headers = crate::value::DictMap::new();
817 headers.put_str("Upgrade", "websocket");
818 headers.put_str("Connection", "Upgrade");
819 if let Some(name) = &negotiated {
820 headers.put_str("Sec-WebSocket-Protocol", name.clone());
821 }
822
823 let idle_ping_ms = options
824 .and_then(|opts| opts.get("idle_ping_ms"))
825 .and_then(|v| v.as_int());
826 let max_message_bytes = options
827 .and_then(|opts| opts.get("max_message_bytes"))
828 .and_then(|v| v.as_int());
829 let on_message = options
830 .and_then(|opts| opts.get("on_message"))
831 .and_then(|v| match v {
832 VmValue::String(name) => Some(name.to_string()),
833 _ => None,
834 });
835
836 let mut env_map = envelope_map(101, VmValue::Nil, BODY_KIND_NONE, headers);
837 env_map.insert(
838 crate::value::intern_key("ws_upgrade"),
839 VmValue::dict({
840 let mut map = crate::value::DictMap::new();
841 map.insert(
842 crate::value::intern_key("subprotocol"),
843 match &negotiated {
844 Some(name) => VmValue::String(arcstr::ArcStr::from(name.clone())),
845 None => VmValue::Nil,
846 },
847 );
848 map.insert(
849 crate::value::intern_key("offered"),
850 VmValue::List(std::sync::Arc::new(
851 offered_subprotocols
852 .iter()
853 .map(|s| VmValue::String(arcstr::ArcStr::from(s.clone())))
854 .collect(),
855 )),
856 );
857 if let Some(ms) = idle_ping_ms {
858 map.insert(crate::value::intern_key("idle_ping_ms"), VmValue::Int(ms));
859 }
860 if let Some(bytes) = max_message_bytes {
861 map.insert(
862 crate::value::intern_key("max_message_bytes"),
863 VmValue::Int(bytes),
864 );
865 }
866 if let Some(handler) = &on_message {
867 map.put_str("on_message", handler.clone());
868 }
869 map
870 }),
871 );
872 Ok(VmValue::dict(env_map))
873}
874
875fn header_lookup(headers: &crate::value::DictMap, name: &str) -> Option<String> {
876 let needle = name.to_ascii_lowercase();
877 headers
878 .iter()
879 .find(|(key, _)| key.to_ascii_lowercase() == needle)
880 .and_then(|(_, value)| match value {
881 VmValue::String(text) => Some(text.to_string()),
882 _ => None,
883 })
884}
885
886fn value_as_bytes(value: &VmValue) -> Vec<u8> {
887 match value {
888 VmValue::Bytes(bytes) => bytes.as_ref().clone(),
889 VmValue::String(text) => text.as_bytes().to_vec(),
890 VmValue::Nil => Vec::new(),
891 other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
899 }
900}
901
902fn negotiate_accept<'a>(header: &str, offers: &[&'a str]) -> Option<&'a str> {
909 let ranges: Vec<MediaRange> = header
910 .split(',')
911 .filter_map(MediaRange::parse)
912 .filter(|range| range.q > 0.0)
913 .collect();
914 if ranges.is_empty() {
915 return None;
916 }
917
918 let mut best: Option<(usize, f32, u8)> = None;
919 for (index, offer) in offers.iter().enumerate() {
920 let (offer_type, offer_subtype) = split_media(offer)?;
921 for range in &ranges {
922 let score = range.match_score(offer_type, offer_subtype);
923 let Some(score) = score else { continue };
924 let q = range.q;
925 let candidate = (index, q, score);
926 best = Some(match best {
927 None => candidate,
928 Some(current) => {
929 if q > current.1
932 || (q == current.1 && score > current.2)
933 || (q == current.1 && score == current.2 && index < current.0)
934 {
935 candidate
936 } else {
937 current
938 }
939 }
940 });
941 }
942 }
943 best.map(|(index, _, _)| offers[index])
944}
945
946struct MediaRange<'a> {
947 type_: &'a str,
948 subtype: &'a str,
949 q: f32,
950}
951
952impl<'a> MediaRange<'a> {
953 fn parse(raw: &'a str) -> Option<Self> {
954 let trimmed = raw.trim();
955 let mut parts = trimmed.split(';');
956 let media = parts.next()?.trim();
957 let (type_, subtype) = split_media(media)?;
958 let mut q = 1.0;
959 for param in parts {
960 let param = param.trim();
961 if let Some(value) = param
962 .strip_prefix("q=")
963 .or_else(|| param.strip_prefix("Q="))
964 {
965 if let Ok(parsed) = value.trim().parse::<f32>() {
966 if (0.0..=1.0).contains(&parsed) {
967 q = parsed;
968 }
969 }
970 }
971 }
972 Some(Self { type_, subtype, q })
973 }
974
975 fn match_score(&self, offer_type: &str, offer_subtype: &str) -> Option<u8> {
976 let type_match = self.type_ == "*" || self.type_.eq_ignore_ascii_case(offer_type);
977 let subtype_match = self.subtype == "*" || self.subtype.eq_ignore_ascii_case(offer_subtype);
978 if !type_match || !subtype_match {
979 return None;
980 }
981 Some(match (self.type_, self.subtype) {
982 ("*", _) => 1,
983 (_, "*") => 2,
984 _ => 3,
985 })
986 }
987}
988
989fn split_media(value: &str) -> Option<(&str, &str)> {
990 let mut iter = value.splitn(2, '/');
991 let type_ = iter.next()?.trim();
992 let subtype = iter.next()?.trim();
993 if type_.is_empty() || subtype.is_empty() {
994 return None;
995 }
996 Some((type_, subtype))
997}
998
999pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
1000 &HTTP_OK_IMPL_DEF,
1001 &HTTP_CREATED_IMPL_DEF,
1002 &HTTP_NO_CONTENT_IMPL_DEF,
1003 &HTTP_ERROR_IMPL_DEF,
1004 &HTTP_REPLY_IMPL_DEF,
1005 &HTTP_REPLY_FROM_IMPL_DEF,
1006 &HTTP_STREAM_IMPL_DEF,
1007 &HTTP_SSE_IMPL_DEF,
1008 &HTTP_ETAG_IMPL_DEF,
1009 &HTTP_CHOOSE_IMPL_DEF,
1010 &HTTP_NOT_MODIFIED_IMPL_DEF,
1011 &HTTP_PUSH_HINTS_IMPL_DEF,
1012 &HTTP_UPGRADE_WS_IMPL_DEF,
1013];
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018 use crate::llm::helpers::vm_value_to_json;
1019
1020 fn dict(value: &VmValue) -> &crate::value::DictMap {
1021 value.as_dict().expect("envelope is a dict")
1022 }
1023
1024 fn run_sync<F, Fut>(future: F) -> Fut::Output
1025 where
1026 F: FnOnce() -> Fut,
1027 Fut: std::future::Future,
1028 {
1029 tokio::runtime::Builder::new_current_thread()
1030 .enable_all()
1031 .build()
1032 .expect("rt")
1033 .block_on(future())
1034 }
1035
1036 #[test]
1037 fn http_ok_produces_tagged_envelope() {
1038 let body = VmValue::String(arcstr::ArcStr::from("hello"));
1039 let response = http_ok_impl(&[body], &mut String::new()).expect("ok");
1040 let map = dict(&response);
1041 assert_eq!(
1042 map.get(HTTP_RESPONSE_TAG_KEY).and_then(|v| match v {
1043 VmValue::String(s) => Some(s.as_str()),
1044 _ => None,
1045 }),
1046 Some(HTTP_RESPONSE_TAG_VERSION)
1047 );
1048 assert!(matches!(map.get("status"), Some(VmValue::Int(200))));
1049 assert_eq!(
1050 map.get("body").map(|v| v.display()).as_deref(),
1051 Some("hello")
1052 );
1053 }
1054
1055 #[test]
1056 fn http_created_sets_location_header() {
1057 let body = VmValue::dict(crate::value::DictMap::from_iter([(
1058 crate::value::intern_key("id"),
1059 VmValue::String(arcstr::ArcStr::from("sess_1")),
1060 )]));
1061 let location = VmValue::String(arcstr::ArcStr::from("/v1/sessions/sess_1"));
1062 let response = http_created_impl(&[body, location], &mut String::new()).expect("created");
1063 let map = dict(&response);
1064 assert!(matches!(map.get("status"), Some(VmValue::Int(201))));
1065 let headers = map
1066 .get("headers")
1067 .and_then(VmValue::as_dict)
1068 .expect("headers");
1069 assert_eq!(
1070 headers.get("Location").map(|v| v.display()).as_deref(),
1071 Some("/v1/sessions/sess_1")
1072 );
1073 }
1074
1075 #[test]
1076 fn http_no_content_omits_body_marker() {
1077 let response = http_no_content_impl(&[], &mut String::new()).expect("no_content");
1078 let map = dict(&response);
1079 assert!(matches!(map.get("status"), Some(VmValue::Int(204))));
1080 assert!(map.get("body").is_none());
1081 assert_eq!(
1082 map.get("body_kind").and_then(|v| match v {
1083 VmValue::String(s) => Some(s.as_str()),
1084 _ => None,
1085 }),
1086 Some(BODY_KIND_NONE)
1087 );
1088 }
1089
1090 #[test]
1091 fn http_error_carries_code_message_and_marker() {
1092 let response = http_error_impl(
1093 &[
1094 VmValue::Int(422),
1095 VmValue::String(arcstr::ArcStr::from("invalid_input")),
1096 VmValue::String(arcstr::ArcStr::from("bad payload")),
1097 VmValue::Nil,
1098 ],
1099 &mut String::new(),
1100 )
1101 .expect("error");
1102 let map = dict(&response);
1103 assert!(matches!(map.get("status"), Some(VmValue::Int(422))));
1104 assert!(matches!(map.get("is_error"), Some(VmValue::Bool(true))));
1105 let body = map
1106 .get("body")
1107 .and_then(VmValue::as_dict)
1108 .expect("body dict");
1109 assert_eq!(
1110 body.get("code").map(|v| v.display()).as_deref(),
1111 Some("invalid_input")
1112 );
1113 assert_eq!(
1114 body.get("message").map(|v| v.display()).as_deref(),
1115 Some("bad payload")
1116 );
1117 }
1118
1119 #[test]
1120 fn http_error_rejects_2xx_status() {
1121 let err = http_error_impl(
1122 &[
1123 VmValue::Int(200),
1124 VmValue::String(arcstr::ArcStr::from("x")),
1125 VmValue::String(arcstr::ArcStr::from("y")),
1126 ],
1127 &mut String::new(),
1128 )
1129 .expect_err("expected reject");
1130 match err {
1131 VmError::Thrown(VmValue::String(text)) => {
1132 assert!(text.contains("4xx or 5xx"), "got: {text}");
1133 }
1134 other => panic!("unexpected error: {other:?}"),
1135 }
1136 }
1137
1138 #[test]
1139 fn http_reply_rejects_out_of_range_status() {
1140 let err =
1141 http_reply_impl(&[VmValue::Int(999)], &mut String::new()).expect_err("out of range");
1142 match err {
1143 VmError::Thrown(VmValue::String(text)) => {
1144 assert!(text.contains("100-599"), "got: {text}");
1145 }
1146 other => panic!("unexpected error: {other:?}"),
1147 }
1148 }
1149
1150 #[test]
1151 fn http_reply_bytes_uses_bytes_body_kind() {
1152 let bytes = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1153 let headers = VmValue::dict(crate::value::DictMap::from_iter([(
1154 crate::value::intern_key("Content-Type"),
1155 VmValue::String(arcstr::ArcStr::from("application/octet-stream")),
1156 )]));
1157 let response =
1158 http_reply_impl(&[VmValue::Int(200), bytes, headers], &mut String::new()).unwrap();
1159 let map = dict(&response);
1160 assert_eq!(
1161 map.get("body_kind").and_then(|v| match v {
1162 VmValue::String(s) => Some(s.as_str()),
1163 _ => None,
1164 }),
1165 Some(BODY_KIND_BYTES)
1166 );
1167 assert!(matches!(map.get("body"), Some(VmValue::Bytes(_))));
1168 }
1169
1170 #[test]
1171 fn http_reply_from_wraps_stream_body_as_chunk_list() {
1172 let result = VmValue::dict(crate::value::DictMap::from_iter([
1173 (crate::value::intern_key("status"), VmValue::Int(202)),
1174 (
1175 crate::value::intern_key("body_kind"),
1176 VmValue::string("stream"),
1177 ),
1178 (
1179 crate::value::intern_key("headers"),
1180 VmValue::dict(crate::value::DictMap::from_iter([(
1181 crate::value::intern_key("Content-Type"),
1182 VmValue::string("text/plain"),
1183 )])),
1184 ),
1185 (crate::value::intern_key("body"), VmValue::string("queued")),
1186 ]));
1187
1188 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1189 let map = dict(&response);
1190 assert!(matches!(map.get("status"), Some(VmValue::Int(202))));
1191 assert_eq!(
1192 map.get("body_kind").and_then(|v| match v {
1193 VmValue::String(s) => Some(s.as_str()),
1194 _ => None,
1195 }),
1196 Some(BODY_KIND_STREAM)
1197 );
1198 let body = match map.get("body") {
1199 Some(VmValue::List(items)) => items,
1200 other => panic!("expected stream body chunk list, got {other:?}"),
1201 };
1202 assert_eq!(body.len(), 1);
1203 assert_eq!(body[0].display(), "queued");
1204 }
1205
1206 #[test]
1207 fn http_reply_from_preserves_existing_stream_chunks() {
1208 let chunks = VmValue::List(std::sync::Arc::new(vec![
1209 VmValue::string("alpha"),
1210 VmValue::string("bravo"),
1211 ]));
1212 let result = VmValue::dict(crate::value::DictMap::from_iter([
1213 (crate::value::intern_key("status"), VmValue::Int(200)),
1214 (
1215 crate::value::intern_key("body_kind"),
1216 VmValue::string("stream"),
1217 ),
1218 (crate::value::intern_key("body"), chunks),
1219 ]));
1220
1221 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1222 let map = dict(&response);
1223 let body = match map.get("body") {
1224 Some(VmValue::List(items)) => items,
1225 other => panic!("expected stream body chunk list, got {other:?}"),
1226 };
1227 assert_eq!(body.len(), 2);
1228 assert_eq!(body[0].display(), "alpha");
1229 assert_eq!(body[1].display(), "bravo");
1230 }
1231
1232 #[test]
1233 fn http_reply_from_preserves_raw_body_for_bytes_kind() {
1234 let raw = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1235 let result = VmValue::dict(crate::value::DictMap::from_iter([
1236 (crate::value::intern_key("status"), VmValue::Int(200)),
1237 (
1238 crate::value::intern_key("body_kind"),
1239 VmValue::string("bytes"),
1240 ),
1241 (crate::value::intern_key("body"), VmValue::string("<lossy>")),
1242 (crate::value::intern_key("raw_body"), raw),
1243 ]));
1244
1245 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1246 let map = dict(&response);
1247 assert_eq!(
1248 map.get("body_kind").and_then(|v| match v {
1249 VmValue::String(s) => Some(s.as_str()),
1250 _ => None,
1251 }),
1252 Some(BODY_KIND_BYTES)
1253 );
1254 match map.get("body") {
1255 Some(VmValue::Bytes(bytes)) => assert_eq!(bytes.as_ref(), &[0x00, 0xff, 0xfe, 0x80]),
1256 other => panic!("expected bytes body, got {other:?}"),
1257 }
1258 }
1259
1260 #[test]
1261 fn http_reply_from_rejects_non_bytes_for_bytes_kind() {
1262 let result = VmValue::dict(crate::value::DictMap::from_iter([
1263 (crate::value::intern_key("status"), VmValue::Int(200)),
1264 (
1265 crate::value::intern_key("body_kind"),
1266 VmValue::string("bytes"),
1267 ),
1268 (
1269 crate::value::intern_key("body"),
1270 VmValue::string("not bytes"),
1271 ),
1272 ]));
1273
1274 let err =
1275 http_reply_from_impl(&[result], &mut String::new()).expect_err("expected bytes error");
1276 match err {
1277 VmError::Thrown(VmValue::String(text)) => {
1278 assert!(text.contains("requires bytes"), "unexpected error: {text}");
1279 }
1280 other => panic!("unexpected error: {other:?}"),
1281 }
1282 }
1283
1284 #[test]
1285 fn http_reply_from_falls_back_to_http_reply_for_text_kind() {
1286 let result = VmValue::dict(crate::value::DictMap::from_iter([
1287 (crate::value::intern_key("status"), VmValue::Int(200)),
1288 (
1289 crate::value::intern_key("body_kind"),
1290 VmValue::string("text"),
1291 ),
1292 (crate::value::intern_key("body"), VmValue::string("hello")),
1293 ]));
1294
1295 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1296 let map = dict(&response);
1297 assert_eq!(
1298 map.get("body_kind").and_then(|v| match v {
1299 VmValue::String(s) => Some(s.as_str()),
1300 _ => None,
1301 }),
1302 Some(BODY_KIND_JSON)
1303 );
1304 assert_eq!(
1305 map.get("body").map(VmValue::display).as_deref(),
1306 Some("hello")
1307 );
1308 }
1309
1310 #[test]
1311 fn http_reply_from_rejects_non_dict_result() {
1312 let err = http_reply_from_impl(&[VmValue::string("nope")], &mut String::new())
1313 .expect_err("expected result type error");
1314 match err {
1315 VmError::Thrown(VmValue::String(text)) => {
1316 assert!(
1317 text.contains("result must be a dict"),
1318 "unexpected error: {text}"
1319 );
1320 }
1321 other => panic!("unexpected error: {other:?}"),
1322 }
1323 }
1324
1325 #[test]
1326 fn http_stream_buffers_list_source() {
1327 let items = vec![
1328 VmValue::String(arcstr::ArcStr::from("a")),
1329 VmValue::String(arcstr::ArcStr::from("b")),
1330 ];
1331 let response = run_sync(|| {
1332 http_stream_impl(
1333 crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1334 vec![
1335 VmValue::List(std::sync::Arc::new(items.clone())),
1336 VmValue::String(arcstr::ArcStr::from("text/plain")),
1337 ],
1338 )
1339 })
1340 .expect("stream");
1341 let map = dict(&response);
1342 assert_eq!(
1343 map.get("body_kind").and_then(|v| match v {
1344 VmValue::String(s) => Some(s.as_str()),
1345 _ => None,
1346 }),
1347 Some(BODY_KIND_STREAM)
1348 );
1349 let body = map.get("body").expect("body");
1350 match body {
1351 VmValue::List(values) => {
1352 assert_eq!(values.len(), 2);
1353 }
1354 other => panic!("expected list body, got {other:?}"),
1355 }
1356 let headers = map
1357 .get("headers")
1358 .and_then(VmValue::as_dict)
1359 .expect("headers");
1360 assert_eq!(
1361 headers.get("Content-Type").map(|v| v.display()).as_deref(),
1362 Some("text/plain")
1363 );
1364 }
1365
1366 #[test]
1367 fn http_sse_sets_event_stream_headers_and_optional_retry() {
1368 let events = vec![VmValue::dict(crate::value::DictMap::from_iter([(
1369 crate::value::intern_key("data"),
1370 VmValue::String(arcstr::ArcStr::from("ping")),
1371 )]))];
1372 let response = run_sync(|| {
1373 http_sse_impl(
1374 crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1375 vec![
1376 VmValue::List(std::sync::Arc::new(events.clone())),
1377 VmValue::Int(2500),
1378 ],
1379 )
1380 })
1381 .expect("sse");
1382 let map = dict(&response);
1383 let headers = map
1384 .get("headers")
1385 .and_then(VmValue::as_dict)
1386 .expect("headers");
1387 assert_eq!(
1388 headers.get("Content-Type").map(|v| v.display()).as_deref(),
1389 Some("text/event-stream")
1390 );
1391 assert_eq!(
1392 headers.get("Cache-Control").map(|v| v.display()).as_deref(),
1393 Some("no-cache")
1394 );
1395 assert!(matches!(map.get("retry_ms"), Some(VmValue::Int(2500))));
1396 }
1397
1398 #[test]
1399 fn parse_envelope_round_trip_through_json() {
1400 let response = http_error_impl(
1401 &[
1402 VmValue::Int(404),
1403 VmValue::String(arcstr::ArcStr::from("not_found")),
1404 VmValue::String(arcstr::ArcStr::from("missing")),
1405 VmValue::dict(crate::value::DictMap::from_iter([(
1406 crate::value::intern_key("id"),
1407 VmValue::String(arcstr::ArcStr::from("sess_404")),
1408 )])),
1409 ],
1410 &mut String::new(),
1411 )
1412 .expect("error");
1413 let json = vm_value_to_json(&response);
1414 let envelope = parse_envelope(&json).expect("envelope parses");
1415 assert_eq!(envelope.status, 404);
1416 assert!(envelope.is_error);
1417 let body = envelope.body.expect("body");
1418 assert_eq!(body["code"], "not_found");
1419 assert_eq!(body["details"]["id"], "sess_404");
1420 }
1421
1422 #[test]
1423 fn parse_envelope_ignores_untagged_dicts() {
1424 let plain = serde_json::json!({"status": 200, "body": {}});
1425 assert!(parse_envelope(&plain).is_none());
1426 }
1427
1428 #[test]
1429 fn http_etag_is_quoted_hex_sha256_of_payload() {
1430 let value = VmValue::String(arcstr::ArcStr::from("hello"));
1431 let etag = http_etag_impl(&[value], &mut String::new()).expect("etag");
1432 match etag {
1433 VmValue::String(text) => {
1434 assert_eq!(
1435 text.as_str(),
1436 "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\""
1437 );
1438 }
1439 other => panic!("expected string, got {other:?}"),
1440 }
1441 }
1442
1443 #[test]
1444 fn http_etag_stable_across_string_and_bytes_for_same_payload() {
1445 let from_string = http_etag_impl(
1446 &[VmValue::String(arcstr::ArcStr::from("hello"))],
1447 &mut String::new(),
1448 )
1449 .unwrap();
1450 let from_bytes = http_etag_impl(
1451 &[VmValue::Bytes(std::sync::Arc::new(b"hello".to_vec()))],
1452 &mut String::new(),
1453 )
1454 .unwrap();
1455 assert_eq!(from_string.display(), from_bytes.display());
1456 }
1457
1458 #[test]
1459 fn http_choose_returns_best_q_match() {
1460 let accept = VmValue::String(arcstr::ArcStr::from(
1461 "application/xml;q=0.5, application/json;q=0.9",
1462 ));
1463 let offers = VmValue::List(std::sync::Arc::new(vec![
1464 VmValue::String(arcstr::ArcStr::from("application/xml")),
1465 VmValue::String(arcstr::ArcStr::from("application/json")),
1466 ]));
1467 let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1468 assert_eq!(chosen.display(), "application/json");
1469 }
1470
1471 #[test]
1472 fn http_choose_prefers_specific_over_wildcard() {
1473 let accept = VmValue::String(arcstr::ArcStr::from("text/*;q=0.5, application/json"));
1474 let offers = VmValue::List(std::sync::Arc::new(vec![
1475 VmValue::String(arcstr::ArcStr::from("text/plain")),
1476 VmValue::String(arcstr::ArcStr::from("application/json")),
1477 ]));
1478 let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1479 assert_eq!(chosen.display(), "application/json");
1480 }
1481
1482 #[test]
1483 fn http_choose_returns_default_for_no_accept() {
1484 let offers = VmValue::List(std::sync::Arc::new(vec![
1485 VmValue::String(arcstr::ArcStr::from("text/plain")),
1486 VmValue::String(arcstr::ArcStr::from("application/json")),
1487 ]));
1488 let chosen = http_choose_impl(&[VmValue::Nil, offers], &mut String::new()).unwrap();
1489 assert_eq!(chosen.display(), "text/plain");
1490 }
1491
1492 #[test]
1493 fn http_choose_overrides_default_with_explicit() {
1494 let offers = VmValue::List(std::sync::Arc::new(vec![
1495 VmValue::String(arcstr::ArcStr::from("text/plain")),
1496 VmValue::String(arcstr::ArcStr::from("application/json")),
1497 ]));
1498 let chosen = http_choose_impl(
1499 &[
1500 VmValue::Nil,
1501 offers,
1502 VmValue::String(arcstr::ArcStr::from("application/json")),
1503 ],
1504 &mut String::new(),
1505 )
1506 .unwrap();
1507 assert_eq!(chosen.display(), "application/json");
1508 }
1509
1510 #[test]
1511 fn http_choose_wildcard_accept_yields_default() {
1512 let offers = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1513 arcstr::ArcStr::from("application/json"),
1514 )]));
1515 let chosen = http_choose_impl(
1516 &[VmValue::String(arcstr::ArcStr::from("*/*")), offers],
1517 &mut String::new(),
1518 )
1519 .unwrap();
1520 assert_eq!(chosen.display(), "application/json");
1521 }
1522
1523 #[test]
1524 fn http_not_modified_envelope_carries_etag() {
1525 let etag = VmValue::String(arcstr::ArcStr::from("\"abc\""));
1526 let response = http_not_modified_impl(&[etag, VmValue::Nil], &mut String::new()).unwrap();
1527 let map = dict(&response);
1528 assert!(matches!(map.get("status"), Some(VmValue::Int(304))));
1529 let headers = map
1530 .get("headers")
1531 .and_then(VmValue::as_dict)
1532 .expect("headers");
1533 assert_eq!(
1534 headers.get("ETag").map(|v| v.display()).as_deref(),
1535 Some("\"abc\"")
1536 );
1537 }
1538
1539 #[test]
1540 fn http_push_hints_appends_link_headers_with_inferred_as() {
1541 let envelope = http_ok_impl(
1542 &[VmValue::dict(crate::value::DictMap::new())],
1543 &mut String::new(),
1544 )
1545 .unwrap();
1546 let paths = VmValue::List(std::sync::Arc::new(vec![
1547 VmValue::String(arcstr::ArcStr::from("/main.css")),
1548 VmValue::String(arcstr::ArcStr::from("/app.js")),
1549 VmValue::String(arcstr::ArcStr::from("/hero.webp")),
1550 VmValue::String(arcstr::ArcStr::from("/inter.woff2")),
1551 VmValue::String(arcstr::ArcStr::from("/manifest.json")),
1552 VmValue::String(arcstr::ArcStr::from("/unknown.xyz")),
1553 ]));
1554 let response =
1555 http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1556 let map = dict(&response);
1557 let headers = map
1558 .get("headers")
1559 .and_then(VmValue::as_dict)
1560 .expect("headers");
1561 let links = match headers.get("Link") {
1562 Some(VmValue::List(items)) => items.clone(),
1563 other => panic!("Link should be a list, got {other:?}"),
1564 };
1565 let rendered: Vec<String> = links
1566 .iter()
1567 .map(|v| match v {
1568 VmValue::String(s) => s.to_string(),
1569 other => panic!("Link entry is not a string: {other:?}"),
1570 })
1571 .collect();
1572 assert_eq!(
1573 rendered,
1574 vec![
1575 "</main.css>; rel=preload; as=style",
1576 "</app.js>; rel=preload; as=script",
1577 "</hero.webp>; rel=preload; as=image",
1578 "</inter.woff2>; rel=preload; as=font",
1579 "</manifest.json>; rel=preload; as=fetch",
1580 "</unknown.xyz>; rel=preload",
1581 ]
1582 );
1583 }
1584
1585 #[test]
1586 fn http_push_hints_handles_querystring_in_path() {
1587 let envelope = http_ok_impl(&[VmValue::Nil], &mut String::new()).unwrap();
1588 let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1589 arcstr::ArcStr::from("/static/app.js?v=42"),
1590 )]));
1591 let response =
1592 http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1593 let map = dict(&response);
1594 let headers = map
1595 .get("headers")
1596 .and_then(VmValue::as_dict)
1597 .expect("headers");
1598 let links = match headers.get("Link") {
1599 Some(VmValue::List(items)) => items.clone(),
1600 other => panic!("Link should be a list, got {other:?}"),
1601 };
1602 assert_eq!(
1603 links[0].display(),
1604 "</static/app.js?v=42>; rel=preload; as=script"
1605 );
1606 }
1607
1608 #[test]
1609 fn http_push_hints_rejects_untagged_envelope() {
1610 let plain = VmValue::dict(crate::value::DictMap::from_iter([(
1611 crate::value::intern_key("status"),
1612 VmValue::Int(200),
1613 )]));
1614 let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1615 arcstr::ArcStr::from("/main.css"),
1616 )]));
1617 let result = http_push_hints_impl(&[plain, paths], &mut String::new());
1618 assert!(
1619 matches!(result, Err(VmError::Thrown(_))),
1620 "untagged dict should be rejected, got {result:?}"
1621 );
1622 }
1623
1624 #[test]
1625 fn http_push_hints_preserves_existing_link_header() {
1626 let envelope = http_reply_impl(
1627 &[
1628 VmValue::Int(200),
1629 VmValue::dict(crate::value::DictMap::new()),
1630 VmValue::dict(crate::value::DictMap::from_iter([(
1631 crate::value::intern_key("Link"),
1632 VmValue::String(arcstr::ArcStr::from("</legacy.css>; rel=preload; as=style")),
1633 )])),
1634 ],
1635 &mut String::new(),
1636 )
1637 .unwrap();
1638 let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1639 arcstr::ArcStr::from("/app.js"),
1640 )]));
1641 let response = http_push_hints_impl(&[envelope, paths], &mut String::new()).unwrap();
1642 let map = dict(&response);
1643 let headers = map
1644 .get("headers")
1645 .and_then(VmValue::as_dict)
1646 .expect("headers");
1647 let links = match headers.get("Link") {
1648 Some(VmValue::List(items)) => items.clone(),
1649 other => panic!("Link should be a list once preloads are added, got {other:?}"),
1650 };
1651 assert_eq!(links.len(), 2);
1652 assert_eq!(links[0].display(), "</legacy.css>; rel=preload; as=style");
1653 assert_eq!(links[1].display(), "</app.js>; rel=preload; as=script");
1654 }
1655
1656 #[test]
1657 fn http_upgrade_ws_envelope_negotiates_subprotocol() {
1658 let req = VmValue::dict(crate::value::DictMap::from_iter([(
1659 crate::value::intern_key("headers"),
1660 VmValue::dict(crate::value::DictMap::from_iter([(
1661 crate::value::intern_key("Sec-WebSocket-Protocol"),
1662 VmValue::String(arcstr::ArcStr::from("v0.harn, v1.harn")),
1663 )])),
1664 )]));
1665 let options = VmValue::dict(crate::value::DictMap::from_iter([(
1666 crate::value::intern_key("subprotocols"),
1667 VmValue::List(std::sync::Arc::new(vec![
1668 VmValue::String(arcstr::ArcStr::from("v1.harn")),
1669 VmValue::String(arcstr::ArcStr::from("v2.harn")),
1670 ])),
1671 )]));
1672 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1673 let map = dict(&response);
1674 assert!(matches!(map.get("status"), Some(VmValue::Int(101))));
1675 let upgrade = map
1676 .get("ws_upgrade")
1677 .and_then(VmValue::as_dict)
1678 .expect("ws_upgrade");
1679 assert_eq!(
1680 upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1681 Some("v1.harn")
1682 );
1683 let headers = map
1684 .get("headers")
1685 .and_then(VmValue::as_dict)
1686 .expect("headers");
1687 assert_eq!(
1688 headers.get("Upgrade").map(|v| v.display()).as_deref(),
1689 Some("websocket")
1690 );
1691 assert_eq!(
1692 headers
1693 .get("Sec-WebSocket-Protocol")
1694 .map(|v| v.display())
1695 .as_deref(),
1696 Some("v1.harn")
1697 );
1698 }
1699
1700 #[test]
1701 fn http_upgrade_ws_picks_client_preferred_when_both_overlap() {
1702 let req = VmValue::dict(crate::value::DictMap::from_iter([(
1711 crate::value::intern_key("headers"),
1712 VmValue::dict(crate::value::DictMap::from_iter([(
1713 crate::value::intern_key("Sec-WebSocket-Protocol"),
1714 VmValue::String(arcstr::ArcStr::from("v2.harn, v1.harn")),
1715 )])),
1716 )]));
1717 let options = VmValue::dict(crate::value::DictMap::from_iter([(
1718 crate::value::intern_key("subprotocols"),
1719 VmValue::List(std::sync::Arc::new(vec![
1720 VmValue::String(arcstr::ArcStr::from("v1.harn")),
1721 VmValue::String(arcstr::ArcStr::from("v2.harn")),
1722 ])),
1723 )]));
1724 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1725 let upgrade = dict(&response)
1726 .get("ws_upgrade")
1727 .and_then(VmValue::as_dict)
1728 .expect("ws_upgrade");
1729 assert_eq!(
1730 upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1731 Some("v2.harn")
1732 );
1733 }
1734
1735 #[test]
1736 fn parse_envelope_round_trips_ws_upgrade_marker() {
1737 let req = VmValue::dict(crate::value::DictMap::from_iter([(
1738 crate::value::intern_key("headers"),
1739 VmValue::dict(crate::value::DictMap::from_iter([(
1740 crate::value::intern_key("Sec-WebSocket-Protocol"),
1741 VmValue::String(arcstr::ArcStr::from("v1.harn")),
1742 )])),
1743 )]));
1744 let options = VmValue::dict(crate::value::DictMap::from_iter([
1745 (
1746 crate::value::intern_key("subprotocols"),
1747 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1748 arcstr::ArcStr::from("v1.harn"),
1749 )])),
1750 ),
1751 (
1752 crate::value::intern_key("idle_ping_ms"),
1753 VmValue::Int(15_000),
1754 ),
1755 ]));
1756 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1757 let json = vm_value_to_json(&response);
1758 let envelope = parse_envelope(&json).expect("envelope parses");
1759 let ws = envelope.ws_upgrade.expect("ws_upgrade present");
1760 assert_eq!(ws.subprotocol.as_deref(), Some("v1.harn"));
1761 assert_eq!(ws.offered, vec!["v1.harn"]);
1762 assert_eq!(ws.idle_ping_ms, Some(15_000));
1763 assert_eq!(envelope.status, 101);
1764 }
1765
1766 #[test]
1767 fn http_upgrade_ws_falls_through_when_no_subprotocols_offered() {
1768 let req = VmValue::dict(crate::value::DictMap::new());
1769 let response = http_upgrade_ws_impl(&[req], &mut String::new()).unwrap();
1770 let map = dict(&response);
1771 let upgrade = map
1772 .get("ws_upgrade")
1773 .and_then(VmValue::as_dict)
1774 .expect("ws_upgrade");
1775 assert!(matches!(upgrade.get("subprotocol"), Some(VmValue::Nil)));
1776 }
1777}