Skip to content
Closed
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.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ members = [
"crates/perry-ext-events",
"crates/perry-ext-decimal",
"crates/perry-ext-dayjs",
"crates/perry-ext-qs",
"crates/perry-ext-moment",
"crates/perry-ext-cheerio",
"crates/perry-ext-sharp",
Expand Down Expand Up @@ -491,6 +492,7 @@ perry-ext-axios = { path = "crates/perry-ext-axios" }
perry-ext-events = { path = "crates/perry-ext-events" }
perry-ext-decimal = { path = "crates/perry-ext-decimal" }
perry-ext-dayjs = { path = "crates/perry-ext-dayjs" }
perry-ext-qs = { path = "crates/perry-ext-qs" }
perry-ext-moment = { path = "crates/perry-ext-moment" }
perry-ext-cheerio = { path = "crates/perry-ext-cheerio" }
perry-ext-sharp = { path = "crates/perry-ext-sharp" }
Expand Down
1 change: 1 addition & 0 deletions changelog.d/8751-native-qs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Native `qs` compatibility:** bundle nested query-string parsing and serialization so Stripe request encoding no longer compiles the AOT-hostile `get-intrinsic` dependency chain.
1 change: 1 addition & 0 deletions crates/perry-api-manifest/src/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub const NATIVE_MODULES: &[&str] = &[
"mysql2/promise", // mysql2's promise-API subpath
"pg", // PostgreSQL client
"uuid", // RFC-4122 UUID generation
"qs", // nested query-string parser/stringifier (Stripe dependency)
"bcrypt", // bcrypt password hashing (replaces the N-API addon)
"argon2", // Argon2 password hashing (replaces the N-API addon)
"ioredis", // Redis/Valkey client
Expand Down
19 changes: 19 additions & 0 deletions crates/perry-api-manifest/src/entries/part_4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1114,4 +1114,23 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[
property("bun", "stdin"),
property("bun", "stdout"),
property("bun", "stderr"),
// --- qs (issue #8751) ---
// Native nested query-string codec. This keeps Stripe's request encoder
// off qs' legacy get-intrinsic/ES-shims dependency chain.
method_sig(
"qs",
"stringify",
false,
None,
&[p_any("value"), p_any("options")],
TypeSpec::String,
),
method_sig(
"qs",
"parse",
false,
None,
&[p_str("input"), p_any("options")],
TypeSpec::Any,
),
];
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/ext_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[
("js_node_forge_", "node-forge"),
// Native runtime TypeScript transpilation subset (#8511).
("js_typescript_", "typescript"),
("js_qs_", "qs"),
];

/// Process-wide collector of provider keys observed during codegen.
Expand Down Expand Up @@ -1172,6 +1173,8 @@ mod tests {
("js_node_forge_create_certificate", "node-forge"),
("js_parcel_watcher_subscribe", "@parcel/watcher"),
("js_parcel_watcher_get_events_since", "@parcel/watcher"),
("js_qs_stringify", "qs"),
("js_qs_parse", "qs"),
] {
assert_symbol_routes_to(symbol, OwnerKind::WellKnown(binding));
}
Expand Down
2 changes: 2 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_dns;
mod node_domain;
mod node_misc;
mod parcel_watcher;
mod qs;
mod thread_lodash;
mod tls_events;
mod tui;
Expand Down Expand Up @@ -178,6 +179,7 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock<Vec<NativeModSig>> = LazyLock::n
v.extend_from_slice(media::MEDIA_ROWS);
v.extend_from_slice(native_profile::NATIVE_PROFILE_ROWS);
v.extend_from_slice(parcel_watcher::PARCEL_WATCHER_ROWS);
v.extend_from_slice(qs::QS_ROWS);
v.extend_from_slice(tui::TUI_ROWS);
v.extend_from_slice(typescript::TYPESCRIPT_ROWS);
v.extend_from_slice(yoga::YOGA_ROWS);
Expand Down
22 changes: 22 additions & 0 deletions crates/perry-codegen/src/lower_call/native_table/qs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use super::*;

pub(super) const QS_ROWS: &[NativeModSig] = &[
NativeModSig {
module: "qs",
has_receiver: false,
method: "stringify",
class_filter: None,
runtime: "js_qs_stringify",
args: &[NA_F64, NA_F64],
ret: NR_STR,
},
NativeModSig {
module: "qs",
has_receiver: false,
method: "parse",
class_filter: None,
runtime: "js_qs_parse",
args: &[NA_STR, NA_F64],
ret: NR_OBJ_FROM_JSON_STR,
},
];
20 changes: 20 additions & 0 deletions crates/perry-ext-qs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "perry-ext-qs"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Native qs compatibility binding for nested query-string parsing and serialization"

[lints]
workspace = true

[lib]
crate-type = ["staticlib", "rlib"]

[dependencies]
perry-ffi.workspace = true
serde_json.workspace = true

[dev-dependencies]
perry-ffi = { workspace = true, features = ["runtime-link"] }
perry-runtime = { workspace = true, features = ["default", "stdlib"] }
128 changes: 128 additions & 0 deletions crates/perry-ext-qs/src/codec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Charset {
Utf8,
Latin1,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Format {
Rfc1738,
Rfc3986,
}

pub(crate) fn encode(input: &str, charset: Charset, format: Format) -> String {
let mut out = String::with_capacity(input.len());
match charset {
Charset::Utf8 => {
for &byte in input.as_bytes() {
if is_safe(byte, format) {
out.push(byte as char);
} else {
push_escape(&mut out, byte);
}
}
}
Charset::Latin1 => {
for unit in input.encode_utf16() {
if unit <= 0xFF {
let byte = unit as u8;
if is_safe(byte, format) {
out.push(byte as char);
} else {
push_escape(&mut out, byte);
}
} else {
out.push_str("%26%23");
out.push_str(&unit.to_string());
out.push_str("%3B");
}
}
}
}
if format == Format::Rfc1738 {
out = out.replace("%20", "+");
}
out
}

pub(crate) fn format_encoded(input: String, format: Format) -> String {
if format == Format::Rfc1738 {
input.replace("%20", "+")
} else {
input
}
}

pub(crate) fn decode(input: &str, charset: Charset) -> String {
let plus_replaced = input.replace('+', " ");
let mut bytes = Vec::with_capacity(plus_replaced.len());
let raw = plus_replaced.as_bytes();
let mut index = 0;
let mut invalid_escape = false;
while index < raw.len() {
if raw[index] == b'%' {
if index + 2 < raw.len() {
if let (Some(high), Some(low)) = (hex(raw[index + 1]), hex(raw[index + 2])) {
bytes.push((high << 4) | low);
index += 3;
continue;
}
}
invalid_escape = true;
}
bytes.push(raw[index]);
index += 1;
}

match charset {
Charset::Utf8 if invalid_escape => plus_replaced,
Charset::Utf8 => String::from_utf8(bytes).unwrap_or(plus_replaced),
Charset::Latin1 => bytes.into_iter().map(char::from).collect(),
}
}

fn is_safe(byte: u8, format: Format) -> bool {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'.' | b'_' | b'~')
|| (format == Format::Rfc1738 && matches!(byte, b'(' | b')'))
}

fn push_escape(out: &mut String, byte: u8) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0xF) as usize] as char);
}

fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn rfc3986_encoding_matches_qs_defaults() {
assert_eq!(
encode("a b[c]/✓", Charset::Utf8, Format::Rfc3986),
"a%20b%5Bc%5D%2F%E2%9C%93"
);
}

#[test]
fn rfc1738_uses_plus_and_preserves_parentheses() {
assert_eq!(encode("a b(c)", Charset::Utf8, Format::Rfc1738), "a+b(c)");
}

#[test]
fn decoder_is_lenient_like_decode_uri_component_wrapper() {
assert_eq!(decode("a+b%5Bc%5D", Charset::Utf8), "a b[c]");
assert_eq!(decode("bad%ZZ", Charset::Utf8), "bad%ZZ");
}
}
46 changes: 46 additions & 0 deletions crates/perry-ext-qs/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Native compatibility binding for [`qs`](https://www.npmjs.com/package/qs).
//!
//! The binding exists primarily so packages such as Stripe can retain qs'
//! nested request encoding without asking Perry's AOT compiler to compile the
//! legacy `get-intrinsic` / ES-shims dependency chain. The implementation is
//! intentionally dependency-light and crosses the runtime only through the
//! stable `perry-ffi` surface plus existing C ABI symbols.

mod codec;
mod options;
mod parse;
mod runtime;
mod stringify;

#[cfg(test)]
mod test_async_shims;

use perry_ffi::{alloc_string, read_string, JsString, StringHeader, TransientRootScope};

/// `qs.stringify(value, options?)`.
#[no_mangle]
pub extern "C" fn js_qs_stringify(value: f64, options: f64) -> *mut StringHeader {
alloc_string(&stringify::stringify(value, options)).as_raw()
}

/// `qs.parse(input, options?)`.
///
/// # Safety
/// `input` must be null or a live Perry `StringHeader` pointer.
#[no_mangle]
pub unsafe extern "C" fn js_qs_parse(
input: *const StringHeader,
options: f64,
) -> *mut StringHeader {
let input = if input.is_null() {
String::new()
} else {
let input = JsString::from_raw(input as *mut StringHeader);
read_string(input).unwrap_or_default().to_owned()
};
let scope = TransientRootScope::enter();
let mut options = options::ParseOptions::from_js(&scope, options);
let value = parse::parse(&input, &mut options);
let json = serde_json::to_string(&value).expect("qs parse tree is JSON serializable");
alloc_string(&json).as_raw()
}
Loading
Loading