diff --git a/CLAUDE.md b/CLAUDE.md index cf1343b470..9882fb3783 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.1275 +**Current Version:** 0.5.1276 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index f5a16168bd..9ec1ad5619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5503,7 +5503,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "base64", @@ -5563,14 +5563,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "cc", "libc", @@ -5578,7 +5578,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "log", @@ -5592,7 +5592,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-hir", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-hir", @@ -5608,7 +5608,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-dispatch", @@ -5617,7 +5617,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-hir", @@ -5625,7 +5625,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "base64", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-hir", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "async-trait", @@ -5674,14 +5674,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "serde", "serde_json", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1275" +version = "0.5.1276" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5700,7 +5700,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "clap", @@ -5715,7 +5715,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "block2", "objc2", @@ -5725,7 +5725,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "argon2", "perry-ffi", @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "reqwest", @@ -5742,7 +5742,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "bcrypt", "perry-ffi", @@ -5750,7 +5750,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "rusqlite", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "scraper", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "perry-runtime", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "chrono", "cron", @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "chrono", "perry-ffi", @@ -5792,7 +5792,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "rust_decimal", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "serde_json", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "perry-runtime", @@ -5824,14 +5824,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "bytes", "http-body-util", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "bytes", "lazy_static", @@ -5862,7 +5862,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "bytes", "h2", @@ -5886,7 +5886,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "lazy_static", "perry-ffi", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "jsonwebtoken", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "lru", "perry-ffi", @@ -5916,7 +5916,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "chrono", "perry-ffi", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "bson", "futures-util", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "chrono", "perry-ffi", @@ -5946,7 +5946,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "nanoid", "perry-ffi", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "bytes", "perry-ffi", @@ -5968,7 +5968,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -5987,7 +5987,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "lettre", "perry-ffi", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "printpdf", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "sqlx", @@ -6014,7 +6014,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "governor", "perry-ffi", @@ -6022,7 +6022,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "fast_image_resize", "image", @@ -6032,14 +6032,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "lazy_static", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "perry-runtime", @@ -6057,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "uuid", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ffi", "regex", @@ -6075,7 +6075,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "futures-util", "lazy_static", @@ -6088,7 +6088,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "brotli", "flate2", @@ -6098,7 +6098,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "dashmap", "once_cell", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-api-manifest", @@ -6125,7 +6125,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-diagnostics", @@ -6137,7 +6137,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "base64", @@ -6178,14 +6178,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6280,14 +6280,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "perry-hir", @@ -6296,14 +6296,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "itoa", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "rand 0.10.1", "serde", @@ -6330,7 +6330,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "block2", @@ -6369,7 +6369,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "block2", @@ -6384,7 +6384,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1275" +version = "0.5.1276" [[package]] name = "perry-ui-test" @@ -6395,11 +6395,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1275" +version = "0.5.1276" [[package]] name = "perry-ui-tvos" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "block2", @@ -6415,7 +6415,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "block2", @@ -6431,7 +6431,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "block2", "libc", @@ -6444,7 +6444,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "base64", "libc", @@ -6461,14 +6461,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "anyhow", "base64", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1275" +version = "0.5.1276" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 4c0dd894e2..8de3599f96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -292,7 +292,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1275" +version = "0.5.1276" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/5756-tanstack-start-compat.md b/changelog.d/5756-tanstack-start-compat.md new file mode 100644 index 0000000000..70f58e7c5c --- /dev/null +++ b/changelog.d/5756-tanstack-start-compat.md @@ -0,0 +1,8 @@ +- Fix TanStack Start package subpath resolution, JSX/runtime binding selection, + closure-contained dynamic imports, and hoisted `TextEncoder`/`TextDecoder` + inference. +- Preserve pull-driven `ReadableStream` bodies through `Response` construction + and cloning, reject invalid `GET`/`HEAD` stream bodies before consuming them, + and expose the fetch/abort reflection surface expected by response wrappers. +- Resolve nested `?script-string` assets relative to their importer and preserve + their source exactly when generating hydration bootstrap modules. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 4b5055341b..b430f5e345 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1527,6 +1527,12 @@ pub(crate) fn class_field_loop_fact_lookup<'f>( } impl<'a> FnCtx<'a> { + pub(crate) fn has_imported_extern_binding(&self, name: &str) -> bool { + self.imported_vars.contains(name) + || self.import_function_prefixes.contains_key(name) + || self.import_function_v8_specifiers.contains_key(name) + } + /// The `Ptr` proof for a receiver expression, if any — the single /// entry point every representation-selection object site consults. /// diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index a272131c5f..f38173d875 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1351,7 +1351,7 @@ pub fn try_lower_extern_func_call( // scope in #689 and continue to fall through to `js_jsx`; the runtime // returns `undefined` for those unrecognised intrinsic sentinels until // the rewriter is extended. - "jsx" | "jsxs" => { + "jsx" | "jsxs" if !ctx.has_imported_extern_binding(name) => { if let Some(call) = try_rewrite_perry_tui_jsx_intrinsic(ctx, name == "jsxs", args)? { return Ok(Some(call)); } diff --git a/crates/perry-hir/src/dynamic_import/visitors.rs b/crates/perry-hir/src/dynamic_import/visitors.rs index 48b149bd5f..2c2e8aa9de 100644 --- a/crates/perry-hir/src/dynamic_import/visitors.rs +++ b/crates/perry-hir/src/dynamic_import/visitors.rs @@ -461,6 +461,13 @@ fn visit_expr_for_dyn_imports_ref(expr: &Expr, f: &mut F) { visit_stmt_for_dyn_imports_ref(s, f); } } + // Closure bodies — mirror the mutable visitor so the collect pass and the + // fill pass traverse dynamic import sites in the same order. + if let Expr::Closure { body, .. } = expr { + for s in body { + visit_stmt_for_dyn_imports_ref(s, f); + } + } walk_expr_children(expr, &mut |child| visit_expr_for_dyn_imports_ref(child, f)); } diff --git a/crates/perry-hir/src/jsx.rs b/crates/perry-hir/src/jsx.rs index 1f496749af..33bc73e0b9 100644 --- a/crates/perry-hir/src/jsx.rs +++ b/crates/perry-hir/src/jsx.rs @@ -13,7 +13,8 @@ use crate::lower::{lower_expr, LoweringContext}; pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement) -> Result { let type_expr = lower_jsx_element_name(ctx, &jsx.opening.name)?; - let mut props_fields: Vec<(String, Expr)> = Vec::new(); + let mut props_parts: Vec<(Option, Expr)> = Vec::new(); + let mut has_spread_attr = false; for attr in &jsx.opening.attrs { match attr { ast::JSXAttrOrSpread::JSXAttr(jsx_attr) => { @@ -31,12 +32,11 @@ pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement None => Expr::Bool(true), // Boolean attribute: Some(val) => lower_jsx_attr_value(ctx, val)?, }; - props_fields.push((attr_name, attr_val)); + props_parts.push((Some(attr_name), attr_val)); } ast::JSXAttrOrSpread::SpreadElement(spread) => { - // Spread attributes ({...obj}) are not yet representable in HIR Object. - // Evaluate for side effects but don't propagate into props. - let _ = lower_expr(ctx, &spread.expr); + has_spread_attr = true; + props_parts.push((None, lower_expr(ctx, &spread.expr)?)); } } } @@ -54,17 +54,24 @@ pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement match children.len() { 0 => {} 1 => { - props_fields.push(("children".to_string(), children.remove(0))); + props_parts.push((Some("children".to_string()), children.remove(0))); } _ => { - props_fields.push(("children".to_string(), Expr::Array(children))); + props_parts.push((Some("children".to_string()), Expr::Array(children))); } } - let props_expr = if props_fields.is_empty() { + let props_expr = if props_parts.is_empty() { Expr::Null + } else if has_spread_attr { + Expr::ObjectSpread { parts: props_parts } } else { - Expr::Object(props_fields) + Expr::Object( + props_parts + .into_iter() + .map(|(key, value)| (key.expect("non-spread JSX prop"), value)) + .collect(), + ) }; // #4950: a module that default-imports the npm `react` package gets diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index e5e2d7247a..380884ac1a 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -28,6 +28,9 @@ use crate::lower_patterns::{ generate_param_destructuring_stmts, get_param_default, get_pat_name, get_pat_type, is_destructuring_pattern, is_rest_param, }; +use crate::lower_types::hoisted_text_codec::{ + infer_hoisted_text_codec_var_type, require_literal_specifier, +}; use super::{lower_expr, LoweringContext}; @@ -779,6 +782,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // shared synthetic class id on the instance and dispatch finds // the prototype methods. Same shallow-walk policy as the // codegen-side `referenced_from_fn` pre-scan. + let mut builtin_aliases_in_var_decl = std::collections::HashSet::new(); for stmt in &block.stmts { if let ast::Stmt::Decl(ast::Decl::Var(var_decl)) = stmt { if var_decl.kind == ast::VarDeclKind::Var { @@ -801,12 +805,34 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul &decl.name, &mut names, ); for name in names { + let ty = if let ast::Pat::Ident(ident) = &decl.name { + if decl.init.as_deref().and_then(require_literal_specifier) + == Some("util") + || decl.init.as_deref().and_then(require_literal_specifier) + == Some("node:util") + { + builtin_aliases_in_var_decl.insert(name.clone()); + } + infer_hoisted_text_codec_var_type(decl, ident, |name| { + builtin_aliases_in_var_decl.contains(name) + || matches!( + ctx.lookup_builtin_module_alias(name), + Some("util" | "node:util") + ) + || matches!( + ctx.lookup_native_module(name), + Some(("util" | "node:util", None)) + ) + }) + } else { + Type::Any + }; let already_in_scope = ctx .locals .lookup_index_in_scope(&name, outer_locals_len) .is_some(); if !already_in_scope { - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local(name.clone(), ty.clone()); // Mark as hoisted so closures created // before the var's init expression see // it through a box (mutable capture), @@ -855,7 +881,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul nested_var_prologue.push(Stmt::Let { id, name, - ty: Type::Any, + ty, mutable: true, init: Some(Expr::Undefined), }); diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 1cfacfce5f..d5268fd86c 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -14,32 +14,93 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; +use crate::lower_types::hoisted_text_codec::{ + infer_hoisted_text_codec_var_type, require_literal_specifier, +}; -fn module_has_strict_mode(ast_module: &ast::Module, source_file_path: &str) -> bool { - // A file is strict-mode code exactly when Node runs it as an ES module. Three - // independent signals make it one (#6542); any is sufficient: - // - // 1. The module FORMAT — an ESM extension (`.mjs`/`.mts`) or an ESM package - // context (`"type":"module"`). This holds even with NO in-file import/ - // export syntax: e.g. Perry's own test-suite `.ts` files carry no module - // syntax yet run strict because the repo's `package.json` sets - // `"type":"module"`, so `Object.freeze(o); o.x = 1` must throw there. - if perry_parser::file_is_es_module_by_format(source_file_path) { - return true; +fn should_enable_react_automatic_jsx(name: &str, ast_module: &ast::Module) -> bool { + let is_jsx_source = name.ends_with(".tsx") + || name.ends_with(".jsx") + || name.contains(".tsx?") + || name.contains(".jsx?"); + if !is_jsx_source { + return false; + } + + let mut has_explicit_react_import = false; + let mut has_react_ecosystem_import = false; + for item in &ast_module.body { + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) = item else { + continue; + }; + let source = import.src.value.to_string_lossy().to_string(); + let has_runtime_value = !import.type_only + && (import.specifiers.is_empty() + || import.specifiers.iter().any(|specifier| match specifier { + ast::ImportSpecifier::Named(named) => !named.is_type_only, + ast::ImportSpecifier::Default(_) | ast::ImportSpecifier::Namespace(_) => true, + })); + let provides_react_object = !import.type_only + && import.specifiers.iter().any(|specifier| { + matches!( + specifier, + ast::ImportSpecifier::Default(_) | ast::ImportSpecifier::Namespace(_) + ) + }); + if source == "react" && provides_react_object { + has_explicit_react_import = true; + } + if has_runtime_value + && (source.starts_with("@tanstack/react-") + || source == "@tanstack/react-router" + || source == "react/jsx-runtime") + { + has_react_ecosystem_import = true; + } + } + + if has_explicit_react_import { + return false; } - // 2. An ES `import`/`export` ANYWHERE in the body makes the source a Module - // (Source Text Module Record) even outside any package context — the - // declaration need not precede other statements, so a trailing - // `export {}` or a `const x = 1; export function f() {}` both count. - if ast_module - .body - .iter() - .any(|item| matches!(item, ast::ModuleItem::ModuleDecl(_))) + + has_react_ecosystem_import + || name.contains("node_modules/@tanstack/react-") + || name.contains("node_modules/@tanstack/react-router/") +} + +fn enable_react_automatic_jsx(module: &mut Module, ctx: &mut LoweringContext) { + const LOCAL: &str = "__perry_react_auto"; + let local = LOCAL.to_string(); + ctx.register_imported_func(local.clone(), local.clone()); + ctx.namespace_import_locals.insert(local.clone()); + ctx.namespace_import_sources + .insert(local.clone(), "react".to_string()); + ctx.react_default_import_local = Some(local.clone()); + module.imports.push(Import { + source: "react".to_string(), + specifiers: vec![ImportSpecifier::Namespace { local }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: None, + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); +} + +fn module_has_strict_mode(ast_module: &ast::Module, source_file_path: &str) -> bool { + // Node treats ESM format/package context and any source-text module as + // strict even without a directive prologue (#6542). + if perry_parser::file_is_es_module_by_format(source_file_path) + || ast_module + .body + .iter() + .any(|item| matches!(item, ast::ModuleItem::ModuleDecl(_))) { return true; } - // 3. Otherwise this is CommonJS script text; it is strict only if the - // directive prologue opens with a `"use strict"` directive. for item in &ast_module.body { let ast::ModuleItem::Stmt(stmt) = item else { break; @@ -435,6 +496,9 @@ pub fn lower_module_full( ctx.seed_imported_class_accessors(seed); } let mut module = Module::new(name); + if should_enable_react_automatic_jsx(name, ast_module) { + enable_react_automatic_jsx(&mut module, &mut ctx); + } // Pre-scan for `new Function` / `Function(...)` constant-argument // resolution: single-assignment module vars, `toString`-bearing object @@ -609,6 +673,7 @@ pub fn lower_module_full( // Pre-register module-level variable declarations so function bodies // declared before the variable can still reference them via lookup_local + let mut builtin_aliases_in_module_vars = HashSet::new(); for item in &ast_module.body { let var_decl = match item { ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(v))) => Some(v), @@ -637,12 +702,24 @@ pub fn lower_module_full( } if let ast::Pat::Ident(ident) = &decl.name { let name = ident.id.sym.to_string(); + if decl.init.as_deref().and_then(require_literal_specifier) == Some("util") + || decl.init.as_deref().and_then(require_literal_specifier) + == Some("node:util") + { + builtin_aliases_in_module_vars.insert(name.clone()); + } if ctx.lookup_local(&name).is_none() { - let ty = ident - .type_ann - .as_ref() - .map(|ann| extract_ts_type(&ann.type_ann)) - .unwrap_or(Type::Any); + let ty = infer_hoisted_text_codec_var_type(decl, ident, |name| { + builtin_aliases_in_module_vars.contains(name) + || matches!( + ctx.lookup_builtin_module_alias(name), + Some("util" | "node:util") + ) + || matches!( + ctx.lookup_native_module(name), + Some(("util" | "node:util", None)) + ) + }); ctx.define_local(name.clone(), ty); ctx.pre_registered_module_vars.insert(name); if var_decl.kind == ast::VarDeclKind::Var { diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 798eae6287..8f30d150ce 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -412,7 +412,7 @@ pub(crate) fn lower_module_decl( // so JSX in this module lowers to // `.createElement(...)` instead of Perry's // eager `js_jsx` adapter (see jsx.rs). - if source == "react" { + if source == "react" && !whole_decl_type_only { ctx.react_default_import_local = Some(local.clone()); } } @@ -443,6 +443,15 @@ pub(crate) fn lower_module_decl( // not lower to StaticMethodCall — see the heuristic // in expr_call::static_and_instance. ctx.namespace_import_locals.insert(local.clone()); + // React namespace imports are the common TSX shape + // (`import * as React from "react"`). They need the + // same non-eager React element semantics as default + // React imports; Perry's native JSX adapter calls + // function components immediately and therefore runs + // hooks outside the reconciler. + if source == "react" && !whole_decl_type_only { + ctx.react_default_import_local = Some(local.clone()); + } // Remember the source so a later bare `export { local }` // re-exports the namespace itself rather than a bare // function symbol (see the local-export branch below). diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 8360c793e1..5e830cbfd2 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -308,6 +308,8 @@ const INFER_TYPE_RECURSION_CAP: u32 = 48; const INFER_TYPE_STACK_RED_ZONE: usize = 256 * 1024; const INFER_TYPE_STACK_SEGMENT: usize = 2 * 1024 * 1024; +pub(crate) mod hoisted_text_codec; + pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> Type { thread_local! { static INFER_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; diff --git a/crates/perry-hir/src/lower_types/hoisted_text_codec.rs b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs new file mode 100644 index 0000000000..e4c41cf48e --- /dev/null +++ b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs @@ -0,0 +1,77 @@ +use crate::types::Type; +use swc_ecma_ast as ast; + +use super::extract_ts_type; + +pub(crate) fn peel_expr_for_hoisted_var_type(expr: &ast::Expr) -> &ast::Expr { + match expr { + ast::Expr::Paren(paren) => peel_expr_for_hoisted_var_type(&paren.expr), + ast::Expr::TsAs(ts_as) => peel_expr_for_hoisted_var_type(&ts_as.expr), + ast::Expr::TsTypeAssertion(ts_assert) => peel_expr_for_hoisted_var_type(&ts_assert.expr), + ast::Expr::TsNonNull(non_null) => peel_expr_for_hoisted_var_type(&non_null.expr), + ast::Expr::TsConstAssertion(const_assert) => { + peel_expr_for_hoisted_var_type(&const_assert.expr) + } + _ => expr, + } +} + +pub(crate) fn require_literal_specifier(expr: &ast::Expr) -> Option<&str> { + let ast::Expr::Call(call) = peel_expr_for_hoisted_var_type(expr) else { + return None; + }; + let ast::Callee::Expr(callee) = &call.callee else { + return None; + }; + let ast::Expr::Ident(ident) = callee.as_ref() else { + return None; + }; + if ident.sym.as_ref() != "require" { + return None; + } + let first_arg = call.args.first()?; + let ast::Expr::Lit(ast::Lit::Str(specifier)) = peel_expr_for_hoisted_var_type(&first_arg.expr) + else { + return None; + }; + Some(specifier.value.as_str().unwrap_or("")) +} + +pub(crate) fn infer_hoisted_text_codec_var_type( + decl: &ast::VarDeclarator, + ident: &ast::BindingIdent, + is_util_alias: impl Fn(&str) -> bool, +) -> Type { + if let Some(ann) = ident.type_ann.as_ref() { + return extract_ts_type(&ann.type_ann); + } + + let Some(init) = decl.init.as_deref().map(peel_expr_for_hoisted_var_type) else { + return Type::Any; + }; + let ast::Expr::New(new_expr) = init else { + return Type::Any; + }; + + match peel_expr_for_hoisted_var_type(new_expr.callee.as_ref()) { + ast::Expr::Ident(ctor) => match ctor.sym.as_ref() { + "TextEncoder" | "TextDecoder" => Type::Named(ctor.sym.to_string()), + _ => Type::Any, + }, + ast::Expr::Member(member) => { + let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = + (member.obj.as_ref(), &member.prop) + else { + return Type::Any; + }; + let prop_name = prop.sym.as_ref(); + if matches!(prop_name, "TextEncoder" | "TextDecoder") && is_util_alias(obj.sym.as_ref()) + { + Type::Named(prop_name.to_string()) + } else { + Type::Any + } + } + _ => Type::Any, + } +} diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 66e2d66e7b..e342ab31f2 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -547,6 +547,45 @@ fn is_uncallable_builtin_super_parent(name: &str) -> bool { ) } +fn is_uncallable_builtin_super_parent_class_id(class_id: u32) -> bool { + if class_id == 0 { + return false; + } + const NAMES: &[&str] = &[ + "Map", + "Set", + "WeakMap", + "WeakSet", + "Array", + "ArrayBuffer", + "DataView", + "Boolean", + "Number", + "String", + "Date", + "RegExp", + "Promise", + "Function", + "BigInt", + "Symbol", + "Object", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + ]; + NAMES + .iter() + .any(|name| super::super::instanceof::global_builtin_constructor_class_id(name) == class_id) +} + /// `super(...)` for `class X extends ` where the /// parent expression is an alias of the global `Request`/`Response` constructor /// — e.g. `@hono/node-server`'s `class Request extends GlobalRequest` with @@ -835,6 +874,17 @@ pub unsafe extern "C" fn js_fetch_or_value_super( } let usable = if bits & TAG_MASK == POINTER_TAG { let p = (bits & PTR_MASK) as usize; + if super::super::class_registry::is_class_object_ptr(p as *const u8) { + let parent_cid = crate::object::js_object_get_class_id(p as *const _); + if parent_cid != 0 { + if let Some(obj) = subclass_this_object_ptr(this_box) { + super::super::class_constructors::run_class_constructor_on_this_flat( + parent_cid, obj as i64, args_ptr, args_len, + ); + } + } + return undef; + } // A real callability test: a closure, or a per-evaluation class // OBJECT (constructor). The prior `class_id != 0` accepted any // pointer-tagged object with a class id — including non-callable @@ -842,7 +892,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // skipped the `parent_closure_in_chain` recovery below and // dispatched `js_native_call_value` on a non-function. crate::closure::is_closure_ptr(p) - || super::super::class_registry::is_class_object_ptr(p as *const u8) } else { // INT32-tagged ClassRefs route through the static super paths // before reaching here; anything else (undefined / a stale @@ -854,6 +903,15 @@ pub unsafe extern "C" fn js_fetch_or_value_super( let cid = crate::object::js_object_get_class_id(obj); if let Some(addr) = super::super::class_registry::parent_closure_in_chain(cid) { callee = f64::from_bits(POINTER_TAG | addr as u64); + } else if let Some(parent_cid) = crate::object::get_parent_class_id(cid) { + if parent_cid != 0 + && !is_uncallable_builtin_super_parent_class_id(parent_cid) + { + super::super::class_constructors::run_class_constructor_on_this_flat( + parent_cid, obj as i64, args_ptr, args_len, + ); + return undef; + } } } } diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 3d0d99d200..4a4ee0e086 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -649,6 +649,62 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ("text", 0), ], ); + // Web Fetch accessors must be visible as accessor descriptors on + // the intrinsic prototype, not just as compiler-known handle + // fields. Libraries such as srvx copy `Response.prototype` + // descriptors onto lightweight response wrappers and provide their + // own getter body. If these names are absent from reflection, the + // wrapper's inherited `.body`/`.headers` reads become undefined and + // streamed SSR responses are dropped before the underlying stream + // is ever pulled. + let accessors: &[&str] = if builtin_name == "Response" { + &[ + "body", + "bodyUsed", + "headers", + "ok", + "redirected", + "status", + "statusText", + "type", + "url", + ] + } else { + &[ + "body", + "bodyUsed", + "cache", + "credentials", + "destination", + "duplex", + "headers", + "integrity", + "keepalive", + "method", + "mode", + "redirect", + "referrer", + "referrerPolicy", + "signal", + "url", + ] + }; + unsafe { + crate::closure::js_register_closure_arity( + global_this_builtin_noop_thunk as *const u8, + 0, + ); + for name in accessors { + let getter = crate::closure::js_closure_alloc( + global_this_builtin_noop_thunk as *const u8, + 0, + ); + if !getter.is_null() { + let getter_bits = crate::value::js_nanbox_pointer(getter as i64).to_bits(); + install_builtin_getter(proto_obj, name, getter_bits); + } + } + } install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); } #[cfg(feature = "global-webfetch")] diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index f69b48ae5f..e686164c51 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -513,6 +513,7 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 { "WeakSet" => 0xFFFF002D, "RegExp" => 0xFFFF0021, "ArrayBuffer" => 0xFFFF0025, + "DataView" => 0xFFFF002B, "Array" => 0xFFFF0024, "Object" => 0xFFFF0050, "Function" => CLASS_ID_FUNCTION, diff --git a/crates/perry-runtime/src/url/abort.rs b/crates/perry-runtime/src/url/abort.rs index 7fd7c364c9..71756ab3a0 100644 --- a/crates/perry-runtime/src/url/abort.rs +++ b/crates/perry-runtime/src/url/abort.rs @@ -21,7 +21,9 @@ const ABORT_METHOD_FIELD: u32 = 2; // field 0: aborted (bool) // field 1: reason (any) // field 2: listeners (array of closure f64 values; may be null/undefined if empty) -const ABORT_SIGNAL_FIELD_COUNT: u32 = 3; +// field 3: addEventListener method +// field 4: removeEventListener method +const ABORT_SIGNAL_FIELD_COUNT: u32 = 5; const TAG_UNDEFINED_AC: u64 = 0x7FFC_0000_0000_0001; const TAG_TRUE_AC: u64 = 0x7FFC_0000_0000_0004; @@ -53,13 +55,49 @@ fn alloc_abort_signal() -> *mut ObjectHeader { signal_keys = js_array_push_f64(signal_keys, create_string_f64("aborted")); signal_keys = js_array_push_f64(signal_keys, create_string_f64("reason")); signal_keys = js_array_push_f64(signal_keys, create_string_f64("_listeners")); + signal_keys = js_array_push_f64(signal_keys, create_string_f64("addEventListener")); + signal_keys = js_array_push_f64(signal_keys, create_string_f64("removeEventListener")); js_object_set_keys(signal, signal_keys); js_object_set_field_f64(signal, 0, f64::from_bits(TAG_FALSE_AC)); js_object_set_field_f64(signal, 1, f64::from_bits(TAG_UNDEFINED_AC)); js_object_set_field_f64(signal, 2, f64::from_bits(TAG_UNDEFINED_AC)); + js_object_set_field_f64(signal, 3, abort_signal_listener_method_value(signal, true)); + js_object_set_field_f64(signal, 4, abort_signal_listener_method_value(signal, false)); signal } +extern "C" fn abort_signal_add_event_listener_method( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, +) -> f64 { + let signal = crate::closure::js_closure_get_capture_ptr(closure, 0) as *mut ObjectHeader; + js_abort_signal_add_listener(signal, event_type, listener); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +extern "C" fn abort_signal_remove_event_listener_method( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, +) -> f64 { + let signal = crate::closure::js_closure_get_capture_ptr(closure, 0) as *mut ObjectHeader; + js_abort_signal_remove_listener(signal, event_type, listener); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn abort_signal_listener_method_value(signal: *mut ObjectHeader, add: bool) -> f64 { + let func = if add { + abort_signal_add_event_listener_method as *const u8 + } else { + abort_signal_remove_event_listener_method as *const u8 + }; + crate::closure::js_register_closure_arity(func, 2); + let closure = crate::closure::js_closure_alloc(func, 1); + crate::closure::js_closure_set_capture_ptr(closure, 0, signal as i64); + crate::value::js_nanbox_pointer(closure as i64) +} + extern "C" fn abort_controller_abort_method( closure: *const crate::closure::ClosureHeader, reason: f64, diff --git a/crates/perry-stdlib/src/fetch/abort_bridge.rs b/crates/perry-stdlib/src/fetch/abort_bridge.rs index 3d9a7c401a..f160cd9b1f 100644 --- a/crates/perry-stdlib/src/fetch/abort_bridge.rs +++ b/crates/perry-stdlib/src/fetch/abort_bridge.rs @@ -229,6 +229,7 @@ pub(crate) async fn run_request( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); let result_bits = super::handle_to_f64(response_id).to_bits(); diff --git a/crates/perry-stdlib/src/fetch/body_metadata.rs b/crates/perry-stdlib/src/fetch/body_metadata.rs index 86ff208b00..ddf0ece719 100644 --- a/crates/perry-stdlib/src/fetch/body_metadata.rs +++ b/crates/perry-stdlib/src/fetch/body_metadata.rs @@ -202,6 +202,7 @@ pub extern "C" fn js_response_static_error() -> f64 { redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); handle_to_f64(id) diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index ef824cc38b..2f5530eaff 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -64,10 +64,13 @@ pub extern "C" fn js_response_body_init_ptr(value: f64) -> i64 { .contains(&value) { let id = value as usize; - // kind == 1 ⇒ live ReadableStream. + // kind == 1 ⇒ live ReadableStream. Stash it for the constructor that + // requested the body-init conversion: Request drains it to bytes, while + // Response preserves it lazily because transformed SSR streams often + // produce data only when the downstream consumer pulls. if crate::streams::js_stream_handle_kind(id) == 1 { - let bytes = crate::streams::drain_readable_into_bytes(id); - return js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) as i64; + PENDING_FETCH_BODY_STREAM_ID.with(|pending| pending.set(id)); + return 0; } } // #5437: a Node `IncomingMessage` body — the request-body bridge Next.js's diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 09bd4b2779..5560d2cd9f 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -7,6 +7,7 @@ use perry_runtime::{ js_array_alloc, js_array_push, js_object_alloc, js_object_set_field, js_object_set_keys, js_string_from_bytes, JSValue, StringHeader, }; +use std::cell::Cell; use std::collections::HashMap; use std::sync::Mutex; @@ -44,6 +45,12 @@ pub use body_metadata::*; mod request_ctor; pub use request_ctor::*; +// Web Fetch `Response` constructor/accessors — split out to keep this file +// under the 2,000-line lint gate. +mod response_ctor; +use response_ctor::alloc_response; +pub use response_ctor::{js_response_clone, js_response_get_headers, js_response_new}; + // Web Fetch constructor validation helpers (#2640 / #2643) — split out to // keep this file under the 2,000-line lint gate. mod validation; @@ -264,6 +271,27 @@ struct FetchResponse { /// each time would silently un-lock a reader). None for an empty body — /// `Response.body` is `ReadableStream | null` (#1650). cached_body_stream_id: Option, + /// Original ReadableStream body passed to `new Response(stream, init)`. + /// Unlike buffered bodies, this must stay lazy: constructing a Response + /// must not synchronously drain a producer whose chunks appear only after + /// downstream pulls (TanStack Start / React SSR relies on that). + body_stream_id: Option, +} + +thread_local! { + static PENDING_FETCH_BODY_STREAM_ID: Cell = const { Cell::new(0) }; +} + +fn take_pending_fetch_body_stream_id() -> Option { + PENDING_FETCH_BODY_STREAM_ID.with(|pending| { + let id = pending.get(); + pending.set(0); + if id != 0 && crate::streams::js_stream_handle_kind(id) == 1 { + Some(id) + } else { + None + } + }) } /// Extract the registry id from a Web Fetch handle f64 value. @@ -428,6 +456,7 @@ pub unsafe extern "C" fn js_fetch_get(url_ptr: *const StringHeader) -> *mut perr redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -503,6 +532,7 @@ pub unsafe extern "C" fn js_fetch_get_with_auth( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -580,6 +610,7 @@ pub unsafe extern "C" fn js_fetch_post_with_auth( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -664,6 +695,7 @@ pub unsafe extern "C" fn js_fetch_post( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -793,18 +825,24 @@ pub extern "C" fn js_response_body_used(handle: f64) -> f64 { fn consume_response_body(handle: f64) -> Result, &'static str> { let response_id = handle_id(handle); - let mut guard = FETCH_RESPONSES.lock().unwrap(); - let resp = guard - .get_mut(&response_id) - .ok_or("Invalid response handle")?; - if !resp.body_present { - return Ok(Vec::new()); - } - if resp.body_used { - return Err(BODY_ALREADY_USED_MESSAGE); + let (body, stream_id) = { + let mut guard = FETCH_RESPONSES.lock().unwrap(); + let resp = guard + .get_mut(&response_id) + .ok_or("Invalid response handle")?; + if !resp.body_present { + return Ok(Vec::new()); + } + if resp.body_used { + return Err(BODY_ALREADY_USED_MESSAGE); + } + resp.body_used = true; + (resp.body.clone(), resp.body_stream_id) + }; + if let Some(stream_id) = stream_id { + return Ok(crate::streams::drain_readable_into_bytes(stream_id)); } - resp.body_used = true; - Ok(resp.body.clone()) + Ok(body) } /// Get response body as text @@ -1264,156 +1302,12 @@ fn alloc_headers(store: HeadersStore) -> usize { id } -fn alloc_response( - status: u16, - status_text: String, - headers: HeadersStore, - body: Vec, - body_present: bool, -) -> usize { - let id = alloc_fetch_handle_id(); - FETCH_RESPONSES.lock().unwrap().insert( - id, - FetchResponse { - status, - status_text, - headers, - body, - body_present, - body_used: false, - type_name: "default".to_string(), - url: String::new(), - redirected: false, - cached_headers_id: None, - cached_body_stream_id: None, - }, - ); - id -} - // ----------------- Headers FFI ----------------- // Moved to the `headers` sub-module (#1649 pushed fetch.rs past the 2,000-line // lint gate; mirrors the earlier fetch_blob.rs extraction). Re-exported below. // ----------------- Response FFI (constructor + extra methods) ----------------- -/// 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 { - // 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(); - 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. - let status_u16 = if status.is_nan() || status == 0.0 { - 200 - } else { - let truncated = status.trunc(); - if !(200.0..=599.0).contains(&truncated) { - throw_fetch_range_error( - "init[\"status\"] must be in the range of 200 to 599, inclusive.", - ); - } - 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) { - throw_fetch_type_error("Invalid statusText"); - } - s - } - None => String::new(), - }; - if body_present && is_null_body_status(status_u16) { - throw_fetch_type_error(&format!( - "Response constructor: Invalid response status code {status_u16}" - )); - } - let headers_id = handle_id(headers_handle); - let headers = if headers_id != 0 { - HEADERS_REGISTRY - .lock() - .unwrap() - .get(&headers_id) - .cloned() - .unwrap_or_default() - } else { - HeadersStore::default() - }; - handle_to_f64(alloc_response( - status_u16, - status_text, - headers, - body, - body_present, - )) -} - -/// response.headers — returns a Headers handle (f64). Lazily allocates a Headers entry -/// from the response's stored header HashMap if one doesn't exist yet. -#[no_mangle] -pub extern "C" fn js_response_get_headers(handle: f64) -> f64 { - let id = handle_id(handle); - let store = { - let guard = FETCH_RESPONSES.lock().unwrap(); - match guard.get(&id) { - Some(resp) => resp.headers.clone(), - None => return f64::from_bits(TAG_UNDEFINED), - } - }; - handle_to_f64(alloc_headers(store)) -} - -/// response.clone() — duplicates the response (deep copy of body + headers) -#[no_mangle] -pub extern "C" fn js_response_clone(handle: f64) -> f64 { - let id = handle_id(handle); - let cloned = { - let guard = FETCH_RESPONSES.lock().unwrap(); - guard.get(&id).map(|resp| { - if resp.body_present && resp.body_used { - unsafe { - throw_fetch_type_error("Response.clone: Body has already been consumed.") - }; - } - FetchResponse { - status: resp.status, - status_text: resp.status_text.clone(), - headers: resp.headers.clone(), - body: resp.body.clone(), - body_present: resp.body_present, - body_used: false, - type_name: resp.type_name.clone(), - url: resp.url.clone(), - redirected: resp.redirected, - cached_headers_id: None, - cached_body_stream_id: None, - } - }) - }; - if let Some(new_resp) = cloned { - let new_id = alloc_fetch_handle_id(); - FETCH_RESPONSES.lock().unwrap().insert(new_id, new_resp); - return handle_to_f64(new_id); - } - f64::from_bits(TAG_UNDEFINED) -} - /// response.arrayBuffer() — returns a real BufferHeader holding the body bytes, /// NaN-boxed as POINTER_TAG so that `new Uint8Array(buf)` and `Buffer.from(buf)` /// see the actual byte contents. `.byteLength` / `.length` access routes through @@ -1681,6 +1575,14 @@ pub unsafe extern "C" fn js_blob_stream(handle: f64) -> f64 { /// single stream, and a fresh one each call would silently unlock a held /// reader (#1650). fn response_body_stream(resp_id: usize) -> f64 { + if let Some(id) = FETCH_RESPONSES + .lock() + .unwrap() + .get(&resp_id) + .and_then(|r| r.body_stream_id) + { + return id as f64; + } if let Some(id) = FETCH_RESPONSES .lock() .unwrap() diff --git a/crates/perry-stdlib/src/fetch/request_ctor.rs b/crates/perry-stdlib/src/fetch/request_ctor.rs index b87792c09f..471b04c593 100644 --- a/crates/perry-stdlib/src/fetch/request_ctor.rs +++ b/crates/perry-stdlib/src/fetch/request_ctor.rs @@ -61,7 +61,8 @@ pub unsafe extern "C" fn js_request_new( // real Blob / buffer / string body is untouched. Mirrors the #5437 fix already // in `js_response_body_init_ptr` (the Response twin), which falls through via // `or_else` rather than if/else. - let body: Option> = + let pending_stream_id = take_pending_fetch_body_stream_id(); + let non_stream_body: Option> = if perry_runtime::value::addr_class::is_handle_band(body_ptr as usize) { crate::fetch::blob_bytes_clone(body_ptr as usize) .or_else(|| dispatch::incoming_message_raw_body_bytes(body_ptr as usize)) @@ -73,9 +74,14 @@ pub unsafe extern "C" fn js_request_new( .or_else(|| dispatch::body_bytes_from_header(body_ptr)) }; // GET/HEAD requests may not carry a body (WHATWG fetch). Refs #2643. - if body.is_some() && (method == "GET" || method == "HEAD") { + if (pending_stream_id.is_some() || non_stream_body.is_some()) + && (method == "GET" || method == "HEAD") + { throw_fetch_type_error("Request with GET/HEAD method cannot have body."); } + let body = pending_stream_id + .map(crate::streams::drain_readable_into_bytes) + .or(non_stream_body); let headers_id_in = handle_id(headers_handle); let headers = if headers_id_in != 0 { HEADERS_REGISTRY diff --git a/crates/perry-stdlib/src/fetch/response_ctor.rs b/crates/perry-stdlib/src/fetch/response_ctor.rs new file mode 100644 index 0000000000..964ebc67b8 --- /dev/null +++ b/crates/perry-stdlib/src/fetch/response_ctor.rs @@ -0,0 +1,156 @@ +use super::*; + +pub(super) fn alloc_response( + status: u16, + status_text: String, + headers: HeadersStore, + body: Vec, + body_present: bool, +) -> usize { + let id = alloc_fetch_handle_id(); + FETCH_RESPONSES.lock().unwrap().insert( + id, + FetchResponse { + status, + status_text, + headers, + body, + body_present, + body_used: false, + type_name: "default".to_string(), + url: String::new(), + redirected: false, + cached_headers_id: None, + cached_body_stream_id: None, + body_stream_id: None, + }, + ); + 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, + status: f64, + status_text_ptr: *const StringHeader, + headers_handle: f64, +) -> f64 { + let body_stream_id = take_pending_fetch_body_stream_id(); + // 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. + let status_u16 = if status.is_nan() || status == 0.0 { + 200 + } else { + let truncated = status.trunc(); + if !(200.0..=599.0).contains(&truncated) { + throw_fetch_range_error( + "init[\"status\"] must be in the range of 200 to 599, inclusive.", + ); + } + 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) { + throw_fetch_type_error("Invalid statusText"); + } + s + } + None => String::new(), + }; + if body_present && is_null_body_status(status_u16) { + throw_fetch_type_error(&format!( + "Response constructor: Invalid response status code {status_u16}" + )); + } + let headers_id = handle_id(headers_handle); + let headers = if headers_id != 0 { + HEADERS_REGISTRY + .lock() + .unwrap() + .get(&headers_id) + .cloned() + .unwrap_or_default() + } else { + HeadersStore::default() + }; + let id = alloc_response(status_u16, status_text, headers, body, body_present); + if let Some(stream_id) = body_stream_id { + if let Some(resp) = FETCH_RESPONSES.lock().unwrap().get_mut(&id) { + resp.body_stream_id = Some(stream_id); + resp.cached_body_stream_id = Some(stream_id); + } + } + handle_to_f64(id) +} + +/// response.headers — returns a Headers handle (f64). Lazily allocates a Headers entry +/// from the response's stored header HashMap if one doesn't exist yet. +#[no_mangle] +pub extern "C" fn js_response_get_headers(handle: f64) -> f64 { + let id = handle_id(handle); + let store = { + let guard = FETCH_RESPONSES.lock().unwrap(); + match guard.get(&id) { + Some(resp) => resp.headers.clone(), + None => return f64::from_bits(TAG_UNDEFINED), + } + }; + handle_to_f64(alloc_headers(store)) +} + +/// response.clone() — duplicates the response (deep copy of body + headers) +#[no_mangle] +pub extern "C" fn js_response_clone(handle: f64) -> f64 { + let id = handle_id(handle); + let cloned = { + let mut guard = FETCH_RESPONSES.lock().unwrap(); + guard.get_mut(&id).map(|resp| { + if resp.body_present && resp.body_used { + unsafe { + throw_fetch_type_error("Response.clone: Body has already been consumed.") + }; + } + let cloned_stream_id = resp.body_stream_id.map(|stream_id| { + let (original, cloned) = + unsafe { crate::streams::tee_readable_stream_ids(stream_id) }; + resp.body_stream_id = Some(original); + resp.cached_body_stream_id = Some(original); + cloned + }); + FetchResponse { + status: resp.status, + status_text: resp.status_text.clone(), + headers: resp.headers.clone(), + body: resp.body.clone(), + body_present: resp.body_present, + body_used: false, + type_name: resp.type_name.clone(), + url: resp.url.clone(), + redirected: resp.redirected, + cached_headers_id: None, + cached_body_stream_id: cloned_stream_id, + body_stream_id: cloned_stream_id, + } + }) + }; + if let Some(new_resp) = cloned { + let new_id = alloc_fetch_handle_id(); + FETCH_RESPONSES.lock().unwrap().insert(new_id, new_resp); + return handle_to_f64(new_id); + } + f64::from_bits(TAG_UNDEFINED) +} diff --git a/crates/perry-stdlib/src/streams.rs b/crates/perry-stdlib/src/streams.rs index c1f61684bc..2099457f0e 100644 --- a/crates/perry-stdlib/src/streams.rs +++ b/crates/perry-stdlib/src/streams.rs @@ -98,6 +98,7 @@ mod transform; mod writable; pub use tee::js_readable_stream_tee; +pub(crate) use tee::tee_readable_stream_ids; use tee::{tee_branches_of, tee_error_branches, tee_source_of}; pub use self::byob::{ diff --git a/crates/perry-stdlib/src/streams/tee.rs b/crates/perry-stdlib/src/streams/tee.rs index 323a41125b..69f090d507 100644 --- a/crates/perry-stdlib/src/streams/tee.rs +++ b/crates/perry-stdlib/src/streams/tee.rs @@ -453,9 +453,7 @@ extern "C" fn tee_pull_microtask(closure: *const ClosureHeader) -> f64 { /// previous implementation snapshot-drained the source's current buffer and /// closed it, which yielded two empty branches for a pull-driven source (e.g. /// react-server-dom's RSC flight producer, which only produces on pull) — #5989. -#[no_mangle] -pub unsafe extern "C" fn js_readable_stream_tee(stream_handle: f64) -> f64 { - let id = stream_handle as usize; +pub(crate) unsafe fn tee_readable_stream_ids(id: usize) -> (usize, usize) { let mut was_locked = false; let mut is_byte_stream = false; let mut source_state = ReadableState::Readable; @@ -549,6 +547,12 @@ pub unsafe extern "C" fn js_readable_stream_tee(stream_handle: f64) -> f64 { idalloc::retire_readable_terminal(id_b); } + (id_a, id_b) +} + +#[no_mangle] +pub unsafe extern "C" fn js_readable_stream_tee(stream_handle: f64) -> f64 { + let (id_a, id_b) = tee_readable_stream_ids(stream_handle as usize); let arr = js_array_alloc(2); js_array_push(arr, JSValue::from_bits(f64::to_bits(id_a as f64))); js_array_push(arr, JSValue::from_bits(f64::to_bits(id_b as f64))); diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index dcecd5b564..8340583463 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1,13 +1,8 @@ //! Module discovery + transitive import walk. -//! -//! Tier 2.1 follow-up (v0.5.341) — extracts `collect_modules` (~380 -//! LOC) from `compile.rs`. Walks the import graph from the entry -//! file, lowers every TypeScript module to HIR, classifies each as -//! native-compiled vs JS-runtime-loaded, and accumulates the result -//! in `CompilationContext.native_modules` / `js_modules`. Runs -//! per-module HIR passes (inline_functions, transform_generators) -//! before adding the module to the context. Source hashes feed the -//! V2.2 codegen cache key derivation. +//! Walks the import graph, lowers TypeScript to HIR, classifies native-compiled +//! versus JS-runtime-loaded modules, and accumulates them in the compilation context. +//! Per-module HIR passes run before insertion, and source hashes feed +//! the V2.2 codegen cache key. use anyhow::{anyhow, Result}; use perry_hir::ModuleKind; @@ -39,6 +34,7 @@ mod feature_detect; mod import_helpers; mod native_addon; mod parse_error; +mod script_string; mod static_require_transform; #[cfg(test)] mod tests; @@ -1303,6 +1299,10 @@ fn collect_module_one( continue; } + if script_string::resolve(ctx, &canonical, import, &mut pending)? { + continue; + } + if let Some(resolved) = cached_resolve_import_with_lexical_base(&import.source, entry_path, &canonical, ctx) { diff --git a/crates/perry/src/commands/compile/collect_modules/script_string.rs b/crates/perry/src/commands/compile/collect_modules/script_string.rs new file mode 100644 index 0000000000..b6540af5e8 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/script_string.rs @@ -0,0 +1,75 @@ +//! `?script-string` asset imports used by TanStack Start's hydration bootstrap. + +use anyhow::{anyhow, Result}; +use perry_hir::{Import, ModuleKind}; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use super::super::CompilationContext; + +fn import_target(specifier: &str) -> Option<&str> { + let (path, query) = specifier.split_once('?')?; + query + .split('&') + .any(|part| part == "script-string") + .then_some(path) +} + +fn source_path(target: &str, importer_path: &Path) -> Option { + if target.starts_with('/') { + super::super::resolve::resolve_absolute_import_paths(target).map(|path| path.canonical_path) + } else { + super::super::resolve::resolve_relative_import_path(target, importer_path) + } +} + +/// Materialize a string asset as a synthetic TypeScript default export. +/// +/// The source is deliberately preserved byte-for-byte. It is executable text, +/// so whitespace trimming or inferred semicolon insertion can change its +/// meaning. Relative targets use the current module, not the graph entry. +pub(super) fn resolve( + ctx: &CompilationContext, + importer_path: &Path, + import: &mut Import, + pending: &mut Vec, +) -> Result { + let Some(target) = import_target(&import.source) else { + return Ok(false); + }; + let Some(source_path) = source_path(target, importer_path) else { + return Ok(false); + }; + let script = fs::read_to_string(&source_path) + .map_err(|e| anyhow!("Failed to read {}: {}", source_path.display(), e))?; + let literal = serde_json::to_string(&script).map_err(|e| { + anyhow!( + "Failed to encode script-string asset {} as a string literal: {}", + source_path.display(), + e + ) + })?; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + source_path.hash(&mut hasher); + import.source.hash(&mut hasher); + script.hash(&mut hasher); + let filename = format!("script-string-{:016x}.ts", hasher.finish()); + let dir = ctx.cache_dir.join("synthetic-modules"); + fs::create_dir_all(&dir).map_err(|e| anyhow!("Failed to create {}: {}", dir.display(), e))?; + let synthetic_path = dir.join(filename); + fs::write(&synthetic_path, format!("export default {};\n", literal)) + .map_err(|e| anyhow!("Failed to write {}: {}", synthetic_path.display(), e))?; + let canonical = synthetic_path.canonicalize().map_err(|e| { + anyhow!( + "Failed to canonicalize synthetic module {}: {}", + synthetic_path.display(), + e + ) + })?; + import.resolved_path = Some(canonical.to_string_lossy().into_owned()); + import.module_kind = ModuleKind::NativeCompiled; + pending.push(synthetic_path); + Ok(true) +} diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 4a43004f35..dbcdf23be9 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -824,7 +824,11 @@ pub(super) fn resolve_package_source_entry( } } - // For subpaths, try src/.ts + // For subpaths, try src/.ts first. If that shorthand does not + // exist, respect package.json "exports" for the subpath before considering + // the package root. Falling back to src/index.ts for a subpath misroutes + // imports like `@tanstack/router-core/isServer` to the root barrel and + // leaves callers linked against symbols that the root never exports. if let Some(sub) = subpath { let src_path = package_dir.join("src").join(sub); if let Some(resolved) = resolve_with_extensions(&src_path) { @@ -832,6 +836,9 @@ pub(super) fn resolve_package_source_entry( return Some(resolved); } } + + let normal_entry = normal_entry?; + return prefer_ts_source_for_package_entry(package_dir, normal_entry); } // Try src/index.ts (most common TS source entry) @@ -844,24 +851,38 @@ pub(super) fn resolve_package_source_entry( // Try using normal entry resolution but prefer TS over JS let normal_entry = normal_entry?; - if is_js_file(&normal_entry) { - // Try .ts equivalent of the .js entry - let ts_path = normal_entry.with_extension("ts"); - if ts_path.exists() && !is_hybrid_cjs_emit_input(&ts_path) { + prefer_ts_source_for_package_entry(package_dir, normal_entry) +} + +fn prefer_ts_source_for_package_entry( + package_dir: &Path, + normal_entry: PathBuf, +) -> Option { + if !is_js_file(&normal_entry) { + return Some(normal_entry); + } + + // Try native TypeScript equivalents of the JS entry first, in the + // same preference order used by resolve_with_extensions. + for ext in ["ts", "tsx", "mts"] { + let ts_path = normal_entry.with_extension(ext); + if ts_path.is_file() && !is_hybrid_cjs_emit_input(&ts_path) { return Some(ts_path); } - // Check src/ directory mirror of lib/ or dist/ path - if let Ok(rel) = normal_entry.strip_prefix(package_dir) { - let rel_str = rel.to_string_lossy(); - if rel_str.starts_with("lib") || rel_str.starts_with("dist") { - let stripped = if rel_str.starts_with("lib") { - rel.strip_prefix("lib") - } else { - rel.strip_prefix("dist") - }; - if let Ok(rest) = stripped { - let src_equiv = package_dir.join("src").join(rest).with_extension("ts"); - if src_equiv.exists() && !is_hybrid_cjs_emit_input(&src_equiv) { + } + // Check src/ directory mirror of lib/ or dist/ path + if let Ok(rel) = normal_entry.strip_prefix(package_dir) { + let rel_str = rel.to_string_lossy(); + if rel_str.starts_with("lib") || rel_str.starts_with("dist") { + let stripped = if rel_str.starts_with("lib") { + rel.strip_prefix("lib") + } else { + rel.strip_prefix("dist") + }; + if let Ok(rest) = stripped { + for ext in ["ts", "tsx", "mts"] { + let src_equiv = package_dir.join("src").join(rest).with_extension(ext); + if src_equiv.is_file() && !is_hybrid_cjs_emit_input(&src_equiv) { return Some(src_equiv); } } diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index bfd18d7c77..95ed9c0792 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1452,6 +1452,8 @@ mod declaration_sidecar_tests { use super::*; use std::collections::{HashMap, HashSet}; + mod compile_package; + fn write_typed_js_package(root: &Path, package_name: &str) -> (PathBuf, PathBuf, PathBuf) { let package_dir = root.join("node_modules").join(package_name); std::fs::create_dir_all(package_dir.join("dist")).expect("mkdir package dist"); @@ -1559,35 +1561,6 @@ mod declaration_sidecar_tests { ); } - #[test] - fn extract_compile_package_dir_uses_path_components() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - let path = root - .join("node_modules") - .join("@noble") - .join("curves") - .join("node_modules") - .join("@noble") - .join("hashes") - .join("src") - .join("sha256.ts"); - - assert_eq!( - extract_compile_package_dir(&path, "@noble/hashes").expect("package dir"), - root.join("node_modules") - .join("@noble") - .join("curves") - .join("node_modules") - .join("@noble") - .join("hashes") - ); - assert_eq!( - extract_compile_package_dir(&path, "@noble/curves").expect("outer package dir"), - root.join("node_modules").join("@noble").join("curves") - ); - } - #[test] fn compile_package_membership_uses_path_components() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/src/commands/compile/resolve/tests/declaration_sidecar_tests/compile_package.rs b/crates/perry/src/commands/compile/resolve/tests/declaration_sidecar_tests/compile_package.rs new file mode 100644 index 0000000000..e813826dd1 --- /dev/null +++ b/crates/perry/src/commands/compile/resolve/tests/declaration_sidecar_tests/compile_package.rs @@ -0,0 +1,84 @@ +use super::*; + +#[test] +fn subpath_exports_do_not_fall_back_to_src_index() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let package_dir = root.join("node_modules").join("pkg"); + std::fs::create_dir_all(package_dir.join("src/feature")).expect("mkdir package"); + std::fs::write( + package_dir.join("package.json"), + r#"{ + "name": "pkg", + "type": "module", + "exports": { + ".": { "import": { "default": "./src/index.ts" } }, + "./feature": { "import": { "default": "./src/feature/server.ts" } } + } + }"#, + ) + .expect("write package.json"); + std::fs::write( + package_dir.join("src/index.ts"), + "export const rootOnly = 1;\n", + ) + .expect("write root"); + std::fs::write( + package_dir.join("src/feature/server.ts"), + "export const subValue = 41;\n", + ) + .expect("write subpath"); + + let importer_dir = root.join("src"); + std::fs::create_dir_all(&importer_dir).expect("mkdir src"); + let importer = importer_dir.join("main.ts"); + std::fs::write(&importer, "import { subValue } from 'pkg/feature';\n").expect("write importer"); + + let compile_packages = HashSet::from(["pkg".to_string()]); + let resolved = resolve_import( + "pkg/feature", + &importer, + root, + &compile_packages, + &HashMap::new(), + ) + .expect("resolve pkg/feature"); + + assert_eq!(resolved.1, ModuleKind::NativeCompiled); + assert_eq!( + resolved.0, + package_dir + .join("src/feature/server.ts") + .canonicalize() + .expect("canonical subpath") + ); +} + +#[test] +fn compile_package_dir_uses_path_components() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let path = root + .join("node_modules") + .join("@noble") + .join("curves") + .join("node_modules") + .join("@noble") + .join("hashes") + .join("src") + .join("sha256.ts"); + + assert_eq!( + extract_compile_package_dir(&path, "@noble/hashes").expect("package dir"), + root.join("node_modules") + .join("@noble") + .join("curves") + .join("node_modules") + .join("@noble") + .join("hashes") + ); + assert_eq!( + extract_compile_package_dir(&path, "@noble/curves").expect("outer package dir"), + root.join("node_modules").join("@noble").join("curves") + ); +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index e0161e2108..677ef4563b 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -3343,7 +3343,10 @@ pub fn run_with_parse_cache( let origin_key_under_origin_name = resolved_origin_name .as_ref() .map(|n| (origin_path.clone(), n.clone())); - if exported_var_names.contains(&origin_key) + let source_exports_object = + exported_var_names.contains(&(resolved_path_str.clone(), exported_name.clone())); + if source_exports_object + || exported_var_names.contains(&origin_key) || origin_key_under_origin_name .as_ref() .map(|k| exported_var_names.contains(k)) diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs new file mode 100644 index 0000000000..0df5877942 --- /dev/null +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -0,0 +1,338 @@ +//! Regression coverage for TanStack Start SSR streaming through Response +//! wrappers. The app path constructs `Response(ReadableStream)` values whose +//! chunks are produced lazily from downstream pulls; eagerly draining only +//! already-buffered chunks turns a valid HTML response into an empty body. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.js"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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) + ); + + 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() +} + +fn compile_and_run_entry(dir: &std::path::Path, entry_name: &str) -> String { + let entry = dir.join(entry_name); + let output = dir.join("main_bin"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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) + ); + + 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 response_preserves_pull_driven_readable_stream_body() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const enc = new TextEncoder() +let pulls = 0 +const stream = new ReadableStream({ + pull(controller) { + pulls++ + controller.enqueue(enc.encode('hello')) + controller.close() + } +}) +const response = new Response(stream, { status: 200 }) +const reader = response.body.getReader() +const first = await reader.read() +console.log('done=' + first.done + ',len=' + (first.value ? first.value.byteLength : 0) + ',pulls=' + pulls) +"#, + ); + assert_eq!(stdout, "done=false,len=5,pulls=1\n"); +} + +#[test] +fn response_clone_tees_pull_driven_stream_body() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const enc = new TextEncoder() +let pulls = 0 +const response = new Response(new ReadableStream({ + pull(controller) { + pulls++ + controller.enqueue(enc.encode('hello')) + controller.close() + } +})) +const clone = response.clone() +const originalRead = await response.body.getReader().read() +const cloneRead = await clone.body.getReader().read() +console.log(originalRead.value.byteLength + ',' + cloneRead.value.byteLength + ',' + pulls) +"#, + ); + assert_eq!(stdout, "5,5,1\n"); +} + +#[test] +fn request_rejects_get_stream_body_without_pulling_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +let pulls = 0 +const body = new ReadableStream({ pull() { pulls++ } }) +try { + new Request('https://example.test/', { method: 'GET', body, duplex: 'half' }) + console.log('did-not-throw') +} catch (error) { + console.log((error instanceof TypeError) + ',' + pulls) +} +"#, + ); + assert_eq!(stdout, "true,0\n"); +} + +#[test] +fn response_prototype_exposes_fetch_accessors_for_wrappers() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const names = Object.getOwnPropertyNames(Response.prototype) +const body = Object.getOwnPropertyDescriptor(Response.prototype, 'body') +const headers = Object.getOwnPropertyDescriptor(Response.prototype, 'headers') +console.log(names.includes('body') + ',' + (typeof body?.get) + ',' + names.includes('headers') + ',' + (typeof headers?.get)) +"#, + ); + assert_eq!(stdout, "true,function,true,function\n"); +} + +#[test] +fn dynamic_import_inside_arrow_closure_is_collected() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.js"), + r#" +const importer = () => import('./lazy.js') +const mod = await importer() +console.log('answer=' + mod.answer) +"#, + ) + .expect("write main"); + std::fs::write(dir.path().join("lazy.js"), "export const answer = 42\n").expect("write lazy"); + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "answer=42\n"); +} + +#[test] +fn script_string_query_imports_compile_to_default_string_asset() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "type": "module", + "perry": { + "compilePackages": ["pkg"], + "allow": { "compilePackages": ["pkg"] } + } + }"#, + ) + .expect("write package"); + let pkg = dir.path().join("node_modules/pkg"); + std::fs::create_dir_all(pkg.join("src")).expect("mkdir pkg"); + std::fs::write( + pkg.join("package.json"), + r#"{ + "name": "pkg", + "type": "module", + "exports": { ".": { "import": { "default": "./src/index.ts" } } } + }"#, + ) + .expect("write pkg package"); + std::fs::write( + pkg.join("src/index.ts"), + "import boot from './boot?script-string'\nexport function readBoot() { return boot }\n", + ) + .expect("write pkg index"); + std::fs::write( + pkg.join("src/boot.ts"), + "// preserve formatting\nself.$_TSR = {\n buffer: []\n}\n", + ) + .expect("write script source"); + std::fs::write( + dir.path().join("main.js"), + "import { readBoot } from 'pkg'\nconst boot = readBoot()\nconsole.log(typeof boot)\nconsole.log(boot.includes('self.$_TSR ='))\nconsole.log(JSON.stringify(boot))\n", + ) + .expect("write main"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!( + stdout, + "string\ntrue\n\"// preserve formatting\\nself.$_TSR = {\\n buffer: []\\n}\\n\"\n" + ); +} + +#[test] +fn map_foreach_property_receiver_preserves_map_callback_shape() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const renderState = { styles: new Map() } +renderState.styles.set('default', { + precedence: 'default', + sheets: new Map([['/assets/styles.css', { href: '/assets/styles.css' }]]) +}) +const seen = [] +renderState.styles.forEach(function(styleQueue, key, map) { + seen.push( + key + ':' + + styleQueue.precedence + ':' + + styleQueue.sheets.size + ':' + + (map === renderState.styles) + ':' + + this.destination + ) +}, { destination: 'html' }) +console.log(seen.join('|')) +"#, + ); + assert_eq!(stdout, "default:default:1:true:html\n"); +} + +#[test] +fn imported_jsx_named_function_remains_an_import_binding() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.js"), + "import { jsx } from './jsx-lib.js'\nconsole.log(jsx('value'))\n", + ) + .expect("write main"); + std::fs::write( + dir.path().join("jsx-lib.js"), + "export function jsx(value) { return 'imported:' + value }\n", + ) + .expect("write lib"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "imported:value\n"); +} + +#[test] +fn react_type_only_import_does_not_disable_automatic_jsx_runtime_binding() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "type": "module", + "perry": { + "compilePackages": ["react", "@tanstack/react-router"], + "allow": { "compilePackages": ["react", "@tanstack/react-router"] } + } + }"#, + ) + .expect("write package"); + let react = dir.path().join("node_modules/react"); + std::fs::create_dir_all(&react).expect("mkdir react"); + std::fs::write( + react.join("package.json"), + r#"{"name":"react","type":"module","exports":{".":"./index.js"}}"#, + ) + .expect("write react package"); + std::fs::write( + react.join("index.js"), + "export function createElement(type, props, ...children) { return 'react:' + type + ':' + children.join('|') }\n", + ) + .expect("write react index"); + let router = dir.path().join("node_modules/@tanstack/react-router"); + std::fs::create_dir_all(&router).expect("mkdir router"); + std::fs::write( + router.join("package.json"), + r#"{"name":"@tanstack/react-router","type":"module","exports":{".":"./index.js"}}"#, + ) + .expect("write router package"); + std::fs::write(router.join("index.js"), "export const Link = 'link'\n") + .expect("write router index"); + std::fs::write( + dir.path().join("main.tsx"), + r#" +import type * as React from 'react' +import { Link } from '@tanstack/react-router' +function App() { return
Hello
} +console.log(App()) +"#, + ) + .expect("write main"); + + let stdout = compile_and_run_entry(dir.path(), "main.tsx"); + assert_eq!(stdout, "react:div:\n"); +} + +#[test] +fn named_imported_exported_function_as_value_stays_callable() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.js"), + "import { callMe } from './lib.js'\nconst fn = callMe\nconsole.log(fn('x'))\n", + ) + .expect("write main"); + std::fs::write( + dir.path().join("lib.js"), + "export function callMe(value) { return 'fn:' + value }\n", + ) + .expect("write lib"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "fn:x\n"); +} diff --git a/tests/test_compile_package_exports_subpath_source.sh b/tests/test_compile_package_exports_subpath_source.sh new file mode 100755 index 0000000000..7375e33638 --- /dev/null +++ b/tests/test_compile_package_exports_subpath_source.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Regression: compilePackages subpath exports must resolve to the subpath's +# declared source entry, not fall back to the package root src/index.ts. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PERRY="${PERRY_BIN:-$SCRIPT_DIR/../target/release/perry}" +[ ! -x "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" +if [ ! -x "$PERRY" ]; then + echo "SKIP: perry binary not found (build with cargo build --release)" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +PKG="$TMPDIR/node_modules/pkg" +CONSUMER="$TMPDIR/node_modules/consumer" +mkdir -p "$PKG/dist/feature" "$PKG/src/feature" "$CONSUMER/src" + +cat > "$TMPDIR/package.json" << 'JSON' +{ + "type": "module", + "perry": { + "compilePackages": ["pkg", "consumer"], + "allow": { "compilePackages": ["pkg", "consumer"] } + } +} +JSON + +cat > "$TMPDIR/main.ts" << 'TS' +import { run } from 'consumer' +console.log('value=' + run()) +TS + +cat > "$PKG/package.json" << 'JSON' +{ + "name": "pkg", + "type": "module", + "exports": { + ".": { "import": { "default": "./src/index.ts" } }, + "./feature": { "import": { "default": "./dist/feature/server.js" } } + } +} +JSON + +cat > "$PKG/src/index.ts" << 'TS' +export const rootOnly = 1 +TS + +cat > "$PKG/dist/feature/server.js" << 'JS' +export const subValue = 0 +JS + +cat > "$PKG/src/feature/server.tsx" << 'TS' +export const subValue = 41 +TS + +cat > "$CONSUMER/package.json" << 'JSON' +{ + "name": "consumer", + "type": "module", + "exports": { + ".": { "import": { "default": "./src/index.ts" } } + }, + "dependencies": { + "pkg": "1.0.0" + } +} +JSON + +cat > "$CONSUMER/src/index.ts" << 'TS' +import { subValue } from 'pkg/feature' +export function run() { return subValue + 1 } +TS + +cd "$TMPDIR" +COMPILE_OUTPUT=$("$PERRY" compile --no-cache main.ts -o out 2>&1) || { + echo "FAIL: compile error" + echo "$COMPILE_OUTPUT" | tail -40 + exit 1 +} + +RUN_OUTPUT=$(./out 2>&1) +if [ "$RUN_OUTPUT" = "value=42" ]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: package subpath exports output mismatch" +echo "Expected: value=42" +echo "Got: $RUN_OUTPUT" +exit 1 diff --git a/tests/test_textencoder_hoisted_function_decl.sh b/tests/test_textencoder_hoisted_function_decl.sh new file mode 100755 index 0000000000..f34cfe68e2 --- /dev/null +++ b/tests/test_textencoder_hoisted_function_decl.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "Perry binary not found; run cargo build -p perry first" >&2 + exit 1 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.js" <<'JS' +var util = require('util') + +function encodeLength(content) { + content = textEncoder.encode(content) + return content.byteLength +} + +var textEncoder = new util.TextEncoder() + +var nestedLen = (function () { + function encodeLengthNested(content) { + content = nestedTextEncoder.encode(content) + return content.byteLength + } + + var nestedTextEncoder = new util.TextEncoder() + return encodeLengthNested('world') +})() + +console.log('len=' + encodeLength('hello') + ',nested=' + nestedLen) +JS + +"$PERRY" compile --no-cache "$TMPDIR/main.js" -o "$TMPDIR/out" >"$TMPDIR/compile.log" 2>&1 || { + cat "$TMPDIR/compile.log" >&2 + exit 1 +} + +output="$($TMPDIR/out)" +if [[ "$output" != "len=5,nested=5" ]]; then + echo "Unexpected output: $output" >&2 + exit 1 +fi