Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion crates/perry-ext-http-server/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<IncomingMessage>(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())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// `req.rawHeaders` — JSON-stringify the original-case `[name, value, ...]`
/// flat list (alternating). TS-side reconstructs an array of strings.
#[no_mangle]
Expand Down
12 changes: 2 additions & 10 deletions crates/perry-ext-http/src/client_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
12 changes: 10 additions & 2 deletions crates/perry-ext-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -327,7 +330,12 @@ unsafe impl Sync for ClientRequestHandle {}
pub struct IncomingMessageHandle {
pub status_code: u16,
pub status_message: String,
pub headers: HashMap<String, String>,
/// 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<String, String>,
pub body: Vec<u8>,
pub listeners: HashMap<String, Vec<i64>>,
Expand Down Expand Up @@ -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::<IncomingMessageHandle, _, _>(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") {
Expand Down
120 changes: 120 additions & 0 deletions crates/perry-ext-http/src/response_headers.rs
Original file line number Diff line number Diff line change
@@ -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<String> = Vec::new();
let mut combined: HashMap<String, String> = HashMap::new();
let mut set_cookie: Vec<String> = 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
}
2 changes: 1 addition & 1 deletion crates/perry-ext-http/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down