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