diff --git a/changelog.d/6834-http-ffi-boundary.md b/changelog.d/6834-http-ffi-boundary.md new file mode 100644 index 0000000000..e199d43940 --- /dev/null +++ b/changelog.d/6834-http-ffi-boundary.md @@ -0,0 +1 @@ +refactor(http): route production HTTP bindings through `perry-ffi` and remove the normal `perry-ext-http` dependency on `perry-runtime` while preserving the selected HTTP archive ABI. diff --git a/changelog.d/6835-remove-stdlib-http-client.md b/changelog.d/6835-remove-stdlib-http-client.md new file mode 100644 index 0000000000..5309f9d83e --- /dev/null +++ b/changelog.d/6835-remove-stdlib-http-client.md @@ -0,0 +1 @@ +refactor(http): remove the duplicate bundled Node HTTP client and keep Node HTTP on `perry-ext-http`. diff --git a/crates/perry-ext-http/Cargo.toml b/crates/perry-ext-http/Cargo.toml index 8d8442ec68..288dbecf44 100644 --- a/crates/perry-ext-http/Cargo.toml +++ b/crates/perry-ext-http/Cargo.toml @@ -24,33 +24,6 @@ tokio-rustls.workspace = true rustls = { workspace = true, features = ["std", "ring", "tls12"] } rustls-pemfile.workspace = true tokio-tungstenite = { workspace = true } -# #2154: Agent argument validation throws `RangeError [ERR_OUT_OF_RANGE]` -# via `js_throw` + `register_error_code_pub`, which are perry-runtime's -# Rust-ABI helpers (not perry-ffi's). Stays consistent with perry-stdlib's -# parallel `http.rs` AgentHandle, which already calls into perry-runtime -# directly. Cargo feature unification keeps the stdlib feature on when -# both stdlib and this crate link the same perry-runtime — no duplicate -# symbols, no behaviour change for the default `full` build. -# #6303: perry-runtime MUST be built here with the same feature set the shipped -# `libperry_runtime.a` / `libperry_stdlib.a` carry (i.e. its `default`). This crate -# is a `staticlib`, so it BUNDLES the perry-runtime rlib objects into -# `libperry_ext_*.a` — and perry links the ext archives BEFORE stdlib/runtime -# (`prefer_well_known_before_stdlib`), so those bundled objects WIN the link for -# every symbol they define. The workspace dep is `default-features = false`, so -# without `"default"` here a per-crate `cargo build -p perry-ext-` (exactly what -# release-packages.yml does in its per-crate loop) bundles a runtime with -# `regex-engine`/`temporal`/... compiled OUT. The dispatchers those features gate -# are exported UNCONDITIONALLY (`js_string_replace_search_dyn`, -# `js_native_call_method`, ...) with the feature-gated logic `#[cfg]`-ed out of the -# BODY — so the degraded copy silently ToString-coerces a RegExp argument and -# searches for it literally instead of matching it (str.replace(re, fn) never fires -# its callback). Keep `"default"` in lock-step with perry-runtime's default feature -# list; the `ext_crates_bundle_a_full_featured_perry_runtime` test (well_known.rs) guards it. -# #6314: `stdlib` drops the bundled no-op `stdlib_stubs` (js_stdlib_init_dispatch, -# ...) from this staticlib's perry-runtime copy. Linked before stdlib, the no-op -# `js_stdlib_init_dispatch` otherwise wins first-definition and never registers -# the tokio reactor — every node:http server dies on its first accept. -perry-runtime = { workspace = true, features = ["default", "external-ws-symbols", "stdlib"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "http2"], default-features = false } tokio = { workspace = true } # Zero-copy body chunks: reqwest::Response::chunk() yields a refcounted @@ -66,4 +39,6 @@ lazy_static.workspace = true socket2.workspace = true [dev-dependencies] +# GC and async test shims call runtime internals; production code uses only perry-ffi. +perry-runtime.workspace = true perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index 7dedb717e7..78d1ba4f40 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -43,8 +43,8 @@ use crate::ensure_gc_scanner_registered; use lazy_static::lazy_static; use perry_ffi::{ - alloc_string, get_handle, get_handle_mut, iter_handles_of_mut, register_handle, GcRootVisitor, - Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, + alloc_string, get_handle, get_handle_mut, iter_handles_of_mut, register_handle, ErrorKind, + GcRootVisitor, Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::Mutex; @@ -253,10 +253,7 @@ fn throw_out_of_range(name: &str, bound: &str, received: f64) -> ! { "The value of \"{}\" is out of range. It must be {}. Received {}", name, bound, received_str ); - let msg_ptr = perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); - perry_runtime::node_submodules::register_error_code_pub(msg_ptr, "ERR_OUT_OF_RANGE"); - let err = perry_runtime::error::js_rangeerror_new(msg_ptr); - perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer(err as i64)) + perry_ffi::throw_with_code(&message, "ERR_OUT_OF_RANGE", ErrorKind::RangeError) } fn format_received_number(n: f64) -> String { @@ -301,44 +298,25 @@ fn validate_positive(name: &str, value: f64) { /// universe so we can't mix them on the `js_object_get_field_by_name` /// boundary. unsafe fn read_field_bits(obj_f64: f64, field: &str) -> Option { - let bits = obj_f64.to_bits(); - let upper = bits >> 48; - let obj_ptr: *const perry_runtime::ObjectHeader = if upper >= 0x7FF8 { - (bits & PTR_MASK) as *const perry_runtime::ObjectHeader - } else if upper == 0 && bits >= 0x10000 { - bits as *const perry_runtime::ObjectHeader - } else { - return None; - }; - if obj_ptr.is_null() { - return None; - } - let key = perry_runtime::js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { + let value = perry_ffi::object_field_by_name(JsValue::from_bits(obj_f64.to_bits()), field); + if value.is_undefined() || value.is_null() { None } else { - Some(val.bits()) + Some(value.bits()) } } -unsafe fn raw_object_ptr_is_null(val_f64: f64) -> bool { - let bits = val_f64.to_bits(); - let upper = bits >> 48; - if upper >= 0x7FF8 { - (bits & PTR_MASK) == 0 - } else { - !(upper == 0 && bits >= 0x10000) - } +unsafe fn raw_object_ptr_is_null(value: f64) -> bool { + !JsValue::from_bits(value.to_bits()).is_pointer_or_raw() } unsafe fn read_number_field(obj_f64: f64, field: &str) -> Option { let bits = read_field_bits(obj_f64, field)?; - let val = perry_runtime::JSValue::from_bits(bits); + let val = JsValue::from_bits(bits); if val.is_number() { Some(val.to_number()) } else if val.is_int32() { - Some(val.as_int32() as f64) + Some(val.to_int32() as f64) } else { None } @@ -346,9 +324,9 @@ unsafe fn read_number_field(obj_f64: f64, field: &str) -> Option { unsafe fn read_bool_field(obj_f64: f64, field: &str) -> Option { let bits = read_field_bits(obj_f64, field)?; - let val = perry_runtime::JSValue::from_bits(bits); + let val = JsValue::from_bits(bits); if val.is_bool() { - Some(val.as_bool()) + Some(val.to_bool()) } else { None } @@ -356,11 +334,11 @@ unsafe fn read_bool_field(obj_f64: f64, field: &str) -> Option { unsafe fn read_string_field(obj_f64: f64, field: &str) -> Option { let bits = read_field_bits(obj_f64, field)?; - let val = perry_runtime::JSValue::from_bits(bits); + let val = JsValue::from_bits(bits); if !val.is_string() { return None; } - let ptr = val.as_string_ptr() as *mut perry_ffi::StringHeader; + let ptr = val.as_string_ptr(); if ptr.is_null() { return None; } @@ -617,16 +595,7 @@ unsafe fn agent_new_with_protocol(options_f64: f64, default_protocol: &str) -> H "The argument 'scheduling' must be one of: 'fifo', 'lifo'. Received {:?}", s ); - let msg_ptr = - perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); - perry_runtime::node_submodules::register_error_code_pub( - msg_ptr, - "ERR_INVALID_ARG_VALUE", - ); - let err = perry_runtime::error::js_typeerror_new(msg_ptr); - perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer( - err as i64, - )) + perry_ffi::throw_with_code(&message, "ERR_INVALID_ARG_VALUE", ErrorKind::TypeError) } agent.scheduling = s; } @@ -857,11 +826,13 @@ fn json_value_to_string(v: &serde_json::Value) -> String { #[no_mangle] pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { - perry_runtime::stub_diag::perry_stub_warn( - "http.Agent keepSocketAlive/reuseSocket", - "reqwest owns the keep-alive pool; per-socket hooks are no-ops", - Some("#4917"), - ); + unsafe { + perry_ffi::warn_stub( + c"http.Agent keepSocketAlive/reuseSocket", + c"reqwest owns the keep-alive pool; per-socket hooks are no-ops", + Some(c"#4917"), + ) + }; handle } diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs index 2ce0e6bb64..9c548ae138 100644 --- a/crates/perry-ext-http/src/client_request_surface.rs +++ b/crates/perry-ext-http/src/client_request_surface.rs @@ -29,12 +29,11 @@ fn null_value() -> f64 { } fn bool_value(value: bool) -> f64 { - f64::from_bits(perry_runtime::JSValue::bool(value).bits()) + f64::from_bits(JsValue::from_bool(value).bits()) } fn string_value(value: &str) -> f64 { - let ptr = perry_runtime::js_string_from_bytes(value.as_ptr(), value.len() as u32); - f64::from_bits(perry_runtime::JSValue::string_ptr(ptr).bits()) + f64::from_bits(JsValue::from_string_ptr(alloc_string(value).as_raw()).bits()) } fn handle_value(handle: Handle) -> f64 { @@ -117,12 +116,16 @@ fn remove_header_by_name(handle: Handle, name: &str) { fn headers_array(handle: Handle, raw: bool) -> f64 { let names = header_names(handle, raw); - let mut arr = perry_runtime::js_array_alloc(names.len() as u32); + let mut array = unsafe { perry_ffi::js_array_alloc(names.len() as u32) }; for name in names { - let ptr = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); - arr = perry_runtime::js_array_push(arr, perry_runtime::JSValue::string_ptr(ptr)); + array = unsafe { + perry_ffi::js_array_push( + array, + JsValue::from_string_ptr(alloc_string(&name).as_raw()), + ) + }; } - f64::from_bits(perry_runtime::JSValue::array_ptr(arr).bits()) + f64::from_bits(JsValue::from_object_ptr(array).bits()) } fn headers_object(handle: Handle) -> f64 { @@ -136,35 +139,29 @@ fn headers_object(handle: Handle) -> f64 { .unwrap_or_default(); entries.sort_by(|a, b| a.0.cmp(&b.0)); entries.dedup_by(|a, b| a.0 == b.0); - - let obj = perry_runtime::js_object_alloc_null_proto(0, entries.len() as u32); - let mut keys = perry_runtime::js_array_alloc(entries.len() as u32); - for (index, (key, value)) in entries.iter().enumerate() { - let key_ptr = perry_runtime::js_string_from_bytes(key.as_ptr(), key.len() as u32); - let value_ptr = perry_runtime::js_string_from_bytes(value.as_ptr(), value.len() as u32); - perry_runtime::js_object_set_field( - obj, - index as u32, - perry_runtime::JSValue::string_ptr(value_ptr), - ); - keys = perry_runtime::js_array_push(keys, perry_runtime::JSValue::string_ptr(key_ptr)); - } - perry_runtime::js_object_set_keys(obj, keys); - f64::from_bits(perry_runtime::JSValue::object_ptr(obj as *mut u8).bits()) + let fields: Vec<(&str, JsValue)> = entries + .iter() + .map(|(key, value)| { + ( + key.as_str(), + JsValue::from_string_ptr(alloc_string(value).as_raw()), + ) + }) + .collect(); + f64::from_bits(perry_ffi::alloc_null_proto_object(&fields).bits()) } /// `{ name: }` — stands in for `.constructor` so /// `out.constructor.name` discriminates ClientRequest/ServerResponse the /// way the corpus outgoing-message tests expect (#4909). pub(crate) fn constructor_object(name: &str) -> f64 { - let obj = perry_runtime::js_object_alloc_null_proto(0, 1); - let key_ptr = perry_runtime::js_string_from_bytes("name".as_ptr(), 4); - let value_ptr = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); - perry_runtime::js_object_set_field(obj, 0, perry_runtime::JSValue::string_ptr(value_ptr)); - let mut keys = perry_runtime::js_array_alloc(1); - keys = perry_runtime::js_array_push(keys, perry_runtime::JSValue::string_ptr(key_ptr)); - perry_runtime::js_object_set_keys(obj, keys); - f64::from_bits(perry_runtime::JSValue::object_ptr(obj as *mut u8).bits()) + f64::from_bits( + perry_ffi::alloc_null_proto_object(&[( + "name", + JsValue::from_string_ptr(alloc_string(name).as_raw()), + )]) + .bits(), + ) } fn socket_value(handle: Handle) -> f64 { @@ -173,9 +170,7 @@ fn socket_value(handle: Handle) -> f64 { } with_state_mut(handle, |state| { if state.socket == 0.0 { - let obj = perry_runtime::js_object_alloc(0, 0); - state.socket = - f64::from_bits(perry_runtime::JSValue::object_ptr(obj as *mut u8).bits()); + state.socket = f64::from_bits(perry_ffi::alloc_object().bits()); } state.socket }) diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index 62c535924d..56de802405 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -851,10 +851,10 @@ unsafe fn invoke_create_socket( // of reading an uninitialized register for the second parameter. static REGISTER_ARITY: Once = Once::new(); REGISTER_ARITY.call_once(|| { - perry_runtime::closure::js_register_closure_arity(http_create_socket_cb as *const u8, 2); + perry_ffi::register_closure_arity(http_create_socket_cb as *const u8, 2); }); - let cb = perry_runtime::closure::js_closure_alloc(http_create_socket_cb as *const u8, 1); + let cb = perry_ffi::alloc_closure(http_create_socket_cb as *const u8, 1); if cb.is_null() { return; } @@ -862,7 +862,7 @@ unsafe fn invoke_create_socket( // (still-stored) method/url/headers/body and resume dispatch. Stored as an // f64 (a small registry id, not a heap pointer) — pointer-free, so it // needs no GC layout fixup, matching `sqlite_tx_wrapper`'s db-handle slot. - perry_runtime::closure::js_closure_set_capture_f64(cb, 0, request_handle as f64); + perry_ffi::set_closure_capture_f64(cb, 0, request_handle as f64); let cb_val = f64::from_bits(POINTER_TAG | (cb as usize as u64 & PTR_MASK)); let req_val = f64::from_bits(POINTER_TAG | (request_handle as u64 & PTR_MASK)); @@ -879,12 +879,11 @@ unsafe fn invoke_create_socket( /// the override hands back a `net.Socket` (POINTER_TAG-boxed handle, or a bare /// small handle on some codegen paths). unsafe extern "C" fn http_create_socket_cb( - closure: *const perry_runtime::ClosureHeader, + closure: *const RawClosureHeader, err: f64, socket: f64, ) -> f64 { - let request_handle = - perry_runtime::closure::js_closure_get_capture_f64(closure, 0) as i64 as Handle; + let request_handle = perry_ffi::closure_capture_f64(closure, 0) as i64 as Handle; // Node calls `cb(err)` on failure, `cb(null, socket)` on success. let err_bits = err.to_bits(); @@ -1468,16 +1467,7 @@ unsafe fn emit_socket_timeout_overflow_warning(ms: f64) { "{value_text} does not fit into a 32-bit signed integer.\n\ Timer duration was truncated to 2147483647." ); - let msg_ptr = perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let label = "TimeoutOverflowWarning"; - let label_ptr = perry_runtime::js_string_from_bytes(label.as_ptr(), label.len() as u32); - let msg_value = f64::from_bits(perry_runtime::JSValue::string_ptr(msg_ptr).bits()); - let label_value = f64::from_bits(perry_runtime::JSValue::string_ptr(label_ptr).bits()); - perry_runtime::process::js_process_emit_warning( - msg_value, - label_value, - f64::from_bits(TAG_UNDEFINED), - ); + perry_ffi::emit_warning(&message, "TimeoutOverflowWarning"); } /// `IncomingMessage.setEncoding(encoding)` for client responses. The same diff --git a/crates/perry-ext-http/src/response_headers.rs b/crates/perry-ext-http/src/response_headers.rs index 0468936456..f6ca382f34 100644 --- a/crates/perry-ext-http/src/response_headers.rs +++ b/crates/perry-ext-http/src/response_headers.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; -use perry_ffi::{alloc_string, JsValue, ObjectHeader}; +use perry_ffi::{alloc_string, js_array_alloc, js_array_push, JsValue, ObjectHeader}; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; @@ -95,14 +95,13 @@ pub(crate) fn build_response_headers_object(raw: &[(String, String)]) -> f64 { 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); + let mut arr = unsafe { 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)); + arr = unsafe { + js_array_push(arr, JsValue::from_string_ptr(alloc_string(cookie).as_raw())) + }; } - JsValue::from_bits(perry_runtime::JSValue::array_ptr(arr).bits()) + JsValue::from_object_ptr(arr) } else if let Some(val) = combined.get(key) { let s = alloc_string(val); JsValue::from_string_ptr(s.as_raw()) diff --git a/crates/perry-ext-http/src/server/mod.rs b/crates/perry-ext-http/src/server/mod.rs index 402616dba6..4e193713e0 100644 --- a/crates/perry-ext-http/src/server/mod.rs +++ b/crates/perry-ext-http/src/server/mod.rs @@ -93,21 +93,12 @@ static GC_REGISTERED: Once = Once::new(); pub(crate) fn ensure_gc_scanner_registered() { GC_REGISTERED.call_once(|| { gc_register_mutable_root_scanner_named("perry-ext-http", scan_http_server_roots); - // #2532 — register the server pump + has-active with perry-runtime - // directly. In a workspace build perry-stdlib drains these via its - // `external-http-server-pump` arm, but an out-of-tree install links - // the prebuilt full stdlib with that arm compiled OUT — so without - // this the accepted requests would never be dispatched and the - // program would hang. Registration is idempotent on the runtime - // side, so the in-tree double-drain is a harmless no-op. - extern "C" { - fn js_register_aux_pump(f: extern "C" fn() -> i32); - fn js_register_aux_has_active(f: extern "C" fn() -> i32); - } - unsafe { - js_register_aux_pump(crate::server::server::js_node_http_server_process_pending); - js_register_aux_has_active(crate::server::server::js_node_http_server_has_active); - } + // Register the extension pump for out-of-tree links where stdlib does + // not compile its HTTP pump arm. Runtime registration is idempotent. + perry_ffi::register_aux_event_pump( + crate::server::server::js_node_http_server_process_pending, + crate::server::server::js_node_http_server_has_active, + ); // Wall 10 — register the handle property/method/property-set dispatch // extensions so erased-receiver `req.url` / `res.end(...)` etc. route to // our handles even when the linked perry-stdlib was built WITHOUT diff --git a/crates/perry-ext-http/src/tls_client.rs b/crates/perry-ext-http/src/tls_client.rs index f857d9294c..7d687d836b 100644 --- a/crates/perry-ext-http/src/tls_client.rs +++ b/crates/perry-ext-http/src/tls_client.rs @@ -36,8 +36,6 @@ //! tests need `rejectUnauthorized:false`. The `ca` trust anchors are //! still wired up so properly-SAN'd certs verify. -use super::PTR_MASK; - /// Parsed client-side TLS options. `Default` is "no TLS customization", /// in which case the caller keeps using the pooled default client. #[derive(Clone, Default, Debug)] @@ -285,26 +283,6 @@ mod tests { /// detect `checkServerIdentity` without a JSON round-trip (which drops /// functions). Mirrors the raw NaN-boxed field read in `agent.rs`. unsafe fn has_function_field(obj_f64: f64, field: &str) -> bool { - let bits = obj_f64.to_bits(); - let upper = bits >> 48; - let obj_ptr: *const perry_runtime::ObjectHeader = if upper >= 0x7FF8 { - (bits & PTR_MASK) as *const perry_runtime::ObjectHeader - } else if upper == 0 && bits >= 0x10000 { - bits as *const perry_runtime::ObjectHeader - } else { - return false; - }; - if obj_ptr.is_null() { - return false; - } - let key = perry_runtime::js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return false; - } - // Closures are NaN-boxed with POINTER_TAG (0x7FFD); a bare raw pointer - // (codegen sometimes hands these back) is also accepted. - let vbits = val.bits(); - let vupper = vbits >> 48; - vupper == 0x7FFD || (vupper == 0 && vbits >= 0x10000) + perry_ffi::object_field_by_name(perry_ffi::JsValue::from_bits(obj_f64.to_bits()), field) + .is_pointer_or_raw() } diff --git a/crates/perry-ext-http/src/validation.rs b/crates/perry-ext-http/src/validation.rs index 8f7af103c3..84dfa2e2bd 100644 --- a/crates/perry-ext-http/src/validation.rs +++ b/crates/perry-ext-http/src/validation.rs @@ -10,7 +10,7 @@ //! `ERR_OUT_OF_RANGE`). Throwing unwinds through the codegen call site back //! to the JS `try` / `assert.throws` frame. -use perry_runtime::fs::validate::throw_type_error_with_code; +use perry_ffi::{throw_with_code, ErrorKind}; /// Node HTTP token bytes (RFC 7230 `tchar`, mirrored from /// `lib/_http_common.js` `tokenRegExp`). Used for both method names and @@ -38,7 +38,7 @@ pub(crate) fn validate_client_url_string(raw: &str) { Err(_) => true, }; if invalid { - throw_type_error_with_code("Invalid URL", "ERR_INVALID_URL"); + throw_with_code("Invalid URL", "ERR_INVALID_URL", ErrorKind::TypeError); } } @@ -55,9 +55,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol // `validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser')`). if let Some(v) = obj.get("insecureHTTPParser") { if !v.is_boolean() && !v.is_null() { - throw_type_error_with_code( + throw_with_code( "The \"options.insecureHTTPParser\" property must be of type boolean.", "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, ); } } @@ -66,9 +67,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol // `validateNumber(timeout, 'timeout')`). `timeout: null` throws. if let Some(v) = obj.get("timeout") { if !v.is_number() { - throw_type_error_with_code( + throw_with_code( "The \"timeout\" argument must be of type number.", "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, ); } } @@ -81,9 +83,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol let normalized = format!("{}:", proto.trim_end_matches(':')); let expected = format!("{default_protocol}:"); if normalized != expected { - throw_type_error_with_code( + throw_with_code( &format!("Protocol \"{normalized}\" not supported. Expected \"{expected}\""), "ERR_INVALID_PROTOCOL", + ErrorKind::TypeError, ); } } @@ -96,9 +99,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol // default instead of throwing (#4970). if let Some(method) = obj.get("method").and_then(|v| v.as_str()) { if !method.is_empty() && !is_valid_token(method) { - throw_type_error_with_code( + throw_with_code( &format!("Method must be a valid HTTP token [\"{method}\"]"), "ERR_INVALID_HTTP_TOKEN", + ErrorKind::TypeError, ); } } @@ -110,9 +114,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol let cp = c as u32; !(0x21..=0xff).contains(&cp) }) { - throw_type_error_with_code( + throw_with_code( "Request path contains unescaped characters", "ERR_UNESCAPED_CHARACTERS", + ErrorKind::TypeError, ); } } @@ -122,15 +127,17 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol if let Some(headers) = obj.get("headers").and_then(|v| v.as_object()) { for (name, value) in headers { if name.eq_ignore_ascii_case("host") && value.is_array() { - throw_type_error_with_code( + throw_with_code( "The \"options.headers.host\" property must be of type string.", "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, ); } if !is_valid_token(name) { - throw_type_error_with_code( + throw_with_code( &format!("Header name must be a valid HTTP token [\"{name}\"]"), "ERR_INVALID_HTTP_TOKEN", + ErrorKind::TypeError, ); } } diff --git a/crates/perry-ffi/src/closure.rs b/crates/perry-ffi/src/closure.rs index 6fd0127e0d..06e4b450f8 100644 --- a/crates/perry-ffi/src/closure.rs +++ b/crates/perry-ffi/src/closure.rs @@ -53,6 +53,36 @@ extern "C" { arg2: f64, arg3: f64, ) -> f64; + fn js_closure_alloc(func_ptr: *const u8, capture_count: u32) -> *mut ClosureHeader; + fn js_register_closure_arity(func_ptr: *const u8, arity: u32); + fn js_closure_get_capture_f64(closure: *const ClosureHeader, index: u32) -> f64; + fn js_closure_set_capture_f64(closure: *mut ClosureHeader, index: u32, value: f64); +} + +/// Register the arity the runtime uses when dispatching a native closure. +pub fn register_closure_arity(func: *const u8, arity: u32) { + unsafe { js_register_closure_arity(func, arity) } +} + +/// Allocate a native closure with `capture_count` f64 capture slots. +pub fn alloc_closure(func: *const u8, capture_count: u32) -> *mut ClosureHeader { + unsafe { js_closure_alloc(func, capture_count) } +} + +/// Read an f64 capture slot from a native closure. +/// +/// # Safety +/// `closure` must point to a live closure with an allocated `index` slot. +pub unsafe fn closure_capture_f64(closure: *const ClosureHeader, index: u32) -> f64 { + js_closure_get_capture_f64(closure, index) +} + +/// Write an f64 capture slot in a native closure. +/// +/// # Safety +/// `closure` must point to a live closure with an allocated `index` slot. +pub unsafe fn set_closure_capture_f64(closure: *mut ClosureHeader, index: u32, value: f64) { + js_closure_set_capture_f64(closure, index, value) } /// Opaque handle to a JS closure (a `*const ClosureHeader`). @@ -142,4 +172,16 @@ mod tests { assert!(null.is_null()); assert!(null.as_raw().is_null()); } + + #[cfg(feature = "runtime-link")] + #[test] + fn native_closure_retains_capture() { + unsafe extern "C" fn callback(_: *const ClosureHeader) -> f64 { + 0.0 + } + register_closure_arity(callback as *const u8, 0); + let closure = alloc_closure(callback as *const u8, 1); + unsafe { set_closure_capture_f64(closure, 0, 42.0) }; + assert_eq!(unsafe { closure_capture_f64(closure, 0) }, 42.0); + } } diff --git a/crates/perry-ffi/src/error.rs b/crates/perry-ffi/src/error.rs index 3e0222a0bb..910fa5de2e 100644 --- a/crates/perry-ffi/src/error.rs +++ b/crates/perry-ffi/src/error.rs @@ -13,7 +13,8 @@ //! through these single extern symbols keeps the registry/throw logic in //! the one runtime copy the dispatch path resolves to. -use crate::JsValue; +use crate::{alloc_string, JsValue}; +use std::ffi::{c_char, CStr}; extern "C" { /// Runtime entry: build an Error subclass with a `.code`. @@ -51,6 +52,8 @@ extern "C" { syscall_len: usize, errno: f64, ) -> f64; + fn js_process_emit_warning(warning: f64, type_name: f64, code: f64); + fn perry_stub_warn_ffi(name: *const c_char, reason: *const c_char, issue: *const c_char); } /// Which JS Error subclass [`throw_with_code`] raises. @@ -125,6 +128,31 @@ pub fn system_error_value(msg: &str, code: &str, syscall: &str, errno: i64) -> J JsValue::from_bits(value.to_bits()) } +/// Queue a Node process warning with the given message and type name. +pub fn emit_warning(message: &str, type_name: &str) { + let message = JsValue::from_string_ptr(alloc_string(message).as_raw()); + let type_name = JsValue::from_string_ptr(alloc_string(type_name).as_raw()); + unsafe { + js_process_emit_warning( + f64::from_bits(message.bits()), + f64::from_bits(type_name.bits()), + f64::from_bits(JsValue::UNDEFINED.bits()), + ) + } +} + +/// Emit the runtime's once-per-symbol no-op warning. +/// +/// # Safety +/// All strings must live for the process lifetime. Use C string literals. +pub unsafe fn warn_stub(name: &'static CStr, reason: &'static CStr, issue: Option<&'static CStr>) { + perry_stub_warn_ffi( + name.as_ptr(), + reason.as_ptr(), + issue.map_or(std::ptr::null(), CStr::as_ptr), + ) +} + /// Borrow the raw bytes of a `Buffer` or `TypedArray` value. Returns /// `None` for any value that is neither (the caller should raise a /// `TypeError` in that case). The borrow is valid for the duration of the diff --git a/crates/perry-ffi/src/event_pump.rs b/crates/perry-ffi/src/event_pump.rs index 6ac75953d2..25bbf246cb 100644 --- a/crates/perry-ffi/src/event_pump.rs +++ b/crates/perry-ffi/src/event_pump.rs @@ -45,6 +45,17 @@ extern "C" { /// one wake — the main-loop tick drains every queue each pass /// regardless. fn js_notify_main_thread(); + fn js_register_aux_pump(f: extern "C" fn() -> i32); + fn js_register_aux_has_active(f: extern "C" fn() -> i32); +} + +/// Register an extension event pump and activity probe with the runtime. +/// Registration is idempotent for each function pointer. +pub fn register_aux_event_pump(pump: extern "C" fn() -> i32, has_active: extern "C" fn() -> i32) { + unsafe { + js_register_aux_pump(pump); + js_register_aux_has_active(has_active); + } } /// Wake the main thread so it picks up a pending event the calling diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index 50668fe0b3..b36e2cce20 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -32,7 +32,7 @@ //! These tag values are part of perry-ffi's stable API — a //! perry-runtime renumbering bumps perry-ffi major. -use crate::{ArrayHeader, ObjectHeader, StringHeader}; +use crate::{alloc_string, ArrayHeader, ObjectHeader, StringHeader}; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; @@ -259,6 +259,12 @@ impl JsValue { std::ptr::null_mut() } } + + /// True when this is a tagged heap pointer or a legacy bare pointer. + #[inline] + pub const fn is_pointer_or_raw(self) -> bool { + self.is_pointer() || (self.0 >> 48 == 0 && self.0 >= 0x10000) + } } impl std::fmt::Debug for JsValue { @@ -322,6 +328,11 @@ extern "C" { /// Write the field at `field_index`. pub fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, value: JsValue); + + fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader; + fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader; + fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader); + fn js_object_get_field_by_name(obj: *const ObjectHeader, key: *const StringHeader) -> JsValue; } /// Compute `(packed_keys_bytes, shape_id)` for use with @@ -336,6 +347,56 @@ extern "C" { /// the same key list, which improves shape sharing across /// crates. `0x4646_0000` ("FF" prefix) namespaces perry-ffi-built /// shapes from perry-stdlib's hand-rolled ones. +/// Allocate an empty ordinary object. +pub fn alloc_object() -> JsValue { + let object = unsafe { js_object_alloc(0, 0) }; + if object.is_null() { + JsValue::UNDEFINED + } else { + JsValue::from_object_ptr(object) + } +} + +/// Allocate a null-prototype object with the given fields. +/// +/// Use this for Node objects such as request headers where inherited keys must +/// not be visible. Field order is preserved in the runtime keys array. +pub fn alloc_null_proto_object(fields: &[(&str, JsValue)]) -> JsValue { + let obj = unsafe { js_object_alloc_null_proto(0, fields.len() as u32) }; + if obj.is_null() { + return JsValue::UNDEFINED; + } + let mut keys = unsafe { js_array_alloc(fields.len() as u32) }; + for (index, (key, value)) in fields.iter().enumerate() { + unsafe { js_object_set_field(obj, index as u32, *value) }; + keys = unsafe { js_array_push(keys, JsValue::from_string_ptr(alloc_string(key).as_raw())) }; + } + unsafe { js_object_set_keys(obj, keys) }; + JsValue::from_object_ptr(obj) +} + +/// Read an own or inherited named field from an object value. +/// +/// Untagged legacy pointers are accepted because older generated call paths +/// can still pass them. Non-object values return `undefined`. +pub fn object_field_by_name(value: JsValue, key: &str) -> JsValue { + let bits = value.bits(); + let obj = if value.is_pointer() { + value.as_pointer::() + } else if bits >> 48 == 0 && bits >= 0x10000 { + bits as *mut ObjectHeader + } else { + std::ptr::null_mut() + }; + if obj.is_null() { + return JsValue::UNDEFINED; + } + let key = alloc_string(key); + unsafe { js_object_get_field_by_name(obj, key.as_raw()) } +} + +/// Compute `(packed_keys_bytes, shape_id)` for use with +/// [`js_object_alloc_with_shape`]. pub fn build_object_shape(keys: &[&str]) -> (Vec, u32) { let mut packed: Vec = Vec::new(); let mut shape_id: u32 = 0x4646_0000; diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index ae69827f26..959d6d5d97 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -72,12 +72,16 @@ pub use handle::{ mod jsvalue; pub use jsvalue::{ - build_object_shape, js_array_alloc, js_array_get, js_array_length, js_array_push, js_array_set, - js_object_alloc_with_shape, js_object_get_field, js_object_set_field, JsValue, + alloc_null_proto_object, alloc_object, build_object_shape, js_array_alloc, js_array_get, + js_array_length, js_array_push, js_array_set, js_object_alloc_with_shape, js_object_get_field, + js_object_set_field, object_field_by_name, JsValue, }; mod closure; -pub use closure::{JsClosure, RawClosureHeader}; +pub use closure::{ + alloc_closure, closure_capture_f64, register_closure_arity, set_closure_capture_f64, JsClosure, + RawClosureHeader, +}; mod bigint; pub use bigint::{alloc_bigint_from_str, read_bigint_limbs}; @@ -90,11 +94,12 @@ pub use json::json_stringify; mod error; pub use error::{ - error_value_with_code, system_error_value, throw_with_code, value_byte_slice, ErrorKind, + emit_warning, error_value_with_code, system_error_value, throw_with_code, value_byte_slice, + warn_stub, ErrorKind, }; mod event_pump; -pub use event_pump::notify_main_thread; +pub use event_pump::{notify_main_thread, register_aux_event_pump}; mod raw_net; pub use raw_net::{raw_net, register_raw_net, RawNetVtable}; diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 79f6c4844b..a21f929790 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -79,12 +79,8 @@ bundled-commander = [] # perry-stdlib's per-tick bridge into it lives behind `external-fastify-pump`. http-server = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "async-runtime"] -# HTTP client (node-fetch, axios) — single umbrella for both. The -# well-known flip strips this when the user imports either module -# (both perry-ext-fetch and perry-ext-axios cover the symbol surface -# under the umbrella). Per-binding splits could land later if a -# program needs e.g. axios-only without fetch, but in practice they -# share the reqwest dep so splitting saves no binary size. +# Web Fetch and Axios compatibility surface. The well-known flip can +# strip this when the external binding owns the imported surface. # `bundled-streams` rides under the umbrella for backwards-compat # with v0.5.571's `--features http-client` callers (which got # `pub mod streams` transitively). The well-known flip strips @@ -92,19 +88,9 @@ http-server = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:bytes", # axios / node-fetch / http / https is imported through the # well-known table — both ends move in lockstep. # -# #5174: `http-client` decomposes into `web-fetch` (the Web Fetch API — -# `fetch()`, `Headers`, `Request`, `Response`, `Blob`/`File`, in -# `src/fetch/` + `src/fetch_blob.rs`) plus the bundled node:http client -# (`src/http.rs` + `src/axios.rs`). The halves share the -# reqwest/async-runtime/streams deps but are otherwise independent. -# Keeping them separable lets the well-known flip strip *only* the -# bundled node:http client when `node:http` routes to perry-ext-http, -# while preserving the Web Fetch FFIs a program needs for a bare -# `new Headers()` / `new Response()`. Linking both the bundled client -# and perry-ext-http previously produced duplicate -# `js_http_process_pending` (et al.) symbols, and perry-ext-http's -# aux-pump call bound to perry-stdlib's empty-queue copy — wedging the -# in-process response pump (#5174). +# #5174: `http-client` decomposes into `web-fetch` (the Web Fetch API) +# plus the Axios compatibility module. Node HTTP is provided only by +# perry-ext-http. web-fetch = ["dep:reqwest", "async-runtime", "bundled-streams"] http-client = ["web-fetch"] diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 8449c41029..56ae4dcf41 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -570,13 +570,6 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 { count += ws_count; } - // Process pending HTTP events (http/https client callbacks) - #[cfg(feature = "http-client")] - { - let http_count = unsafe { crate::http::js_http_process_pending() }; - count += http_count; - } - // Process pending raw TCP socket events (net.Socket). // v0.5.579 — gate now fires for `bundled-net` (perry-stdlib's // own implementation) AND `external-net-pump` (which the diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 969bbfc787..09bce746c7 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -83,10 +83,6 @@ pub unsafe extern "C" fn js_handle_property_set_dispatch( // #4904: Agent tunables (`agent.maxSockets = 4`) and the // `agent.createConnection = fn` monkeypatch pattern Node's tests use. - #[cfg(feature = "http-client")] - if crate::http::dispatch_agent_property_set(handle, property_name, value) { - return; - } #[cfg(feature = "external-http-client-pump")] if matches!( property_name, @@ -487,7 +483,7 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { #[cfg(feature = "web-fetch")] fn js_register_global_fetch_body_init_ptr(f: extern "C" fn(f64) -> i64); // #4965: Headers → `res.setHeaders` entries-JSON producer. - #[cfg(feature = "http-client")] + #[cfg(feature = "web-fetch")] fn js_register_global_headers_entries_json( f: extern "C" fn(f64) -> *mut perry_runtime::StringHeader, ); @@ -532,7 +528,7 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { ); #[cfg(feature = "web-fetch")] js_register_global_fetch_body_init_ptr(crate::fetch::js_response_body_init_ptr); - #[cfg(feature = "http-client")] + #[cfg(feature = "web-fetch")] js_register_global_headers_entries_json(crate::fetch::js_headers_setheaders_entries_json); #[cfg(feature = "web-fetch")] js_register_global_headers_object_json(crate::fetch::js_headers_fetch_object_json); diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index fb8ec2e65f..04ebcaed5a 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -55,11 +55,6 @@ pub unsafe extern "C" fn js_handle_method_dispatch( return value; } - #[cfg(feature = "http-client")] - if let Some(value) = unsafe { crate::http::dispatch_agent_method(handle, method_name, &args) } { - return value; - } - #[cfg(feature = "external-http-client-pump")] { extern "C" { @@ -93,11 +88,6 @@ pub unsafe extern "C" fn js_handle_method_dispatch( } } - #[cfg(feature = "http-client")] - if let Some(value) = crate::http::dispatch_client_request_method(handle, method_name, &args) { - return value; - } - // node:sqlite DatabaseSync handle. Keep this before the better-sqlite3 // SQLite fallbacks because method names like prepare/exec/close overlap // but the lifecycle/error semantics are intentionally different. diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index 4f3c5944d7..3f893dec17 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -36,16 +36,6 @@ pub unsafe extern "C" fn js_handle_property_dispatch( return value; } - #[cfg(feature = "http-client")] - if let Some(value) = crate::http::dispatch_agent_property(handle, property_name) { - return value; - } - - #[cfg(feature = "http-client")] - if let Some(value) = crate::http::dispatch_client_request_property(handle, property_name) { - return value; - } - #[cfg(all(feature = "tls", not(target_os = "ios"), not(target_os = "android")))] if let Some(value) = crate::tls::dispatch_tls_property(handle, property_name) { return value; diff --git a/crates/perry-stdlib/src/http.rs b/crates/perry-stdlib/src/http.rs deleted file mode 100644 index cb5e65023e..0000000000 --- a/crates/perry-stdlib/src/http.rs +++ /dev/null @@ -1,1999 +0,0 @@ -//! HTTP/HTTPS client module (Node.js http/https compatible) -//! -//! Native implementation of Node.js http.request(), http.get(), https.request(), https.get() -//! using reqwest. Provides callback-based API matching the Node.js pattern used by SDKs -//! like twitter-api-v2, rss-parser, web-push, etc. -//! -//! Both http and https share this implementation — reqwest handles TLS based on URL scheme. - -use perry_runtime::{ - js_array_get_jsvalue, js_array_length, js_closure_call0, js_closure_call1, - js_object_get_field_by_name, js_object_keys, js_string_from_bytes, ArrayHeader, ClosureHeader, - JSValue, StringHeader, -}; -use std::collections::HashMap; -use std::sync::Mutex; - -use crate::common::async_bridge::spawn; -use crate::common::{for_each_handle_mut_of, get_handle_mut, register_handle, Handle}; - -mod client_request_surface; -pub(crate) use client_request_surface::{ - dispatch_client_request_method, dispatch_client_request_property, -}; -mod agent_dispatch; -pub(crate) use agent_dispatch::{ - dispatch_agent_method, dispatch_agent_property, dispatch_agent_property_set, -}; -#[cfg(feature = "external-http-client-pump")] -mod external_client_request; - -extern "C" { - fn js_value_is_closure(value_bits: i64) -> i32; - fn js_class_method_bind( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, - ) -> f64; -} - -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -/// Pending HTTP events to be processed on the main thread -static HTTP_PENDING_EVENTS: once_cell::sync::Lazy>> = - once_cell::sync::Lazy::new(|| Mutex::new(Vec::new())); - -/// Push an HTTP event and wake the main thread (issue #84). -/// Every producer is inside an `async move { ... }` running on a tokio -/// worker — without the notify the event waits for the next event-loop -/// timeout to be picked up. -fn push_http_event(ev: PendingHttpEvent) { - HTTP_PENDING_EVENTS.lock().unwrap().push(ev); - perry_runtime::event_pump::js_notify_main_thread(); -} - -static HTTP_GC_REGISTERED: std::sync::Once = std::sync::Once::new(); - -/// Register the http GC root scanner exactly once. User closures passed -/// to `http.request(options, cb)` or `req.on('error', cb)` / `res.on(...)` -/// are stored inside ClientRequestHandle / IncomingMessageHandle values -/// in the handle registry and would otherwise not be marked by GC — -/// issue #35 pattern, same root cause as net.Socket listeners. -fn ensure_gc_scanner_registered() { - HTTP_GC_REGISTERED.call_once(|| { - perry_runtime::gc::gc_register_mutable_root_scanner_named( - "stdlib:http", - scan_http_roots_mut, - ); - }); -} - -/// GC root scanner for HTTP callback closures. Walks every -/// ClientRequestHandle (response callback + 'error' listeners) and -/// IncomingMessageHandle ('data' / 'end' / 'error' listeners) in the -/// handle registry. -#[allow(dead_code)] -fn scan_http_roots(mark: &mut dyn FnMut(f64)) { - let mut visitor = perry_runtime::gc::RuntimeRootVisitor::for_copy(mark); - scan_http_roots_mut(&mut visitor); -} - -fn scan_http_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { - for_each_handle_mut_of::(|req| { - visitor.visit_i64_slot(&mut req.response_callback); - for cb_vec in req.listeners.values_mut() { - for cb in cb_vec.iter_mut() { - visitor.visit_i64_slot(cb); - } - } - }); - - for_each_handle_mut_of::(|msg| { - for cb_vec in msg.listeners.values_mut() { - for cb in cb_vec.iter_mut() { - visitor.visit_i64_slot(cb); - } - } - }); - - // #2154: stored `agent.createConnection` / `.createSocket` closure - // pointers. Skip the 0-slot to avoid emitting an invalid root for - // agents that haven't had an override assigned. - for_each_handle_mut_of::(|agent| { - if agent.create_connection != 0 { - visitor.visit_i64_slot(&mut agent.create_connection); - } - if agent.create_socket != 0 { - visitor.visit_i64_slot(&mut agent.create_socket); - } - }); - - client_request_surface::scan_roots(visitor); -} - -/// Events that fire on the main thread via js_http_process_pending -enum PendingHttpEvent { - /// Response received: (request_handle, status, status_message, headers, body) - Response { - request_handle: Handle, - status: u16, - status_message: String, - headers: Vec<(String, String)>, - body: Vec, - }, - /// Error on request: (request_handle, error_message) - Error { - request_handle: Handle, - error_message: String, - }, -} - -/// ClientRequest handle — accumulates request options before sending -pub struct ClientRequestHandle { - /// HTTP method - method: String, - /// Full URL to request - url: String, - /// Request headers - headers: HashMap, - /// Request body (accumulated via write()) - body: Vec, - /// Response callback closure pointer (receives IncomingMessage handle) - response_callback: i64, - /// Event listeners: 'error' callbacks - listeners: HashMap>, - /// Timeout in milliseconds - timeout_ms: Option, - /// Whether end() has been called (prevents double-send) - ended: bool, - /// `options.agent` handle (#2154). When non-zero, dispatch reads the - /// Agent's `keepAlive` / `maxFreeSockets` / `keepAliveMsecs` and - /// folds them into the per-request reqwest::ClientBuilder config so - /// pool-related Agent options are honored instead of ignored. - agent_handle: Handle, -} - -/// Agent handle — Node's `http.Agent` / `https.Agent`. Perry's -/// `http.request` honors the Agent for its connection-pool config -/// (#2154); the rest of the fields are still pure metadata mirrored -/// from Node's defaults so `getName(options)` and the property -/// accessors agree byte-for-byte with Node's `lib/_http_agent.js`. -/// -/// Trackers: #2129 (initial constructor + getName), #2154 (validation -/// + per-agent client + socket-counter accessors + setters). -pub struct AgentHandle { - /// `https.Agent` defaults to `"https:"`, `http.Agent` to `"http:"`. - /// `null` is a legitimate value (some tests set it explicitly). - pub protocol: Option, - pub keep_alive: bool, - pub keep_alive_msecs: f64, - pub max_sockets: f64, - pub max_total_sockets: f64, - pub max_free_sockets: f64, - pub scheduling: String, - pub timeout_ms: Option, - /// `agent.destroy()` flips this so the `destroyed` accessor mirrors - /// Node's getter (#2154). - pub destroyed: bool, - /// User-supplied `createConnection` override closure pointer (#2154). - /// Storage + GC-rooting only today — full happy-path invocation - /// needs net.Socket-shaped JS objects and is tracked separately. - pub create_connection: i64, - pub create_socket: i64, -} - -impl Default for AgentHandle { - fn default() -> Self { - AgentHandle { - protocol: Some("http:".to_string()), - keep_alive: false, - keep_alive_msecs: 1000.0, - max_sockets: f64::INFINITY, - max_total_sockets: f64::INFINITY, - max_free_sockets: 256.0, - scheduling: "lifo".to_string(), - timeout_ms: None, - destroyed: false, - create_connection: 0, - create_socket: 0, - } - } -} - -/// IncomingMessage handle — represents an HTTP response -pub struct IncomingMessageHandle { - /// HTTP status code - pub status_code: u16, - /// HTTP status message - pub status_message: String, - /// Response headers - pub headers: HashMap, - /// Response body - pub body: Vec, - /// Event listeners: 'data', 'end', 'error' callbacks - pub listeners: HashMap>, - /// Encoding requested through `res.setEncoding(enc)`. - pub encoding: Option, -} - -/// Helper to extract string from StringHeader pointer -unsafe fn string_from_header(ptr: *const StringHeader) -> Option { - if ptr.is_null() { - return None; - } - let len = (*ptr).byte_len as usize; - let data_ptr = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data_ptr, len); - std::str::from_utf8(bytes).ok().map(|s| s.to_string()) -} - -/// Helper to extract a string field from a NaN-boxed JS object -unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> Option { - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - // Must be a pointer-like value (POINTER_TAG 0x7FFD or raw pointer) - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - return None; - }; - if obj_ptr.is_null() { - return None; - } - - let key_str = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - let field_val = js_object_get_field_by_name(obj_ptr, key_str); - - if field_val.is_undefined() || field_val.is_null() { - return None; - } - - if field_val.is_string() { - let str_ptr = field_val.as_string_ptr(); - if !str_ptr.is_null() { - return string_from_header(str_ptr); - } - } - - // Try to extract from a number (port is often a number) - if field_val.is_number() { - return Some(format!("{}", field_val.as_number() as i64)); - } - - None -} - -/// Helper to extract a number field from a NaN-boxed JS object -unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option { - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - return None; - }; - if obj_ptr.is_null() { - return None; - } - - let key_str = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - let field_val = js_object_get_field_by_name(obj_ptr, key_str); - - if field_val.is_undefined() || field_val.is_null() { - return None; - } - - if field_val.is_number() { - return Some(field_val.as_number()); - } - - None -} - -/// Helper to fetch a raw NaN-boxed field value from a JS object by name. -/// Returns None when the receiver is not a pointer-like value; returns -/// `Some(JSValue::undefined())` when the field is absent (matches the -/// underlying `js_object_get_field_by_name` behavior for `obj.missing`). -unsafe fn get_object_field_raw(obj_f64: f64, field_name: &str) -> Option { - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - return None; - }; - if obj_ptr.is_null() { - return None; - } - let key_str = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - Some(js_object_get_field_by_name(obj_ptr, key_str)) -} - -/// Returns true iff the JS value is "truthy" enough that Node's -/// `name += options.field` branch fires (i.e. `if (options.field)`): -/// not undefined, not null, not the empty string, not 0, not false. -fn jsvalue_is_truthy(v: JSValue) -> bool { - if v.is_undefined() || v.is_null() { - return false; - } - if v.is_bool() { - return v.as_bool(); - } - if v.is_int32() { - return v.as_int32() != 0; - } - if v.is_number() { - let n = v.as_number(); - return n != 0.0 && !n.is_nan(); - } - if v.is_string() || v.is_short_string() { - let s_ptr = perry_runtime::value::js_get_string_pointer_unified(f64::from_bits(v.bits())); - if s_ptr == 0 { - return false; - } - let header = s_ptr as *const StringHeader; - unsafe { (*header).byte_len > 0 } - } else { - // Other pointer values (objects, arrays, buffers) are always truthy - // in JS. - true - } -} - -/// Coerce a JS value to its string representation, matching how -/// `name += options.field` does ToString in JS. Strings/numbers/bools -/// flow through directly; arrays comma-join; buffers stringify their -/// content; objects fall back to "[object Object]". -unsafe fn jsvalue_to_string(v: JSValue) -> String { - let header = perry_runtime::value::js_jsvalue_to_string(f64::from_bits(v.bits())); - string_from_header(header).unwrap_or_default() -} - -/// `JSON.stringify(v)` as a Rust `String`. Used by https.Agent.getName -/// for the `sigalgs` field, which Node serializes as JSON. -unsafe fn jsvalue_to_json_string(v: JSValue) -> String { - let header = perry_runtime::json::js_json_stringify(f64::from_bits(v.bits()), 0); - string_from_header(header).unwrap_or_default() -} - -/// Helper to extract headers from a NaN-boxed JS headers object -unsafe fn extract_headers_from_object(obj_f64: f64) -> HashMap { - let mut result = HashMap::new(); - - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *mut perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *mut perry_runtime::ObjectHeader - } else { - return result; - }; - if obj_ptr.is_null() { - return result; - } - - // Get the keys array - let keys_ptr = js_object_keys(obj_ptr); - if keys_ptr.is_null() { - return result; - } - let len = js_array_length(keys_ptr); - - for i in 0..len { - let key_bits = js_array_get_jsvalue(keys_ptr, i); - let key_val = JSValue::from_bits(key_bits); - if key_val.is_string() { - let key_str_ptr = key_val.as_string_ptr(); - if !key_str_ptr.is_null() { - if let Some(key) = string_from_header(key_str_ptr) { - // Get value for this key - let val = js_object_get_field_by_name( - obj_ptr as *const perry_runtime::ObjectHeader, - key_str_ptr, - ); - if val.is_string() { - let val_ptr = val.as_string_ptr(); - if !val_ptr.is_null() { - if let Some(value) = string_from_header(val_ptr) { - result.insert(key, value); - } - } - } - } - } - } - } - - result -} - -/// Build URL from Node.js http.request options object -/// Options can have: hostname, host, port, path, protocol -unsafe fn build_url_from_options(options_f64: f64, default_protocol: &str) -> String { - let protocol = get_object_string_field(options_f64, "protocol") - .unwrap_or_else(|| format!("{}:", default_protocol)); - let protocol = protocol.trim_end_matches(':'); - - let hostname = get_object_string_field(options_f64, "hostname") - .or_else(|| get_object_string_field(options_f64, "host")) - .unwrap_or_else(|| "localhost".to_string()); - - // Remove port from hostname if present (host can be "hostname:port") - let hostname = hostname.split(':').next().unwrap_or("localhost"); - - let port = get_object_string_field(options_f64, "port") - .or_else(|| get_object_number_field(options_f64, "port").map(|n| format!("{}", n as u16))); - - let path = get_object_string_field(options_f64, "path").unwrap_or_else(|| "/".to_string()); - - match port { - Some(p) => format!("{}://{}:{}{}", protocol, hostname, p, path), - None => format!("{}://{}{}", protocol, hostname, path), - } -} - -/// Check if a f64 value is a NaN-boxed string pointer -fn is_string_value(val: f64) -> bool { - let bits = val.to_bits(); - let upper = bits >> 48; - upper == 0x7FFF // STRING_TAG -} - -/// Extract string from a NaN-boxed string value -unsafe fn extract_string_value(val: f64) -> Option { - let bits = val.to_bits(); - let upper = bits >> 48; - let ptr = if upper == 0x7FFF { - // STRING_TAG - (bits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader - } else if upper == 0x7FFD { - // POINTER_TAG (sometimes strings use this) - (bits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader - } else if upper == 0 && bits >= 0x10000 { - bits as *const StringHeader - } else { - return None; - }; - if ptr.is_null() { - return None; - } - string_from_header(ptr) -} - -// ======================================================================== -// Agent extraction (used by http.request / https.request / http.get) -// ======================================================================== - -/// Extract `options.agent` from a NaN-boxed options object. Returns 0 -/// when the field is missing, not a pointer, or doesn't resolve to an -/// AgentHandle. #2154. -unsafe fn extract_agent_handle(options_f64: f64) -> Handle { - let obj_bits = options_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - return 0; - }; - if obj_ptr.is_null() { - return 0; - } - let key = js_string_from_bytes("agent".as_ptr(), 5); - let val = js_object_get_field_by_name(obj_ptr, key); - if !val.is_pointer() { - return 0; - } - let candidate = (val.bits() & 0x0000_FFFF_FFFF_FFFF) as Handle; - if get_handle_mut::(candidate).is_some() { - candidate - } else { - 0 - } -} - -fn normalize_url(raw: String, default_protocol: &str) -> String { - if raw.starts_with("http://") || raw.starts_with("https://") { - raw - } else if raw.is_empty() { - String::new() - } else { - format!("{}://{}", default_protocol, raw) - } -} - -unsafe fn url_from_js_value(value: f64, default_protocol: &str) -> String { - if is_string_value(value) { - return normalize_url( - extract_string_value(value).unwrap_or_default(), - default_protocol, - ); - } - if let Some(href) = get_object_string_field(value, "href") { - return normalize_url(href, default_protocol); - } - build_url_from_options(value, default_protocol) -} - -#[derive(Default)] -struct RequestOverload { - primary: Option, - options: Option, - callback: i64, -} - -unsafe fn parse_request_overload(args_array: i64) -> RequestOverload { - let mut out = RequestOverload::default(); - let arr_ptr = args_array as *const ArrayHeader; - if arr_ptr.is_null() || (args_array as u64) >> 48 != 0 { - return out; - } - let len = (*arr_ptr).length as usize; - let elements = (arr_ptr as *const u8).add(std::mem::size_of::()) as *const u64; - for i in 0..len { - let bits = *elements.add(i); - if js_value_is_closure(bits as i64) != 0 { - out.callback = (bits & 0x0000_FFFF_FFFF_FFFF) as i64; - continue; - } - let value = f64::from_bits(bits); - if out.primary.is_none() { - out.primary = Some(value); - } else if out.options.is_none() { - out.options = Some(value); - } - } - out -} - -unsafe fn request_parts_from_options( - primary: f64, - options: f64, - default_protocol: &str, - _auto_end: bool, -) -> (String, String, HashMap, Option, Handle) { - let method = get_object_string_field(options, "method") - .unwrap_or_else(|| "GET".to_string()) - .to_uppercase(); - let url = url_from_js_value(primary, default_protocol); - let mut headers = HashMap::new(); - if let Some(headers_val) = get_object_field_raw(options, "headers") { - if !headers_val.is_undefined() && !headers_val.is_null() { - headers = extract_headers_from_object(f64::from_bits(headers_val.bits())); - } - } - let timeout_ms = get_object_number_field(options, "timeout").map(|n| n as u64); - let agent_handle = extract_agent_handle(options); - (method, url, headers, timeout_ms, agent_handle) -} - -unsafe fn build_request_from_overload( - overload: RequestOverload, - default_protocol: &str, - force_get: bool, -) -> Handle { - ensure_gc_scanner_registered(); - let undefined = f64::from_bits(JSValue::undefined().bits()); - let primary = overload.primary.unwrap_or(undefined); - let options = overload.options.unwrap_or(primary); - let (method, url, headers, timeout_ms, agent_handle) = - request_parts_from_options(primary, options, default_protocol, force_get); - let handle = register_handle(ClientRequestHandle { - method, - url, - headers, - body: Vec::new(), - response_callback: overload.callback, - listeners: HashMap::new(), - timeout_ms, - ended: false, - agent_handle, - }); - if force_get { - js_http_client_request_end(handle, undefined); - } - handle -} - -// ======================================================================== -// FFI Functions -// ======================================================================== - -/// http.request(options, callback) -> ClientRequest handle -/// -/// options: NaN-boxed JS object with hostname, port, path, method, headers -/// callback: closure pointer for response callback (receives IncomingMessage handle) -/// -/// Returns a ClientRequest handle (i64) -#[no_mangle] -pub unsafe extern "C" fn js_http_request(options_f64: f64, callback_i64: i64) -> Handle { - ensure_gc_scanner_registered(); - let method = get_object_string_field(options_f64, "method") - .unwrap_or_else(|| "GET".to_string()) - .to_uppercase(); - - let url = build_url_from_options(options_f64, "http"); - - let mut headers = HashMap::new(); - - // Extract headers sub-object - let obj_bits = options_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - std::ptr::null() - }; - - if !obj_ptr.is_null() { - let headers_key = js_string_from_bytes("headers".as_ptr(), 7); - let headers_val = js_object_get_field_by_name(obj_ptr, headers_key); - if !headers_val.is_undefined() && !headers_val.is_null() { - let headers_f64 = f64::from_bits(headers_val.bits()); - headers = extract_headers_from_object(headers_f64); - } - } - - let timeout_ms = get_object_number_field(options_f64, "timeout").map(|n| n as u64); - let agent_handle = extract_agent_handle(options_f64); - - register_handle(ClientRequestHandle { - method, - url, - headers, - body: Vec::new(), - response_callback: callback_i64, - listeners: HashMap::new(), - timeout_ms, - ended: false, - agent_handle, - }) -} - -/// `new http.ClientRequest(options)` (#4904). Perry's client model defers -/// the actual send to `.end()`, so constructing is exactly `http.request` -/// without a response callback. Node coerces a falsy `options.method` to -/// `GET` — mirror that here (`http.request` keeps whatever string it got). -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_standalone_new(options_f64: f64) -> Handle { - let handle = js_http_request(options_f64, 0); - if let Some(req) = get_handle_mut::(handle) { - if req.method.is_empty() { - req.method = "GET".to_string(); - } - } - handle -} - -/// https.request(options, callback) -> ClientRequest handle -/// Same as http.request but defaults to https protocol -#[no_mangle] -pub unsafe extern "C" fn js_https_request(options_f64: f64, callback_i64: i64) -> Handle { - ensure_gc_scanner_registered(); - let method = get_object_string_field(options_f64, "method") - .unwrap_or_else(|| "GET".to_string()) - .to_uppercase(); - - let url = build_url_from_options(options_f64, "https"); - - let mut headers = HashMap::new(); - - let obj_bits = options_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - std::ptr::null() - }; - - if !obj_ptr.is_null() { - let headers_key = js_string_from_bytes("headers".as_ptr(), 7); - let headers_val = js_object_get_field_by_name(obj_ptr, headers_key); - if !headers_val.is_undefined() && !headers_val.is_null() { - let headers_f64 = f64::from_bits(headers_val.bits()); - headers = extract_headers_from_object(headers_f64); - } - } - - let timeout_ms = get_object_number_field(options_f64, "timeout").map(|n| n as u64); - let agent_handle = extract_agent_handle(options_f64); - - register_handle(ClientRequestHandle { - method, - url, - headers, - body: Vec::new(), - response_callback: callback_i64, - listeners: HashMap::new(), - timeout_ms, - ended: false, - agent_handle, - }) -} - -#[no_mangle] -pub unsafe extern "C" fn js_https_request_variadic(args_array: i64) -> Handle { - build_request_from_overload(parse_request_overload(args_array), "https", false) -} - -/// http.get(url_or_options, callback) -> ClientRequest handle -/// Convenience method: sets method to GET and auto-calls end() -/// -/// First arg can be a string URL or an options object -#[no_mangle] -pub unsafe extern "C" fn js_http_get(url_or_options_f64: f64, callback_i64: i64) -> Handle { - ensure_gc_scanner_registered(); - let (url, headers, timeout_ms, agent_handle) = if is_string_value(url_or_options_f64) { - let url = extract_string_value(url_or_options_f64).unwrap_or_default(); - (url, HashMap::new(), None, 0) - } else { - // Options object - let url = build_url_from_options(url_or_options_f64, "http"); - let mut headers = HashMap::new(); - - let obj_bits = url_or_options_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - std::ptr::null() - }; - - if !obj_ptr.is_null() { - let headers_key = js_string_from_bytes("headers".as_ptr(), 7); - let headers_val = js_object_get_field_by_name(obj_ptr, headers_key); - if !headers_val.is_undefined() && !headers_val.is_null() { - let headers_f64 = f64::from_bits(headers_val.bits()); - headers = extract_headers_from_object(headers_f64); - } - } - - let timeout_ms = get_object_number_field(url_or_options_f64, "timeout").map(|n| n as u64); - let agent_handle = extract_agent_handle(url_or_options_f64); - - (url, headers, timeout_ms, agent_handle) - }; - - let handle = register_handle(ClientRequestHandle { - method: "GET".to_string(), - url, - headers, - body: Vec::new(), - response_callback: callback_i64, - listeners: HashMap::new(), - timeout_ms, - ended: false, - agent_handle, - }); - - // GET auto-calls end() - js_http_client_request_end(handle, f64::from_bits(JSValue::undefined().bits())); - - handle -} - -/// https.get(url_or_options, callback) -> ClientRequest handle -/// Same as http.get but defaults to https -#[no_mangle] -pub unsafe extern "C" fn js_https_get(url_or_options_f64: f64, callback_i64: i64) -> Handle { - ensure_gc_scanner_registered(); - let (url, headers, timeout_ms, agent_handle) = if is_string_value(url_or_options_f64) { - let url = extract_string_value(url_or_options_f64).unwrap_or_default(); - // If URL doesn't start with https://, prepend it - let url = if url.starts_with("http://") || url.starts_with("https://") { - url - } else { - format!("https://{}", url) - }; - (url, HashMap::new(), None, 0) - } else { - let url = build_url_from_options(url_or_options_f64, "https"); - let mut headers = HashMap::new(); - - let obj_bits = url_or_options_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - std::ptr::null() - }; - - if !obj_ptr.is_null() { - let headers_key = js_string_from_bytes("headers".as_ptr(), 7); - let headers_val = js_object_get_field_by_name(obj_ptr, headers_key); - if !headers_val.is_undefined() && !headers_val.is_null() { - let headers_f64 = f64::from_bits(headers_val.bits()); - headers = extract_headers_from_object(headers_f64); - } - } - - let timeout_ms = get_object_number_field(url_or_options_f64, "timeout").map(|n| n as u64); - let agent_handle = extract_agent_handle(url_or_options_f64); - - (url, headers, timeout_ms, agent_handle) - }; - - let handle = register_handle(ClientRequestHandle { - method: "GET".to_string(), - url, - headers, - body: Vec::new(), - response_callback: callback_i64, - listeners: HashMap::new(), - timeout_ms, - ended: false, - agent_handle, - }); - - // GET auto-calls end() - js_http_client_request_end(handle, f64::from_bits(JSValue::undefined().bits())); - - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_https_get_variadic(args_array: i64) -> Handle { - build_request_from_overload(parse_request_overload(args_array), "https", true) -} - -/// ClientRequest.write(body) — append data to request body -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_write(handle: Handle, body_f64: f64) -> Handle { - if let Some(req) = get_handle_mut::(handle) { - if let Some(body_str) = extract_string_value(body_f64) { - req.body.extend_from_slice(body_str.as_bytes()); - } - return handle; - } - - #[cfg(feature = "external-http-client-pump")] - { - let _ = unsafe { external_client_request::dispatch_method(handle, "write", &[body_f64]) }; - } - handle -} - -/// ClientRequest.end(body?) — finalize request and send it -/// Optional body parameter is appended before sending. -/// Spawns async reqwest request and queues response for main thread processing. -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_end(handle: Handle, body_f64: f64) -> Handle { - // Append optional body - if let Some(body_str) = extract_string_value(body_f64) { - if let Some(req) = get_handle_mut::(handle) { - req.body.extend_from_slice(body_str.as_bytes()); - } - } - - // Extract request data for async task - let (method, url, headers, body, timeout_ms, agent_pool) = { - let req = match get_handle_mut::(handle) { - Some(r) => r, - None => { - #[cfg(feature = "external-http-client-pump")] - { - let _ = unsafe { - external_client_request::dispatch_method(handle, "end", &[body_f64]) - }; - } - return handle; - } - }; - if req.ended { - return handle; // Already sent - } - req.ended = true; - // #2154: pull the Agent's pool config out NOW (still on the main - // thread; tokio worker can't safely touch the handle registry). - // `(keep_alive, max_free_sockets, keep_alive_msecs)` — None when - // the caller didn't pass `options.agent`, in which case we - // build a vanilla reqwest::Client below. - let agent_pool = if req.agent_handle != 0 { - get_handle_mut::(req.agent_handle) - .map(|a| (a.keep_alive, a.max_free_sockets, a.keep_alive_msecs)) - } else { - None - }; - ( - req.method.clone(), - req.url.clone(), - req.headers.clone(), - req.body.clone(), - req.timeout_ms, - agent_pool, - ) - }; - - // Spawn async HTTP request - let req_handle = handle; - spawn(async move { - let mut builder = reqwest::Client::builder(); - // Node's http client never follows redirects; disable reqwest's default (rationale: `apply_node_proxy_policy` in `perry-ext-http`). - builder = builder.redirect(reqwest::redirect::Policy::none()); - builder = if let Some(timeout) = timeout_ms { - builder.timeout(std::time::Duration::from_millis(timeout)) - } else { - builder.timeout(std::time::Duration::from_secs(30)) - }; - // #2154: honor Agent pool config when one is supplied. Without - // an Agent we keep the prior vanilla builder (no idle pool - // override) — Perry's stdlib http path historically created a - // fresh Client per request and we don't want to silently - // change that for code that doesn't opt in via options.agent. - if let Some((keep_alive, max_free_sockets, keep_alive_msecs)) = agent_pool { - let pool_max_idle = if keep_alive { - if !max_free_sockets.is_finite() || max_free_sockets > usize::MAX as f64 { - 256 - } else { - max_free_sockets.max(1.0) as usize - } - } else { - 0 - }; - let idle_timeout = if keep_alive { - let ms = if keep_alive_msecs.is_finite() && keep_alive_msecs > 0.0 { - keep_alive_msecs - } else { - 1000.0 - }; - std::time::Duration::from_millis(ms as u64) - } else { - std::time::Duration::from_millis(0) - }; - builder = builder - .pool_max_idle_per_host(pool_max_idle) - .pool_idle_timeout(idle_timeout); - } - let client = match builder.build() { - Ok(c) => c, - Err(e) => { - push_http_event(PendingHttpEvent::Error { - request_handle: req_handle, - error_message: format!("Failed to create HTTP client: {}", e), - }); - return; - } - }; - - let mut request = match method.as_str() { - "POST" => client.post(&url), - "PUT" => client.put(&url), - "DELETE" => client.delete(&url), - "PATCH" => client.patch(&url), - "HEAD" => client.head(&url), - "OPTIONS" => client.request(reqwest::Method::OPTIONS, &url), - _ => client.get(&url), - }; - - // Add headers - for (key, value) in &headers { - request = request.header(key.as_str(), value.as_str()); - } - - // Add body if non-empty - if !body.is_empty() { - request = request.body(body); - } - - match request.send().await { - Ok(response) => { - let status = response.status().as_u16(); - let status_message = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - - let mut resp_headers = Vec::new(); - for (key, value) in response.headers() { - if let Ok(v) = value.to_str() { - resp_headers.push((key.to_string(), v.to_string())); - } - } - - let body = response.bytes().await.unwrap_or_default().to_vec(); - - push_http_event(PendingHttpEvent::Response { - request_handle: req_handle, - status, - status_message, - headers: resp_headers, - body, - }); - } - Err(e) => { - push_http_event(PendingHttpEvent::Error { - request_handle: req_handle, - error_message: format!("{}", e), - }); - } - } - }); - - handle -} - -/// ClientRequest/IncomingMessage .on(event, callback) — register event listener -/// Works for both ClientRequest ('error') and IncomingMessage ('data', 'end', 'error') -#[no_mangle] -pub unsafe extern "C" fn js_http_on( - handle: Handle, - event_name_ptr: *const StringHeader, - callback_ptr: i64, -) -> Handle { - ensure_gc_scanner_registered(); - let event_name = match string_from_header(event_name_ptr) { - Some(name) => name, - None => return handle, - }; - - if callback_ptr == 0 { - return handle; - } - - // Try ClientRequest first - if let Some(req) = get_handle_mut::(handle) { - req.listeners - .entry(event_name) - .or_insert_with(Vec::new) - .push(callback_ptr); - return handle; - } - - // Try IncomingMessage - if let Some(res) = get_handle_mut::(handle) { - res.listeners - .entry(event_name) - .or_insert_with(Vec::new) - .push(callback_ptr); - return handle; - } - - handle -} - -/// ClientRequest.setHeader(name, value) — set a request header -#[no_mangle] -pub unsafe extern "C" fn js_http_set_header( - handle: Handle, - name_ptr: *const StringHeader, - value_ptr: *const StringHeader, -) -> Handle { - let name = match string_from_header(name_ptr) { - Some(n) => n, - None => return handle, - }; - let value = match string_from_header(value_ptr) { - Some(v) => v, - None => return handle, - }; - - if client_request_surface::is_client_request_handle(handle) { - client_request_surface::set_header(handle, &name, value); - return handle; - } - - #[cfg(feature = "external-http-client-pump")] - { - let name_value = f64::from_bits(0x7FFF_0000_0000_0000u64 | (name_ptr as u64 & PTR_MASK)); - let value_value = f64::from_bits(0x7FFF_0000_0000_0000u64 | (value_ptr as u64 & PTR_MASK)); - let _ = unsafe { - external_client_request::dispatch_method( - handle, - "setHeader", - &[name_value, value_value], - ) - }; - } - - handle -} - -/// ClientRequest.setTimeout(ms) — set request timeout -#[no_mangle] -pub unsafe extern "C" fn js_http_set_timeout(handle: Handle, ms: f64) -> Handle { - if let Some(req) = get_handle_mut::(handle) { - req.timeout_ms = Some(ms as u64); - return handle; - } - - #[cfg(feature = "external-http-client-pump")] - { - let _ = unsafe { external_client_request::dispatch_method(handle, "setTimeout", &[ms]) }; - } - handle -} - -/// IncomingMessage.setEncoding(encoding) — store the requested text encoding -/// for response data events and return the receiver for chaining. -#[no_mangle] -pub unsafe extern "C" fn js_http_incoming_message_set_encoding( - handle: Handle, - encoding_ptr: *const StringHeader, -) -> Handle { - let encoding = string_from_header(encoding_ptr).unwrap_or_else(|| "utf8".to_string()); - let mut matched = false; - if let Some(res) = get_handle_mut::(handle) { - res.encoding = Some(encoding); - matched = true; - } - if matched { - return handle; - } - - #[cfg(feature = "external-http-client-pump")] - { - extern "C" { - fn js_ext_http_client_incoming_message_is_handle(handle: i64) -> i32; - fn js_ext_http_client_incoming_message_set_encoding( - handle: i64, - encoding_ptr: *const StringHeader, - ) -> i64; - } - if js_ext_http_client_incoming_message_is_handle(handle) != 0 { - js_ext_http_client_incoming_message_set_encoding(handle, encoding_ptr); - return handle; - } - } - - #[cfg(feature = "external-http-server-pump")] - { - extern "C" { - fn js_ext_http_incoming_message_is_handle(handle: i64) -> i32; - fn js_node_http_im_set_encoding(handle: i64, encoding_ptr: *const StringHeader) -> i64; - } - if js_ext_http_incoming_message_is_handle(handle) != 0 { - js_node_http_im_set_encoding(handle, encoding_ptr); - } - } - handle -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_method(handle: Handle) -> *mut StringHeader { - let method = match get_handle_mut::(handle) { - Some(req) => req.method.clone(), - None => { - #[cfg(feature = "external-http-client-pump")] - if let Some(ptr) = unsafe { external_client_request::string_property(handle, "method") } - { - return ptr; - } - String::new() - } - }; - unsafe { js_string_from_bytes(method.as_ptr(), method.len() as u32) } -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_protocol(handle: Handle) -> *mut StringHeader { - let protocol = match get_handle_mut::(handle) { - Some(req) => reqwest::Url::parse(&req.url) - .map(|u| format!("{}:", u.scheme())) - .unwrap_or_default(), - None => { - #[cfg(feature = "external-http-client-pump")] - if let Some(ptr) = - unsafe { external_client_request::string_property(handle, "protocol") } - { - return ptr; - } - String::new() - } - }; - unsafe { js_string_from_bytes(protocol.as_ptr(), protocol.len() as u32) } -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_host(handle: Handle) -> *mut StringHeader { - let host = match get_handle_mut::(handle) { - Some(req) => reqwest::Url::parse(&req.url) - .ok() - .and_then(|u| u.host_str().map(|s| s.to_string())) - .unwrap_or_default(), - None => { - #[cfg(feature = "external-http-client-pump")] - if let Some(ptr) = unsafe { external_client_request::string_property(handle, "host") } { - return ptr; - } - String::new() - } - }; - unsafe { js_string_from_bytes(host.as_ptr(), host.len() as u32) } -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_path(handle: Handle) -> *mut StringHeader { - let path = match get_handle_mut::(handle) { - Some(req) => reqwest::Url::parse(&req.url) - .map(|u| { - let mut path = u.path().to_string(); - if path.is_empty() { - path.push('/'); - } - if let Some(q) = u.query() { - path.push('?'); - path.push_str(q); - } - path - }) - .unwrap_or_default(), - None => { - #[cfg(feature = "external-http-client-pump")] - if let Some(ptr) = unsafe { external_client_request::string_property(handle, "path") } { - return ptr; - } - String::new() - } - }; - unsafe { js_string_from_bytes(path.as_ptr(), path.len() as u32) } -} - -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_listener_count( - handle: Handle, - event_ptr: *const StringHeader, -) -> f64 { - let event = match string_from_header(event_ptr) { - Some(e) => e, - None => return 0.0, - }; - match get_handle_mut::(handle) { - Some(req) => { - let explicit = req.listeners.get(&event).map(|v| v.len()).unwrap_or(0); - let implicit_response = if event == "response" && req.response_callback != 0 { - 1 - } else { - 0 - }; - (explicit + implicit_response) as f64 - } - None => { - #[cfg(feature = "external-http-client-pump")] - { - let event_value = - f64::from_bits(0x7FFF_0000_0000_0000u64 | (event_ptr as u64 & PTR_MASK)); - if let Some(value) = unsafe { - external_client_request::dispatch_method( - handle, - "listenerCount", - &[event_value], - ) - } { - return value; - } - } - 0.0 - } - } -} - -/// IncomingMessage.statusCode — get response status code -#[no_mangle] -pub extern "C" fn js_http_status_code(handle: Handle) -> f64 { - if let Some(res) = get_handle_mut::(handle) { - return res.status_code as f64; - } - 0.0 -} - -/// IncomingMessage.statusMessage — get response status message -#[no_mangle] -pub extern "C" fn js_http_status_message(handle: Handle) -> *mut StringHeader { - if let Some(res) = get_handle_mut::(handle) { - return js_string_from_bytes(res.status_message.as_ptr(), res.status_message.len() as u32); - } - js_string_from_bytes("".as_ptr(), 0) -} - -/// IncomingMessage.headers — get response headers as a JS object -/// Returns a NaN-boxed object pointer (f64) -#[no_mangle] -pub unsafe extern "C" fn js_http_response_headers(handle: Handle) -> f64 { - if let Some(res) = get_handle_mut::(handle) { - // Build a JS object with the headers - let obj = perry_runtime::js_object_alloc(0, res.headers.len() as u32); - let keys_arr = perry_runtime::js_array_alloc(res.headers.len() as u32); - - for (idx, (key, val)) in res.headers.iter().enumerate() { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - perry_runtime::js_array_push(keys_arr, JSValue::string_ptr(key_ptr)); - let val_ptr = js_string_from_bytes(val.as_ptr(), val.len() as u32); - perry_runtime::js_object_set_field(obj, idx as u32, JSValue::string_ptr(val_ptr)); - } - perry_runtime::js_object_set_keys(obj, keys_arr); - - return f64::from_bits(JSValue::object_ptr(obj as *mut u8).bits()); - } - - #[cfg(feature = "external-http-server-pump")] - { - extern "C" { - fn js_ext_http_incoming_message_is_handle(handle: i64) -> i32; - fn js_ext_http_incoming_message_dispatch_property( - handle: i64, - property_ptr: *const u8, - property_len: usize, - ) -> f64; - } - if js_ext_http_incoming_message_is_handle(handle) != 0 { - return js_ext_http_incoming_message_dispatch_property(handle, b"headers".as_ptr(), 7); - } - } - - f64::from_bits(JSValue::undefined().bits()) -} - -/// Process pending HTTP events on the main thread. -/// Called from js_stdlib_process_pending(). -/// Returns number of events processed. -/// -/// #1114 followup: same per-tick scratch-Vec discipline as the fastify -/// (e538caa7), net, and ws pumps. Called every event-loop iteration + -/// every inline `await` poll iteration; the original -/// `Vec::drain(..).collect()` was a per-call heap alloc that contributed -/// to the GC `madvise` churn under sustained HTTP client traffic. -#[no_mangle] -pub unsafe extern "C" fn js_http_process_pending() -> i32 { - thread_local! { - static SCRATCH: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; - } - let mut events = SCRATCH.with(|s| std::mem::take(&mut *s.borrow_mut())); - events.clear(); - { - let mut guard = HTTP_PENDING_EVENTS.lock().unwrap(); - events.append(&mut *guard); - } - - let count = events.len() as i32; - - for event in events.drain(..) { - match event { - PendingHttpEvent::Response { - request_handle, - status, - status_message, - headers, - body, - } => { - // Get the response callback and error listeners from the ClientRequest - let (response_callback, _error_listeners) = { - match get_handle_mut::(request_handle) { - Some(req) => ( - req.response_callback, - req.listeners.get("error").cloned().unwrap_or_default(), - ), - None => continue, - } - }; - - // Create IncomingMessage handle - let mut headers_map = HashMap::new(); - for (k, v) in headers { - headers_map.insert(k, v); - } - - let body_clone = body.clone(); - - let incoming_handle = register_handle(IncomingMessageHandle { - status_code: status, - status_message, - headers: headers_map, - body, - listeners: HashMap::new(), - encoding: None, - }); - - // Call the response callback with the IncomingMessage handle - // The handle must be NaN-boxed with POINTER_TAG so the closure - // parameter extraction (js_nanbox_get_pointer) can extract it - if response_callback != 0 { - let closure_ptr = response_callback as *const ClosureHeader; - let handle_f64 = f64::from_bits( - 0x7FFD_0000_0000_0000u64 | (incoming_handle as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - js_closure_call1(closure_ptr, handle_f64); - } - - // After the response callback has returned, data/end listeners - // should be registered on the IncomingMessage. Fire them now. - - // Fire 'data' event with the full body as a single chunk - let data_listeners: Vec = { - match get_handle_mut::(incoming_handle) { - Some(res) => res.listeners.get("data").cloned().unwrap_or_default(), - None => Vec::new(), - } - }; - - if !data_listeners.is_empty() && !body_clone.is_empty() { - // Create a NaN-boxed string from the body - let body_str = - js_string_from_bytes(body_clone.as_ptr(), body_clone.len() as u32); - let body_f64 = f64::from_bits( - 0x7FFF_0000_0000_0000u64 | (body_str as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - - for cb in data_listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, body_f64); - } - } - } - - // Fire 'end' event - let end_listeners: Vec = { - match get_handle_mut::(incoming_handle) { - Some(res) => res.listeners.get("end").cloned().unwrap_or_default(), - None => Vec::new(), - } - }; - - for cb in end_listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call0(closure); - } - } - } - - PendingHttpEvent::Error { - request_handle, - error_message, - } => { - // Get 'error' listeners from the ClientRequest - let error_listeners: Vec = { - match get_handle_mut::(request_handle) { - Some(req) => req.listeners.get("error").cloned().unwrap_or_default(), - None => Vec::new(), - } - }; - - if !error_listeners.is_empty() { - // Create error string as NaN-boxed value - let err_str = - js_string_from_bytes(error_message.as_ptr(), error_message.len() as u32); - let err_f64 = f64::from_bits( - 0x7FFF_0000_0000_0000u64 | (err_str as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - - for cb in error_listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, err_f64); - } - } - } - } - } - } - - // Restore the (capacity-retaining) buffer to the thread-local so the - // next tick reuses it. A re-entrant pump call during dispatch may - // have left a grown buffer in the slot — keep whichever is larger. - SCRATCH.with(|s| { - let mut slot = s.borrow_mut(); - if events.capacity() >= slot.capacity() { - *slot = events; - } - }); - - count -} - -// ======================================================================== -// http.Agent / https.Agent (#2129) -// ======================================================================== - -/// `new http.Agent(options?)` — register a fresh AgentHandle. `options` is -/// either undefined or a NaN-boxed object whose recognized fields override -/// the defaults; unknown fields are ignored (Node behavior). -/// -/// Mirrors Node's argument validation for the small set of options whose -/// rejection is observable (`maxTotalSockets` and `maxSockets`: number, -/// finite, > 0). Other options are no-op overrides today because Perry -/// does not pool sockets. -#[no_mangle] -pub unsafe extern "C" fn js_http_agent_new(options_f64: f64) -> Handle { - js_http_agent_new_with_protocol(options_f64, b"http:".as_ptr(), 5) -} - -#[no_mangle] -pub unsafe extern "C" fn js_https_agent_new(options_f64: f64) -> Handle { - js_http_agent_new_with_protocol(options_f64, b"https:".as_ptr(), 6) -} - -/// #2154: throw `RangeError [ERR_OUT_OF_RANGE]` with Node's exact -/// message shape — `The value of "" is out of range. It must be -/// . Received `. The `assert.throws(..., { code: ... })` -/// path in test-http-agent-maxtotalsockets.js (and adjacent tests) -/// reads the `code` property so we need both the RangeError class and -/// the side-table code registration. -fn throw_agent_out_of_range(name: &str, bound: &str, received: f64) -> ! { - let received_str = if received.is_nan() { - "NaN".to_string() - } else if received.is_infinite() { - if received.is_sign_negative() { - "-Infinity".to_string() - } else { - "Infinity".to_string() - } - } else if received.fract() == 0.0 && received.abs() < 1e21 { - format!("{}", received as i64) - } else { - format!("{}", received) - }; - let message = format!( - "The value of \"{}\" is out of range. It must be {}. Received {}", - name, bound, received_str - ); - let msg_ptr = unsafe { js_string_from_bytes(message.as_ptr(), message.len() as u32) }; - perry_runtime::node_submodules::register_error_code_pub(msg_ptr, "ERR_OUT_OF_RANGE"); - let err = perry_runtime::error::js_rangeerror_new(msg_ptr); - perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer(err as i64)) -} - -fn validate_agent_positive(name: &str, v: f64) { - // `+Infinity` is the Node default for maxSockets/maxTotalSockets, so - // accept it explicitly even though `v > 0.0` would also pass — keep - // the symmetry clear with the ext-http mirror. - if v.is_infinite() && v.is_sign_positive() { - return; - } - if v.is_nan() || v <= 0.0 { - throw_agent_out_of_range(name, "> 0", v); - } -} - -unsafe fn js_http_agent_new_with_protocol( - options_f64: f64, - default_protocol_ptr: *const u8, - default_protocol_len: usize, -) -> Handle { - let default_protocol = std::str::from_utf8(std::slice::from_raw_parts( - default_protocol_ptr, - default_protocol_len, - )) - .unwrap_or("http:") - .to_string(); - - let mut agent = AgentHandle { - protocol: Some(default_protocol), - ..AgentHandle::default() - }; - - let opts_bits = options_f64.to_bits(); - let opts_undef = - opts_bits == JSValue::undefined().bits() || opts_bits == JSValue::null().bits(); - - if !opts_undef { - if let Some(v) = get_object_number_field(options_f64, "keepAliveMsecs") { - if v.is_nan() || v < 0.0 { - throw_agent_out_of_range("keepAliveMsecs", ">= 0", v); - } - agent.keep_alive_msecs = v; - } - if let Some(v) = get_object_number_field(options_f64, "maxSockets") { - validate_agent_positive("maxSockets", v); - agent.max_sockets = v; - } - if let Some(v) = get_object_number_field(options_f64, "maxFreeSockets") { - validate_agent_positive("maxFreeSockets", v); - agent.max_free_sockets = v; - } - if let Some(v) = get_object_number_field(options_f64, "maxTotalSockets") { - validate_agent_positive("maxTotalSockets", v); - agent.max_total_sockets = v; - } - if let Some(v) = get_object_number_field(options_f64, "timeout") { - agent.timeout_ms = Some(v); - } - if let Some(s) = get_object_string_field(options_f64, "scheduling") { - agent.scheduling = s; - } - // `keepAlive` is a boolean; reuse the object header reader. - let obj_bits = options_f64.to_bits(); - let upper = obj_bits >> 48; - let obj_ptr = if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const perry_runtime::ObjectHeader - } else { - std::ptr::null() - }; - if !obj_ptr.is_null() { - let key = js_string_from_bytes("keepAlive".as_ptr(), 9); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_bool() { - agent.keep_alive = val.as_bool(); - } - // #2154: storage for createConnection / createSocket - // overrides. GC-rooted via `scan_http_roots_mut` below. - for (slot_field, slot) in [ - ("createConnection", &mut agent.create_connection), - ("createSocket", &mut agent.create_socket), - ] { - let key = js_string_from_bytes(slot_field.as_ptr(), slot_field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_pointer() { - *slot = (val.bits() & 0x0000_FFFF_FFFF_FFFF) as i64; - } - } - } - } - - register_handle(agent) -} - -/// `agent.getName([options])` — Node's canonical key under which sockets are -/// pooled. The base shape is `${host}:${port}:${localAddress}` with optional -/// `:${family}` and `:${socketPath}` appended. For https.Agent instances -/// 20 extra fields are appended (ca, cert, ciphers, key, …) per Node's -/// `lib/https.js`. Tests assert exact strings; see -/// `test/parallel/test-http-agent-getname.js` and -/// `test/parallel/test-https-agent-getname.js`. -#[no_mangle] -pub unsafe extern "C" fn js_http_agent_get_name( - handle: Handle, - options_f64: f64, -) -> *mut StringHeader { - let is_https = get_handle_mut::(handle) - .and_then(|a| a.protocol.as_deref().map(|p| p == "https:")) - .unwrap_or(false); - - let mut name = build_http_agent_name(options_f64); - if is_https { - append_https_agent_name_fields(&mut name, options_f64); - } - js_string_from_bytes(name.as_ptr(), name.len() as u32) -} - -/// Compute the http.Agent.getName portion of the pool key. -unsafe fn build_http_agent_name(options_f64: f64) -> String { - let opts_bits = options_f64.to_bits(); - let opts_undef = - opts_bits == JSValue::undefined().bits() || opts_bits == JSValue::null().bits(); - - if opts_undef { - return "localhost::".to_string(); - } - - let host = - get_object_string_field(options_f64, "host").unwrap_or_else(|| "localhost".to_string()); - let port = get_object_string_field(options_f64, "port").unwrap_or_default(); - let local_address = get_object_string_field(options_f64, "localAddress").unwrap_or_default(); - - let mut name = format!("{}:{}:{}", host, port, local_address); - - // Per Node's lib/_http_agent.js: family is appended FIRST (when 4 or 6), - // then socketPath. Both are independent — Node appends each separately - // if present. - if let Some(family) = get_object_number_field(options_f64, "family") { - let f = family as i64; - if f == 4 || f == 6 { - name.push(':'); - name.push_str(&f.to_string()); - } - } - if let Some(socket_path) = get_object_string_field(options_f64, "socketPath") { - name.push(':'); - name.push_str(&socket_path); - } - - name -} - -/// Append the 20 https.Agent.getName extension fields onto an already-built -/// http.Agent.getName prefix. Mirrors `Agent.prototype.getName` in Node's -/// `lib/https.js` (v22.x): every field gets its own `:` separator regardless -/// of whether the value is present, so an Agent with no options produces 20 -/// trailing colons. -unsafe fn append_https_agent_name_fields(name: &mut String, options_f64: f64) { - let opts_bits = options_f64.to_bits(); - let opts_undef = - opts_bits == JSValue::undefined().bits() || opts_bits == JSValue::null().bits(); - - if opts_undef { - // 20 empty fields → 20 trailing colons (1 separator per field). - for _ in 0..20 { - name.push(':'); - } - return; - } - - // Most fields use the `if (options.field) name += options.field;` shape - // — truthy → append ToString-coerced value. A small group - // (rejectUnauthorized, honorCipherOrder, secureOptions) checks - // `!== undefined` instead, so `false` and `0` are appended. - let host_value = get_object_field_raw(options_f64, "host"); - - let push_truthy_string = |name: &mut String, field: &str| { - name.push(':'); - if let Some(v) = get_object_field_raw(options_f64, field) { - if jsvalue_is_truthy(v) { - name.push_str(&jsvalue_to_string(v)); - } - } - }; - let push_defined = |name: &mut String, field: &str| { - name.push(':'); - if let Some(v) = get_object_field_raw(options_f64, field) { - if !v.is_undefined() { - name.push_str(&jsvalue_to_string(v)); - } - } - }; - - push_truthy_string(name, "ca"); - push_truthy_string(name, "cert"); - push_truthy_string(name, "clientCertEngine"); - push_truthy_string(name, "ciphers"); - push_truthy_string(name, "key"); - push_truthy_string(name, "pfx"); - push_defined(name, "rejectUnauthorized"); - - // servername appears only when defined AND distinct from host. - name.push(':'); - if let Some(sn) = get_object_field_raw(options_f64, "servername") { - if jsvalue_is_truthy(sn) { - let same_as_host = match host_value { - Some(h) if jsvalue_is_truthy(h) => jsvalue_to_string(h) == jsvalue_to_string(sn), - _ => false, - }; - if !same_as_host { - name.push_str(&jsvalue_to_string(sn)); - } - } - } - - push_truthy_string(name, "minVersion"); - push_truthy_string(name, "maxVersion"); - push_truthy_string(name, "secureProtocol"); - push_truthy_string(name, "crl"); - push_defined(name, "honorCipherOrder"); - push_truthy_string(name, "ecdhCurve"); - push_truthy_string(name, "dhparam"); - push_defined(name, "secureOptions"); - push_truthy_string(name, "sessionIdContext"); - - // sigalgs is JSON-stringified (Node: `name += JSONStringify(options.sigalgs)`). - name.push(':'); - if let Some(v) = get_object_field_raw(options_f64, "sigalgs") { - if jsvalue_is_truthy(v) { - name.push_str(&jsvalue_to_json_string(v)); - } - } - - push_truthy_string(name, "privateKeyIdentifier"); - push_truthy_string(name, "privateKeyEngine"); -} - -/// `agent.keepSocketAlive(socket)` / `agent.reuseSocket(socket, req)` — -/// this Agent flavor exposes no per-socket hooks to act on, so these return -/// the receiver for chainability but otherwise do nothing. Warn once instead -/// of silently succeeding (#4917). (Default builds route http through -/// perry-ext-http, where reqwest owns the keep-alive pool.) -#[no_mangle] -pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { - perry_runtime::stub_diag::perry_stub_warn( - "http.Agent keepSocketAlive/reuseSocket", - "this http Agent has no per-socket hooks; the call is a no-op", - Some("#4917"), - ); - handle -} - -/// Property getters — `agent.maxSockets`, `agent.keepAlive`, etc. Return -/// the per-instance value where one was set; fall back to Node defaults -/// when the handle is missing (synthetic agent reads). -#[no_mangle] -pub extern "C" fn js_http_agent_max_sockets(handle: Handle) -> f64 { - get_handle_mut::(handle) - .map(|a| a.max_sockets) - .unwrap_or(f64::INFINITY) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_max_free_sockets(handle: Handle) -> f64 { - get_handle_mut::(handle) - .map(|a| a.max_free_sockets) - .unwrap_or(256.0) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_max_total_sockets(handle: Handle) -> f64 { - get_handle_mut::(handle) - .map(|a| a.max_total_sockets) - .unwrap_or(f64::INFINITY) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_keep_alive_msecs(handle: Handle) -> f64 { - get_handle_mut::(handle) - .map(|a| a.keep_alive_msecs) - .unwrap_or(1000.0) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_keep_alive(handle: Handle) -> f64 { - let keep_alive = get_handle_mut::(handle) - .map(|a| a.keep_alive) - .unwrap_or(false); - f64::from_bits(JSValue::bool(keep_alive).bits()) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_protocol(handle: Handle) -> *mut StringHeader { - let s = get_handle_mut::(handle) - .and_then(|a| a.protocol.clone()) - .unwrap_or_else(|| "http:".to_string()); - unsafe { js_string_from_bytes(s.as_ptr(), s.len() as u32) } -} - -#[no_mangle] -pub unsafe extern "C" fn js_http_agent_set_protocol( - handle: Handle, - value_ptr: *const StringHeader, -) { - if let Some(agent) = get_handle_mut::(handle) { - if value_ptr.is_null() { - agent.protocol = None; - } else if let Some(s) = string_from_header(value_ptr) { - agent.protocol = Some(s); - } - } -} - -// #2154: validating setters for the tunable Agent properties. Node lets -// user code do `agent.maxSockets = 4` and rejects invalid writes with the -// same RangeError the constructor throws. - -#[no_mangle] -pub extern "C" fn js_http_agent_set_max_sockets(handle: Handle, value: f64) { - validate_agent_positive("maxSockets", value); - if let Some(agent) = get_handle_mut::(handle) { - agent.max_sockets = value; - } -} - -#[no_mangle] -pub extern "C" fn js_http_agent_set_max_free_sockets(handle: Handle, value: f64) { - validate_agent_positive("maxFreeSockets", value); - if let Some(agent) = get_handle_mut::(handle) { - agent.max_free_sockets = value; - } -} - -#[no_mangle] -pub extern "C" fn js_http_agent_set_max_total_sockets(handle: Handle, value: f64) { - validate_agent_positive("maxTotalSockets", value); - if let Some(agent) = get_handle_mut::(handle) { - agent.max_total_sockets = value; - } -} - -#[no_mangle] -pub extern "C" fn js_http_agent_set_keep_alive_msecs(handle: Handle, value: f64) { - if value.is_nan() || value < 0.0 { - throw_agent_out_of_range("keepAliveMsecs", ">= 0", value); - } - if let Some(agent) = get_handle_mut::(handle) { - agent.keep_alive_msecs = value; - } -} - -#[no_mangle] -pub extern "C" fn js_http_agent_set_keep_alive(handle: Handle, value: f64) { - let on = value != 0.0 && !value.is_nan(); - if let Some(agent) = get_handle_mut::(handle) { - agent.keep_alive = on; - } -} - -/// `agent.destroyed`. Always 0/1 (matches the runtime's number ABI on -/// the `__get_` path). -#[no_mangle] -pub extern "C" fn js_http_agent_destroyed(handle: Handle) -> f64 { - let destroyed = get_handle_mut::(handle) - .map(|a| a.destroyed) - .unwrap_or(false); - f64::from_bits(JSValue::bool(destroyed).bits()) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_default_port(handle: Handle) -> f64 { - match get_handle_mut::(handle) - .and_then(|a| a.protocol.clone()) - .unwrap_or_else(|| "http:".to_string()) - .as_str() - { - "https:" => 443.0, - "http:" => 80.0, - _ => 0.0, - } -} - -/// `agent.destroy()` — flag the agent as destroyed (so the `destroyed` -/// getter returns true) and return the handle for chainability. -#[no_mangle] -pub extern "C" fn js_http_agent_destroy(handle: Handle) -> Handle { - if let Some(agent) = get_handle_mut::(handle) { - agent.destroyed = true; - } - handle -} - -#[no_mangle] -pub extern "C" fn js_http_agent_set_create_connection(handle: Handle, closure_ptr: i64) { - if let Some(agent) = get_handle_mut::(handle) { - agent.create_connection = closure_ptr; - } -} - -#[no_mangle] -pub extern "C" fn js_http_agent_set_create_socket(handle: Handle, closure_ptr: i64) { - if let Some(agent) = get_handle_mut::(handle) { - agent.create_socket = closure_ptr; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn root_scanner_emits_request_and_response_listeners() { - let mut req_listeners = HashMap::new(); - req_listeners.insert("error".to_string(), vec![0x1234_5678]); - let req_handle = register_handle(ClientRequestHandle { - method: "GET".to_string(), - url: "http://example.test".to_string(), - headers: HashMap::new(), - body: Vec::new(), - response_callback: 0x2345_6780, - listeners: req_listeners, - timeout_ms: None, - ended: false, - agent_handle: 0, - }); - - let mut msg_listeners = HashMap::new(); - msg_listeners.insert("data".to_string(), vec![0x3456_7890]); - let msg_handle = register_handle(IncomingMessageHandle { - status_code: 200, - status_message: "OK".to_string(), - headers: HashMap::new(), - body: Vec::new(), - listeners: msg_listeners, - encoding: None, - }); - - let mut emitted = Vec::new(); - scan_http_roots(&mut |value| emitted.push(value.to_bits())); - - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x1234_5678))); - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x2345_6780))); - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x3456_7890))); - crate::common::drop_handle(req_handle); - crate::common::drop_handle(msg_handle); - } -} diff --git a/crates/perry-stdlib/src/http/agent_dispatch.rs b/crates/perry-stdlib/src/http/agent_dispatch.rs deleted file mode 100644 index cdba7653f8..0000000000 --- a/crates/perry-stdlib/src/http/agent_dispatch.rs +++ /dev/null @@ -1,164 +0,0 @@ -use perry_runtime::JSValue; - -use crate::common::{get_handle_mut, Handle}; - -use super::{ - js_class_method_bind, js_http_agent_destroy, js_http_agent_get_name, js_http_agent_noop_self, - AgentHandle, POINTER_TAG, PTR_MASK, -}; - -fn bind_agent_method(handle: Handle, name: &'static [u8]) -> i64 { - (bind_agent_method_value(handle, name).to_bits() & PTR_MASK) as i64 -} - -fn bind_agent_method_value(handle: Handle, name: &'static [u8]) -> f64 { - let instance = f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)); - unsafe { js_class_method_bind(instance, name.as_ptr(), name.len()) } -} - -fn pointer_value(ptr: i64) -> f64 { - if ptr == 0 { - f64::from_bits(JSValue::undefined().bits()) - } else { - f64::from_bits(POINTER_TAG | (ptr as u64 & PTR_MASK)) - } -} - -pub(crate) fn dispatch_agent_property(handle: Handle, property: &str) -> Option { - get_handle_mut::(handle)?; - Some(match property { - "createConnection" => pointer_value(js_http_agent_create_connection(handle)), - "createSocket" => pointer_value(js_http_agent_create_socket(handle)), - "getName" => bind_agent_method_value(handle, b"getName"), - "destroy" => bind_agent_method_value(handle, b"destroy"), - "keepSocketAlive" => bind_agent_method_value(handle, b"keepSocketAlive"), - "reuseSocket" => bind_agent_method_value(handle, b"reuseSocket"), - // #4904: data properties — Agents constructed through the dynamic - // value path (`const { Agent } = require('http'); new Agent(...)`) - // read these through handle property dispatch rather than the - // class-filtered native rows. - "maxSockets" => super::js_http_agent_max_sockets(handle), - "maxFreeSockets" => super::js_http_agent_max_free_sockets(handle), - "maxTotalSockets" => super::js_http_agent_max_total_sockets(handle), - "keepAliveMsecs" => super::js_http_agent_keep_alive_msecs(handle), - "keepAlive" => super::js_http_agent_keep_alive(handle), - "destroyed" => super::js_http_agent_destroyed(handle), - "defaultPort" => super::js_http_agent_default_port(handle), - "protocol" => { - let ptr = super::js_http_agent_protocol(handle); - if ptr.is_null() { - f64::from_bits(JSValue::undefined().bits()) - } else { - f64::from_bits(JSValue::string_ptr(ptr).bits()) - } - } - "sockets" => js_http_agent_sockets(handle), - "freeSockets" => js_http_agent_free_sockets(handle), - "requests" => js_http_agent_requests(handle), - _ => return None, - }) -} - -/// #4904: property writes on a dynamically-dispatched Agent — -/// `agent.maxSockets = 4` and the `agent.createConnection = fn` -/// monkeypatch pattern Node's own tests use. Returns `true` when claimed. -pub(crate) fn dispatch_agent_property_set(handle: Handle, property: &str, value: f64) -> bool { - if get_handle_mut::(handle).is_none() { - return false; - } - match property { - "maxSockets" => super::js_http_agent_set_max_sockets(handle, value), - "maxFreeSockets" => super::js_http_agent_set_max_free_sockets(handle, value), - "maxTotalSockets" => super::js_http_agent_set_max_total_sockets(handle, value), - "keepAliveMsecs" => super::js_http_agent_set_keep_alive_msecs(handle, value), - "keepAlive" => super::js_http_agent_set_keep_alive(handle, value), - "createConnection" | "createSocket" => { - let bits = value.to_bits(); - let ptr = if JSValue::from_bits(bits).is_pointer() { - (bits & PTR_MASK) as i64 - } else { - 0 - }; - if property == "createConnection" { - super::js_http_agent_set_create_connection(handle, ptr); - } else { - super::js_http_agent_set_create_socket(handle, ptr); - } - } - _ => return false, - } - true -} - -pub(crate) unsafe fn dispatch_agent_method( - handle: Handle, - method: &str, - args: &[f64], -) -> Option { - get_handle_mut::(handle)?; - Some(match method { - "getName" => { - let options = args - .first() - .copied() - .unwrap_or_else(|| f64::from_bits(JSValue::undefined().bits())); - let ptr = js_http_agent_get_name(handle, options); - f64::from_bits(JSValue::string_ptr(ptr).bits()) - } - "destroy" => pointer_value(js_http_agent_destroy(handle)), - "keepSocketAlive" | "reuseSocket" => pointer_value(js_http_agent_noop_self(handle)), - _ => return None, - }) -} - -/// Allocate the empty object Node exposes for `agent.sockets`, -/// `agent.freeSockets`, and `agent.requests` before any requests are pooled. -fn empty_object_bits_f64() -> f64 { - let obj = perry_runtime::js_object_alloc(0, 0); - if obj.is_null() { - return f64::from_bits(JSValue::undefined().bits()); - } - f64::from_bits(JSValue::object_ptr(obj as *mut u8).bits()) -} - -#[no_mangle] -pub extern "C" fn js_http_agent_sockets(handle: Handle) -> f64 { - let _ = handle; - empty_object_bits_f64() -} - -#[no_mangle] -pub extern "C" fn js_http_agent_free_sockets(handle: Handle) -> f64 { - let _ = handle; - empty_object_bits_f64() -} - -#[no_mangle] -pub extern "C" fn js_http_agent_requests(handle: Handle) -> f64 { - let _ = handle; - empty_object_bits_f64() -} - -#[no_mangle] -pub extern "C" fn js_http_agent_create_connection(handle: Handle) -> i64 { - let stored = get_handle_mut::(handle) - .map(|a| a.create_connection) - .unwrap_or(0); - if stored != 0 { - stored - } else { - bind_agent_method(handle, b"createConnection") - } -} - -#[no_mangle] -pub extern "C" fn js_http_agent_create_socket(handle: Handle) -> i64 { - let stored = get_handle_mut::(handle) - .map(|a| a.create_socket) - .unwrap_or(0); - if stored != 0 { - stored - } else { - bind_agent_method(handle, b"createSocket") - } -} diff --git a/crates/perry-stdlib/src/http/client_request_surface.rs b/crates/perry-stdlib/src/http/client_request_surface.rs deleted file mode 100644 index 7be0a8c070..0000000000 --- a/crates/perry-stdlib/src/http/client_request_surface.rs +++ /dev/null @@ -1,409 +0,0 @@ -use super::*; -use std::sync::Mutex; - -#[derive(Default)] -struct ClientRequestSurfaceState { - aborted: bool, - destroyed: bool, - socket: f64, -} - -static CLIENT_REQUEST_SURFACE: once_cell::sync::Lazy< - Mutex>, -> = once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); - -fn undefined_value() -> f64 { - f64::from_bits(JSValue::undefined().bits()) -} - -fn null_value() -> f64 { - f64::from_bits(JSValue::null().bits()) -} - -fn bool_value(value: bool) -> f64 { - f64::from_bits(JSValue::bool(value).bits()) -} - -fn string_value(value: &str) -> f64 { - let ptr = js_string_from_bytes(value.as_ptr(), value.len() as u32); - f64::from_bits(JSValue::string_ptr(ptr).bits()) -} - -fn handle_value(handle: Handle) -> f64 { - f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)) -} - -pub(super) fn scan_roots(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { - for state in CLIENT_REQUEST_SURFACE.lock().unwrap().values_mut() { - if state.socket != 0.0 { - visitor.visit_nanbox_f64_slot(&mut state.socket); - } - } -} - -pub(crate) fn is_client_request_handle(handle: Handle) -> bool { - get_handle_mut::(handle).is_some() -} - -fn with_state_mut(handle: Handle, f: impl FnOnce(&mut ClientRequestSurfaceState) -> T) -> T { - let mut states = CLIENT_REQUEST_SURFACE.lock().unwrap(); - f(states.entry(handle).or_default()) -} - -fn find_header_key(req: &ClientRequestHandle, name: &str) -> Option { - req.headers - .keys() - .find(|key| key.eq_ignore_ascii_case(name)) - .cloned() -} - -fn header_names(handle: Handle, raw: bool) -> Vec { - let mut names = get_handle_mut::(handle) - .map(|req| { - req.headers - .keys() - .map(|key| { - if raw { - key.clone() - } else { - key.to_ascii_lowercase() - } - }) - .collect::>() - }) - .unwrap_or_default(); - names.sort(); - names.dedup(); - names -} - -pub(super) fn set_header(handle: Handle, name: &str, value: String) { - if let Some(req) = get_handle_mut::(handle) { - if let Some(existing) = find_header_key(req, name) { - req.headers.remove(&existing); - } - req.headers.insert(name.to_string(), value); - } -} - -fn get_header_by_name(handle: Handle, name: &str) -> Option { - get_handle_mut::(handle).and_then(|req| { - let key = find_header_key(req, name)?; - req.headers.get(&key).cloned() - }) -} - -fn remove_header_by_name(handle: Handle, name: &str) { - if let Some(req) = get_handle_mut::(handle) { - if let Some(key) = find_header_key(req, name) { - req.headers.remove(&key); - } - } -} - -fn headers_array(handle: Handle, raw: bool) -> f64 { - let names = header_names(handle, raw); - let mut arr = perry_runtime::js_array_alloc(names.len() as u32); - for name in names { - let ptr = js_string_from_bytes(name.as_ptr(), name.len() as u32); - arr = perry_runtime::js_array_push(arr, JSValue::string_ptr(ptr)); - } - f64::from_bits(JSValue::array_ptr(arr).bits()) -} - -fn headers_object(handle: Handle) -> f64 { - let mut entries = get_handle_mut::(handle) - .map(|req| { - req.headers - .iter() - .map(|(key, value)| (key.to_ascii_lowercase(), value.clone())) - .collect::>() - }) - .unwrap_or_default(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); - entries.dedup_by(|a, b| a.0 == b.0); - - let obj = perry_runtime::js_object_alloc_null_proto(0, entries.len() as u32); - let mut keys = perry_runtime::js_array_alloc(entries.len() as u32); - for (index, (key, value)) in entries.iter().enumerate() { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - let value_ptr = js_string_from_bytes(value.as_ptr(), value.len() as u32); - perry_runtime::js_object_set_field(obj, index as u32, JSValue::string_ptr(value_ptr)); - keys = perry_runtime::js_array_push(keys, JSValue::string_ptr(key_ptr)); - } - perry_runtime::js_object_set_keys(obj, keys); - f64::from_bits(JSValue::object_ptr(obj as *mut u8).bits()) -} - -fn socket_value(handle: Handle) -> f64 { - if !is_client_request_handle(handle) { - return undefined_value(); - } - with_state_mut(handle, |state| { - if state.socket == 0.0 { - let obj = perry_runtime::js_object_alloc(0, 0); - state.socket = f64::from_bits(JSValue::object_ptr(obj as *mut u8).bits()); - } - state.socket - }) -} - -fn state_bool(handle: Handle, property: &str) -> f64 { - let ended = get_handle_mut::(handle) - .map(|req| req.ended) - .unwrap_or(false); - let states = CLIENT_REQUEST_SURFACE.lock().unwrap(); - let state = states.get(&handle); - bool_value(match property { - "aborted" => state.map(|s| s.aborted).unwrap_or(false), - "destroyed" => state.map(|s| s.destroyed).unwrap_or(false), - "finished" | "writableEnded" | "writableFinished" => ended, - "reusedSocket" => false, - _ => false, - }) -} - -fn string_arg(args: &[f64], index: usize) -> Option { - args.get(index) - .copied() - .and_then(|value| unsafe { extract_string_value(value) }) -} - -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_get_header( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - string_from_header(name_ptr) - .and_then(|name| get_header_by_name(handle, &name)) - .map(|value| string_value(&value)) - .unwrap_or_else(undefined_value) -} - -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_has_header( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - let has = string_from_header(name_ptr) - .and_then(|name| get_header_by_name(handle, &name)) - .is_some(); - bool_value(has) -} - -#[no_mangle] -pub unsafe extern "C" fn js_http_client_request_remove_header( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - if let Some(name) = string_from_header(name_ptr) { - remove_header_by_name(handle, &name); - } - undefined_value() -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_get_header_names(handle: Handle) -> f64 { - headers_array(handle, false) -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_get_raw_header_names(handle: Handle) -> f64 { - headers_array(handle, true) -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_get_headers(handle: Handle) -> f64 { - headers_object(handle) -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_abort(handle: Handle) -> f64 { - if is_client_request_handle(handle) { - with_state_mut(handle, |state| { - state.aborted = true; - state.destroyed = true; - }); - } - undefined_value() -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_destroy(handle: Handle, _error: f64) -> Handle { - if is_client_request_handle(handle) { - with_state_mut(handle, |state| state.destroyed = true); - } - handle -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_noop_undefined( - handle: Handle, - _arg0: f64, - _arg1: f64, -) -> f64 { - let _ = handle; - undefined_value() -} - -/// Twin of perry-ext-http's `js_http_client_request_flush_headers` for -/// non-auto-optimize links: the stdlib client dispatches the whole exchange -/// at `end()`, so flushHeaders stays a no-op here. -#[no_mangle] -pub extern "C" fn js_http_client_request_flush_headers( - handle: Handle, - _arg0: f64, - _arg1: f64, -) -> f64 { - let _ = handle; - undefined_value() -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_aborted(handle: Handle) -> f64 { - state_bool(handle, "aborted") -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_destroyed(handle: Handle) -> f64 { - state_bool(handle, "destroyed") -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_finished(handle: Handle) -> f64 { - state_bool(handle, "finished") -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_reused_socket(handle: Handle) -> f64 { - state_bool(handle, "reusedSocket") -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_max_headers_count(handle: Handle) -> f64 { - let _ = handle; - null_value() -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_writable_ended(handle: Handle) -> f64 { - state_bool(handle, "writableEnded") -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_writable_finished(handle: Handle) -> f64 { - state_bool(handle, "writableFinished") -} - -#[no_mangle] -pub extern "C" fn js_http_client_request_socket(handle: Handle) -> f64 { - socket_value(handle) -} - -pub(crate) fn dispatch_client_request_property(handle: Handle, property: &str) -> Option { - if !is_client_request_handle(handle) { - return None; - } - let method: Option<&'static [u8]> = match property { - "on" => Some(b"on"), - "end" => Some(b"end"), - "write" => Some(b"write"), - "setHeader" => Some(b"setHeader"), - "setTimeout" => Some(b"setTimeout"), - "listenerCount" => Some(b"listenerCount"), - "getHeader" => Some(b"getHeader"), - "hasHeader" => Some(b"hasHeader"), - "removeHeader" => Some(b"removeHeader"), - "getHeaderNames" => Some(b"getHeaderNames"), - "getHeaders" => Some(b"getHeaders"), - "getRawHeaderNames" => Some(b"getRawHeaderNames"), - "abort" => Some(b"abort"), - "destroy" => Some(b"destroy"), - "flushHeaders" => Some(b"flushHeaders"), - "cork" => Some(b"cork"), - "uncork" => Some(b"uncork"), - "setNoDelay" => Some(b"setNoDelay"), - "setSocketKeepAlive" => Some(b"setSocketKeepAlive"), - _ => None, - }; - if let Some(name) = method { - return Some(unsafe { - js_class_method_bind(handle_value(handle), name.as_ptr(), name.len()) - }); - } - Some(match property { - "method" => { - f64::from_bits(JSValue::string_ptr(js_http_client_request_method(handle)).bits()) - } - "protocol" => { - f64::from_bits(JSValue::string_ptr(js_http_client_request_protocol(handle)).bits()) - } - "host" => f64::from_bits(JSValue::string_ptr(js_http_client_request_host(handle)).bits()), - "path" => f64::from_bits(JSValue::string_ptr(js_http_client_request_path(handle)).bits()), - "aborted" => js_http_client_request_aborted(handle), - "destroyed" => js_http_client_request_destroyed(handle), - "finished" => js_http_client_request_finished(handle), - "reusedSocket" => js_http_client_request_reused_socket(handle), - "maxHeadersCount" => js_http_client_request_max_headers_count(handle), - "writableEnded" => js_http_client_request_writable_ended(handle), - "writableFinished" => js_http_client_request_writable_finished(handle), - "socket" | "connection" => js_http_client_request_socket(handle), - _ => return None, - }) -} - -pub(crate) fn dispatch_client_request_method( - handle: Handle, - method: &str, - args: &[f64], -) -> Option { - if !is_client_request_handle(handle) { - return None; - } - Some(match method { - "setHeader" => { - let name = string_arg(args, 0).unwrap_or_default(); - let value = string_arg(args, 1).unwrap_or_default(); - set_header(handle, &name, value); - handle_value(handle) - } - "getHeader" => string_arg(args, 0) - .and_then(|name| get_header_by_name(handle, &name)) - .map(|value| string_value(&value)) - .unwrap_or_else(undefined_value), - "hasHeader" => bool_value( - string_arg(args, 0) - .and_then(|name| get_header_by_name(handle, &name)) - .is_some(), - ), - "removeHeader" => { - if let Some(name) = string_arg(args, 0) { - remove_header_by_name(handle, &name); - } - undefined_value() - } - "getHeaderNames" => headers_array(handle, false), - "getHeaders" => headers_object(handle), - "getRawHeaderNames" => headers_array(handle, true), - "listenerCount" => { - let event = string_arg(args, 0).unwrap_or_default(); - get_handle_mut::(handle) - .map(|req| { - let explicit = req.listeners.get(&event).map(|v| v.len()).unwrap_or(0); - let implicit_response = if event == "response" && req.response_callback != 0 { - 1 - } else { - 0 - }; - (explicit + implicit_response) as f64 - }) - .unwrap_or(0.0) - } - "abort" => js_http_client_request_abort(handle), - "destroy" => handle_value(js_http_client_request_destroy(handle, undefined_value())), - "flushHeaders" | "cork" | "uncork" | "setNoDelay" | "setSocketKeepAlive" => { - undefined_value() - } - _ => return None, - }) -} diff --git a/crates/perry-stdlib/src/http/external_client_request.rs b/crates/perry-stdlib/src/http/external_client_request.rs deleted file mode 100644 index a39bb7cd43..0000000000 --- a/crates/perry-stdlib/src/http/external_client_request.rs +++ /dev/null @@ -1,62 +0,0 @@ -use perry_runtime::StringHeader; - -use crate::common::Handle; - -use super::{POINTER_TAG, PTR_MASK}; - -pub(super) unsafe fn dispatch_method(handle: Handle, method: &str, args: &[f64]) -> Option { - extern "C" { - fn js_ext_http_client_request_is_handle(handle: i64) -> i32; - fn js_ext_http_client_request_dispatch_method( - handle: i64, - method_ptr: *const u8, - method_len: usize, - args_ptr: *const f64, - args_len: usize, - ) -> f64; - } - if unsafe { js_ext_http_client_request_is_handle(handle) } == 0 { - return None; - } - Some(unsafe { - js_ext_http_client_request_dispatch_method( - handle, - method.as_ptr(), - method.len(), - args.as_ptr(), - args.len(), - ) - }) -} - -unsafe fn dispatch_property(handle: Handle, property: &str) -> Option { - extern "C" { - fn js_ext_http_client_request_is_handle(handle: i64) -> i32; - fn js_ext_http_client_request_dispatch_property( - handle: i64, - property_ptr: *const u8, - property_len: usize, - ) -> f64; - } - if unsafe { js_ext_http_client_request_is_handle(handle) } == 0 { - return None; - } - Some(unsafe { - js_ext_http_client_request_dispatch_property(handle, property.as_ptr(), property.len()) - }) -} - -pub(super) unsafe fn string_property(handle: Handle, property: &str) -> Option<*mut StringHeader> { - let value = unsafe { dispatch_property(handle, property) }?; - let bits = value.to_bits(); - let tag = bits & !PTR_MASK; - if tag != 0x7FFF_0000_0000_0000 && tag != POINTER_TAG { - return None; - } - let ptr = (bits & PTR_MASK) as *mut StringHeader; - if ptr.is_null() { - None - } else { - Some(ptr) - } -} diff --git a/crates/perry-stdlib/src/jsonwebtoken.rs b/crates/perry-stdlib/src/jsonwebtoken.rs index 6120a2bffd..d7b9f7cbba 100644 --- a/crates/perry-stdlib/src/jsonwebtoken.rs +++ b/crates/perry-stdlib/src/jsonwebtoken.rs @@ -260,7 +260,7 @@ pub unsafe extern "C" fn js_jwt_sign_dyn( } /// Coerce a NaN-boxed JSValue (`f64`) into a raw `*const ObjectHeader` -/// pointer. Mirrors the upper-bits sniff used in `perry-stdlib/src/http.rs`. +/// pointer. Mirrors the upper-bits sniff used by the native HTTP bindings. /// Returns null when the value isn't pointer-shaped. unsafe fn jsvalue_to_object_ptr(obj_f64: f64) -> *const ObjectHeader { let obj_bits = obj_f64.to_bits(); diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index b85d7c74ce..944176c634 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -6,7 +6,7 @@ //! # Features //! - `core` - Minimal runtime (always included) //! - `http-server` - Native HTTP server (hyper-based) -//! - `http-client` - HTTP client (reqwest/node-fetch) +//! - `http-client` - Web Fetch and Axios compatibility surface //! - `database` - All databases (postgres, mysql, sqlite, redis, mongodb) //! - `crypto` - Cryptographic functions //! - `compression` - zlib compression @@ -131,13 +131,8 @@ pub use framework::*; // `external-fastify-pump` feature (drained from `async_bridge`). // === Web Fetch API (fetch / Headers / Request / Response / Blob) === -// #5174: gated on `web-fetch`, NOT `http-client`. The Web Fetch surface -// (reqwest-backed `fetch()` + the WHATWG data types) is independent of -// the bundled node:http client below, so a program that only needs -// `new Headers()` while routing `node:http` to perry-ext-http keeps -// these without dragging in the colliding bundled http.rs symbols. -// `http-client = ["web-fetch"]`, so `--features http-client` still -// compiles all of this exactly as before. +// #5174: gated on `web-fetch`, not `http-client`, so Web Fetch stays +// independent from the external node:http implementation. #[cfg(feature = "web-fetch")] pub mod fetch; #[cfg(feature = "web-fetch")] @@ -149,16 +144,7 @@ pub mod fetch_blob; #[cfg(feature = "web-fetch")] pub use fetch_blob::*; -// === Bundled node:http client (http.request / http.get / axios) === -// Stays on `http-client`. The well-known flip strips `http-client` -// (keeping `web-fetch`) when `node:http` routes to perry-ext-http, so -// these modules — which export the same `js_http_*` symbols as -// perry-ext-http — are absent and can't collide (#5174). -#[cfg(feature = "http-client")] -pub mod http; -#[cfg(feature = "http-client")] -pub use http::*; - +// === Axios compatibility surface === #[cfg(feature = "http-client")] pub mod axios; #[cfg(feature = "http-client")] diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index bfc93dc634..eaf72cfd27 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -143,23 +143,8 @@ pub(crate) fn build_optimized_libs( // through perry-stdlib's tokio. Their workspace-built .a stays // fine. let mut tokio_using_bindings: Vec<(String, String, Option)> = Vec::new(); - // Closes #589: hono + node:http combinations dropped js_headers_new / - // js_response_new / js_request_new at link time. The well-known flip - // strips perry-stdlib's `http-client` feature when `node:http` is - // imported and routes to perry-ext-http — but perry-ext-http only - // exports the HTTP-client surface (`js_http_*` / `js_node_http_*`), - // not the Web Fetch ctors that hono's compiled output references. - // - // When the user's TS code (or any compilePackages-resolved module like - // hono) constructs `new Headers(...)` / `new Request(...)` / `new Response(...)`, - // the HIR sets `ctx.uses_fetch = true` (see - // `crates/perry-hir/src/destructuring.rs::1469-1492` + the explicit - // `fetch(...)` arms in `lower/expr_call.rs`). Keep `http-client` below - // so perry-stdlib supplies both the constructors and the erased-type - // Request/Response/Headers/Blob dispatch registries. Do not synthesize - // the `"fetch"` well-known binding from `uses_fetch`: perry-ext-fetch has - // separate registries, so a builtin `new Request()` constructed there - // would make `(req as any).url` miss stdlib's dispatch path. + // Web Fetch is selected independently from the external node:http + // binding. `uses_fetch` adds `web-fetch` in compute_required_features. if use_well_known { for module in &iteration_set { let module_normalized = module.strip_prefix("node:").unwrap_or(module); @@ -248,31 +233,6 @@ pub(crate) fn build_optimized_libs( // `compute_required_features` consulted above, so we // know exactly what to remove. for feat in crate::commands::stdlib_features::module_to_features(module_normalized) { - // Fix #589 / #5174: `node:http` / `node:https` / - // `node:http2` map to `http-client`, but that umbrella - // covers BOTH the bundled node:http client - // (`src/http.rs` + `src/axios.rs`) AND the Web Fetch - // FFIs (`js_headers_new`, `js_response_new`, - // `js_request_new`, …). When a program uses - // `new Headers()` / `new Response()` (directly or via a - // compilePackages package like hono) while also - // importing `node:http`, we must keep the Web Fetch - // half but drop the bundled client — otherwise its - // `js_http_process_pending` (and the rest of the - // `js_http_*` surface) duplicate perry-ext-http's - // symbols, and perry-ext-http's aux-pump call binds to - // perry-stdlib's empty-queue copy, wedging the - // in-process response pump (#5174). Since `http-client - // = ["web-fetch"]`, strip the umbrella and re-assert - // `web-fetch`: fetch.rs/fetch_blob.rs stay, - // http.rs/axios.rs go. The well-known staticlib - // (perry-ext-http) is still - // added for the actual node:http surface. - if *feat == "http-client" && ctx.uses_fetch { - features.remove("http-client"); - features.insert("web-fetch"); - continue; - } // Refs #643: keep `database-sqlite` enabled even when // `better-sqlite3` routes to perry-ext-better-sqlite3. // perry-stdlib's `dispatch_sqlite_stmt` (the dynamic diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index df7222684e..5438864028 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -33,16 +33,11 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // spellings need the same feature for auto-optimized stdlib builds. "streams" | "stream/web" | "stream_web" | "fs/promises" => &["bundled-streams"], - // ── HTTP client (reqwest) ───────────────────────────────────── - // `http` / `https` / `http2` join the `http-client` umbrella since - // they bottom out in reqwest just like axios + node-fetch — and - // perry-ext-http (issue #577) needs the same async-runtime - // bridge for `perry_ffi_spawn_blocking_with_reactor`. The - // well-known flip swaps perry-stdlib's http.rs for perry-ext-http - // (v0.5.571); `http2` flips to the same staticlib. Programs that import `streams` - // should NOT also use the well-known flip — streams stays in - // perry-stdlib until its own port lands. - "axios" | "node-fetch" | "http" | "https" | "http2" => &["http-client"], + // ── Web Fetch and Axios compatibility surface ──────────────── + // Node HTTP/HTTPS/HTTP2 are provided by perry-ext-http and need + // no perry-stdlib feature. Axios and node-fetch still use the + // legacy umbrella for compatibility. + "axios" | "node-fetch" => &["http-client"], // ── WebSocket ───────────────────────────────────────────────── // `websocket` umbrella retained for backwards-compat; @@ -221,17 +216,16 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // events won't propagate to user callbacks. "readline" => &["async-runtime"], - // Modules with no optional perry-stdlib dependency (decimal.js, - // bignumber.js, lru-cache, commander, exponential-backoff, http, - // https, events, async_hooks, worker_threads, …) — handled by - // always-on stdlib code. + // Modules with no optional perry-stdlib dependency (http, https, + // http2, events, async_hooks, worker_threads, …) are provided by + // external bindings or always-on runtime code. _ => &[], } } /// Compute the union of perry-stdlib features required to cover every /// native module the project imports, plus features needed to satisfy -/// non-import-based usage flags (e.g. `uses_fetch` ⇒ `http-client`). +/// non-import-based usage flags (e.g. `uses_fetch` ⇒ `web-fetch`). pub fn compute_required_features( native_module_imports: &BTreeSet, uses_fetch: bool, @@ -244,13 +238,8 @@ pub fn compute_required_features( } } // Built-in `fetch()` / `node-fetch` and the WHATWG data types - // (`Headers` / `Request` / `Response` / `Blob`) bottom out in reqwest - // but do NOT need perry-stdlib's bundled node:http client. #5174: ask - // for `web-fetch` (just `src/fetch/` + `src/fetch_blob.rs`), not the - // `http-client` umbrella that also pulls in `src/http.rs` / `src/axios.rs`. - // When the program ALSO imports `node:http`, that import adds - // `http-client` separately and the well-known flip strips it down to - // `web-fetch` — so the bundled client never collides with perry-ext-http. + // (`Headers` / `Request` / `Response` / `Blob`) use the Web Fetch + // feature directly, without enabling Axios. if uses_fetch { features.insert("web-fetch"); } @@ -285,4 +274,12 @@ mod tests { let features = compute_required_features(&imports, false, false); assert!(features.contains("bundled-streams")); } + + #[test] + fn node_http_uses_external_binding_without_legacy_client_feature() { + assert!(module_to_features("http").is_empty()); + assert!(module_to_features("node:https").is_empty()); + assert!(module_to_features("http2").is_empty()); + assert_eq!(module_to_features("axios"), &["http-client"]); + } } diff --git a/docs/src/contributing/crate-policy.md b/docs/src/contributing/crate-policy.md index 89c4f7612f..dbfdffdce5 100644 --- a/docs/src/contributing/crate-policy.md +++ b/docs/src/contributing/crate-policy.md @@ -63,8 +63,6 @@ reason to split or merge it. - Native bindings use `perry-ffi` as their production interface to Perry. - A production dependency from `perry-ext-*` to `perry-runtime` is forbidden. - `perry-ext-http` is the sole recorded migration debt while its missing FFI - capabilities are introduced. - Test binaries may enable `perry-ffi/runtime-link`; that edge provides runtime symbols for tests and is not part of the binding's distributed contract. - Runtime and stdlib functionality must have one production implementation. diff --git a/workspace-architecture.json b/workspace-architecture.json index f85ce7d398..4fa21ef747 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -3,9 +3,7 @@ "expected_default_members": [ "perry" ], - "allowed_binding_runtime_dependencies": [ - "perry-ext-http" - ], + "allowed_binding_runtime_dependencies": [], "ci": { "linux_host_excluded_members": [ "perry-ui-android",