diff --git a/crates/perry-ext-http-server/src/request.rs b/crates/perry-ext-http-server/src/request.rs index e1872af61e..5fa5e627be 100644 --- a/crates/perry-ext-http-server/src/request.rs +++ b/crates/perry-ext-http-server/src/request.rs @@ -249,11 +249,79 @@ pub(crate) fn incoming_http_version_part(handle: i64, minor: bool) -> f64 { #[no_mangle] pub extern "C" fn js_node_http_im_headers_json(handle: i64) -> *mut StringHeader { let s = get_handle::(handle) - .map(|im| serde_json::to_string(&im.headers).unwrap_or_else(|_| "{}".to_string())) + .map(|im| combined_headers_json(&im.raw_headers)) .unwrap_or_else(|| "{}".to_string()); alloc_string(&s).as_raw() } +/// Single-value request headers: per Node's `_http_incoming.js` +/// `matchKnownFields`, duplicates of these are discarded (first wins) +/// rather than joined with `, `. `set-cookie` is excluded — it always +/// accumulates into an array. +fn is_single_value_header(name: &str) -> bool { + matches!( + name, + "age" + | "authorization" + | "content-length" + | "content-type" + | "etag" + | "expires" + | "from" + | "host" + | "if-modified-since" + | "if-unmodified-since" + | "last-modified" + | "location" + | "max-forwards" + | "proxy-authorization" + | "referer" + | "retry-after" + | "server" + | "user-agent" + ) +} + +/// Build the combined `req.headers` JSON object from the raw +/// `(name, value)` pairs, applying Node's `matchKnownFields` rules +/// (#5079): `set-cookie` → string array (even for one cookie), +/// single-value fields keep-first, everything else joined with `, `. +/// Keys are lower-cased to match Node's `headers` view. +fn combined_headers_json(raw: &[(String, String)]) -> String { + use serde_json::Value; + // Key order in the serialized object is not significant here (the + // previous `HashMap` serialization was already unordered); what + // matters is that `set-cookie` surfaces as an array and other + // duplicates combine per Node's rules. + let mut map = serde_json::Map::new(); + for (name, value) in raw { + let key = name.to_ascii_lowercase(); + if key == "set-cookie" { + match map.get_mut(&key) { + Some(Value::Array(arr)) => arr.push(Value::String(value.clone())), + _ => { + map.insert(key, Value::Array(vec![Value::String(value.clone())])); + } + } + continue; + } + match map.get_mut(&key) { + Some(Value::String(existing)) => { + if !is_single_value_header(&key) { + // Node's `matchKnownFields`: duplicate `cookie` headers are + // joined with "; ", everything else with ", ". + existing.push_str(if key == "cookie" { "; " } else { ", " }); + existing.push_str(value); + } + } + _ => { + map.insert(key, Value::String(value.clone())); + } + } + } + serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string()) +} + /// `req.rawHeaders` — JSON-stringify the original-case `[name, value, ...]` /// flat list (alternating). TS-side reconstructs an array of strings. #[no_mangle] diff --git a/crates/perry-ext-http/src/client_events.rs b/crates/perry-ext-http/src/client_events.rs index fd372ddf80..7e3e675be6 100644 --- a/crates/perry-ext-http/src/client_events.rs +++ b/crates/perry-ext-http/src/client_events.rs @@ -118,10 +118,6 @@ pub(crate) unsafe fn handle_response_event( .map(|r| r.response_callback) .unwrap_or(0); - let mut headers_map = HashMap::new(); - for (k, v) in headers { - headers_map.insert(k, v); - } let mut trailers_map = HashMap::new(); for (k, v) in trailers { trailers_map.insert(k, v); @@ -131,7 +127,7 @@ pub(crate) unsafe fn handle_response_event( let incoming = register_handle(IncomingMessageHandle { status_code: status, status_message, - headers: headers_map, + headers, trailers: trailers_map, body, listeners: HashMap::new(), @@ -222,14 +218,10 @@ pub(crate) unsafe fn handle_response_head_event( return; } - let mut headers_map = HashMap::new(); - for (k, v) in headers { - headers_map.insert(k, v); - } let incoming = register_handle(IncomingMessageHandle { status_code: status, status_message, - headers: headers_map, + headers, trailers: HashMap::new(), body: Vec::new(), listeners: HashMap::new(), diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index fee6f316f4..efb5f726ab 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -87,6 +87,9 @@ use validation::{validate_client_options, validate_client_url_string}; // failure, …) into the Node `Error` shape (`.code`/`.syscall`/`.errno`). mod transport_error; +mod response_headers; +use response_headers::build_response_headers_object; + use lazy_static::lazy_static; use perry_ffi::{ alloc_string, gc_register_mutable_root_scanner_named, get_handle_mut, iter_handles_of_mut, @@ -327,7 +330,12 @@ unsafe impl Sync for ClientRequestHandle {} pub struct IncomingMessageHandle { pub status_code: u16, pub status_message: String, - pub headers: HashMap, + /// Raw `(name, value)` header pairs in arrival order, multiplicity + /// preserved. The combined `res.headers` view (Node's + /// `matchKnownFields` rules: `set-cookie` → array, single-value + /// fields keep-first, everything else joined with `, `) is built + /// lazily in [`build_response_headers_object`] (#5079). + pub headers: Vec<(String, String)>, pub trailers: HashMap, pub body: Vec, pub listeners: HashMap>, @@ -1449,7 +1457,7 @@ pub extern "C" fn js_http_status_message(handle: Handle) -> *mut StringHeader { pub extern "C" fn js_http_response_headers(handle: Handle) -> f64 { let mut out = f64::from_bits(TAG_UNDEFINED); with_handle_mut::(handle, |res| { - out = map_to_js_object(&res.headers); + out = build_response_headers_object(&res.headers); }); if out.to_bits() == TAG_UNDEFINED { if let Some(server_out) = server_incoming_property(handle, "headers") { diff --git a/crates/perry-ext-http/src/response_headers.rs b/crates/perry-ext-http/src/response_headers.rs new file mode 100644 index 0000000000..0468936456 --- /dev/null +++ b/crates/perry-ext-http/src/response_headers.rs @@ -0,0 +1,120 @@ +//! Combined `IncomingMessage.headers` view construction (#5079) — split out +//! of `lib.rs` to keep that file under the 2000-line file-size cap. +//! +//! Applies Node's `_http_incoming.js` `matchKnownFields` rules to the raw +//! `(name, value)` header pairs: `set-cookie` always becomes a string array, +//! a small set of single-value headers keep the first value, `cookie` +//! duplicates join with `"; "`, and everything else joins with `", "`. + +use std::collections::HashMap; + +use perry_ffi::{alloc_string, JsValue, ObjectHeader}; + +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + +/// Single-value response headers: per Node's `_http_incoming.js` +/// `matchKnownFields`, a duplicate of any of these is discarded (the +/// first value wins) rather than joined with `, `. `set-cookie` is not +/// in this list — it always accumulates into an array. +fn is_single_value_header(name: &str) -> bool { + matches!( + name, + "age" + | "authorization" + | "content-length" + | "content-type" + | "etag" + | "expires" + | "from" + | "host" + | "if-modified-since" + | "if-unmodified-since" + | "last-modified" + | "location" + | "max-forwards" + | "proxy-authorization" + | "referer" + | "retry-after" + | "server" + | "user-agent" + ) +} + +/// Build the combined `IncomingMessage.headers` object from the raw +/// `(name, value)` pairs, applying Node's `matchKnownFields` rules +/// (#5079): +/// +/// * `set-cookie` → **always** a string array, even for one cookie; +/// * single-value fields ([`is_single_value_header`]) → first value wins; +/// * `cookie` → duplicates joined with `; `; +/// * everything else → duplicates joined with `, `. +/// +/// Header names are lower-cased, matching Node's `headers` view. +pub(crate) fn build_response_headers_object(raw: &[(String, String)]) -> f64 { + let mut out = f64::from_bits(TAG_UNDEFINED); + + // Insertion-ordered accumulation. `set_cookie` is collected + // separately so a single cookie still surfaces as an array. + let mut order: Vec = Vec::new(); + let mut combined: HashMap = HashMap::new(); + let mut set_cookie: Vec = Vec::new(); + let mut saw_set_cookie = false; + + for (name, value) in raw { + let key = name.to_ascii_lowercase(); + if key == "set-cookie" { + if !saw_set_cookie { + saw_set_cookie = true; + order.push(key); + } + set_cookie.push(value.clone()); + continue; + } + match combined.get_mut(&key) { + Some(existing) => { + if !is_single_value_header(&key) { + // Node's `matchKnownFields`: duplicate `cookie` headers join + // with "; ", everything else with ", ". + existing.push_str(if key == "cookie" { "; " } else { ", " }); + existing.push_str(value); + } + } + None => { + order.push(key.clone()); + combined.insert(key, value.clone()); + } + } + } + + let count = order.len() as u32; + let key_refs: Vec<&str> = order.iter().map(|s| s.as_str()).collect(); + let (packed, shape_id) = perry_ffi::build_object_shape(&key_refs); + let obj: *mut ObjectHeader = unsafe { + perry_ffi::js_object_alloc_with_shape(shape_id, count, packed.as_ptr(), packed.len() as u32) + }; + if !obj.is_null() { + for (i, key) in order.iter().enumerate() { + let v = if key == "set-cookie" { + let mut arr = perry_runtime::js_array_alloc(set_cookie.len() as u32); + for cookie in &set_cookie { + let ptr = + perry_runtime::js_string_from_bytes(cookie.as_ptr(), cookie.len() as u32); + arr = + perry_runtime::js_array_push(arr, perry_runtime::JSValue::string_ptr(ptr)); + } + JsValue::from_bits(perry_runtime::JSValue::array_ptr(arr).bits()) + } else if let Some(val) = combined.get(key) { + let s = alloc_string(val); + JsValue::from_string_ptr(s.as_raw()) + } else { + continue; + }; + unsafe { + perry_ffi::js_object_set_field(obj, i as u32, v); + } + } + let v = JsValue::from_object_ptr(obj as *mut u8); + out = f64::from_bits(v.bits()); + } + out +} diff --git a/crates/perry-ext-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index b63c198f45..ada1fdd78c 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -81,7 +81,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { let incoming_handle = register_handle(IncomingMessageHandle { status_code: 200, status_message: "OK".to_string(), - headers: HashMap::new(), + headers: Vec::new(), trailers: HashMap::new(), body: Vec::new(), listeners: incoming_listeners,