we (web engine): Experimental web browser project to understand the limits of Claude
at encoding-sniffing 710 lines 25 kB view raw
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}