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 dot = pre_query.rfind('.')?;
799 let ext = &pre_query[dot + 1..];
800 Some(match ext.to_ascii_lowercase().as_str() {
801 "css" => "style",
802 "js" | "mjs" => "script",
803 "json" => "fetch",
804 "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "ico" => "image",
805 "woff" | "woff2" | "ttf" | "otf" => "font",
806 _ => return None,
807 })
808}
809
810#[harn_builtin(
811 exposure = "pure",
812 effects = [],
813 sig = "http_upgrade_ws(req: dict, options?: dict) -> dict",
814 category = "http_response"
815)]
816fn http_upgrade_ws_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
817 let req = args
818 .first()
819 .and_then(VmValue::as_dict)
820 .ok_or_else(|| thrown_err("http_upgrade_ws: req must be a dict"))?;
821 let options = args.get(1).and_then(VmValue::as_dict);
822
823 let request_subprotocols = req
824 .get("headers")
825 .and_then(VmValue::as_dict)
826 .and_then(|headers| header_lookup(headers, "sec-websocket-protocol"))
827 .map(|raw| {
828 raw.split(',')
829 .map(|s| s.trim().to_string())
830 .filter(|s| !s.is_empty())
831 .collect::<Vec<_>>()
832 })
833 .unwrap_or_default();
834 let offered_subprotocols = options
835 .and_then(|opts| opts.get("subprotocols"))
836 .and_then(|value| match value {
837 VmValue::List(items) => Some(
838 items
839 .iter()
840 .filter_map(|v| match v {
841 VmValue::String(s) => Some(s.to_string()),
842 _ => None,
843 })
844 .collect::<Vec<_>>(),
845 ),
846 _ => None,
847 })
848 .unwrap_or_default();
849
850 let negotiated = request_subprotocols
856 .iter()
857 .find(|client| offered_subprotocols.iter().any(|name| name == *client))
858 .cloned();
859
860 let mut headers = crate::value::DictMap::new();
861 headers.put_str("Upgrade", "websocket");
862 headers.put_str("Connection", "Upgrade");
863 if let Some(name) = &negotiated {
864 headers.put_str("Sec-WebSocket-Protocol", name.clone());
865 }
866
867 let idle_ping_ms = options
868 .and_then(|opts| opts.get("idle_ping_ms"))
869 .and_then(|v| v.as_int());
870 let max_message_bytes = options
871 .and_then(|opts| opts.get("max_message_bytes"))
872 .and_then(|v| v.as_int());
873 let on_message = options
874 .and_then(|opts| opts.get("on_message"))
875 .and_then(|v| match v {
876 VmValue::String(name) => Some(name.to_string()),
877 _ => None,
878 });
879
880 let mut env_map = envelope_map(101, VmValue::Nil, BODY_KIND_NONE, headers);
881 env_map.insert(
882 crate::value::intern_key("ws_upgrade"),
883 VmValue::dict({
884 let mut map = crate::value::DictMap::new();
885 map.insert(
886 crate::value::intern_key("subprotocol"),
887 match &negotiated {
888 Some(name) => VmValue::String(arcstr::ArcStr::from(name.clone())),
889 None => VmValue::Nil,
890 },
891 );
892 map.insert(
893 crate::value::intern_key("offered"),
894 VmValue::List(std::sync::Arc::new(
895 offered_subprotocols
896 .iter()
897 .map(|s| VmValue::String(arcstr::ArcStr::from(s.clone())))
898 .collect(),
899 )),
900 );
901 if let Some(ms) = idle_ping_ms {
902 map.insert(crate::value::intern_key("idle_ping_ms"), VmValue::Int(ms));
903 }
904 if let Some(bytes) = max_message_bytes {
905 map.insert(
906 crate::value::intern_key("max_message_bytes"),
907 VmValue::Int(bytes),
908 );
909 }
910 if let Some(handler) = &on_message {
911 map.put_str("on_message", handler.clone());
912 }
913 map
914 }),
915 );
916 Ok(VmValue::dict(env_map))
917}
918
919fn header_lookup(headers: &crate::value::DictMap, name: &str) -> Option<String> {
920 let needle = name.to_ascii_lowercase();
921 headers
922 .iter()
923 .find(|(key, _)| key.to_ascii_lowercase() == needle)
924 .and_then(|(_, value)| match value {
925 VmValue::String(text) => Some(text.to_string()),
926 _ => None,
927 })
928}
929
930fn value_as_bytes(value: &VmValue) -> Vec<u8> {
931 match value {
932 VmValue::Bytes(bytes) => bytes.as_ref().clone(),
933 VmValue::String(text) => text.as_bytes().to_vec(),
934 VmValue::Nil => Vec::new(),
935 other => crate::stdlib::json::vm_value_to_json(other).into_bytes(),
943 }
944}
945
946fn negotiate_accept(header: &str, offers: &[String]) -> Option<String> {
953 let ranges: Vec<MediaRange> = header
954 .split(',')
955 .filter_map(MediaRange::parse)
956 .filter(|range| range.q > 0.0)
957 .collect();
958 if ranges.is_empty() {
959 return None;
960 }
961
962 let mut best: Option<(usize, f32, u8)> = None;
963 for (index, offer) in offers.iter().enumerate() {
964 let (offer_type, offer_subtype) = split_media(offer)?;
965 for range in &ranges {
966 let score = range.match_score(offer_type, offer_subtype);
967 let Some(score) = score else { continue };
968 let q = range.q;
969 let candidate = (index, q, score);
970 best = Some(match best {
971 None => candidate,
972 Some(current) => {
973 if q > current.1
976 || (q == current.1 && score > current.2)
977 || (q == current.1 && score == current.2 && index < current.0)
978 {
979 candidate
980 } else {
981 current
982 }
983 }
984 });
985 }
986 }
987 best.map(|(index, _, _)| offers[index].clone())
988}
989
990struct MediaRange<'a> {
991 type_: &'a str,
992 subtype: &'a str,
993 q: f32,
994}
995
996impl<'a> MediaRange<'a> {
997 fn parse(raw: &'a str) -> Option<Self> {
998 let trimmed = raw.trim();
999 let mut parts = trimmed.split(';');
1000 let media = parts.next()?.trim();
1001 let (type_, subtype) = split_media(media)?;
1002 let mut q = 1.0;
1003 for param in parts {
1004 let param = param.trim();
1005 if let Some(value) = param
1006 .strip_prefix("q=")
1007 .or_else(|| param.strip_prefix("Q="))
1008 {
1009 if let Ok(parsed) = value.trim().parse::<f32>() {
1010 if (0.0..=1.0).contains(&parsed) {
1011 q = parsed;
1012 }
1013 }
1014 }
1015 }
1016 Some(Self { type_, subtype, q })
1017 }
1018
1019 fn match_score(&self, offer_type: &str, offer_subtype: &str) -> Option<u8> {
1020 let type_match = self.type_ == "*" || self.type_.eq_ignore_ascii_case(offer_type);
1021 let subtype_match = self.subtype == "*" || self.subtype.eq_ignore_ascii_case(offer_subtype);
1022 if !type_match || !subtype_match {
1023 return None;
1024 }
1025 Some(match (self.type_, self.subtype) {
1026 ("*", _) => 1,
1027 (_, "*") => 2,
1028 _ => 3,
1029 })
1030 }
1031}
1032
1033fn split_media(value: &str) -> Option<(&str, &str)> {
1034 let mut iter = value.splitn(2, '/');
1035 let type_ = iter.next()?.trim();
1036 let subtype = iter.next()?.trim();
1037 if type_.is_empty() || subtype.is_empty() {
1038 return None;
1039 }
1040 Some((type_, subtype))
1041}
1042
1043pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
1044 &HTTP_OK_IMPL_DEF,
1045 &HTTP_CREATED_IMPL_DEF,
1046 &HTTP_NO_CONTENT_IMPL_DEF,
1047 &HTTP_ERROR_IMPL_DEF,
1048 &HTTP_REPLY_IMPL_DEF,
1049 &HTTP_REPLY_FROM_IMPL_DEF,
1050 &HTTP_STREAM_IMPL_DEF,
1051 &HTTP_SSE_IMPL_DEF,
1052 &HTTP_ETAG_IMPL_DEF,
1053 &HTTP_CHOOSE_IMPL_DEF,
1054 &HTTP_NOT_MODIFIED_IMPL_DEF,
1055 &HTTP_PUSH_HINTS_IMPL_DEF,
1056 &HTTP_UPGRADE_WS_IMPL_DEF,
1057];
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062 use crate::llm::helpers::vm_value_to_json;
1063
1064 fn dict(value: &VmValue) -> &crate::value::DictMap {
1065 value.as_dict().expect("envelope is a dict")
1066 }
1067
1068 fn run_sync<F, Fut>(future: F) -> Fut::Output
1069 where
1070 F: FnOnce() -> Fut,
1071 Fut: std::future::Future,
1072 {
1073 tokio::runtime::Builder::new_current_thread()
1074 .enable_all()
1075 .build()
1076 .expect("rt")
1077 .block_on(future())
1078 }
1079
1080 #[test]
1081 fn http_ok_produces_tagged_envelope() {
1082 let body = VmValue::String(arcstr::ArcStr::from("hello"));
1083 let response = http_ok_impl(&[body], &mut String::new()).expect("ok");
1084 let map = dict(&response);
1085 assert_eq!(
1086 map.get(HTTP_RESPONSE_TAG_KEY).and_then(|v| match v {
1087 VmValue::String(s) => Some(s.as_str()),
1088 _ => None,
1089 }),
1090 Some(HTTP_RESPONSE_TAG_VERSION)
1091 );
1092 assert!(matches!(map.get("status"), Some(VmValue::Int(200))));
1093 assert_eq!(
1094 map.get("body").map(|v| v.display()).as_deref(),
1095 Some("hello")
1096 );
1097 }
1098
1099 #[test]
1100 fn http_created_sets_location_header() {
1101 let body = VmValue::dict(crate::value::DictMap::from_iter([(
1102 crate::value::intern_key("id"),
1103 VmValue::String(arcstr::ArcStr::from("sess_1")),
1104 )]));
1105 let location = VmValue::String(arcstr::ArcStr::from("/v1/sessions/sess_1"));
1106 let response = http_created_impl(&[body, location], &mut String::new()).expect("created");
1107 let map = dict(&response);
1108 assert!(matches!(map.get("status"), Some(VmValue::Int(201))));
1109 let headers = map
1110 .get("headers")
1111 .and_then(VmValue::as_dict)
1112 .expect("headers");
1113 assert_eq!(
1114 headers.get("Location").map(|v| v.display()).as_deref(),
1115 Some("/v1/sessions/sess_1")
1116 );
1117 }
1118
1119 #[test]
1120 fn http_no_content_omits_body_marker() {
1121 let response = http_no_content_impl(&[], &mut String::new()).expect("no_content");
1122 let map = dict(&response);
1123 assert!(matches!(map.get("status"), Some(VmValue::Int(204))));
1124 assert!(map.get("body").is_none());
1125 assert_eq!(
1126 map.get("body_kind").and_then(|v| match v {
1127 VmValue::String(s) => Some(s.as_str()),
1128 _ => None,
1129 }),
1130 Some(BODY_KIND_NONE)
1131 );
1132 }
1133
1134 #[test]
1135 fn http_error_carries_code_message_and_marker() {
1136 let response = http_error_impl(
1137 &[
1138 VmValue::Int(422),
1139 VmValue::String(arcstr::ArcStr::from("invalid_input")),
1140 VmValue::String(arcstr::ArcStr::from("bad payload")),
1141 VmValue::Nil,
1142 ],
1143 &mut String::new(),
1144 )
1145 .expect("error");
1146 let map = dict(&response);
1147 assert!(matches!(map.get("status"), Some(VmValue::Int(422))));
1148 assert!(matches!(map.get("is_error"), Some(VmValue::Bool(true))));
1149 let body = map
1150 .get("body")
1151 .and_then(VmValue::as_dict)
1152 .expect("body dict");
1153 assert_eq!(
1154 body.get("code").map(|v| v.display()).as_deref(),
1155 Some("invalid_input")
1156 );
1157 assert_eq!(
1158 body.get("message").map(|v| v.display()).as_deref(),
1159 Some("bad payload")
1160 );
1161 }
1162
1163 #[test]
1164 fn http_error_rejects_2xx_status() {
1165 let err = http_error_impl(
1166 &[
1167 VmValue::Int(200),
1168 VmValue::String(arcstr::ArcStr::from("x")),
1169 VmValue::String(arcstr::ArcStr::from("y")),
1170 ],
1171 &mut String::new(),
1172 )
1173 .expect_err("expected reject");
1174 match err {
1175 VmError::Thrown(VmValue::String(text)) => {
1176 assert!(text.contains("4xx or 5xx"), "got: {text}");
1177 }
1178 other => panic!("unexpected error: {other:?}"),
1179 }
1180 }
1181
1182 #[test]
1183 fn http_reply_rejects_out_of_range_status() {
1184 let err =
1185 http_reply_impl(&[VmValue::Int(999)], &mut String::new()).expect_err("out of range");
1186 match err {
1187 VmError::Thrown(VmValue::String(text)) => {
1188 assert!(text.contains("100-599"), "got: {text}");
1189 }
1190 other => panic!("unexpected error: {other:?}"),
1191 }
1192 }
1193
1194 #[test]
1195 fn http_reply_bytes_uses_bytes_body_kind() {
1196 let bytes = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1197 let headers = VmValue::dict(crate::value::DictMap::from_iter([(
1198 crate::value::intern_key("Content-Type"),
1199 VmValue::String(arcstr::ArcStr::from("application/octet-stream")),
1200 )]));
1201 let response =
1202 http_reply_impl(&[VmValue::Int(200), bytes, headers], &mut String::new()).unwrap();
1203 let map = dict(&response);
1204 assert_eq!(
1205 map.get("body_kind").and_then(|v| match v {
1206 VmValue::String(s) => Some(s.as_str()),
1207 _ => None,
1208 }),
1209 Some(BODY_KIND_BYTES)
1210 );
1211 assert!(matches!(map.get("body"), Some(VmValue::Bytes(_))));
1212 }
1213
1214 #[test]
1215 fn http_reply_from_wraps_stream_body_as_chunk_list() {
1216 let result = VmValue::dict(crate::value::DictMap::from_iter([
1217 (crate::value::intern_key("status"), VmValue::Int(202)),
1218 (
1219 crate::value::intern_key("body_kind"),
1220 VmValue::string("stream"),
1221 ),
1222 (
1223 crate::value::intern_key("headers"),
1224 VmValue::dict(crate::value::DictMap::from_iter([(
1225 crate::value::intern_key("Content-Type"),
1226 VmValue::string("text/plain"),
1227 )])),
1228 ),
1229 (crate::value::intern_key("body"), VmValue::string("queued")),
1230 ]));
1231
1232 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1233 let map = dict(&response);
1234 assert!(matches!(map.get("status"), Some(VmValue::Int(202))));
1235 assert_eq!(
1236 map.get("body_kind").and_then(|v| match v {
1237 VmValue::String(s) => Some(s.as_str()),
1238 _ => None,
1239 }),
1240 Some(BODY_KIND_STREAM)
1241 );
1242 let body = match map.get("body") {
1243 Some(VmValue::List(items)) => items,
1244 other => panic!("expected stream body chunk list, got {other:?}"),
1245 };
1246 assert_eq!(body.len(), 1);
1247 assert_eq!(body[0].display(), "queued");
1248 }
1249
1250 #[test]
1251 fn http_reply_from_preserves_existing_stream_chunks() {
1252 let chunks = VmValue::List(std::sync::Arc::new(vec![
1253 VmValue::string("alpha"),
1254 VmValue::string("bravo"),
1255 ]));
1256 let result = VmValue::dict(crate::value::DictMap::from_iter([
1257 (crate::value::intern_key("status"), VmValue::Int(200)),
1258 (
1259 crate::value::intern_key("body_kind"),
1260 VmValue::string("stream"),
1261 ),
1262 (crate::value::intern_key("body"), chunks),
1263 ]));
1264
1265 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1266 let map = dict(&response);
1267 let body = match map.get("body") {
1268 Some(VmValue::List(items)) => items,
1269 other => panic!("expected stream body chunk list, got {other:?}"),
1270 };
1271 assert_eq!(body.len(), 2);
1272 assert_eq!(body[0].display(), "alpha");
1273 assert_eq!(body[1].display(), "bravo");
1274 }
1275
1276 #[test]
1277 fn http_reply_from_preserves_raw_body_for_bytes_kind() {
1278 let raw = VmValue::Bytes(std::sync::Arc::new(vec![0x00, 0xff, 0xfe, 0x80]));
1279 let result = VmValue::dict(crate::value::DictMap::from_iter([
1280 (crate::value::intern_key("status"), VmValue::Int(200)),
1281 (
1282 crate::value::intern_key("body_kind"),
1283 VmValue::string("bytes"),
1284 ),
1285 (crate::value::intern_key("body"), VmValue::string("<lossy>")),
1286 (crate::value::intern_key("raw_body"), raw),
1287 ]));
1288
1289 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1290 let map = dict(&response);
1291 assert_eq!(
1292 map.get("body_kind").and_then(|v| match v {
1293 VmValue::String(s) => Some(s.as_str()),
1294 _ => None,
1295 }),
1296 Some(BODY_KIND_BYTES)
1297 );
1298 match map.get("body") {
1299 Some(VmValue::Bytes(bytes)) => assert_eq!(bytes.as_ref(), &[0x00, 0xff, 0xfe, 0x80]),
1300 other => panic!("expected bytes body, got {other:?}"),
1301 }
1302 }
1303
1304 #[test]
1305 fn http_reply_from_rejects_non_bytes_for_bytes_kind() {
1306 let result = VmValue::dict(crate::value::DictMap::from_iter([
1307 (crate::value::intern_key("status"), VmValue::Int(200)),
1308 (
1309 crate::value::intern_key("body_kind"),
1310 VmValue::string("bytes"),
1311 ),
1312 (
1313 crate::value::intern_key("body"),
1314 VmValue::string("not bytes"),
1315 ),
1316 ]));
1317
1318 let err =
1319 http_reply_from_impl(&[result], &mut String::new()).expect_err("expected bytes error");
1320 match err {
1321 VmError::Thrown(VmValue::String(text)) => {
1322 assert!(text.contains("requires bytes"), "unexpected error: {text}");
1323 }
1324 other => panic!("unexpected error: {other:?}"),
1325 }
1326 }
1327
1328 #[test]
1329 fn http_reply_from_falls_back_to_http_reply_for_text_kind() {
1330 let result = VmValue::dict(crate::value::DictMap::from_iter([
1331 (crate::value::intern_key("status"), VmValue::Int(200)),
1332 (
1333 crate::value::intern_key("body_kind"),
1334 VmValue::string("text"),
1335 ),
1336 (crate::value::intern_key("body"), VmValue::string("hello")),
1337 ]));
1338
1339 let response = http_reply_from_impl(&[result], &mut String::new()).unwrap();
1340 let map = dict(&response);
1341 assert_eq!(
1342 map.get("body_kind").and_then(|v| match v {
1343 VmValue::String(s) => Some(s.as_str()),
1344 _ => None,
1345 }),
1346 Some(BODY_KIND_JSON)
1347 );
1348 assert_eq!(
1349 map.get("body").map(VmValue::display).as_deref(),
1350 Some("hello")
1351 );
1352 }
1353
1354 #[test]
1355 fn http_reply_from_rejects_non_dict_result() {
1356 let err = http_reply_from_impl(&[VmValue::string("nope")], &mut String::new())
1357 .expect_err("expected result type error");
1358 match err {
1359 VmError::Thrown(VmValue::String(text)) => {
1360 assert!(
1361 text.contains("result must be a dict"),
1362 "unexpected error: {text}"
1363 );
1364 }
1365 other => panic!("unexpected error: {other:?}"),
1366 }
1367 }
1368
1369 #[test]
1370 fn http_stream_buffers_list_source() {
1371 let items = vec![
1372 VmValue::String(arcstr::ArcStr::from("a")),
1373 VmValue::String(arcstr::ArcStr::from("b")),
1374 ];
1375 let response = run_sync(|| {
1376 http_stream_impl(
1377 crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1378 vec![
1379 VmValue::List(std::sync::Arc::new(items.clone())),
1380 VmValue::String(arcstr::ArcStr::from("text/plain")),
1381 ],
1382 )
1383 })
1384 .expect("stream");
1385 let map = dict(&response);
1386 assert_eq!(
1387 map.get("body_kind").and_then(|v| match v {
1388 VmValue::String(s) => Some(s.as_str()),
1389 _ => None,
1390 }),
1391 Some(BODY_KIND_STREAM)
1392 );
1393 let body = map.get("body").expect("body");
1394 match body {
1395 VmValue::List(values) => {
1396 assert_eq!(values.len(), 2);
1397 }
1398 other => panic!("expected list body, got {other:?}"),
1399 }
1400 let headers = map
1401 .get("headers")
1402 .and_then(VmValue::as_dict)
1403 .expect("headers");
1404 assert_eq!(
1405 headers.get("Content-Type").map(|v| v.display()).as_deref(),
1406 Some("text/plain")
1407 );
1408 }
1409
1410 #[test]
1411 fn http_sse_sets_event_stream_headers_and_optional_retry() {
1412 let events = vec![VmValue::dict(crate::value::DictMap::from_iter([(
1413 crate::value::intern_key("data"),
1414 VmValue::String(arcstr::ArcStr::from("ping")),
1415 )]))];
1416 let response = run_sync(|| {
1417 http_sse_impl(
1418 crate::vm::AsyncBuiltinCtx::for_test(Vm::new()),
1419 vec![
1420 VmValue::List(std::sync::Arc::new(events.clone())),
1421 VmValue::Int(2500),
1422 ],
1423 )
1424 })
1425 .expect("sse");
1426 let map = dict(&response);
1427 let headers = map
1428 .get("headers")
1429 .and_then(VmValue::as_dict)
1430 .expect("headers");
1431 assert_eq!(
1432 headers.get("Content-Type").map(|v| v.display()).as_deref(),
1433 Some("text/event-stream")
1434 );
1435 assert_eq!(
1436 headers.get("Cache-Control").map(|v| v.display()).as_deref(),
1437 Some("no-cache")
1438 );
1439 assert!(matches!(map.get("retry_ms"), Some(VmValue::Int(2500))));
1440 }
1441
1442 #[test]
1443 fn parse_envelope_round_trip_through_json() {
1444 let response = http_error_impl(
1445 &[
1446 VmValue::Int(404),
1447 VmValue::String(arcstr::ArcStr::from("not_found")),
1448 VmValue::String(arcstr::ArcStr::from("missing")),
1449 VmValue::dict(crate::value::DictMap::from_iter([(
1450 crate::value::intern_key("id"),
1451 VmValue::String(arcstr::ArcStr::from("sess_404")),
1452 )])),
1453 ],
1454 &mut String::new(),
1455 )
1456 .expect("error");
1457 let json = vm_value_to_json(&response);
1458 let envelope = parse_envelope(&json).expect("envelope parses");
1459 assert_eq!(envelope.status, 404);
1460 assert!(envelope.is_error);
1461 let body = envelope.body.expect("body");
1462 assert_eq!(body["code"], "not_found");
1463 assert_eq!(body["details"]["id"], "sess_404");
1464 }
1465
1466 #[test]
1467 fn parse_envelope_ignores_untagged_dicts() {
1468 let plain = serde_json::json!({"status": 200, "body": {}});
1469 assert!(parse_envelope(&plain).is_none());
1470 }
1471
1472 #[test]
1473 fn http_etag_is_quoted_hex_sha256_of_payload() {
1474 let value = VmValue::String(arcstr::ArcStr::from("hello"));
1475 let etag = http_etag_impl(&[value], &mut String::new()).expect("etag");
1476 match etag {
1477 VmValue::String(text) => {
1478 assert_eq!(
1479 text.as_str(),
1480 "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\""
1481 );
1482 }
1483 other => panic!("expected string, got {other:?}"),
1484 }
1485 }
1486
1487 #[test]
1488 fn http_etag_stable_across_string_and_bytes_for_same_payload() {
1489 let from_string = http_etag_impl(
1490 &[VmValue::String(arcstr::ArcStr::from("hello"))],
1491 &mut String::new(),
1492 )
1493 .unwrap();
1494 let from_bytes = http_etag_impl(
1495 &[VmValue::Bytes(std::sync::Arc::new(b"hello".to_vec()))],
1496 &mut String::new(),
1497 )
1498 .unwrap();
1499 assert_eq!(from_string.display(), from_bytes.display());
1500 }
1501
1502 #[test]
1503 fn http_choose_returns_best_q_match() {
1504 let accept = VmValue::String(arcstr::ArcStr::from(
1505 "application/xml;q=0.5, application/json;q=0.9",
1506 ));
1507 let offers = VmValue::List(std::sync::Arc::new(vec![
1508 VmValue::String(arcstr::ArcStr::from("application/xml")),
1509 VmValue::String(arcstr::ArcStr::from("application/json")),
1510 ]));
1511 let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1512 assert_eq!(chosen.display(), "application/json");
1513 }
1514
1515 #[test]
1516 fn http_choose_prefers_specific_over_wildcard() {
1517 let accept = VmValue::String(arcstr::ArcStr::from("text/*;q=0.5, application/json"));
1518 let offers = VmValue::List(std::sync::Arc::new(vec![
1519 VmValue::String(arcstr::ArcStr::from("text/plain")),
1520 VmValue::String(arcstr::ArcStr::from("application/json")),
1521 ]));
1522 let chosen = http_choose_impl(&[accept, offers], &mut String::new()).unwrap();
1523 assert_eq!(chosen.display(), "application/json");
1524 }
1525
1526 #[test]
1527 fn http_choose_returns_default_for_no_accept() {
1528 let offers = VmValue::List(std::sync::Arc::new(vec![
1529 VmValue::String(arcstr::ArcStr::from("text/plain")),
1530 VmValue::String(arcstr::ArcStr::from("application/json")),
1531 ]));
1532 let chosen = http_choose_impl(&[VmValue::Nil, offers], &mut String::new()).unwrap();
1533 assert_eq!(chosen.display(), "text/plain");
1534 }
1535
1536 #[test]
1537 fn http_choose_overrides_default_with_explicit() {
1538 let offers = VmValue::List(std::sync::Arc::new(vec![
1539 VmValue::String(arcstr::ArcStr::from("text/plain")),
1540 VmValue::String(arcstr::ArcStr::from("application/json")),
1541 ]));
1542 let chosen = http_choose_impl(
1543 &[
1544 VmValue::Nil,
1545 offers,
1546 VmValue::String(arcstr::ArcStr::from("application/json")),
1547 ],
1548 &mut String::new(),
1549 )
1550 .unwrap();
1551 assert_eq!(chosen.display(), "application/json");
1552 }
1553
1554 #[test]
1555 fn http_choose_wildcard_accept_yields_default() {
1556 let offers = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1557 arcstr::ArcStr::from("application/json"),
1558 )]));
1559 let chosen = http_choose_impl(
1560 &[VmValue::String(arcstr::ArcStr::from("*/*")), offers],
1561 &mut String::new(),
1562 )
1563 .unwrap();
1564 assert_eq!(chosen.display(), "application/json");
1565 }
1566
1567 #[test]
1568 fn http_not_modified_envelope_carries_etag() {
1569 let etag = VmValue::String(arcstr::ArcStr::from("\"abc\""));
1570 let response = http_not_modified_impl(&[etag, VmValue::Nil], &mut String::new()).unwrap();
1571 let map = dict(&response);
1572 assert!(matches!(map.get("status"), Some(VmValue::Int(304))));
1573 let headers = map
1574 .get("headers")
1575 .and_then(VmValue::as_dict)
1576 .expect("headers");
1577 assert_eq!(
1578 headers.get("ETag").map(|v| v.display()).as_deref(),
1579 Some("\"abc\"")
1580 );
1581 }
1582
1583 #[test]
1584 fn http_push_hints_appends_link_headers_with_inferred_as() {
1585 let envelope = http_ok_impl(
1586 &[VmValue::dict(crate::value::DictMap::new())],
1587 &mut String::new(),
1588 )
1589 .unwrap();
1590 let paths = VmValue::List(std::sync::Arc::new(vec![
1591 VmValue::String(arcstr::ArcStr::from("/main.css")),
1592 VmValue::String(arcstr::ArcStr::from("/app.js")),
1593 VmValue::String(arcstr::ArcStr::from("/hero.webp")),
1594 VmValue::String(arcstr::ArcStr::from("/inter.woff2")),
1595 VmValue::String(arcstr::ArcStr::from("/manifest.json")),
1596 VmValue::String(arcstr::ArcStr::from("/unknown.xyz")),
1597 ]));
1598 let response =
1599 http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1600 let map = dict(&response);
1601 let headers = map
1602 .get("headers")
1603 .and_then(VmValue::as_dict)
1604 .expect("headers");
1605 let links = match headers.get("Link") {
1606 Some(VmValue::List(items)) => items.clone(),
1607 other => panic!("Link should be a list, got {other:?}"),
1608 };
1609 let rendered: Vec<String> = links
1610 .iter()
1611 .map(|v| match v {
1612 VmValue::String(s) => s.to_string(),
1613 other => panic!("Link entry is not a string: {other:?}"),
1614 })
1615 .collect();
1616 assert_eq!(
1617 rendered,
1618 vec![
1619 "</main.css>; rel=preload; as=style",
1620 "</app.js>; rel=preload; as=script",
1621 "</hero.webp>; rel=preload; as=image",
1622 "</inter.woff2>; rel=preload; as=font",
1623 "</manifest.json>; rel=preload; as=fetch",
1624 "</unknown.xyz>; rel=preload",
1625 ]
1626 );
1627 }
1628
1629 #[test]
1630 fn http_push_hints_handles_querystring_in_path() {
1631 let envelope = http_ok_impl(&[VmValue::Nil], &mut String::new()).unwrap();
1632 let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1633 arcstr::ArcStr::from("/static/app.js?v=42"),
1634 )]));
1635 let response =
1636 http_push_hints_impl(&[envelope, paths], &mut String::new()).expect("push_hints");
1637 let map = dict(&response);
1638 let headers = map
1639 .get("headers")
1640 .and_then(VmValue::as_dict)
1641 .expect("headers");
1642 let links = match headers.get("Link") {
1643 Some(VmValue::List(items)) => items.clone(),
1644 other => panic!("Link should be a list, got {other:?}"),
1645 };
1646 assert_eq!(
1647 links[0].display(),
1648 "</static/app.js?v=42>; rel=preload; as=script"
1649 );
1650 }
1651
1652 #[test]
1653 fn http_push_hints_rejects_untagged_envelope() {
1654 let plain = VmValue::dict(crate::value::DictMap::from_iter([(
1655 crate::value::intern_key("status"),
1656 VmValue::Int(200),
1657 )]));
1658 let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1659 arcstr::ArcStr::from("/main.css"),
1660 )]));
1661 let result = http_push_hints_impl(&[plain, paths], &mut String::new());
1662 assert!(
1663 matches!(result, Err(VmError::Thrown(_))),
1664 "untagged dict should be rejected, got {result:?}"
1665 );
1666 }
1667
1668 #[test]
1669 fn http_push_hints_preserves_existing_link_header() {
1670 let envelope = http_reply_impl(
1671 &[
1672 VmValue::Int(200),
1673 VmValue::dict(crate::value::DictMap::new()),
1674 VmValue::dict(crate::value::DictMap::from_iter([(
1675 crate::value::intern_key("Link"),
1676 VmValue::String(arcstr::ArcStr::from("</legacy.css>; rel=preload; as=style")),
1677 )])),
1678 ],
1679 &mut String::new(),
1680 )
1681 .unwrap();
1682 let paths = VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1683 arcstr::ArcStr::from("/app.js"),
1684 )]));
1685 let response = http_push_hints_impl(&[envelope, paths], &mut String::new()).unwrap();
1686 let map = dict(&response);
1687 let headers = map
1688 .get("headers")
1689 .and_then(VmValue::as_dict)
1690 .expect("headers");
1691 let links = match headers.get("Link") {
1692 Some(VmValue::List(items)) => items.clone(),
1693 other => panic!("Link should be a list once preloads are added, got {other:?}"),
1694 };
1695 assert_eq!(links.len(), 2);
1696 assert_eq!(links[0].display(), "</legacy.css>; rel=preload; as=style");
1697 assert_eq!(links[1].display(), "</app.js>; rel=preload; as=script");
1698 }
1699
1700 #[test]
1701 fn http_upgrade_ws_envelope_negotiates_subprotocol() {
1702 let req = VmValue::dict(crate::value::DictMap::from_iter([(
1703 crate::value::intern_key("headers"),
1704 VmValue::dict(crate::value::DictMap::from_iter([(
1705 crate::value::intern_key("Sec-WebSocket-Protocol"),
1706 VmValue::String(arcstr::ArcStr::from("v0.harn, v1.harn")),
1707 )])),
1708 )]));
1709 let options = VmValue::dict(crate::value::DictMap::from_iter([(
1710 crate::value::intern_key("subprotocols"),
1711 VmValue::List(std::sync::Arc::new(vec![
1712 VmValue::String(arcstr::ArcStr::from("v1.harn")),
1713 VmValue::String(arcstr::ArcStr::from("v2.harn")),
1714 ])),
1715 )]));
1716 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1717 let map = dict(&response);
1718 assert!(matches!(map.get("status"), Some(VmValue::Int(101))));
1719 let upgrade = map
1720 .get("ws_upgrade")
1721 .and_then(VmValue::as_dict)
1722 .expect("ws_upgrade");
1723 assert_eq!(
1724 upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1725 Some("v1.harn")
1726 );
1727 let headers = map
1728 .get("headers")
1729 .and_then(VmValue::as_dict)
1730 .expect("headers");
1731 assert_eq!(
1732 headers.get("Upgrade").map(|v| v.display()).as_deref(),
1733 Some("websocket")
1734 );
1735 assert_eq!(
1736 headers
1737 .get("Sec-WebSocket-Protocol")
1738 .map(|v| v.display())
1739 .as_deref(),
1740 Some("v1.harn")
1741 );
1742 }
1743
1744 #[test]
1745 fn http_upgrade_ws_picks_client_preferred_when_both_overlap() {
1746 let req = VmValue::dict(crate::value::DictMap::from_iter([(
1755 crate::value::intern_key("headers"),
1756 VmValue::dict(crate::value::DictMap::from_iter([(
1757 crate::value::intern_key("Sec-WebSocket-Protocol"),
1758 VmValue::String(arcstr::ArcStr::from("v2.harn, v1.harn")),
1759 )])),
1760 )]));
1761 let options = VmValue::dict(crate::value::DictMap::from_iter([(
1762 crate::value::intern_key("subprotocols"),
1763 VmValue::List(std::sync::Arc::new(vec![
1764 VmValue::String(arcstr::ArcStr::from("v1.harn")),
1765 VmValue::String(arcstr::ArcStr::from("v2.harn")),
1766 ])),
1767 )]));
1768 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1769 let upgrade = dict(&response)
1770 .get("ws_upgrade")
1771 .and_then(VmValue::as_dict)
1772 .expect("ws_upgrade");
1773 assert_eq!(
1774 upgrade.get("subprotocol").map(|v| v.display()).as_deref(),
1775 Some("v2.harn")
1776 );
1777 }
1778
1779 #[test]
1780 fn parse_envelope_round_trips_ws_upgrade_marker() {
1781 let req = VmValue::dict(crate::value::DictMap::from_iter([(
1782 crate::value::intern_key("headers"),
1783 VmValue::dict(crate::value::DictMap::from_iter([(
1784 crate::value::intern_key("Sec-WebSocket-Protocol"),
1785 VmValue::String(arcstr::ArcStr::from("v1.harn")),
1786 )])),
1787 )]));
1788 let options = VmValue::dict(crate::value::DictMap::from_iter([
1789 (
1790 crate::value::intern_key("subprotocols"),
1791 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1792 arcstr::ArcStr::from("v1.harn"),
1793 )])),
1794 ),
1795 (
1796 crate::value::intern_key("idle_ping_ms"),
1797 VmValue::Int(15_000),
1798 ),
1799 ]));
1800 let response = http_upgrade_ws_impl(&[req, options], &mut String::new()).unwrap();
1801 let json = vm_value_to_json(&response);
1802 let envelope = parse_envelope(&json).expect("envelope parses");
1803 let ws = envelope.ws_upgrade.expect("ws_upgrade present");
1804 assert_eq!(ws.subprotocol.as_deref(), Some("v1.harn"));
1805 assert_eq!(ws.offered, vec!["v1.harn"]);
1806 assert_eq!(ws.idle_ping_ms, Some(15_000));
1807 assert_eq!(envelope.status, 101);
1808 }
1809
1810 #[test]
1811 fn http_upgrade_ws_falls_through_when_no_subprotocols_offered() {
1812 let req = VmValue::dict(crate::value::DictMap::new());
1813 let response = http_upgrade_ws_impl(&[req], &mut String::new()).unwrap();
1814 let map = dict(&response);
1815 let upgrade = map
1816 .get("ws_upgrade")
1817 .and_then(VmValue::as_dict)
1818 .expect("ws_upgrade");
1819 assert!(matches!(upgrade.get("subprotocol"), Some(VmValue::Nil)));
1820 }
1821}