diff --git a/crates/perry-codegen/src/expr/helpers.rs b/crates/perry-codegen/src/expr/helpers.rs index ecb8a3757..6ea7f144e 100644 --- a/crates/perry-codegen/src/expr/helpers.rs +++ b/crates/perry-codegen/src/expr/helpers.rs @@ -307,6 +307,23 @@ pub(crate) fn is_global_this_builtin_name(name: &str) -> bool { | "TextDecoderStream" | "CompressionStream" | "DecompressionStream" + // The three core Web Streams constructors. Perry already implements + // them (`new ReadableStream(…)` and `x instanceof ReadableStream` are + // both lowered, and `ReadableStream` has a class id), but the NAMES + // were never registered as globalThis builtins — so a bare + // `ReadableStream` identifier did not resolve: `typeof + // ReadableStream === "undefined"` and `"ReadableStream" in globalThis` + // was false, while Node exposes all three as functions. + // + // Libraries feature-detect exactly this (`typeof ReadableStream !== + // "undefined" ? … : …`) to decide how to consume a `fetch()` body. + // Getting "undefined" sent a large esbuild-bundled CLI app down the + // wrong branch, which then threw "The first argument must be a + // Readable, a ReadableStream, or an async iterable" and aborted its + // background tar-stream downloads entirely. + | "ReadableStream" + | "WritableStream" + | "TransformStream" | "Navigator" | "URL" | "URLSearchParams" diff --git a/crates/perry-runtime/src/object/global_this_tables.rs b/crates/perry-runtime/src/object/global_this_tables.rs index c65c8ee49..907d4f249 100644 --- a/crates/perry-runtime/src/object/global_this_tables.rs +++ b/crates/perry-runtime/src/object/global_this_tables.rs @@ -55,6 +55,17 @@ pub(crate) const GLOBAL_THIS_BUILTIN_CONSTRUCTORS: &[&str] = &[ "TextDecoderStream", "CompressionStream", "DecompressionStream", + // The three core Web Streams constructors. Perry implements them (codegen + // lowers `new ReadableStream(…)` and `x instanceof ReadableStream`, and the + // class has an id), but the NAMES were never registered here or in codegen's + // globalThis-builtin list — so a bare `ReadableStream` identifier resolved to + // nothing: `typeof ReadableStream === "undefined"` and `"ReadableStream" in + // globalThis` was false, whereas Node exposes all three as functions. + // Libraries feature-detect precisely that (`typeof ReadableStream !== + // "undefined" ? … : …`) when deciding how to consume a `fetch()` body. + "ReadableStream", + "WritableStream", + "TransformStream", "Navigator", "URL", "URLSearchParams", diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 9c4f902e2..9a3a8bce8 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -93,6 +93,52 @@ fn is_native_module_namespace_value(value: f64, expected: &str) -> bool { /// `js_instanceof`. Returns FALSE for non-class-ref type values (matches /// JS spec: `1 instanceof 2` throws, but Perry returns false defensively). /// Refs #420 / #618 followup. +#[no_mangle] +/// Map a builtin constructor VALUE (the `ClosureHeader`-backed function installed +/// on `globalThis`) back to the class id that codegen passes to `js_instanceof` +/// for the static `x instanceof ` form. +/// +/// Only the natively-backed builtins need this: their instances are handles +/// (stream / fetch registries), not heap objects with a real prototype chain, so +/// `js_instanceof` brand-checks them via the kind probes rather than a chain walk. +/// Heap-backed builtins already resolve through the class-id / prototype paths. +fn builtin_ctor_class_id_from_value(type_ref: f64) -> Option { + let closure = + crate::value::js_nanbox_get_pointer(type_ref) as *const crate::closure::ClosureHeader; + if closure.is_null() { + return None; + } + if unsafe { (*closure).type_tag } != crate::closure::CLOSURE_MAGIC { + return None; + } + let name_value = crate::closure::closure_get_dynamic_prop(closure as usize, "name"); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let (ptr, len) = crate::string::str_bytes_from_jsvalue(name_value, &mut scratch)?; + if ptr.is_null() { + return None; + } + let name = + std::str::from_utf8(unsafe { std::slice::from_raw_parts(ptr, len as usize) }).ok()?; + let class_id = match name { + "ReadableStream" => 0xFFFF_0060, + "WritableStream" => 0xFFFF_0061, + "TransformStream" => 0xFFFF_0062, + "Response" => 0xFFFF_0028, + "Request" => 0xFFFF_0029, + "Headers" => 0xFFFF_002A, + "Blob" => 0xFFFF_0026, + _ => return None, + }; + // The name alone is forgeable (`function Response() {}` in user code). + // Require IDENTITY with the builtin constructor installed on + // `globalThis`: only the genuine builtin value brand-checks instances. + let global_ctor = crate::object::js_get_global_this_builtin_value(name.as_ptr(), name.len()); + if crate::value::js_nanbox_get_pointer(global_ctor) as usize != closure as usize { + return None; + } + Some(class_id) +} + #[no_mangle] pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; @@ -206,6 +252,29 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { return js_instanceof(value, class_id); } } + // A builtin constructor held in a VARIABLE — `const RS = ReadableStream; body + // instanceof RS` — arrives here as the ClosureHeader-backed function installed + // on `globalThis`, so none of the class-id paths above match and the prototype + // walk below returns false. Codegen only special-cases the *static identifier* + // form (`body instanceof ReadableStream`), where it hands the builtin class id + // straight to `js_instanceof`, which brand-checks these natively-backed values + // via the stream / fetch kind probes (their instances are handles, not heap + // objects with a real prototype chain). + // + // Minified bundles almost always alias constructors into locals, so the + // variable form is the common one in the wild: `x instanceof ` for + // ReadableStream / Response / Headers silently returned `false` while Node + // returns `true`. That made a large esbuild-bundled CLI app mis-detect its + // `fetch()` body, throw "The first argument must be a Readable, a + // ReadableStream, or an async iterable", and abort its background + // tar-stream downloads entirely. + // + // Recover the builtin's name from the constructor closure (recorded by + // `set_bound_native_closure_name` when globalThis is populated) and reuse the + // static path's class id, so both spellings agree. + if let Some(class_id) = builtin_ctor_class_id_from_value(type_ref) { + return js_instanceof(value, class_id); + } if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } { if module == "stream" && matches!(