we (web engine): Experimental web browser project to understand the limits of Claude
1//! Fetch API: `fetch()`, Response, Headers.
2//!
3//! Network I/O runs on background threads; the event loop polls for completed
4//! requests and resolves/rejects the returned promises.
5
6use std::cell::RefCell;
7use std::sync::{Arc, Mutex};
8
9use crate::builtins::{
10 create_promise_object_pub, make_native, reject_promise_internal, resolve_promise_internal,
11 set_builtin_prop,
12};
13use crate::gc::{Gc, GcRef};
14use crate::vm::*;
15
16// ── Pending fetch state ─────────────────────────────────────────
17
18/// Result of a completed HTTP fetch.
19pub struct FetchResult {
20 pub status: u16,
21 pub status_text: String,
22 pub headers: Vec<(String, String)>,
23 pub body: Vec<u8>,
24 pub url: String,
25}
26
27/// A fetch that is in-flight or just completed.
28struct PendingFetch {
29 /// The promise returned to JS.
30 promise: GcRef,
31 /// Shared slot: the background thread writes the result here.
32 result: Arc<Mutex<Option<Result<FetchResult, String>>>>,
33}
34
35thread_local! {
36 static FETCH_STATE: RefCell<Vec<PendingFetch>> = const { RefCell::new(Vec::new()) };
37}
38
39/// Reset fetch state (useful for tests).
40pub fn reset_fetch_state() {
41 FETCH_STATE.with(|s| s.borrow_mut().clear());
42}
43
44/// Returns true if there are any in-flight fetches.
45pub fn has_pending_fetches() -> bool {
46 FETCH_STATE.with(|s| !s.borrow().is_empty())
47}
48
49/// Collect GC roots for pending fetch promises.
50pub fn fetch_gc_roots() -> Vec<GcRef> {
51 FETCH_STATE.with(|s| s.borrow().iter().map(|f| f.promise).collect())
52}
53
54/// A completed fetch ready to be resolved.
55pub struct CompletedFetch {
56 pub promise: GcRef,
57 pub result: Result<FetchResult, String>,
58}
59
60/// Take all completed fetches (leaving in-flight ones).
61pub fn take_completed_fetches() -> Vec<CompletedFetch> {
62 FETCH_STATE.with(|s| {
63 let mut state = s.borrow_mut();
64 let mut completed = Vec::new();
65 let mut still_pending = Vec::new();
66
67 for pending in state.drain(..) {
68 let taken = pending.result.lock().unwrap().take();
69 if let Some(result) = taken {
70 completed.push(CompletedFetch {
71 promise: pending.promise,
72 result,
73 });
74 } else {
75 still_pending.push(pending);
76 }
77 }
78
79 *state = still_pending;
80 completed
81 })
82}
83
84// ── fetch() native function ─────────────────────────────────────
85
86/// `fetch(url, options?)` — initiate an HTTP request, return Promise<Response>.
87pub fn fetch_native(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
88 // Parse URL argument.
89 let url_str = match args.first() {
90 Some(v) => v.to_js_string(ctx.gc),
91 None => {
92 return Err(RuntimeError::type_error(
93 "fetch requires at least one argument",
94 ))
95 }
96 };
97
98 // Parse options (method, headers, body).
99 let mut method = "GET".to_string();
100 let mut req_headers: Vec<(String, String)> = Vec::new();
101 let mut body: Option<Vec<u8>> = None;
102
103 if let Some(Value::Object(opts_ref)) = args.get(1) {
104 if let Some(HeapObject::Object(data)) = ctx.gc.get(*opts_ref) {
105 // method
106 if let Some(prop) = data.properties.get("method") {
107 if let Value::String(m) = &prop.value {
108 method = m.to_uppercase();
109 }
110 }
111 // body
112 if let Some(prop) = data.properties.get("body") {
113 if let Value::String(b) = &prop.value {
114 body = Some(b.as_bytes().to_vec());
115 }
116 }
117 // headers — read from nested object
118 if let Some(prop) = data.properties.get("headers") {
119 if let Some(hdrs_ref) = prop.value.gc_ref() {
120 if let Some(HeapObject::Object(hdr_data)) = ctx.gc.get(hdrs_ref) {
121 for (key, hdr_prop) in &hdr_data.properties {
122 if hdr_prop.enumerable {
123 if let Value::String(v) = &hdr_prop.value {
124 req_headers.push((key.clone(), v.clone()));
125 }
126 }
127 }
128 }
129 }
130 }
131 }
132 }
133
134 // Create a pending promise.
135 let promise = create_promise_object_pub(ctx.gc);
136
137 // Shared slot for the result.
138 let result_slot: Arc<Mutex<Option<Result<FetchResult, String>>>> = Arc::new(Mutex::new(None));
139 let slot_clone = result_slot.clone();
140
141 // Register the pending fetch.
142 FETCH_STATE.with(|s| {
143 s.borrow_mut().push(PendingFetch {
144 promise,
145 result: result_slot,
146 });
147 });
148
149 // Spawn a background thread for the network I/O.
150 std::thread::spawn(move || {
151 let result = do_fetch(&url_str, &method, &req_headers, body.as_deref());
152 let mut lock = slot_clone.lock().unwrap();
153 *lock = Some(result);
154 });
155
156 Ok(Value::Object(promise))
157}
158
159/// Perform the actual HTTP fetch (runs on a background thread).
160fn do_fetch(
161 url_str: &str,
162 method: &str,
163 headers: &[(String, String)],
164 body: Option<&[u8]>,
165) -> Result<FetchResult, String> {
166 let url = we_url::Url::parse(url_str).map_err(|e| format!("Invalid URL: {e}"))?;
167
168 let mut req_headers = we_net::http::Headers::new();
169 for (key, val) in headers {
170 req_headers.add(key, val);
171 }
172
173 let http_method = match method {
174 "GET" => we_net::http::Method::Get,
175 "POST" => we_net::http::Method::Post,
176 "PUT" => we_net::http::Method::Put,
177 "DELETE" => we_net::http::Method::Delete,
178 "HEAD" => we_net::http::Method::Head,
179 "OPTIONS" => we_net::http::Method::Options,
180 "PATCH" => we_net::http::Method::Patch,
181 other => return Err(format!("Unsupported HTTP method: {other}")),
182 };
183
184 let mut client = we_net::client::HttpClient::new();
185 let response = client
186 .request(http_method, &url, &req_headers, body)
187 .map_err(|e| format!("Network error: {e}"))?;
188
189 let resp_headers: Vec<(String, String)> = response
190 .headers
191 .iter()
192 .map(|(k, v)| (k.to_string(), v.to_string()))
193 .collect();
194
195 Ok(FetchResult {
196 status: response.status_code,
197 status_text: response.reason,
198 headers: resp_headers,
199 body: response.body,
200 url: url.serialize(),
201 })
202}
203
204// ── Response object creation ────────────────────────────────────
205
206/// Create a JS Response object from a completed fetch result.
207pub fn create_response_object(gc: &mut Gc<HeapObject>, result: &FetchResult) -> GcRef {
208 let mut data = ObjectData::new();
209
210 data.properties.insert(
211 "status".to_string(),
212 Property::data(Value::Number(result.status as f64)),
213 );
214 data.properties.insert(
215 "statusText".to_string(),
216 Property::data(Value::String(result.status_text.clone())),
217 );
218 data.properties.insert(
219 "ok".to_string(),
220 Property::data(Value::Boolean((200..300).contains(&result.status))),
221 );
222 data.properties.insert(
223 "url".to_string(),
224 Property::data(Value::String(result.url.clone())),
225 );
226
227 // Store body as hidden property for text()/json().
228 let body_str = String::from_utf8_lossy(&result.body).to_string();
229 data.properties.insert(
230 "__body__".to_string(),
231 Property::builtin(Value::String(body_str)),
232 );
233
234 let resp_ref = gc.alloc(HeapObject::Object(data));
235
236 // Create Headers object.
237 let headers_ref = create_headers_object(gc, &result.headers);
238 set_builtin_prop(gc, resp_ref, "headers", Value::Object(headers_ref));
239
240 // Register methods.
241 let text_fn = make_native(gc, "text", response_text);
242 set_builtin_prop(gc, resp_ref, "text", Value::Function(text_fn));
243
244 let json_fn = make_native(gc, "json", response_json);
245 set_builtin_prop(gc, resp_ref, "json", Value::Function(json_fn));
246
247 resp_ref
248}
249
250/// `response.text()` — returns Promise<String> with the response body.
251fn response_text(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
252 let body_str = get_hidden_body(ctx.gc, &ctx.this);
253 let promise = create_promise_object_pub(ctx.gc);
254 resolve_promise_internal(ctx.gc, promise, Value::String(body_str));
255 Ok(Value::Object(promise))
256}
257
258/// `response.json()` — returns Promise<Object> with parsed JSON body.
259fn response_json(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
260 let body_str = get_hidden_body(ctx.gc, &ctx.this);
261 let promise = create_promise_object_pub(ctx.gc);
262 let json_args = [Value::String(body_str)];
263 match crate::builtins::json_parse_pub(&json_args, ctx) {
264 Ok(parsed) => resolve_promise_internal(ctx.gc, promise, parsed),
265 Err(e) => {
266 let err_val = Value::String(e.to_string());
267 reject_promise_internal(ctx.gc, promise, err_val);
268 }
269 }
270 Ok(Value::Object(promise))
271}
272
273/// Read the hidden `__body__` string from a Response object.
274fn get_hidden_body(gc: &Gc<HeapObject>, this: &Value) -> String {
275 if let Some(obj_ref) = this.gc_ref() {
276 if let Some(HeapObject::Object(data)) = gc.get(obj_ref) {
277 if let Some(prop) = data.properties.get("__body__") {
278 if let Value::String(s) = &prop.value {
279 return s.clone();
280 }
281 }
282 }
283 }
284 String::new()
285}
286
287// ── Headers object ──────────────────────────────────────────────
288
289/// Create a JS Headers object from response headers.
290///
291/// Headers are stored as hidden indexed properties for case-insensitive
292/// lookup. Duplicate header names are combined with ", " per HTTP spec.
293fn create_headers_object(gc: &mut Gc<HeapObject>, headers: &[(String, String)]) -> GcRef {
294 // Combine headers with same name (case-insensitive).
295 let mut combined: Vec<(String, String)> = Vec::new();
296 for (name, value) in headers {
297 let lower = name.to_lowercase();
298 if let Some(existing) = combined.iter_mut().find(|(n, _)| *n == lower) {
299 existing.1 = format!("{}, {value}", existing.1);
300 } else {
301 combined.push((lower, value.clone()));
302 }
303 }
304
305 let mut data = ObjectData::new();
306 data.properties.insert(
307 "__hdr_count__".to_string(),
308 Property::builtin(Value::Number(combined.len() as f64)),
309 );
310 for (i, (name, value)) in combined.iter().enumerate() {
311 data.properties.insert(
312 format!("__hdr_{i}_n__"),
313 Property::builtin(Value::String(name.clone())),
314 );
315 data.properties.insert(
316 format!("__hdr_{i}_v__"),
317 Property::builtin(Value::String(value.clone())),
318 );
319 }
320
321 let obj_ref = gc.alloc(HeapObject::Object(data));
322
323 let get_fn = make_native(gc, "get", headers_get);
324 set_builtin_prop(gc, obj_ref, "get", Value::Function(get_fn));
325
326 let has_fn = make_native(gc, "has", headers_has);
327 set_builtin_prop(gc, obj_ref, "has", Value::Function(has_fn));
328
329 let set_fn = make_native(gc, "set", headers_set);
330 set_builtin_prop(gc, obj_ref, "set", Value::Function(set_fn));
331
332 let delete_fn = make_native(gc, "delete", headers_delete);
333 set_builtin_prop(gc, obj_ref, "delete", Value::Function(delete_fn));
334
335 obj_ref
336}
337
338/// Read header entries from a Headers object: returns (count, Vec<(name, value)>).
339fn read_header_entries(gc: &Gc<HeapObject>, obj_ref: GcRef) -> Vec<(String, String)> {
340 let mut entries = Vec::new();
341 if let Some(HeapObject::Object(data)) = gc.get(obj_ref) {
342 let count = data
343 .properties
344 .get("__hdr_count__")
345 .and_then(|p| {
346 if let Value::Number(n) = &p.value {
347 Some(*n as usize)
348 } else {
349 None
350 }
351 })
352 .unwrap_or(0);
353
354 for i in 0..count {
355 let name = data
356 .properties
357 .get(&format!("__hdr_{i}_n__"))
358 .and_then(|p| {
359 if let Value::String(s) = &p.value {
360 Some(s.clone())
361 } else {
362 None
363 }
364 })
365 .unwrap_or_default();
366 let value = data
367 .properties
368 .get(&format!("__hdr_{i}_v__"))
369 .and_then(|p| {
370 if let Value::String(s) = &p.value {
371 Some(s.clone())
372 } else {
373 None
374 }
375 })
376 .unwrap_or_default();
377 entries.push((name, value));
378 }
379 }
380 entries
381}
382
383/// `headers.get(name)` — case-insensitive lookup, returns string or null.
384fn headers_get(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
385 let name = match args.first() {
386 Some(Value::String(s)) => s.to_lowercase(),
387 Some(v) => v.to_js_string(ctx.gc).to_lowercase(),
388 None => return Ok(Value::Null),
389 };
390 let obj_ref = match ctx.this.gc_ref() {
391 Some(r) => r,
392 None => return Ok(Value::Null),
393 };
394 let entries = read_header_entries(ctx.gc, obj_ref);
395 for (n, v) in entries {
396 if n == name {
397 return Ok(Value::String(v));
398 }
399 }
400 Ok(Value::Null)
401}
402
403/// `headers.has(name)` — case-insensitive check.
404fn headers_has(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
405 let name = match args.first() {
406 Some(Value::String(s)) => s.to_lowercase(),
407 Some(v) => v.to_js_string(ctx.gc).to_lowercase(),
408 None => return Ok(Value::Boolean(false)),
409 };
410 let obj_ref = match ctx.this.gc_ref() {
411 Some(r) => r,
412 None => return Ok(Value::Boolean(false)),
413 };
414 let entries = read_header_entries(ctx.gc, obj_ref);
415 Ok(Value::Boolean(entries.iter().any(|(n, _)| n == &name)))
416}
417
418/// `headers.set(name, value)` — set or replace a header.
419fn headers_set(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
420 let name = match args.first() {
421 Some(Value::String(s)) => s.to_lowercase(),
422 Some(v) => v.to_js_string(ctx.gc).to_lowercase(),
423 None => return Ok(Value::Undefined),
424 };
425 let value = match args.get(1) {
426 Some(Value::String(s)) => s.clone(),
427 Some(v) => v.to_js_string(ctx.gc),
428 None => String::new(),
429 };
430 let obj_ref = match ctx.this.gc_ref() {
431 Some(r) => r,
432 None => return Ok(Value::Undefined),
433 };
434 let mut entries = read_header_entries(ctx.gc, obj_ref);
435 let mut found = false;
436 for entry in &mut entries {
437 if entry.0 == name {
438 entry.1 = value.clone();
439 found = true;
440 break;
441 }
442 }
443 if !found {
444 entries.push((name, value));
445 }
446 write_header_entries(ctx.gc, obj_ref, &entries);
447 Ok(Value::Undefined)
448}
449
450/// `headers.delete(name)` — remove a header.
451fn headers_delete(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
452 let name = match args.first() {
453 Some(Value::String(s)) => s.to_lowercase(),
454 Some(v) => v.to_js_string(ctx.gc).to_lowercase(),
455 None => return Ok(Value::Undefined),
456 };
457 let obj_ref = match ctx.this.gc_ref() {
458 Some(r) => r,
459 None => return Ok(Value::Undefined),
460 };
461 let mut entries = read_header_entries(ctx.gc, obj_ref);
462 entries.retain(|(n, _)| n != &name);
463 write_header_entries(ctx.gc, obj_ref, &entries);
464 Ok(Value::Undefined)
465}
466
467/// Rewrite header entries into a Headers object.
468fn write_header_entries(gc: &mut Gc<HeapObject>, obj_ref: GcRef, entries: &[(String, String)]) {
469 if let Some(HeapObject::Object(data)) = gc.get_mut(obj_ref) {
470 // Remove old header entries.
471 data.properties.retain(|k, _| !k.starts_with("__hdr_"));
472
473 // Write new entries.
474 data.properties.insert(
475 "__hdr_count__".to_string(),
476 Property::builtin(Value::Number(entries.len() as f64)),
477 );
478 for (i, (name, value)) in entries.iter().enumerate() {
479 data.properties.insert(
480 format!("__hdr_{i}_n__"),
481 Property::builtin(Value::String(name.clone())),
482 );
483 data.properties.insert(
484 format!("__hdr_{i}_v__"),
485 Property::builtin(Value::String(value.clone())),
486 );
487 }
488 }
489}
490
491// ── Registration ────────────────────────────────────────────────
492
493/// Register the `fetch` global function in the VM.
494pub fn init_fetch_api(vm: &mut Vm) {
495 let fetch_fn = make_native(&mut vm.gc, "fetch", fetch_native);
496 vm.set_global("fetch", Value::Function(fetch_fn));
497}
498
499// ── Tests ───────────────────────────────────────────────────────
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::builtins;
505 use crate::compiler;
506 use crate::parser::Parser;
507 use std::cell::RefCell;
508 use std::rc::Rc;
509
510 struct CapturedConsole {
511 log_messages: RefCell<Vec<String>>,
512 }
513
514 impl CapturedConsole {
515 fn new() -> Self {
516 Self {
517 log_messages: RefCell::new(Vec::new()),
518 }
519 }
520 }
521
522 impl ConsoleOutput for CapturedConsole {
523 fn log(&self, message: &str) {
524 self.log_messages.borrow_mut().push(message.to_string());
525 }
526 fn error(&self, _message: &str) {}
527 fn warn(&self, _message: &str) {}
528 }
529
530 struct RcConsole(Rc<CapturedConsole>);
531
532 impl ConsoleOutput for RcConsole {
533 fn log(&self, message: &str) {
534 self.0.log(message);
535 }
536 fn error(&self, message: &str) {
537 self.0.error(message);
538 }
539 fn warn(&self, message: &str) {
540 self.0.warn(message);
541 }
542 }
543
544 #[test]
545 fn test_fetch_returns_promise() {
546 reset_fetch_state();
547 crate::timers::reset_timers();
548
549 let source = r#"typeof fetch"#;
550 let program = Parser::parse(source).expect("parse failed");
551 let func = compiler::compile(&program).expect("compile failed");
552 let mut vm = Vm::new();
553 init_fetch_api(&mut vm);
554 let result = vm.execute(&func).expect("execute failed");
555 assert_eq!(result.to_js_string(&vm.gc), "function");
556 }
557
558 #[test]
559 fn test_fetch_returns_promise_object() {
560 reset_fetch_state();
561 crate::timers::reset_timers();
562
563 // fetch() should return something (a promise) even if the URL is bogus.
564 // We can't easily test network fetches in unit tests, so we test the
565 // synchronous parts: argument parsing, promise creation.
566 let source = r#"
567 var p = fetch("http://0.0.0.0:1/nonexistent");
568 typeof p
569 "#;
570 let program = Parser::parse(source).expect("parse failed");
571 let func = compiler::compile(&program).expect("compile failed");
572 let mut vm = Vm::new();
573 init_fetch_api(&mut vm);
574 let result = vm.execute(&func).expect("execute failed");
575 assert_eq!(result.to_js_string(&vm.gc), "object");
576 }
577
578 #[test]
579 fn test_fetch_no_args_error() {
580 reset_fetch_state();
581 crate::timers::reset_timers();
582
583 let source = r#"
584 try {
585 fetch();
586 "no error";
587 } catch(e) {
588 "caught: " + e.message;
589 }
590 "#;
591 let program = Parser::parse(source).expect("parse failed");
592 let func = compiler::compile(&program).expect("compile failed");
593 let mut vm = Vm::new();
594 init_fetch_api(&mut vm);
595 let result = vm.execute(&func).expect("execute failed");
596 let s = result.to_js_string(&vm.gc);
597 assert!(
598 s.contains("fetch requires"),
599 "expected error about missing args, got: {s}"
600 );
601 }
602
603 /// Execute JS with fetch API, pump event loop, return console logs.
604 fn eval_with_fetch(source: &str, max_iterations: usize) -> Vec<String> {
605 reset_fetch_state();
606 crate::timers::reset_timers();
607 let console = Rc::new(CapturedConsole::new());
608 let program = Parser::parse(source).expect("parse failed");
609 let func = compiler::compile(&program).expect("compile failed");
610 let mut vm = Vm::new();
611 init_fetch_api(&mut vm);
612 vm.set_console_output(Box::new(RcConsole(console.clone())));
613 vm.execute(&func).expect("execute failed");
614 vm.run_event_loop(max_iterations)
615 .expect("event loop failed");
616 let result = console.log_messages.borrow().clone();
617 result
618 }
619
620 #[test]
621 fn test_fetch_rejects_on_network_error() {
622 // Connect to a port that almost certainly won't be listening.
623 let logs = eval_with_fetch(
624 r#"
625 fetch("http://127.0.0.1:1/fail").then(
626 function(resp) { console.log("resolved: " + resp.status); },
627 function(err) { console.log("rejected"); }
628 );
629 "#,
630 200,
631 );
632 assert_eq!(logs, vec!["rejected"]);
633 }
634
635 #[test]
636 fn test_response_object_properties() {
637 // Test that create_response_object creates the right structure.
638 let mut gc = crate::gc::Gc::new();
639 builtins::init_promise_proto_for_test(&mut gc);
640
641 let result = FetchResult {
642 status: 200,
643 status_text: "OK".to_string(),
644 headers: vec![
645 ("Content-Type".to_string(), "text/plain".to_string()),
646 ("X-Custom".to_string(), "hello".to_string()),
647 ],
648 body: b"hello world".to_vec(),
649 url: "http://example.com/".to_string(),
650 };
651
652 let resp_ref = create_response_object(&mut gc, &result);
653 let resp_obj = gc.get(resp_ref).unwrap();
654 if let HeapObject::Object(data) = resp_obj {
655 // Check status
656 assert!(matches!(
657 data.properties.get("status").map(|p| &p.value),
658 Some(Value::Number(n)) if *n == 200.0
659 ));
660 // Check ok
661 assert!(matches!(
662 data.properties.get("ok").map(|p| &p.value),
663 Some(Value::Boolean(true))
664 ));
665 // Check statusText
666 assert!(matches!(
667 data.properties.get("statusText").map(|p| &p.value),
668 Some(Value::String(s)) if s == "OK"
669 ));
670 // Check url
671 assert!(matches!(
672 data.properties.get("url").map(|p| &p.value),
673 Some(Value::String(s)) if s == "http://example.com/"
674 ));
675 } else {
676 panic!("expected Object");
677 }
678 }
679
680 #[test]
681 fn test_headers_get_case_insensitive() {
682 let mut gc = crate::gc::Gc::new();
683 let headers = vec![
684 ("Content-Type".to_string(), "text/html".to_string()),
685 ("X-Foo".to_string(), "bar".to_string()),
686 ];
687 let hdr_ref = create_headers_object(&mut gc, &headers);
688 let entries = read_header_entries(&gc, hdr_ref);
689 assert_eq!(entries.len(), 2);
690 // All stored lowercased
691 assert!(entries
692 .iter()
693 .any(|(n, v)| n == "content-type" && v == "text/html"));
694 assert!(entries.iter().any(|(n, v)| n == "x-foo" && v == "bar"));
695 }
696
697 #[test]
698 fn test_headers_duplicate_combining() {
699 let mut gc = crate::gc::Gc::new();
700 let headers = vec![
701 ("Set-Cookie".to_string(), "a=1".to_string()),
702 ("Set-Cookie".to_string(), "b=2".to_string()),
703 ];
704 let hdr_ref = create_headers_object(&mut gc, &headers);
705 let entries = read_header_entries(&gc, hdr_ref);
706 assert_eq!(entries.len(), 1);
707 assert_eq!(entries[0].0, "set-cookie");
708 assert_eq!(entries[0].1, "a=1, b=2");
709 }
710}