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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions changelog.d/7032-ext-undici.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 4 additions & 0 deletions crates/perry-api-manifest/src/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-api-manifest/src/entries/part_4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/lower_call/native_table/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod node_misc;
mod thread_lodash;
mod tls_events;
mod tui;
mod undici;
mod utils_crypto;
mod yoga;

Expand Down Expand Up @@ -177,6 +178,9 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock<Vec<NativeModSig>> = 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
});

Expand Down
107 changes: 107 additions & 0 deletions crates/perry-codegen/src/lower_call/native_table/undici.rs
Original file line number Diff line number Diff line change
@@ -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,
},
];
110 changes: 96 additions & 14 deletions crates/perry-ext-fetch/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<reqwest::Client>> =
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 <base64>`). reqwest performs
/// HTTP CONNECT tunneling for https targets automatically.
fn build_proxy_client(uri: &str, token: Option<&str>) -> Result<reqwest::Client, String> {
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)]
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down
39 changes: 39 additions & 0 deletions crates/perry-ext-undici/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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-<x>` (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).
Loading
Loading