diff --git a/CLAUDE.md b/CLAUDE.md index 89807f8b15..a87a61fe03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1585 +**Current Version:** 0.5.1586 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e5cf6e645d..291201d85d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" [[package]] name = "perry" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1585" +version = "0.5.1586" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1585" +version = "0.5.1586" [[package]] name = "perry-parser" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1585" +version = "0.5.1586" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1585" +version = "0.5.1586" [[package]] name = "perry-ui-tvos" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1585" +version = "0.5.1586" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index a9b3a5b729..515f32311e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1585" +version = "0.5.1586" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10368-response-null-body-status.md b/changelog.d/10368-response-null-body-status.md new file mode 100644 index 0000000000..374e2fbc62 --- /dev/null +++ b/changelog.d/10368-response-null-body-status.md @@ -0,0 +1,20 @@ +Fixed `Response.json(value, init)` skipping the `ResponseInit` validation that +`new Response(body, init)` applies (#10360). Both now share one check, in +Node's order: status range (`RangeError`), then `statusText` (`TypeError`), then +the body/null-body-status conflict. So `Response.json({a: 1}, {status: 204})` +throws Node's `TypeError: Response constructor: Invalid response status code +204` instead of returning a 204, and `Response.json({}, {status: 600})` throws +a `RangeError` instead of returning a 600. The fix covers both perry-stdlib and +perry-ext-fetch. + +Programs compiled with `--platform bun` follow Bun instead: a body with a +null-body status (204/205/304) is accepted by both constructors, so +`new Response("", {status: 204})` works. The compiler seeds +`__perry_runtime.setBunPlatform()` into every module's init next to the #9599 +`globalThis.Bun` install, which sets a runtime flag +(`perry-runtime/src/bun_compat/platform.rs`, `js_set_bun_platform` / +`js_bun_platform_enabled`) before any dependency's top-level code runs. + +Tests: `test-files/test_gap_response_null_body_status_10360.ts` (Node parity) +and `crates/perry/tests/issue_10360_bun_platform_response_null_body.rs` (Bun +1.3.14 output under `--platform bun`, plus a node-platform control). diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index 3ffab742cd..ba2c5bd511 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -536,3 +536,26 @@ fn dylib_closures_keep_native_roots() { "dylib roots must not be demoted to the shadow stack:\n{closure}" ); } + +/// #10360: `--platform bun` seeds `__perry_runtime.setBunPlatform()` into +/// module init; it must lower to the runtime flag setter, which the fetch +/// Response paths read to follow Bun's null-body-status leniency. +#[test] +fn set_bun_platform_marker_lowers_to_the_runtime_flag_setter() { + let mut module = empty_module(); + module.init = vec![Stmt::Expr(Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "setBunPlatform".to_string(), + args: Vec::new(), + })]; + let ir = String::from_utf8(compile_module(&module, entry_opts("executable")).unwrap()) + .expect("LLVM IR should be UTF-8"); + assert!( + ir.contains("call void @js_set_bun_platform()"), + "expected the Bun platform marker call in module init:\n{ir}" + ); + // Control: the default (node) platform never emits the call. + assert!(!emitted_ir("executable").contains("call void @js_set_bun_platform")); +} diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index e4df635e5b..c1e5a9a059 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -173,6 +173,13 @@ .block() .call(DOUBLE, "js_has_path_module", &[(DOUBLE, &path)])); } + // #10360: seeded into every module init under `--platform bun` + // so the runtime can follow Bun where its Web APIs differ from + // Node's (e.g. the Response null-body-status check). + "setBunPlatform" => { + ctx.block().call_void("js_set_bun_platform", &[]); + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } _ => {} } } diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 65602436d0..3f39151452 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -407,6 +407,8 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_run_module_init_catching", VOID, &[I64]); module.declare_function("js_require_path_module", DOUBLE, &[DOUBLE]); module.declare_function("js_has_path_module", DOUBLE, &[DOUBLE]); + // #10360: `--platform bun` marker (see `__perry_runtime.setBunPlatform`). + module.declare_function("js_set_bun_platform", VOID, &[]); // Next.js wall 54 (part 2): register a Deferred module's `__init` address by // path so a runtime `require(absolutePath)` can trigger its lazy init. module.declare_function("js_register_path_init", VOID, &[PTR, I64, I64]); diff --git a/crates/perry-ext-fetch/src/lib.rs b/crates/perry-ext-fetch/src/lib.rs index e616abd76b..72ed823dbb 100644 --- a/crates/perry-ext-fetch/src/lib.rs +++ b/crates/perry-ext-fetch/src/lib.rs @@ -22,8 +22,8 @@ use std::sync::Mutex; mod gc; mod validation; use validation::{ - is_forbidden_method, is_null_body_status, is_redirect_status, is_valid_status_text, - normalize_method, parse_redirect_location, redirect_status_from_value, + is_forbidden_method, is_redirect_status, normalize_method, parse_redirect_location, + redirect_status_from_value, response_init, }; use validation::{throw_range_error, throw_type_error}; @@ -1224,31 +1224,7 @@ pub unsafe extern "C" fn js_response_new( let body_opt = read_str(body_ptr); let body_present = body_opt.is_some(); let body = body_opt.unwrap_or_default().into_bytes(); - // NaN/0.0 are the codegen "no status field" sentinels → default 200. - // Otherwise truncate toward zero + range-check 200..=599 (#2640). - let status = if status.is_nan() || status == 0.0 { - 200 - } else { - let truncated = status.trunc(); - if !(200.0..=599.0).contains(&truncated) { - throw_range_error("init[\"status\"] must be in the range of 200 to 599, inclusive."); - } - truncated as u16 - }; - let status_text = match read_str(status_text_ptr) { - Some(s) => { - if !is_valid_status_text(&s) { - throw_type_error("Invalid statusText"); - } - s - } - None => String::new(), - }; - if body_present && is_null_body_status(status) { - throw_type_error(&format!( - "Response constructor: Invalid response status code {status}" - )); - } + let (status, status_text) = response_init(status, read_str(status_text_ptr), body_present); let headers_id = handle_id(headers_handle); let headers = if headers_id != 0 { HEADERS_HANDLES @@ -1419,15 +1395,10 @@ pub unsafe extern "C" fn js_response_static_json( ) -> f64 { let v = JsValue::from_bits(value.to_bits()); let body = perry_ffi::json_stringify(v).unwrap_or_default(); - // #2638: honor `init.status` / `init.statusText` / `init.headers`. - let status = if init_status.is_nan() || init_status == 0.0 { - 200 - } else { - init_status as u16 - }; - // Node's `Response.json` leaves statusText "" when not provided — it does - // not fall back to the status reason phrase. - let status_text = read_str(init_status_text_ptr).unwrap_or_default(); + // #2638: honor `init.status` / `init.statusText` / `init.headers`, with + // the same validation as `new Response` (#10360) — the JSON body is + // always present, so a null-body status throws outside Bun mode. + let (status, status_text) = response_init(init_status, read_str(init_status_text_ptr), true); // Start from any user-provided headers, then add the default content-type // only if the init headers didn't already set one. let headers_id = handle_id(headers_handle); diff --git a/crates/perry-ext-fetch/src/validation.rs b/crates/perry-ext-fetch/src/validation.rs index acccf1eab9..f719a19d34 100644 --- a/crates/perry-ext-fetch/src/validation.rs +++ b/crates/perry-ext-fetch/src/validation.rs @@ -18,6 +18,8 @@ extern "C" { fn js_typeerror_new(message: *mut StringHeader) -> *mut u8; fn js_rangeerror_new(message: *mut StringHeader) -> *mut u8; fn js_throw(value: f64) -> !; + // perry-runtime `bun_compat::platform` (#10360): 1 under `--platform bun`. + fn js_bun_platform_enabled() -> i32; } pub(crate) unsafe fn throw_type_error(msg: &str) -> ! { @@ -46,6 +48,46 @@ pub(crate) fn is_null_body_status(status: u16) -> bool { matches!(status, 101 | 103 | 204 | 205 | 304) } +/// Validate a `ResponseInit` the way Node's `initializeResponse` does, in its +/// order: status range, then statusText, then the body/null-body-status +/// conflict. Shared by `new Response` and `Response.json` so the two +/// construction paths cannot disagree (#10360). Returns (status, statusText). +/// +/// NaN / 0.0 status are the codegen "no status field" sentinels → 200; +/// anything else is truncated toward zero and range-checked (#2640). A +/// missing statusText is "" (#2640). A body under a null-body status is a +/// TypeError in Node but accepted by Bun, so `--platform bun` skips it. +pub(crate) unsafe fn response_init( + status: f64, + status_text: Option, + body_present: bool, +) -> (u16, String) { + let status = if status.is_nan() || status == 0.0 { + 200 + } else { + let truncated = status.trunc(); + if !(200.0..=599.0).contains(&truncated) { + throw_range_error("init[\"status\"] must be in the range of 200 to 599, inclusive."); + } + truncated as u16 + }; + let status_text = match status_text { + Some(s) => { + if !is_valid_status_text(&s) { + throw_type_error("Invalid statusText"); + } + s + } + None => String::new(), + }; + if body_present && is_null_body_status(status) && js_bun_platform_enabled() == 0 { + throw_type_error(&format!( + "Response constructor: Invalid response status code {status}" + )); + } + (status, status_text) +} + /// Web Fetch forbidden request methods — rejected by the Request ctor. pub(crate) fn is_forbidden_method(method_upper: &str) -> bool { matches!(method_upper, "CONNECT" | "TRACE" | "TRACK") diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 80f96bc208..30639513c6 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -27,6 +27,7 @@ mod cli_utils; mod cli_utils_stub; mod glob; mod jsc; +mod platform; mod plugin; mod spawn; mod string_width; @@ -51,6 +52,7 @@ pub use cli_utils::*; pub use cli_utils_stub::*; pub use glob::js_bun_glob_new; pub use jsc::js_bun_jsc_heap_stats; +pub use platform::{js_bun_platform_enabled, js_set_bun_platform}; pub use plugin::{decorate_bun_plugin, js_bun_plugin}; pub use spawn::{js_bun_spawn, js_bun_terminal_new}; pub use string_width::bun_string_width; diff --git a/crates/perry-runtime/src/bun_compat/platform.rs b/crates/perry-runtime/src/bun_compat/platform.rs new file mode 100644 index 0000000000..ff7206be55 --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/platform.rs @@ -0,0 +1,47 @@ +//! Runtime view of `perry compile --platform bun` (#10360). +//! +//! The platform is a compile-time choice, but a few Web APIs differ between +//! Node and Bun at runtime. Under `--platform bun` the compiler seeds every +//! module's init with `__perry_runtime.setBunPlatform()` (next to the +//! `globalThis.Bun` install), so the flag is set before any user code runs. +//! It is process-global and only ever turns on. +//! +//! Both setter and getter are `#[no_mangle]` so perry-stdlib and the ext +//! crates (which link the runtime by symbol, not by Rust path) all read the +//! same flag. + +use std::sync::atomic::{AtomicBool, Ordering}; + +static BUN_PLATFORM: AtomicBool = AtomicBool::new(false); + +/// Called from generated module init under `--platform bun`. +#[no_mangle] +pub extern "C" fn js_set_bun_platform() { + BUN_PLATFORM.store(true, Ordering::Relaxed); +} + +/// 1 when the program was compiled with `--platform bun`, else 0. +#[no_mangle] +pub extern "C" fn js_bun_platform_enabled() -> i32 { + i32::from(BUN_PLATFORM.load(Ordering::Relaxed)) +} + +#[cfg(test)] +pub(crate) fn reset_bun_platform_for_test() { + BUN_PLATFORM.store(false, Ordering::Relaxed); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bun_platform_flag_defaults_off_and_turns_on() { + reset_bun_platform_for_test(); + assert_eq!(js_bun_platform_enabled(), 0); + js_set_bun_platform(); + assert_eq!(js_bun_platform_enabled(), 1); + reset_bun_platform_for_test(); + assert_eq!(js_bun_platform_enabled(), 0); + } +} diff --git a/crates/perry-stdlib/src/fetch/body_clone.rs b/crates/perry-stdlib/src/fetch/body_clone.rs index be7907cb77..553bc4c1ff 100644 --- a/crates/perry-stdlib/src/fetch/body_clone.rs +++ b/crates/perry-stdlib/src/fetch/body_clone.rs @@ -110,14 +110,11 @@ pub unsafe extern "C" fn js_response_static_json( } else { string_from_header(str_ptr).unwrap_or_else(|| "null".to_string()) }; - let status_u16 = if init_status.is_nan() || init_status == 0.0 { - 200 - } else { - init_status as u16 - }; - // Node's `Response.json` leaves statusText "" when not provided — it does - // not fall back to the status reason phrase. - let status_text = string_from_header(init_status_text_ptr).unwrap_or_default(); + // Same init validation as `new Response` (#10360): Node range-checks the + // status, validates statusText (default "", not the reason phrase), and + // rejects the always-present JSON body under a null-body status. + let (status_u16, status_text) = + super::response_ctor::response_init(init_status, init_status_text_ptr, true); // Start from any user-provided headers, then add the default content-type // only if the init headers didn't already set one. let headers_id = handle_id(headers_handle); diff --git a/crates/perry-stdlib/src/fetch/response_ctor.rs b/crates/perry-stdlib/src/fetch/response_ctor.rs index 5df66a6486..5fb195bf89 100644 --- a/crates/perry-stdlib/src/fetch/response_ctor.rs +++ b/crates/perry-stdlib/src/fetch/response_ctor.rs @@ -28,30 +28,24 @@ pub(super) fn alloc_response( id } -/// new Response(body, statusOpt, statusTextPtrOpt, headersHandleOpt) -/// - body_ptr: StringHeader for the body, or null for "" -/// - status: f64 (200 default) -/// - status_text_ptr: StringHeader for statusText, or null for "" -/// - headers_handle: f64 numeric handle from js_headers_new, or 0 -#[no_mangle] -pub unsafe extern "C" fn js_response_new( - body_ptr: *const StringHeader, +/// Validate a `ResponseInit` the way Node's `initializeResponse` does, in its +/// order: status range, then statusText, then the body/null-body-status +/// conflict. Shared by `new Response` and `Response.json` so the two +/// construction paths cannot disagree (#10360). Returns (status, statusText). +/// +/// - `status`: NaN / 0.0 are the codegen "no status field" sentinels. Node +/// defaults missing status to 200; any explicit value is truncated toward +/// zero then range-checked against 200..=599 (199.9 → RangeError, 599.9 → +/// 599). Refs #2640. +/// - `statusText`: Node defaults it to the empty string (NOT the canonical +/// reason phrase) and validates the reason-phrase token. Refs #2640. +/// - A body with a null-body status (204/205/304) is a TypeError in Node, but +/// Bun accepts it, so `--platform bun` programs skip the check. +pub(super) unsafe fn response_init( status: f64, status_text_ptr: *const StringHeader, - headers_handle: f64, -) -> f64 { - let body_stream_id = take_pending_fetch_body_stream_id(); - // Consume before validation so a throwing constructor cannot leak body - // metadata into the next Response construction on this thread. - let body_content_type = take_pending_fetch_body_content_type(); - // Lossless raw-byte read so binary bodies survive byte-for-byte (#5435). - let body_opt = dispatch::body_bytes_from_header(body_ptr); - let body_present = body_opt.is_some() || body_stream_id.is_some(); - let body = body_opt.unwrap_or_default(); - // NaN / 0.0 are the codegen "no status field" sentinels. Node defaults - // missing status to 200; any explicit value is truncated toward zero - // then range-checked against 200..=599 (199.9 → RangeError, 599.9 → - // 599). Refs #2640. + body_present: bool, +) -> (u16, String) { let status_u16 = if status.is_nan() || status == 0.0 { 200 } else { @@ -63,8 +57,6 @@ pub unsafe extern "C" fn js_response_new( } truncated as u16 }; - // Node defaults statusText to the empty string (NOT the canonical - // reason phrase) and validates the reason-phrase token. Refs #2640. let status_text = match string_from_header(status_text_ptr) { Some(s) => { if !is_valid_status_text(&s) { @@ -74,11 +66,38 @@ pub unsafe extern "C" fn js_response_new( } None => String::new(), }; - if body_present && is_null_body_status(status_u16) { + if body_present + && is_null_body_status(status_u16) + && perry_runtime::bun_compat::js_bun_platform_enabled() == 0 + { throw_fetch_type_error(&format!( "Response constructor: Invalid response status code {status_u16}" )); } + (status_u16, status_text) +} + +/// new Response(body, statusOpt, statusTextPtrOpt, headersHandleOpt) +/// - body_ptr: StringHeader for the body, or null for "" +/// - status: f64 (200 default) +/// - status_text_ptr: StringHeader for statusText, or null for "" +/// - headers_handle: f64 numeric handle from js_headers_new, or 0 +#[no_mangle] +pub unsafe extern "C" fn js_response_new( + body_ptr: *const StringHeader, + status: f64, + status_text_ptr: *const StringHeader, + headers_handle: f64, +) -> f64 { + let body_stream_id = take_pending_fetch_body_stream_id(); + // Consume before validation so a throwing constructor cannot leak body + // metadata into the next Response construction on this thread. + let body_content_type = take_pending_fetch_body_content_type(); + // Lossless raw-byte read so binary bodies survive byte-for-byte (#5435). + let body_opt = dispatch::body_bytes_from_header(body_ptr); + let body_present = body_opt.is_some() || body_stream_id.is_some(); + let body = body_opt.unwrap_or_default(); + let (status_u16, status_text) = response_init(status, status_text_ptr, body_present); let headers_id = handle_id(headers_handle); let registered = (headers_id != 0) .then(|| HEADERS_REGISTRY.lock().unwrap().get(&headers_id).cloned()) diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index cb0b2f13cb..320eb65545 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -755,6 +755,19 @@ fn collect_module_one( value: Box::new(perry_hir::Expr::NativeModuleRef("bun".to_string())), }), ); + // #10360: tell the runtime too, for Web APIs whose Bun behavior + // differs from Node's. Same every-module seeding as above, so the + // flag is on before any dependency's top-level code runs. + hir_module.init.insert( + 0, + perry_hir::Stmt::Expr(perry_hir::Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "setBunPlatform".to_string(), + args: Vec::new(), + }), + ); } // Preserve native result types before async lowering splits awaited values diff --git a/crates/perry/tests/issue_10360_bun_platform_response_null_body.rs b/crates/perry/tests/issue_10360_bun_platform_response_null_body.rs new file mode 100644 index 0000000000..39bf09af79 --- /dev/null +++ b/crates/perry/tests/issue_10360_bun_platform_response_null_body.rs @@ -0,0 +1,114 @@ +//! #10360 — a body under a null-body status (204/205/304) throws in Node for +//! both `new Response(...)` and `Response.json(...)`, but Bun accepts it. +//! Perry follows Node by default (gap test +//! `test_gap_response_null_body_status_10360.ts`) and Bun under +//! `--platform bun`, including in a dependency's top-level code. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +const DEPENDENCY: &str = r#" +export const depStatus = (() => { + try { + return String(new Response("", { status: 204 }).status); + } catch (e: any) { + return "THREW " + e.constructor.name; + } +})(); +"#; + +const MAIN: &str = r#" +import { depStatus } from "./dependency.ts"; + +const t = (label: string, f: () => any) => { + try { + console.log(label, JSON.stringify(f())); + } catch (e: any) { + console.log(label, "THREW " + e.constructor.name); + } +}; +console.log("dep '' 204", depStatus); +t("ctor '' 204", () => new Response("", { status: 204 }).status); +t("ctor 'x' 205", () => new Response("x", { status: 205 }).status); +t("ctor 'x' 304", () => new Response("x", { status: 304 }).status); +t("ctor null 204", () => new Response(null, { status: 204 }).status); +t("json 204", () => Response.json({ a: 1 }, { status: 204 }).status); +t("json 600", () => Response.json({}, { status: 600 }).status); +"#; + +fn compile_and_run(platform: Option<&str>) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("dependency.ts"), DEPENDENCY).expect("write dependency"); + std::fs::write(dir.path().join("main.ts"), MAIN).expect("write entry"); + let output = dir.path().join("main_bin"); + let mut command = Command::new(perry_bin()); + command + .current_dir(dir.path()) + .arg("compile") + .arg(dir.path().join("main.ts")) + .arg("-o") + .arg(&output); + if let Some(platform) = platform { + command.arg("--platform").arg(platform); + } + let compile = command.output().expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + run(&output, dir.path()) +} + +fn run(output: &Path, dir: &Path) -> String { + let run = Command::new(output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn bun_platform_accepts_a_body_with_a_null_body_status() { + // Bun 1.3.14 prints exactly this. The status range check is not a + // null-body rule and still throws in Bun (error text is covered by the + // gap test, and Bun words it differently, so only the class is printed). + let expected = "\ +dep '' 204 204 +ctor '' 204 204 +ctor 'x' 205 205 +ctor 'x' 304 304 +ctor null 204 204 +json 204 204 +json 600 THREW RangeError +"; + assert_eq!(compile_and_run(Some("bun")), expected); +} + +#[test] +fn node_platform_rejects_a_body_with_a_null_body_status() { + // Control for the test above: the same program without `--platform bun` + // follows Node, so the Bun result is the platform switch, not a lost check. + let expected = "\ +dep '' 204 THREW TypeError +ctor '' 204 THREW TypeError +ctor 'x' 205 THREW TypeError +ctor 'x' 304 THREW TypeError +ctor null 204 204 +json 204 THREW TypeError +json 600 THREW RangeError +"; + assert_eq!(compile_and_run(None), expected); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 0154c8bf04..255e2b120d 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -176,6 +176,13 @@ or `Bun.file` stdio. On POSIX targets, `Bun.Terminal` can attach a subprocess to Perry's native PTY implementation; ConPTY-backed terminals are not yet available on Windows. +Web APIs otherwise keep Node's behavior; the one runtime difference in this +mode is that a `Response` may carry a body under a null-body status (204, 205, +304), as in Bun: +`new Response("", { status: 204 })` and `Response.json(value, { status: 204 })` +succeed, where the default `node` mode throws Node's +`TypeError: Response constructor: Invalid response status code 204`. + ## Embedding Assets Bake static files (an SPA `dist/`, images, JSON, fonts, …) into the standalone diff --git a/test-files/test_gap_response_null_body_status_10360.ts b/test-files/test_gap_response_null_body_status_10360.ts new file mode 100644 index 0000000000..bd5471075e --- /dev/null +++ b/test-files/test_gap_response_null_body_status_10360.ts @@ -0,0 +1,54 @@ +// #10360: a Response init with a null-body status (204/205/304) and a +// non-null body is a TypeError in Node for BOTH `new Response(...)` and +// `Response.json(...)`; `Response.json` also shares the constructor's status +// range and statusText checks. (Bun accepts the null-body case — Perry +// follows that only under `--platform bun`, covered by +// crates/perry/tests/issue_10360_bun_platform_response_null_body.rs.) +const t = (label: string, f: () => any) => { + try { + const v = f(); + console.log(label, v === undefined ? "undefined" : JSON.stringify(v)); + } catch (e: any) { + console.log(label, "THREW " + e.constructor.name + ": " + e.message); + } +}; + +// --- constructor: null / undefined body is fine under any status --- +t("ctor null 204", () => new Response(null, { status: 204 }).status); +t("ctor undefined 204", () => new Response(undefined, { status: 204 }).status); +t("ctor null 304", () => new Response(null, { status: 304 }).status); +t("ctor no-body 205", () => new Response(undefined, { status: 205, statusText: "Reset" }).statusText); + +// --- constructor: non-null body + null-body status throws --- +t("ctor 'x' 204", () => new Response("x", { status: 204 }).status); +t("ctor 'x' 205", () => new Response("x", { status: 205 }).status); +t("ctor 'x' 304", () => new Response("x", { status: 304 }).status); +t("ctor '' 204", () => new Response("", { status: 204 }).status); + +// --- constructor: same body, other statuses --- +t("ctor 'x' 201", () => new Response("x", { status: 201 }).status); +t("ctor 'x' 599", () => new Response("x", { status: 599 }).status); +t("ctor '' 200", () => new Response("", { status: 200 }).status); + +// --- Response.json: always has a body, so a null-body status throws --- +t("json 204", () => Response.json({ a: 1 }, { status: 204 }).status); +t("json 205", () => Response.json({ a: 1 }, { status: 205 }).status); +t("json 304", () => Response.json(null, { status: 304 }).status); +const noContent = { status: 204 }; +t("json 204 via variable init", () => Response.json({ a: 1 }, noContent).status); + +// --- Response.json: shares the constructor's status range / statusText checks --- +t("json 99", () => Response.json({}, { status: 99 }).status); +t("json 600", () => Response.json({}, { status: 600 }).status); +t("json 199.9", () => Response.json({}, { status: 199.9 }).status); +t("json 599.9", () => Response.json({}, { status: 599.9 }).status); +t("json bad statusText", () => Response.json({}, { statusText: "bad\nline" }).status); +const badStatus = { status: 600 }; +t("json 600 via variable init", () => Response.json({}, badStatus).status); + +// --- Response.json: valid inits still work --- +t("json default", () => Response.json({ a: 1 }).status); +t("json 201", () => { + const r = Response.json({ a: 1 }, { status: 201, statusText: "Created" }); + return [r.status, r.statusText, r.headers.get("content-type")]; +});