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