diff --git a/Cargo.lock b/Cargo.lock index 4b73c47b1d..658e02c3a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6025,6 +6025,15 @@ dependencies = [ "perry-runtime", ] +[[package]] +name = "perry-ext-undici" +version = "0.5.1265" +dependencies = [ + "perry-ffi", + "perry-runtime", + "serde_json", +] + [[package]] name = "perry-ext-uuid" version = "0.5.1265" diff --git a/Cargo.toml b/Cargo.toml index bf38957d78..12baa1eda0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/perry-ext-pg", "crates/perry-ext-mysql2", "crates/perry-ext-fetch", + "crates/perry-ext-undici", "crates/perry-ext-mongodb", "crates/perry-ext-ws", "crates/perry-ext-net", @@ -197,6 +198,8 @@ codegen-units = 16 codegen-units = 16 [profile.release.package.perry-ext-net] codegen-units = 16 +[profile.release.package.perry-ext-undici] +codegen-units = 16 [profile.release.package.perry-ext-ws] codegen-units = 16 @@ -282,6 +285,8 @@ codegen-units = 16 codegen-units = 16 [profile.dist.package.perry-ext-net] codegen-units = 16 +[profile.dist.package.perry-ext-undici] +codegen-units = 16 [profile.dist.package.perry-ext-ws] codegen-units = 16 @@ -450,6 +455,7 @@ perry-ext-ioredis = { path = "crates/perry-ext-ioredis" } perry-ext-pg = { path = "crates/perry-ext-pg" } perry-ext-mysql2 = { path = "crates/perry-ext-mysql2" } perry-ext-fetch = { path = "crates/perry-ext-fetch" } +perry-ext-undici = { path = "crates/perry-ext-undici" } perry-ext-mongodb = { path = "crates/perry-ext-mongodb" } perry-ext-ws = { path = "crates/perry-ext-ws" } perry-ext-net = { path = "crates/perry-ext-net" } diff --git a/changelog.d/7032-ext-undici.md b/changelog.d/7032-ext-undici.md new file mode 100644 index 0000000000..ee232b27c9 --- /dev/null +++ b/changelog.d/7032-ext-undici.md @@ -0,0 +1 @@ +**`undici` native binding (`perry-ext-undici`):** `import { ProxyAgent, setGlobalDispatcher, fetch, Agent, getGlobalDispatcher } from 'undici'` is now served by perry's native fetch stack instead of AOT-compiling undici's ~50k lines of JS. `new ProxyAgent(uri | { uri, token? })` + `setGlobalDispatcher(agent)` install a process-wide proxy on the native fetch client (new `js_fetch_set_global_proxy` in perry-stdlib's `web-fetch` module, mirrored in perry-ext-fetch): reqwest then CONNECT-tunnels https targets, absolute-form-proxies http, and sends the token as `Proxy-Authorization` — the exact surface Socket Firewall needs. `fetch` from `'undici'` reuses the native Web Fetch lowering; installing a plain `Agent` clears the proxy; `getGlobalDispatcher()` returns the installed handle (lazily creating undici's implicit global Agent). `request()` and the pool/mock/stream surface are not implemented and fail with a clear error. Targets undici's stable dispatcher API (unchanged across 6.x → 7.x). diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 591d1fdfd9..937d186480 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -154,6 +154,10 @@ pub const NATIVE_MODULES: &[&str] = &[ "redis", // npm `redis` client (aliases ioredis) "rate-limiter-flexible", // rate limiting "fetch", // bare-name alias for the node-fetch surface + // `undici` (#466) — served by perry's native fetch stack via the + // bundled perry-ext-undici wrapper (ProxyAgent / Agent / + // setGlobalDispatcher / getGlobalDispatcher / fetch subset). + "undici", // `@perryts/pdf` — official PDF creation package (#516). // Bundled wrapper lives in `crates/perry-ext-pdf`; the producer // side companion to the existing PdfView widget. d.ts at diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 56c4995efe..fa8269c7e2 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -299,6 +299,24 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ class("node-fetch", "Response"), class("node-fetch", "Blob"), class("node-fetch", "FormData"), + // --- undici (perry-ext-undici, #466) — dispatcher subset served + // by perry's native fetch stack. `fetch` lowers through the + // name-keyed Expr::FetchWithOptions arm (no dispatch row); + // constructors route via the bare-ident `new` arm in + // perry-hir's expr_new.rs. `request` exists but rejects with a + // clear "use fetch" error. --- + class("undici", "ProxyAgent"), + class("undici", "Agent"), + method("undici", "ProxyAgent", false, None), + method("undici", "Agent", false, None), + method("undici", "setGlobalDispatcher", false, None), + method("undici", "getGlobalDispatcher", false, None), + method("undici", "fetch", false, None), + method("undici", "request", false, None), + method("undici", "close", true, Some("ProxyAgent")), + method("undici", "close", true, Some("Agent")), + method("undici", "destroy", true, Some("ProxyAgent")), + method("undici", "destroy", true, Some("Agent")), // --- bignumber.js — alias surface for decimal.js. The wrapper // dispatches to the same perry-ext-decimal implementation. --- class("bignumber.js", "BigNumber"), diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index d430221501..d8d7165c7d 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -33,6 +33,7 @@ mod node_misc; mod thread_lodash; mod tls_events; mod tui; +mod undici; mod utils_crypto; mod yoga; @@ -177,6 +178,9 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n v.extend_from_slice(http_client::HTTP_CLIENT_ROWS); v.extend_from_slice(http_server::HTTP_SERVER_ROWS); v.extend_from_slice(http_http2::HTTP_HTTP2_ROWS); + // Appended last (v0.5.1265, #466) so pre-existing rows keep their + // declaration order for `iter_native_module_table` consumers. + v.extend_from_slice(undici::UNDICI_ROWS); v }); diff --git a/crates/perry-codegen/src/lower_call/native_table/undici.rs b/crates/perry-codegen/src/lower_call/native_table/undici.rs new file mode 100644 index 0000000000..19b998512e --- /dev/null +++ b/crates/perry-codegen/src/lower_call/native_table/undici.rs @@ -0,0 +1,107 @@ +//! `undici` dispatch rows (#466) — served by the bundled +//! perry-ext-undici wrapper over perry's native fetch stack. +//! +//! Constructors are receiver-less rows reached through the bare-ident +//! `new ProxyAgent(...)` / `new Agent(...)` arm in +//! `perry-hir/src/lower/expr_new.rs` (mirrors the `http`/`https` +//! `Agent` route). `setGlobalDispatcher(agent)` pushes the agent's +//! proxy config into the shared fetch client state +//! (`js_fetch_set_global_proxy`); `fetch` from `'undici'` never lands +//! here — the name-keyed arm in `expr_call/globals.rs` lowers it to +//! `Expr::FetchWithOptions` like the global fetch. `request` is a +//! clear-error reject (perry serves the dispatcher/fetch subset). + +use super::*; + +pub(super) const UNDICI_ROWS: &[NativeModSig] = &[ + // ── constructors ─────────────────────────────────────────────── + // `new ProxyAgent(uri | { uri, token? })` — the NA_STR coercion + // (`js_value_to_str_ptr_for_ffi`) passes a URI string through and + // JSON-stringifies an options object; the wrapper parses either. + NativeModSig { + module: "undici", + has_receiver: false, + method: "ProxyAgent", + class_filter: None, + runtime: "js_undici_proxy_agent_new", + args: &[NA_STR], + ret: NR_PTR, + }, + NativeModSig { + module: "undici", + has_receiver: false, + method: "Agent", + class_filter: None, + runtime: "js_undici_agent_new", + args: &[NA_STR], + ret: NR_PTR, + }, + // ── module-level functions ───────────────────────────────────── + NativeModSig { + module: "undici", + has_receiver: false, + method: "setGlobalDispatcher", + class_filter: None, + runtime: "js_undici_set_global_dispatcher", + args: &[NA_PTR], + ret: NR_VOID, + }, + NativeModSig { + module: "undici", + has_receiver: false, + method: "getGlobalDispatcher", + class_filter: None, + runtime: "js_undici_get_global_dispatcher", + args: &[], + ret: NR_PTR, + }, + // `request(url, options?)` — rejects with a "not implemented, use + // fetch" error; the row exists so users get that clear message at + // runtime instead of a silent fall-through. + NativeModSig { + module: "undici", + has_receiver: false, + method: "request", + class_filter: None, + runtime: "js_undici_request", + args: &[NA_STR, NA_STR], + ret: NR_PROMISE, + }, + // ── Agent / ProxyAgent instance methods ──────────────────────── + NativeModSig { + module: "undici", + has_receiver: true, + method: "close", + class_filter: Some("ProxyAgent"), + runtime: "js_undici_agent_close", + args: &[], + ret: NR_PROMISE, + }, + NativeModSig { + module: "undici", + has_receiver: true, + method: "close", + class_filter: Some("Agent"), + runtime: "js_undici_agent_close", + args: &[], + ret: NR_PROMISE, + }, + NativeModSig { + module: "undici", + has_receiver: true, + method: "destroy", + class_filter: Some("ProxyAgent"), + runtime: "js_undici_agent_destroy", + args: &[], + ret: NR_PROMISE, + }, + NativeModSig { + module: "undici", + has_receiver: true, + method: "destroy", + class_filter: Some("Agent"), + runtime: "js_undici_agent_destroy", + args: &[], + ret: NR_PROMISE, + }, +]; diff --git a/crates/perry-ext-fetch/src/lib.rs b/crates/perry-ext-fetch/src/lib.rs index e0b2078339..ec62a252a3 100644 --- a/crates/perry-ext-fetch/src/lib.rs +++ b/crates/perry-ext-fetch/src/lib.rs @@ -192,13 +192,93 @@ lazy_static! { /// reqwest::Client (~250 KB) and the memory never gets reused. /// Sets a default User-Agent so endpoints that reject anonymous /// requests (api.github.com etc.) work out of the box. - static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder() + static ref HTTP_CLIENT: reqwest::Client = fetch_client_builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + /// Global proxy override installed by `undici.setGlobalDispatcher(new + /// ProxyAgent(...))` via `js_fetch_set_global_proxy` (perry-ext-undici). + /// `None` = direct connections through `HTTP_CLIENT`. Mirrors + /// perry-stdlib's copy byte-for-byte (this crate shadows the stdlib + /// symbols when `node-fetch`/`fetch` is imported — see + /// `prefer_well_known_before_stdlib`). + static ref GLOBAL_PROXY_CLIENT: std::sync::RwLock> = + std::sync::RwLock::new(None); +} + +/// Shared builder options for every fetch client (direct or proxied). +fn fetch_client_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() .user_agent(concat!("perry/", env!("CARGO_PKG_VERSION"))) .pool_idle_timeout(std::time::Duration::from_secs(90)) .pool_max_idle_per_host(16) .tcp_keepalive(std::time::Duration::from_secs(60)) +} + +/// The client every fetch path must use: the proxied client when a global +/// dispatcher proxy is installed, the pooled direct client otherwise. +fn fetch_client() -> reqwest::Client { + if let Ok(guard) = GLOBAL_PROXY_CLIENT.read() { + if let Some(client) = guard.as_ref() { + return client.clone(); + } + } + HTTP_CLIENT.clone() +} + +/// Build a reqwest client that routes every request through `uri`. +/// `token` is undici's `ProxyAgent` token — the literal value for the +/// `Proxy-Authorization` header (e.g. `Basic `). reqwest performs +/// HTTP CONNECT tunneling for https targets automatically. +fn build_proxy_client(uri: &str, token: Option<&str>) -> Result { + let mut proxy = + reqwest::Proxy::all(uri).map_err(|e| format!("Invalid proxy URI \"{uri}\": {e}"))?; + if let Some(token) = token { + let value = reqwest::header::HeaderValue::from_str(token) + .map_err(|e| format!("Invalid proxy token: {e}"))?; + proxy = proxy.custom_http_auth(value); + } + fetch_client_builder() + .proxy(proxy) .build() - .unwrap_or_else(|_| reqwest::Client::new()); + .map_err(|e| format!("Failed to build proxy client: {e}")) +} + +/// Install (or clear) the process-wide fetch proxy. Called by +/// perry-ext-undici's `setGlobalDispatcher` glue. A null `uri_ptr` clears +/// the proxy (an undici `Agent` dispatcher = direct connections). +/// Returns 1.0 on success, 0.0 when the proxy URI/token is invalid (the +/// current proxy state is left unchanged in that case). +/// +/// # Safety +/// Both pointers must be null or Perry-runtime `StringHeader`s. +#[no_mangle] +pub unsafe extern "C" fn js_fetch_set_global_proxy( + uri_ptr: *const StringHeader, + token_ptr: *const StringHeader, +) -> f64 { + if uri_ptr.is_null() { + if let Ok(mut guard) = GLOBAL_PROXY_CLIENT.write() { + *guard = None; + return 1.0; + } + return 0.0; + } + let Some(uri) = read_str(uri_ptr).filter(|uri| !uri.is_empty()) else { + return 0.0; + }; + let token = read_str(token_ptr).filter(|t| !t.is_empty()); + match build_proxy_client(&uri, token.as_deref()) { + Ok(client) => { + if let Ok(mut guard) = GLOBAL_PROXY_CLIENT.write() { + *guard = Some(client); + 1.0 + } else { + 0.0 + } + } + Err(_) => 0.0, + } } #[derive(Clone)] @@ -422,13 +502,14 @@ fn do_fetch( ) { spawn_blocking(move || { let outcome = tokio::runtime::Handle::current().block_on(async move { + let client = fetch_client(); let mut req = match method.to_uppercase().as_str() { - "POST" => HTTP_CLIENT.post(&url), - "PUT" => HTTP_CLIENT.put(&url), - "DELETE" => HTTP_CLIENT.delete(&url), - "PATCH" => HTTP_CLIENT.patch(&url), - "HEAD" => HTTP_CLIENT.head(&url), - _ => HTTP_CLIENT.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), + _ => client.get(&url), }; for (k, v) in &custom_headers { req = req.header(k.as_str(), v.as_str()); @@ -711,7 +792,7 @@ pub unsafe extern "C" fn js_fetch_text(url_ptr: *const StringHeader) -> *mut Pro }; spawn_blocking(move || { let result = tokio::runtime::Handle::current().block_on(async move { - HTTP_CLIENT + fetch_client() .get(&url) .send() .await @@ -763,12 +844,13 @@ pub unsafe extern "C" fn js_fetch_stream_start( spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async move { + let client = fetch_client(); let mut req = match method.to_uppercase().as_str() { - "POST" => HTTP_CLIENT.post(&url), - "PUT" => HTTP_CLIENT.put(&url), - "DELETE" => HTTP_CLIENT.delete(&url), - "PATCH" => HTTP_CLIENT.patch(&url), - _ => HTTP_CLIENT.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + _ => client.get(&url), }; for (k, v) in &custom_headers { req = req.header(k.as_str(), v.as_str()); diff --git a/crates/perry-ext-undici/Cargo.toml b/crates/perry-ext-undici/Cargo.toml new file mode 100644 index 0000000000..3f2f91116f --- /dev/null +++ b/crates/perry-ext-undici/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "perry-ext-undici" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native bindings for npm `undici` — uses only `perry-ffi`. Thin glue over perry's native fetch stack: ProxyAgent/Agent dispatcher handles + setGlobalDispatcher wiring into the shared fetch proxy state (js_fetch_set_global_proxy). Targets undici's stable dispatcher API (unchanged across 6.x/7.x)." + +[lints] +workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +perry-ffi.workspace = true +perry-runtime = { workspace = true, features = ["default", "stdlib"] } +serde_json.workspace = true + +[dev-dependencies] +perry-ffi = { workspace = true, features = ["runtime-link"] } +# #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. +# `stdlib`: this crate bundles perry-runtime into its staticlib and is co-linked +# with the real perry-stdlib, so drop the bundled no-op stdlib_stubs that would +# otherwise shadow perry-stdlib's real symbols (#6314). `default`: keep the copy +# feature-identical to the shipped runtime so gated dispatchers behave (#6303). diff --git a/crates/perry-ext-undici/src/lib.rs b/crates/perry-ext-undici/src/lib.rs new file mode 100644 index 0000000000..a0210c742a --- /dev/null +++ b/crates/perry-ext-undici/src/lib.rs @@ -0,0 +1,380 @@ +//! Native bindings for the npm `undici` package — uses only `perry-ffi`. +//! +//! Perry already ships a native Web Fetch stack (perry-stdlib `fetch/`, +//! mirrored by `perry-ext-fetch`), so this crate is thin glue rather than +//! an AOT compile of undici's ~50k lines of JS: +//! +//! - `new ProxyAgent(uri | { uri, token? })` stores the proxy config in a +//! perry-ffi handle. +//! - `setGlobalDispatcher(agent)` pushes that config into the native fetch +//! client via the `js_fetch_set_global_proxy` symbol (defined by BOTH +//! perry-stdlib's fetch and perry-ext-fetch; whichever copy is linked +//! wins — see `prefer_well_known_before_stdlib`). From then on every +//! `fetch()` — global or imported from `undici` — tunnels through the +//! proxy (reqwest performs HTTP CONNECT for https targets, absolute-form +//! proxying for http, and sends the token as `Proxy-Authorization`). +//! - `new Agent(...)` is a direct-connection dispatcher; installing it +//! clears any proxy. +//! - `fetch` from `'undici'` is served by perry's native fetch (the +//! codegen lowers it to the same `js_fetch_*` symbols as global fetch). +//! - `getGlobalDispatcher()` returns the installed dispatcher handle +//! (creating a default `Agent` on first call, matching undici). +//! - `request()` is NOT implemented — it rejects with a clear error +//! pointing at `fetch`. undici's streams/pools/mock surface is likewise +//! out of scope for this binding. +//! +//! # Version compatibility +//! +//! Targets undici's stable dispatcher API: `ProxyAgent`, `Agent`, +//! `setGlobalDispatcher`, `getGlobalDispatcher`, and `fetch` are unchanged +//! across undici 6.x → 7.x, so the binding tracks both majors. +//! +//! # Known limitations +//! +//! - `ProxyAgent` options other than `uri`/`token` (`requestTls`, +//! `proxyTls`, `clientFactory`, connection-pool tuning) are accepted but +//! ignored — the native reqwest client owns TLS and pooling. +//! - `agent.close()`/`agent.destroy()` resolve immediately; the native +//! client's connection pool is process-global and is not torn down. + +use perry_ffi::{ + read_string, register_handle, throw_with_code, with_handle, ErrorKind, Handle, JsPromise, + JsString, Promise, StringHeader, +}; +use std::sync::Mutex; + +/// Proxy configuration parsed from `new ProxyAgent(...)` options. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProxyConfig { + /// Proxy origin, e.g. `http://127.0.0.1:8080`. + pub uri: String, + /// Literal `Proxy-Authorization` header value, e.g. `Basic `. + pub token: Option, +} + +/// A dispatcher handle — what `setGlobalDispatcher` accepts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Dispatcher { + /// `new Agent(...)` — direct connections (no proxy). + Agent, + /// `new ProxyAgent(...)` — route through an HTTP(S) proxy. + ProxyAgent(ProxyConfig), +} + +/// The handle most recently installed via `setGlobalDispatcher`, plus the +/// lazily created default `Agent` that `getGlobalDispatcher()` returns +/// before anything was installed (mirrors undici's implicit global Agent). +static GLOBAL_DISPATCHER: Mutex> = Mutex::new(None); + +unsafe fn read_str(ptr: *const StringHeader) -> Option { + if ptr.is_null() { + return None; + } + let h = JsString::from_raw(ptr as *mut StringHeader); + read_string(h).map(String::from) +} + +/// Parse `new ProxyAgent(opts)` options. The codegen coerces the argument +/// with `js_value_to_str_ptr_for_ffi`, so `raw` is either the URI string +/// itself (`new ProxyAgent('http://localhost:8080')`) or the options +/// object JSON-stringified (`new ProxyAgent({ uri, token })`). +/// +/// Exposed (not part of the FFI surface) so unit tests can pin the parse +/// behavior without a runtime. +pub fn parse_proxy_agent_options(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("ProxyAgent: expected a proxy uri or an options object".to_string()); + } + let (uri, token) = if trimmed.starts_with('{') { + let value: serde_json::Value = serde_json::from_str(trimmed) + .map_err(|e| format!("ProxyAgent: invalid options object: {e}"))?; + let uri = value + .get("uri") + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| "ProxyAgent: options.uri is required".to_string())?; + let token = value + .get("token") + .and_then(|v| v.as_str()) + .map(str::to_string); + (uri, token) + } else { + (trimmed.to_string(), None) + }; + // undici requires an absolute proxy origin; catch the common mistake + // ("localhost:8080") here rather than deep inside reqwest. + if !uri.contains("://") { + return Err(format!( + "ProxyAgent: invalid proxy uri \"{uri}\" (expected an absolute URL like http://host:port)" + )); + } + Ok(ProxyConfig { uri, token }) +} + +// The shared fetch proxy state — defined by perry-stdlib's fetch (bundled +// in the runtime archive) and by perry-ext-fetch; whichever copy is linked +// resolves this symbol. Passing a null `uri` clears the proxy. +unsafe extern "C" { + fn js_fetch_set_global_proxy( + uri_ptr: *const StringHeader, + token_ptr: *const StringHeader, + ) -> f64; +} + +/// Apply a dispatcher's config to the native fetch client. Returns an +/// error string when the underlying client rejected the proxy config. +fn apply_dispatcher(dispatcher: &Dispatcher) -> Result<(), String> { + match dispatcher { + Dispatcher::Agent => { + // SAFETY: null pointers are the documented "clear proxy" input. + let ok = unsafe { js_fetch_set_global_proxy(std::ptr::null(), std::ptr::null()) }; + if ok == 1.0 { + Ok(()) + } else { + Err("setGlobalDispatcher: failed to clear the fetch proxy".to_string()) + } + } + Dispatcher::ProxyAgent(config) => { + let uri = perry_ffi::alloc_string(&config.uri); + let token = config + .token + .as_deref() + .map(perry_ffi::alloc_string) + .map(|s| s.as_raw() as *const StringHeader) + .unwrap_or(std::ptr::null()); + // SAFETY: both pointers are freshly allocated Perry strings + // (or null for "no token"). + let ok = unsafe { js_fetch_set_global_proxy(uri.as_raw(), token) }; + if ok == 1.0 { + Ok(()) + } else { + Err(format!( + "setGlobalDispatcher: native fetch rejected proxy uri \"{}\"", + config.uri + )) + } + } + } +} + +/// `new ProxyAgent(uri | { uri, token? })`. +/// +/// # Safety +/// `opts_ptr` must be null or a Perry-runtime `StringHeader` (the codegen's +/// `js_value_to_str_ptr_for_ffi` coercion: a URI string, or the options +/// object JSON-stringified). +#[no_mangle] +pub unsafe extern "C" fn js_undici_proxy_agent_new(opts_ptr: *const StringHeader) -> Handle { + let raw = read_str(opts_ptr).unwrap_or_default(); + match parse_proxy_agent_options(&raw) { + Ok(config) => register_handle(Dispatcher::ProxyAgent(config)), + Err(msg) => throw_with_code(&msg, "UND_ERR_INVALID_ARG", ErrorKind::TypeError), + } +} + +/// `new Agent(options?)` — a direct-connection dispatcher. Options +/// (pool sizing, keep-alive tuning) are accepted and ignored: the native +/// reqwest client owns pooling. +/// +/// # Safety +/// `_opts_ptr` must be null or a Perry-runtime `StringHeader`. +#[no_mangle] +pub unsafe extern "C" fn js_undici_agent_new(_opts_ptr: *const StringHeader) -> Handle { + register_handle(Dispatcher::Agent) +} + +/// `setGlobalDispatcher(agent)` — apply the dispatcher's proxy config to +/// the native fetch client and remember it for `getGlobalDispatcher()`. +/// Returns undefined (NR_VOID row); failures throw. +#[no_mangle] +pub extern "C" fn js_undici_set_global_dispatcher(handle: Handle) { + let applied = with_handle(handle, |dispatcher: &Dispatcher| { + apply_dispatcher(dispatcher) + }); + match applied { + Some(Ok(())) => { + *GLOBAL_DISPATCHER.lock().unwrap() = Some(handle); + } + Some(Err(msg)) => throw_with_code(&msg, "UND_ERR_INVALID_ARG", ErrorKind::TypeError), + None => throw_with_code( + "setGlobalDispatcher: argument must be an undici Agent or ProxyAgent", + "UND_ERR_INVALID_ARG", + ErrorKind::TypeError, + ), + } +} + +/// `getGlobalDispatcher()` — the installed dispatcher, or a lazily created +/// default `Agent` (undici's implicit global agent) when none was set. +#[no_mangle] +pub extern "C" fn js_undici_get_global_dispatcher() -> Handle { + let mut guard = GLOBAL_DISPATCHER.lock().unwrap(); + if let Some(handle) = *guard { + return handle; + } + let handle = register_handle(Dispatcher::Agent); + *guard = Some(handle); + handle +} + +/// `agent.close()` / `proxyAgent.close()` -> Promise. The +/// native connection pool is process-global, so this only resolves. +#[no_mangle] +pub extern "C" fn js_undici_agent_close(_handle: Handle) -> *mut Promise { + let promise = JsPromise::new(); + let raw = promise.as_raw(); + promise.resolve_undefined(); + raw +} + +/// `agent.destroy()` / `proxyAgent.destroy()` -> Promise. +#[no_mangle] +pub extern "C" fn js_undici_agent_destroy(_handle: Handle) -> *mut Promise { + let promise = JsPromise::new(); + let raw = promise.as_raw(); + promise.resolve_undefined(); + raw +} + +/// `request(url, options?)` — not implemented by perry-ext-undici. The +/// dispatcher/fetch subset is what perry serves natively; `request`'s +/// stream-based body mixin has no native counterpart yet. +/// +/// # Safety +/// Both pointers must be null or Perry-runtime `StringHeader`s. +#[no_mangle] +pub unsafe extern "C" fn js_undici_request( + _url_ptr: *const StringHeader, + _opts_ptr: *const StringHeader, +) -> *mut Promise { + let promise = JsPromise::new(); + let raw = promise.as_raw(); + promise.reject_string( + "undici.request is not implemented by perry-ext-undici — use fetch() (undici's fetch is \ + served by perry's native fetch, and honors setGlobalDispatcher)", + ); + raw +} + +#[cfg(test)] +mod test_shims { + //! Test-only definition of the `js_fetch_set_global_proxy` symbol the + //! crate declares `extern` (provided by perry-stdlib / perry-ext-fetch + //! in real links). Records calls so tests can assert the wiring. + use perry_ffi::StringHeader; + use std::sync::Mutex; + + pub(crate) static CALLS: Mutex, Option)>> = Mutex::new(Vec::new()); + + #[no_mangle] + pub unsafe extern "C" fn js_fetch_set_global_proxy( + uri_ptr: *const StringHeader, + token_ptr: *const StringHeader, + ) -> f64 { + let uri = super::read_str(uri_ptr); + let token = super::read_str(token_ptr); + // Mirror the real impl's contract: reject a clearly bogus URI so + // the error path is testable. + if uri.as_deref().is_some_and(|u| u.contains("invalid")) { + return 0.0; + } + CALLS.lock().unwrap().push((uri, token)); + 1.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plain_uri_string() { + let config = parse_proxy_agent_options("http://127.0.0.1:8080").unwrap(); + assert_eq!(config.uri, "http://127.0.0.1:8080"); + assert_eq!(config.token, None); + } + + #[test] + fn parses_options_object_with_token() { + // What `js_value_to_str_ptr_for_ffi` hands us for + // `new ProxyAgent({ uri: ..., token: ... })`. + let config = parse_proxy_agent_options( + r#"{"uri":"http://proxy.example:3128","token":"Basic dXNlcjpwYXNz"}"#, + ) + .unwrap(); + assert_eq!(config.uri, "http://proxy.example:3128"); + assert_eq!(config.token.as_deref(), Some("Basic dXNlcjpwYXNz")); + } + + #[test] + fn options_object_without_token_is_fine() { + let config = parse_proxy_agent_options(r#"{"uri":"https://proxy.example:443"}"#).unwrap(); + assert_eq!(config.uri, "https://proxy.example:443"); + assert_eq!(config.token, None); + } + + #[test] + fn extra_options_are_ignored() { + // requestTls etc. are accepted-and-ignored, not an error. + let config = parse_proxy_agent_options( + r#"{"uri":"http://p:8080","requestTls":{"rejectUnauthorized":false}}"#, + ) + .unwrap(); + assert_eq!(config.uri, "http://p:8080"); + } + + #[test] + fn missing_uri_is_an_error() { + let err = parse_proxy_agent_options(r#"{"token":"Basic x"}"#).unwrap_err(); + assert!(err.contains("uri is required"), "got: {err}"); + } + + #[test] + fn relative_uri_is_an_error() { + let err = parse_proxy_agent_options("localhost:8080").unwrap_err(); + assert!(err.contains("absolute URL"), "got: {err}"); + } + + #[test] + fn empty_input_is_an_error() { + assert!(parse_proxy_agent_options("").is_err()); + assert!(parse_proxy_agent_options(" ").is_err()); + } + + #[test] + fn set_global_dispatcher_applies_proxy_and_tracks_handle() { + let uri = perry_ffi::alloc_string(r#"{"uri":"http://127.0.0.1:9099","token":"Basic zz"}"#); + let agent = unsafe { js_undici_proxy_agent_new(uri.as_raw()) }; + assert!(agent > 0); + + js_undici_set_global_dispatcher(agent); + assert_eq!(js_undici_get_global_dispatcher(), agent); + + let calls = test_shims::CALLS.lock().unwrap(); + let last = calls.last().expect("proxy call recorded"); + assert_eq!(last.0.as_deref(), Some("http://127.0.0.1:9099")); + assert_eq!(last.1.as_deref(), Some("Basic zz")); + } + + #[test] + fn installing_plain_agent_clears_proxy() { + let agent = unsafe { js_undici_agent_new(std::ptr::null()) }; + js_undici_set_global_dispatcher(agent); + let calls = test_shims::CALLS.lock().unwrap(); + let last = calls.last().expect("clear call recorded"); + assert_eq!( + last.0, None, + "Agent install must clear the proxy (null uri)" + ); + } + + #[test] + fn get_global_dispatcher_creates_default_agent() { + // Runs in the same process as other tests; either a previous + // install or the lazily created default must yield a live handle. + let handle = js_undici_get_global_dispatcher(); + assert!(handle > 0); + assert!(perry_ffi::handle_exists(handle)); + } +} diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 05f1189a7b..3fe7cc2125 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -441,6 +441,32 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R }); } + // #466: `new ProxyAgent(...)` / `new Agent(...)` imported from + // `undici`. Same shape as the http/https Agent arm above: a + // receiver-less NativeMethodCall whose method is the exported + // class name, dispatched through the `undici` rows in + // NATIVE_MODULE_TABLE (perry-ext-undici's + // `js_undici_proxy_agent_new` / `js_undici_agent_new`). The + // var-decl machinery then tags the local as an + // ("undici", "ProxyAgent"|"Agent") native instance so + // `.close()` / `.destroy()` dispatch by class filter. + let undici_ctor = ctx + .lookup_native_module(&class_name) + .and_then(|(module, export)| match (module, export) { + ("undici", Some(ctor @ ("ProxyAgent" | "Agent"))) => Some(ctor.to_string()), + _ => None, + }); + if let Some(ctor) = undici_ctor { + let args = lower_optional_args(ctx, new_expr.args.as_deref())?; + return Ok(Expr::NativeMethodCall { + module: "undici".to_string(), + class_name: None, + object: None, + method: ctor, + args, + }); + } + if matches!( ctx.lookup_native_module(&class_name), Some(("v8", Some("GCProfiler"))) diff --git a/crates/perry-stdlib/src/fetch/abort_bridge.rs b/crates/perry-stdlib/src/fetch/abort_bridge.rs index 721abc58b5..3d9a7c401a 100644 --- a/crates/perry-stdlib/src/fetch/abort_bridge.rs +++ b/crates/perry-stdlib/src/fetch/abort_bridge.rs @@ -189,7 +189,7 @@ pub(crate) async fn run_request( custom_headers, } = inputs; let request_future = async move { - let client = super::HTTP_CLIENT.clone(); + let client = super::fetch_client(); let mut request = match method.to_uppercase().as_str() { "POST" => client.post(&url), "PUT" => client.put(&url), diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 29f7fab196..09bd4b2779 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -87,13 +87,92 @@ lazy_static::lazy_static! { /// the box. Per-request `User-Agent` headers passed via `fetch(url, { /// headers: { "User-Agent": "..." } })` override this default; reqwest's /// `RequestBuilder::header` replaces the client-level value. - static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder() + static ref HTTP_CLIENT: reqwest::Client = fetch_client_builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + /// Global proxy override installed by `undici.setGlobalDispatcher(new + /// ProxyAgent(...))` via `js_fetch_set_global_proxy` (perry-ext-undici). + /// `None` = direct connections through `HTTP_CLIENT`. The client is + /// prebuilt at install time so per-request cost stays a clone (Arc bump). + static ref GLOBAL_PROXY_CLIENT: std::sync::RwLock> = + std::sync::RwLock::new(None); +} + +/// Shared builder options for every fetch client (direct or proxied). +fn fetch_client_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() .user_agent(concat!("perry/", env!("CARGO_PKG_VERSION"))) .pool_idle_timeout(std::time::Duration::from_secs(90)) .pool_max_idle_per_host(16) .tcp_keepalive(std::time::Duration::from_secs(60)) +} + +/// The client every fetch path must use: the proxied client when a global +/// dispatcher proxy is installed, the pooled direct client otherwise. +pub(crate) fn fetch_client() -> reqwest::Client { + if let Ok(guard) = GLOBAL_PROXY_CLIENT.read() { + if let Some(client) = guard.as_ref() { + return client.clone(); + } + } + HTTP_CLIENT.clone() +} + +/// Build a reqwest client that routes every request through `uri`. +/// `token` is undici's `ProxyAgent` token — the literal value for the +/// `Proxy-Authorization` header (e.g. `Basic `). reqwest performs +/// HTTP CONNECT tunneling for https targets automatically. +fn build_proxy_client(uri: &str, token: Option<&str>) -> Result { + let mut proxy = + reqwest::Proxy::all(uri).map_err(|e| format!("Invalid proxy URI \"{uri}\": {e}"))?; + if let Some(token) = token { + let value = reqwest::header::HeaderValue::from_str(token) + .map_err(|e| format!("Invalid proxy token: {e}"))?; + proxy = proxy.custom_http_auth(value); + } + fetch_client_builder() + .proxy(proxy) .build() - .unwrap_or_else(|_| reqwest::Client::new()); + .map_err(|e| format!("Failed to build proxy client: {e}")) +} + +/// Install (or clear) the process-wide fetch proxy. Called by +/// perry-ext-undici's `setGlobalDispatcher` glue; also exported so any +/// other binding can reuse it. A null `uri_ptr` clears the proxy +/// (an undici `Agent` dispatcher = direct connections). Returns 1.0 on +/// success, 0.0 when the proxy URI/token is invalid (the current proxy +/// state is left unchanged in that case). +/// +/// # Safety +/// Both pointers must be null or Perry-runtime `StringHeader`s. +#[no_mangle] +pub unsafe extern "C" fn js_fetch_set_global_proxy( + uri_ptr: *const StringHeader, + token_ptr: *const StringHeader, +) -> f64 { + if uri_ptr.is_null() { + if let Ok(mut guard) = GLOBAL_PROXY_CLIENT.write() { + *guard = None; + return 1.0; + } + return 0.0; + } + let Some(uri) = string_from_header(uri_ptr).filter(|uri| !uri.is_empty()) else { + return 0.0; + }; + let token = string_from_header(token_ptr).filter(|t| !t.is_empty()); + match build_proxy_client(&uri, token.as_deref()) { + Ok(client) => { + if let Ok(mut guard) = GLOBAL_PROXY_CLIENT.write() { + *guard = Some(client); + 1.0 + } else { + 0.0 + } + } + Err(_) => 0.0, + } } fn alloc_fetch_handle_id() -> usize { @@ -319,7 +398,7 @@ pub unsafe extern "C" fn js_fetch_get(url_ptr: *const StringHeader) -> *mut perr }; spawn(async move { - match HTTP_CLIENT.get(&url).send().await { + match fetch_client().get(&url).send().await { Ok(response) => { let status = response.status().as_u16(); let status_text = response @@ -390,7 +469,7 @@ pub unsafe extern "C" fn js_fetch_get_with_auth( let auth_header = string_from_header(auth_header_ptr).unwrap_or_default(); spawn(async move { - let client = HTTP_CLIENT.clone(); + let client = fetch_client(); let mut request = client.get(&url); if !auth_header.is_empty() { request = request.header("Authorization", &auth_header); @@ -466,7 +545,7 @@ pub unsafe extern "C" fn js_fetch_post_with_auth( let body = string_from_header(body_ptr).unwrap_or_default(); spawn(async move { - let client = HTTP_CLIENT.clone(); + let client = fetch_client(); let mut request = client.post(&url).header("Content-Type", "application/json"); if !auth_header.is_empty() { request = request.header("Authorization", &auth_header); @@ -548,7 +627,7 @@ pub unsafe extern "C" fn js_fetch_post( string_from_header(content_type_ptr).unwrap_or_else(|| "application/json".to_string()); spawn(async move { - let client = HTTP_CLIENT.clone(); + let client = fetch_client(); match client .post(&url) .header("Content-Type", &content_type) @@ -861,7 +940,7 @@ pub unsafe extern "C" fn js_fetch_text( }; spawn(async move { - match HTTP_CLIENT.get(&url).send().await { + match fetch_client().get(&url).send().await { Ok(response) => match response.text().await { Ok(text) => { let result_str = js_string_from_bytes(text.as_ptr(), text.len() as u32); @@ -918,7 +997,7 @@ pub unsafe extern "C" fn js_fetch_stream_start( ); let sid = stream_id; spawn(async move { - let client = HTTP_CLIENT.clone(); + let client = fetch_client(); let mut request = match method.to_uppercase().as_str() { "POST" => client.post(&url), "PUT" => client.put(&url), diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 7a033f3f45..5528dad927 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -41,6 +41,18 @@ pub(crate) fn build_optimized_libs( let imports_fastify = iteration_set .iter() .any(|m| m.strip_prefix("node:").unwrap_or(m) == "fastify"); + let imports_undici = iteration_set + .iter() + .any(|m| m.strip_prefix("node:").unwrap_or(m) == "undici"); + if imports_undici && !use_well_known { + eprintln!( + "error: `import 'undici'` requires the external perry-ext-undici wrapper, but the \ + well-known flip is disabled (PERRY_DISABLE_WELL_KNOWN). There is no in-stdlib undici \ + namespace fallback; unset PERRY_DISABLE_WELL_KNOWN so undici routes to \ + perry-ext-undici." + ); + std::process::exit(1); + } if imports_fastify && !use_well_known { eprintln!( "error: `import 'fastify'` requires the external perry-ext-fastify wrapper, but the \ @@ -282,6 +294,18 @@ pub(crate) fn build_optimized_libs( }) { features.insert("async-runtime"); } + // `undici` (#466): perry-ext-undici is thin glue over the + // native Web Fetch stack. Its `setGlobalDispatcher` writes + // the proxy config through `js_fetch_set_global_proxy`, + // defined in perry-stdlib's `web-fetch` module (and mirrored + // by perry-ext-fetch). A ProxyAgent-only program never calls + // `fetch()` in a way `uses_fetch` detects, so re-assert + // `web-fetch` here or the wrapper's extern reference dangles + // at link time. (`web-fetch` implies `async-runtime`, which + // the wrapper's JsPromise surface needs anyway.) + if module_normalized == "undici" { + features.insert("web-fetch"); + } // v0.5.579 — when the flip strips `bundled-net`, activate // `external-net-pump` so perry-stdlib's // `js_stdlib_process_pending` knows to call into diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index b797515c08..9c55b51cdd 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -665,6 +665,12 @@ pub(crate) fn binding_needs_shared_tokio(module: &str) -> bool { | "axios" | "node-fetch" | "fetch" + // undici — glue over the native fetch stack (network I/O family). + // The wrapper itself has no tokio dep today, but it rides the + // shared build so the driver auto-builds its archive alongside + // the runtime, and so a future `request()` implementation that + // pulls tokio/reqwest can't silently hit the CONTEXT collision. + | "undici" // HTTP server (hyper) | "fastify" // Database drivers (mongodb, sqlx, redis) diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index dcfcee0fa1..cd6863c30e 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -312,6 +312,13 @@ fn cpu_only_wrappers_do_not_need_shared_tokio() { assert!(!binding_needs_shared_tokio("dotenv")); } +#[test] +fn undici_needs_shared_tokio() { + // perry-ext-undici is network-I/O-family glue over the native fetch + // stack; it rides the shared build (see the freshness.rs comment). + assert!(binding_needs_shared_tokio("undici")); +} + #[test] fn unknown_modules_default_to_workspace_path() { // Defensive default: if a module isn't in the allowlist, @@ -344,6 +351,17 @@ fn explicit_node_fetch_import_still_routes_to_well_known_fetch() { assert!(modules.contains("node-fetch")); } +#[test] +fn explicit_undici_import_routes_to_well_known_undici() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut ctx = CompilationContext::new(dir.path().to_path_buf()); + ctx.native_module_imports.insert("undici".to_string()); + + let modules = well_known_iteration_set(&ctx); + + assert!(modules.contains("undici")); +} + #[test] fn http2_import_enables_http2_constants_cross_feature() { // #6468: importing `node:http2` records "http2" in `native_module_imports`, diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 2eff17feaa..1e8e004929 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -137,7 +137,12 @@ mod tests; /// Packages that Perry provides built-in native extensions for. /// These must never be loaded into V8 — Perry's codegen intercepts all imports /// from these packages and replaces them with native calls. -const PERRY_NATIVE_EXTENSION_PACKAGES: &[&str] = &["ioredis", "ethers", "mysql2", "ws", "dotenv"]; +// `undici` is here because it's an extremely common transitive install: +// without the guard, a deep import reached through another package's +// compiled JS would make the walker read undici's real sources (llhttp +// wasm) instead of routing to perry-ext-undici. +const PERRY_NATIVE_EXTENSION_PACKAGES: &[&str] = + &["ioredis", "ethers", "mysql2", "ws", "dotenv", "undici"]; /// Check if a file path is inside a Perry native extension package (has built-in stdlib support) /// or a package that has perry.nativeLibrary in its package.json. diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index 2f0f1af4f6..05b34435e6 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -294,6 +294,13 @@ mod tests { assert_eq!(binding.lib, "perry_ext_dotenv"); } + #[test] + fn undici_is_registered() { + let binding = lookup_well_known("undici").expect("undici must be a well-known binding"); + assert_eq!(binding.krate, "perry-ext-undici"); + assert_eq!(binding.lib, "perry_ext_undici"); + } + #[test] fn node_prefix_stripped_on_lookup() { let bare = lookup_well_known("dotenv"); diff --git a/crates/perry/src/commands/sandbox_profile.rs b/crates/perry/src/commands/sandbox_profile.rs index b8a83ba6d4..da358af62c 100644 --- a/crates/perry/src/commands/sandbox_profile.rs +++ b/crates/perry/src/commands/sandbox_profile.rs @@ -64,6 +64,7 @@ pub fn build_macos_profile(ctx: &CompilationContext) -> String { || imports_module(ctx, "node-fetch") || imports_module(ctx, "redis") || imports_module(ctx, "ioredis") + || imports_module(ctx, "undici") || ctx.uses_fetch; let needs_fs_write = imports_module(ctx, "fs") // The HIR-level FsWriteFileSync / FsMkdirSync / FsAppendFileSync @@ -199,6 +200,14 @@ mod tests { assert!(profile.contains("(allow network*)")); } + #[test] + fn undici_import_unlocks_network() { + let mut ctx = empty_ctx(); + ctx.native_module_imports.insert("undici".into()); + let profile = build_macos_profile(&ctx); + assert!(profile.contains("(allow network*)")); + } + #[test] fn child_process_import_unlocks_spawn() { let mut ctx = empty_ctx(); diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index 85c2e64b15..3fa6f34c49 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -39,6 +39,16 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // legacy umbrella for compatibility. "axios" | "node-fetch" => &["http-client"], + // `undici` (#466) has no perry-stdlib copy to strip — the wrapper + // crate (perry-ext-undici) is thin glue over the native Web Fetch + // stack. The wrapper's `setGlobalDispatcher` writes proxy state + // through `js_fetch_set_global_proxy`, which lives in stdlib's + // `web-fetch` module; the well-known flip re-asserts `web-fetch` + // for undici imports (see optimized_libs/driver.rs) rather than + // naming it here, because everything named here gets STRIPPED by + // the flip loop. + "undici" => &[], + // ── WebSocket ───────────────────────────────────────────────── // `websocket` umbrella retained for backwards-compat; // per-binding gate is `bundled-ws` (v0.5.571) so the @@ -331,4 +341,13 @@ mod tests { assert!(module_to_features("http2").is_empty()); assert_eq!(module_to_features("axios"), &["http-client"]); } + + #[test] + fn undici_maps_to_no_stdlib_features() { + // perry-ext-undici owns the whole undici surface; there's no + // stdlib copy for the well-known flip to strip. The `web-fetch` + // dependency of its setGlobalDispatcher glue is re-asserted by + // the flip loop in optimized_libs/driver.rs, not named here. + assert_eq!(module_to_features("undici"), &[] as &[&str]); + } } diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 2ec5634d1f..cc37f429a1 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -550,3 +550,27 @@ tracking = "#516" crate = "perry-ext-ads" lib = "perry_ext_ads" tracking = "#867" + +# `undici` — Node's HTTP client library, served by perry's NATIVE +# fetch/http stack instead of AOT-compiling undici's ~50k lines of JS. +# Subset binding: ProxyAgent / Agent / setGlobalDispatcher / +# getGlobalDispatcher are real; `fetch` re-uses the native Web Fetch +# lowering; `request` and the pool/mock/stream surface are not +# implemented. Targets undici's stable dispatcher API (unchanged +# across 6.x → 7.x). +[bindings.undici] +crate = "perry-ext-undici" +lib = "perry_ext_undici" +tracking = "#466" + +# Upstream provenance pin (see PR #7031 / docs upstream-pins.md). undici's +# stable dispatcher API (ProxyAgent/Agent/setGlobalDispatcher) is unchanged +# across the 6.x/7.x/8.x majors this wrapper targets; the pin tracks one +# release for provenance, not the API-compat range. +[bindings.undici.upstream] +version = "8.9.0" +sha256 = "f554abb3e9352e04bc325208066a25c229163d8408bb1d5161db3d793445d69c" +repo = "https://github.com/nodejs/undici" +ref = "21a8e1ed1843e74c3004a2926c12bb0ceaca6b71" +ported-at = "8.9.0" +date = "2026-07-30" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 577659ece1..54d8383746 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 1981 entries across 119 modules +// Coverage: 1989 entries across 120 modules type PerryU32 = number & { readonly __perryU32?: never }; type PerryU64 = number & { readonly __perryU64?: never }; @@ -3886,6 +3886,25 @@ declare module "tursodb" { export function open(...args: any[]): any; } +declare module "undici" { + /** stdlib */ + export class Agent { [key: string]: any; } + /** stdlib */ + export class ProxyAgent { [key: string]: any; } + /** stdlib */ + export function Agent(...args: any[]): any; + /** stdlib */ + export function ProxyAgent(...args: any[]): any; + /** stdlib */ + export function fetch(...args: any[]): any; + /** stdlib */ + export function getGlobalDispatcher(...args: any[]): any; + /** stdlib */ + export function request(...args: any[]): any; + /** stdlib */ + export function setGlobalDispatcher(...args: any[]): any; +} + declare module "url" { /** stdlib */ export class URL { [key: string]: any; } diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 9dc52b6000..bb55e9466d 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2887 entries across 121 modules. +Total: 2899 entries across 122 modules. ## Modules @@ -116,6 +116,7 @@ Total: 2887 entries across 121 modules. - [`tls`](#tls) - [`tty`](#tty) - [`tursodb`](#tursodb) +- [`undici`](#undici) - [`url`](#url) - [`util`](#util) - [`util/types`](#utiltypes) @@ -3528,6 +3529,26 @@ Total: 2887 entries across 121 modules. - `queryAll` — instance - `queryOne` — instance +## `undici` + +### Classes + +- `Agent` +- `ProxyAgent` + +### Methods + +- `Agent` — module +- `ProxyAgent` — module +- `close` — instance *(class: `ProxyAgent`)* +- `close` — instance *(class: `Agent`)* +- `destroy` — instance *(class: `ProxyAgent`)* +- `destroy` — instance *(class: `Agent`)* +- `fetch` — module +- `getGlobalDispatcher` — module +- `request` — module +- `setGlobalDispatcher` — module + ## `url` ### Classes diff --git a/workspace-architecture.json b/workspace-architecture.json index 4fa21ef747..6dfa49001f 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -197,6 +197,10 @@ "category": "binding", "decision": "externalize" }, + "perry-ext-undici": { + "category": "binding", + "decision": "externalize" + }, "perry-ext-http": { "category": "binding", "decision": "keep"