From b7c85955288cc8127089b3c2762b153ef430e94c Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 12:01:31 +0200 Subject: [PATCH 1/9] Fix TanStack Start package compatibility --- crates/perry-hir/src/lower/expr_function.rs | 18 +++- crates/perry-hir/src/lower/lower_module_fn.rs | 24 +++-- crates/perry-hir/src/lower_types.rs | 73 +++++++++++++++ crates/perry/src/commands/compile/resolve.rs | 18 +++- .../src/commands/compile/resolve/tests.rs | 55 ++++++++++++ ..._compile_package_exports_subpath_source.sh | 90 +++++++++++++++++++ .../test_textencoder_hoisted_function_decl.sh | 39 ++++++++ 7 files changed, 309 insertions(+), 8 deletions(-) create mode 100755 tests/test_compile_package_exports_subpath_source.sh create mode 100755 tests/test_textencoder_hoisted_function_decl.sh diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index e5e2d7247a..295254f345 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -28,6 +28,7 @@ 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::{infer_hoisted_text_codec_var_type, require_literal_specifier}; use super::{lower_expr, LoweringContext}; @@ -779,6 +780,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 +803,26 @@ 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) + }) + } 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); // Mark as hoisted so closures created // before the var's init expression see // it through a box (mutable capture), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 1cfacfce5f..7b169489d9 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -14,6 +14,7 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; +use crate::lower_types::{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 @@ -622,6 +623,7 @@ pub fn lower_module_full( _ => None, }; if let Some(var_decl) = var_decl { + let mut builtin_aliases_in_decl = HashSet::new(); for decl in &var_decl.decls { // #4461: `var X = class { ... }` is lowered as a class // expression bound to the name `X` (see stmt.rs) — the class @@ -637,12 +639,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_decl.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_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)) + ) + }); 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_types.rs b/crates/perry-hir/src/lower_types.rs index 8360c793e1..73241d553d 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -308,6 +308,79 @@ 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) 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, + } +} + 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/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 4a43004f35..effc755ee1 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,6 +851,13 @@ pub(super) fn resolve_package_source_entry( // Try using normal entry resolution but prefer TS over JS let normal_entry = normal_entry?; + 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) { // Try .ts equivalent of the .js entry let ts_path = normal_entry.with_extension("ts"); @@ -869,7 +883,7 @@ pub(super) fn resolve_package_source_entry( } } - None + Some(normal_entry) } /// Resolve exports field from package.json diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index bfd18d7c77..5ac8c863af 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1559,6 +1559,61 @@ mod declaration_sidecar_tests { ); } + #[test] + fn compile_package_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 extract_compile_package_dir_uses_path_components() { let dir = tempfile::tempdir().expect("tempdir"); 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..91f4370b87 --- /dev/null +++ b/tests/test_compile_package_exports_subpath_source.sh @@ -0,0 +1,90 @@ +#!/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="$SCRIPT_DIR/../target/release/perry" +[ ! -f "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" +if [ ! -f "$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/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": "./src/feature/server.ts" } } + } +} +JSON + +cat > "$PKG/src/index.ts" << 'TS' +export const rootOnly = 1 +TS + +cat > "$PKG/src/feature/server.ts" << '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..99c4b6ec6f --- /dev/null +++ b/tests/test_textencoder_hoisted_function_decl.sh @@ -0,0 +1,39 @@ +#!/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' +function encodeLength(content) { + content = textEncoder.encode(content) + return content.byteLength +} + +var util = require('util'), + textEncoder = new util.TextEncoder() + +console.log('len=' + encodeLength('hello')) +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" ]]; then + echo "Unexpected output: $output" >&2 + exit 1 +fi From 40b0cde9dd42aa62f93964ed34443f807fd60700 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 12:45:31 +0200 Subject: [PATCH 2/9] fix: address TanStack Start compatibility feedback --- crates/perry-hir/src/lower/expr_function.rs | 12 ++- crates/perry-hir/src/lower/lower_module_fn.rs | 4 +- crates/perry-hir/src/lower_types.rs | 73 +----------------- .../src/lower_types/hoisted_text_codec.rs | 77 +++++++++++++++++++ crates/perry/src/commands/compile/resolve.rs | 19 +++-- ..._compile_package_exports_subpath_source.sh | 10 ++- .../test_textencoder_hoisted_function_decl.sh | 19 ++++- 7 files changed, 126 insertions(+), 88 deletions(-) create mode 100644 crates/perry-hir/src/lower_types/hoisted_text_codec.rs diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 295254f345..3dd416708d 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -28,7 +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::{infer_hoisted_text_codec_var_type, require_literal_specifier}; +use crate::lower_types::hoisted_text_codec::{ + infer_hoisted_text_codec_var_type, require_literal_specifier, +}; use super::{lower_expr, LoweringContext}; @@ -813,6 +815,14 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } 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 diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 7b169489d9..c426bd3a89 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -14,7 +14,9 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; -use crate::lower_types::{infer_hoisted_text_codec_var_type, require_literal_specifier}; +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 diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 73241d553d..5e830cbfd2 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -308,78 +308,7 @@ 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) 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, - } -} +pub(crate) mod hoisted_text_codec; pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> Type { thread_local! { 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..8a790385eb --- /dev/null +++ b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs @@ -0,0 +1,77 @@ +use perry_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/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index effc755ee1..ebf9ab8afc 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -859,10 +859,13 @@ fn prefer_ts_source_for_package_entry( normal_entry: PathBuf, ) -> Option { 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) { - return Some(ts_path); + // 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) { @@ -874,9 +877,11 @@ fn prefer_ts_source_for_package_entry( 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) { - return Some(src_equiv); + 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/tests/test_compile_package_exports_subpath_source.sh b/tests/test_compile_package_exports_subpath_source.sh index 91f4370b87..93b3e29533 100755 --- a/tests/test_compile_package_exports_subpath_source.sh +++ b/tests/test_compile_package_exports_subpath_source.sh @@ -17,7 +17,7 @@ trap "rm -rf $TMPDIR" EXIT PKG="$TMPDIR/node_modules/pkg" CONSUMER="$TMPDIR/node_modules/consumer" -mkdir -p "$PKG/src/feature" "$CONSUMER/src" +mkdir -p "$PKG/dist/feature" "$PKG/src/feature" "$CONSUMER/src" cat > "$TMPDIR/package.json" << 'JSON' { @@ -40,7 +40,7 @@ cat > "$PKG/package.json" << 'JSON' "type": "module", "exports": { ".": { "import": { "default": "./src/index.ts" } }, - "./feature": { "import": { "default": "./src/feature/server.ts" } } + "./feature": { "import": { "default": "./dist/feature/server.js" } } } } JSON @@ -49,7 +49,11 @@ cat > "$PKG/src/index.ts" << 'TS' export const rootOnly = 1 TS -cat > "$PKG/src/feature/server.ts" << '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 diff --git a/tests/test_textencoder_hoisted_function_decl.sh b/tests/test_textencoder_hoisted_function_decl.sh index 99c4b6ec6f..f34cfe68e2 100755 --- a/tests/test_textencoder_hoisted_function_decl.sh +++ b/tests/test_textencoder_hoisted_function_decl.sh @@ -16,15 +16,26 @@ 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 util = require('util'), - textEncoder = new util.TextEncoder() +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')) +console.log('len=' + encodeLength('hello') + ',nested=' + nestedLen) JS "$PERRY" compile --no-cache "$TMPDIR/main.js" -o "$TMPDIR/out" >"$TMPDIR/compile.log" 2>&1 || { @@ -33,7 +44,7 @@ JS } output="$($TMPDIR/out)" -if [[ "$output" != "len=5" ]]; then +if [[ "$output" != "len=5,nested=5" ]]; then echo "Unexpected output: $output" >&2 exit 1 fi From 39388c9c431f376dae8a3dd5294f9ba4c9830bc4 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 18:01:58 +0200 Subject: [PATCH 3/9] Fix TanStack Start SSR stream compatibility --- .../src/lower_call/extern_func.rs | 2 +- .../perry-hir/src/dynamic_import/visitors.rs | 7 + crates/perry-hir/src/jsx.rs | 53 ++++--- crates/perry-hir/src/lower/expr_function.rs | 4 +- crates/perry-hir/src/lower/lower_module_fn.rs | 103 +++++++++++--- crates/perry-hir/src/lower/module_decl.rs | 11 +- .../object/field_get_set/get_field_by_name.rs | 20 ++- .../src/object/field_set_by_name.rs | 24 ++++ .../src/object/global_this/fetch_globals.rs | 61 +++++++- .../src/object/global_this/proto_methods.rs | 56 ++++++++ crates/perry-runtime/src/proxy/put_value.rs | 21 +++ crates/perry-runtime/src/url/abort.rs | 40 +++++- .../src/common/dispatch/property_dispatch.rs | 5 +- crates/perry-stdlib/src/fetch/abort_bridge.rs | 1 + .../perry-stdlib/src/fetch/body_metadata.rs | 1 + crates/perry-stdlib/src/fetch/dispatch.rs | 9 +- crates/perry-stdlib/src/fetch/mod.rs | 82 ++++++++--- crates/perry-stdlib/src/fetch/request_ctor.rs | 10 +- .../src/commands/compile/run_pipeline.rs | 5 +- .../tests/issue_5756_response_stream_body.rs | 134 ++++++++++++++++++ 20 files changed, 568 insertions(+), 81 deletions(-) create mode 100644 crates/perry/tests/issue_5756_response_stream_body.rs diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index a272131c5f..4e80b1819e 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.imported_vars.contains(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..1eae563aca 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 @@ -178,9 +185,26 @@ pub(crate) fn lower_jsx_fragment( }), property: "Fragment".to_string(), }; - if let Some(call) = react_create_element_call(ctx, fragment_type, &props_expr) { - return Ok(call); - } + return Ok(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(if let Some(id) = ctx.lookup_local(&react_local) { + Expr::LocalGet(id) + } else { + Expr::ExternFuncRef { + name: ctx + .lookup_imported_func(&react_local) + .unwrap_or(&react_local) + .to_string(), + param_types: Vec::new(), + return_type: Type::Any, + } + }), + property: "createElement".to_string(), + }), + args: vec![fragment_type, props_expr], + type_args: Vec::new(), + byte_offset: 0, + }); } Ok(Expr::Call { @@ -189,17 +213,12 @@ pub(crate) fn lower_jsx_fragment( param_types: Vec::new(), return_type: Type::Any, }), - // Fragment marker: inline "__Fragment" string. perry-react's jsx() checks - // `type === "__Fragment"` to detect fragment elements. args: vec![Expr::String("__Fragment".to_string()), props_expr], type_args: Vec::new(), byte_offset: 0, }) } -/// Lower a JSX element name to an HIR expression. -/// Lowercase tag names (HTML intrinsics) become string literals. -/// Uppercase tag names (components) are looked up as identifiers. pub(crate) fn lower_jsx_element_name( ctx: &mut LoweringContext, name: &ast::JSXElementName, diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 3dd416708d..380884ac1a 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -832,7 +832,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul .lookup_index_in_scope(&name, outer_locals_len) .is_some(); if !already_in_scope { - let id = ctx.define_local(name.clone(), ty); + 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), @@ -881,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 c426bd3a89..e81ceec762 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -18,31 +18,89 @@ 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; + } } - // 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(_))) + + if has_explicit_react_import { + return false; + } + + 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; @@ -438,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 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-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 3b12973f24..04dac3aba1 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1424,17 +1424,15 @@ pub extern "C" fn js_object_get_field_by_name( let f = f64::from_bits(obj as u64); if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { let id = f as usize; - if crate::value::addr_class::is_stream_id_band(id) { - if let Some(probe) = crate::object::stream_handle_probe() { - unsafe { - if probe(id) { - if let Some(dispatch) = handle_property_dispatch() { - let key_ptr = (key as *const u8) - .add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let bits = dispatch(id as i64, key_ptr, key_len); - return JSValue::from_bits(bits.to_bits()); - } + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = handle_property_dispatch() { + let key_ptr = + (key as *const u8).add(std::mem::size_of::()); + let key_len = (*key).byte_len as usize; + let bits = dispatch(id as i64, key_ptr, key_len); + return JSValue::from_bits(bits.to_bits()); } } } diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 5d5b0aa62c..6cfc5effb1 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -870,6 +870,30 @@ pub extern "C" fn js_object_set_field_by_name( } } } + // #5756: Web Streams handles are represented as finite f64 ids in the + // stream-id band (not NaN-boxed pointers). Reads already route those ids + // through `handle_property_dispatch`; writes need the matching setter path + // so userland/React can attach expando fields like `stream.allReady`. + { + let f = f64::from_bits(obj as u64); + if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { + let id = f as usize; + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = handle_property_set_dispatch() { + let name_ptr = + (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + dispatch(id as i64, name_ptr, name_len, value); + } + return; + } + } + } + } + } + // Strip NaN-boxing tags if present (defensive: handle POINTER_TAG, UNDEFINED, NULL, etc.) let obj = { let bits = obj as u64; 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..f306a45547 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,46 @@ 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", + "SharedArrayBuffer", + "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 +875,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 +893,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 +904,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/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 235ee2c98f..083d0c33fb 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -167,6 +167,27 @@ pub extern "C" fn js_put_value_set( return value; } } + // Web Streams handles are finite f64 ids in the stream-id band, not + // heap objects. They still need ordinary expando property writes for + // userland fields such as ReactDOM's `stream.allReady`. Route through + // the registered handle setter so stdlib-owned handle storage remains + // consistent with stdlib-owned handle reads. + if let Some(name) = key_to_rust_string(property_key) { + if target.is_finite() && target > 0.0 && target.fract() == 0.0 { + let id = target as usize; + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = crate::object::handle_property_set_dispatch() { + dispatch(id as i64, name.as_ptr(), name.len(), value); + } + return value; + } + } + } + } + } + // Date / RegExp / Error exotic cells: route to the expando-aware // setter — the ordinary path below would bit-cast them. Throws on a // rejected strict write. (See `object::exotic_expando`.) 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/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index 852e390154..9c8653bd2d 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -53,7 +53,10 @@ pub unsafe extern "C" fn js_handle_property_dispatch( .contains(&(handle as usize)) && crate::streams::js_stream_handle_is_registered(handle as usize) { - return crate::streams::dispatch_stream_property(handle as f64, property_name); + let value = crate::streams::dispatch_stream_property(handle as f64, property_name); + if value.to_bits() != 0x7FFC_0000_0000_0001 { + return value; + } } if let Some(value) = 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..55bf77b8f7 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; @@ -264,6 +265,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 +450,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 +526,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 +604,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 +689,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 +819,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 @@ -1286,6 +1318,7 @@ fn alloc_response( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); id @@ -1309,9 +1342,10 @@ pub unsafe extern "C" fn js_response_new( 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(); + 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 @@ -1355,13 +1389,14 @@ pub unsafe extern "C" fn js_response_new( } else { HeadersStore::default() }; - handle_to_f64(alloc_response( - status_u16, - status_text, - headers, - body, - body_present, - )) + 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 @@ -1403,6 +1438,7 @@ pub extern "C" fn js_response_clone(handle: f64) -> f64 { redirected: resp.redirected, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, } }) }; @@ -1681,6 +1717,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/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index e0161e2108..c52c32a25f 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 = source_module + .is_some_and(|m| m.exported_objects.iter().any(|n| n == &exported_name)); + 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..1c238e1443 --- /dev/null +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -0,0 +1,134 @@ +//! 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_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"); +} From 4bffb9bea45807ed460c8ec05de8448cac424740 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 20:29:54 +0200 Subject: [PATCH 4/9] Fix TanStack Start hydration bootstrap output --- .../src/commands/compile/collect_modules.rs | 84 +++++++++++++++++++ .../tests/issue_5756_response_stream_body.rs | 69 +++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index dcecd5b564..36b32d8ce6 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -18,6 +18,7 @@ use perry_transform::{ }; use std::collections::{HashMap, HashSet}; use std::fs; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use crate::commands::progress::{ProgressSnapshot, VerboseProgress}; @@ -92,6 +93,73 @@ pub(super) fn is_nextjs_runtime_module(path: &std::path::Path) -> bool { .any(|w| w[0] == std::ffi::OsStr::new(".next") && w[1] == std::ffi::OsStr::new("server")) } +fn script_string_import_target(specifier: &str) -> Option<&str> { + let (path, query) = specifier.split_once('?')?; + if query.split('&').any(|part| part == "script-string") { + Some(path) + } else { + None + } +} + +fn compact_script_string_source(source: &str) -> String { + let lines: Vec<_> = source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + let mut out = String::new(); + for (idx, line) in lines.iter().enumerate() { + out.push_str(line); + let last = idx + 1 == lines.len(); + if !last && !line.ends_with('{') && !line.ends_with(',') { + out.push(';'); + } + } + out +} + +fn synthesize_script_string_module( + ctx: &CompilationContext, + importer_path: &std::path::Path, + specifier: &str, +) -> Result> { + let Some(target_specifier) = script_string_import_target(specifier) else { + return Ok(None); + }; + let resolved = if target_specifier.starts_with('/') { + super::resolve::resolve_absolute_import_paths(target_specifier) + .map(|path| path.canonical_path) + } else { + super::resolve::resolve_relative_import_path(target_specifier, importer_path) + }; + let Some(source_path) = resolved else { + return Ok(None); + }; + let raw = fs::read_to_string(&source_path) + .map_err(|e| anyhow!("Failed to read {}: {}", source_path.display(), e))?; + let script = compact_script_string_source(&raw); + 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); + specifier.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))?; + Ok(Some(synthetic_path)) +} + /// Collect all modules to compile (transitive closure of imports) pub(super) fn collect_modules( entry_path: &PathBuf, @@ -1303,6 +1371,22 @@ fn collect_module_one( continue; } + if let Some(synthetic_path) = + synthesize_script_string_module(ctx, entry_path, &import.source)? + { + let resolved_path = synthetic_path.canonicalize().map_err(|e| { + anyhow!( + "Failed to canonicalize synthetic module {}: {}", + synthetic_path.display(), + e + ) + })?; + import.resolved_path = Some(resolved_path.to_string_lossy().to_string()); + import.module_kind = ModuleKind::NativeCompiled; + pending.push(synthetic_path); + continue; + } + if let Some(resolved) = cached_resolve_import_with_lexical_base(&import.source, entry_path, &canonical, ctx) { diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs index 1c238e1443..9b5ee55750 100644 --- a/crates/perry/tests/issue_5756_response_stream_body.rs +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -132,3 +132,72 @@ console.log('answer=' + mod.answer) 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"), "self.$_TSR = { buffer: [] }\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(boot === true)\n", + ) + .expect("write main"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "string\ntrue\nfalse\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"); +} From 9e6b2d2e22182f92cd6f5c9b36cb32c9b5c9019a Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 20:48:24 +0200 Subject: [PATCH 5/9] Address TanStack Start review feedback --- .../src/lower_call/extern_func.rs | 6 +- .../src/object/global_this/fetch_globals.rs | 1 - crates/perry-runtime/src/object/instanceof.rs | 3 + .../src/commands/compile/run_pipeline.rs | 4 +- .../tests/issue_5756_response_stream_body.rs | 86 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index 4e80b1819e..a00e90f85c 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1351,7 +1351,11 @@ 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" if !ctx.imported_vars.contains(name) => { + "jsx" | "jsxs" + if !ctx.imported_vars.contains(name) + && !ctx.import_function_prefixes.contains_key(name) + && !ctx.import_function_v8_specifiers.contains_key(name) => + { if let Some(call) = try_rewrite_perry_tui_jsx_intrinsic(ctx, name == "jsxs", args)? { return Ok(Some(call)); } 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 f306a45547..e342ab31f2 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -558,7 +558,6 @@ fn is_uncallable_builtin_super_parent_class_id(class_id: u32) -> bool { "WeakSet", "Array", "ArrayBuffer", - "SharedArrayBuffer", "DataView", "Boolean", "Number", diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index f69b48ae5f..35558057c5 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -513,6 +513,9 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 { "WeakSet" => 0xFFFF002D, "RegExp" => 0xFFFF0021, "ArrayBuffer" => 0xFFFF0025, + "DataView" => 0xFFFF002B, + "WeakMap" => 0xFFFF002C, + "WeakSet" => 0xFFFF002D, "Array" => 0xFFFF0024, "Object" => 0xFFFF0050, "Function" => CLASS_ID_FUNCTION, diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index c52c32a25f..677ef4563b 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -3343,8 +3343,8 @@ pub fn run_with_parse_cache( let origin_key_under_origin_name = resolved_origin_name .as_ref() .map(|n| (origin_path.clone(), n.clone())); - let source_exports_object = source_module - .is_some_and(|m| m.exported_objects.iter().any(|n| n == &exported_name)); + 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 diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs index 9b5ee55750..95f3dac7e1 100644 --- a/crates/perry/tests/issue_5756_response_stream_body.rs +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -201,3 +201,89 @@ 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"); +} From 8392895fa70cedfb7f4b4fb4a98f61837f477c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 13:17:53 +0200 Subject: [PATCH 6/9] fix: audit TanStack Start compatibility changes --- crates/perry-hir/src/jsx.rs | 28 ++---- crates/perry-hir/src/lower/lower_module_fn.rs | 6 +- .../src/lower_types/hoisted_text_codec.rs | 2 +- .../object/field_get_set/get_field_by_name.rs | 20 ++-- .../src/object/field_set_by_name.rs | 24 ----- crates/perry-runtime/src/object/instanceof.rs | 2 - crates/perry-runtime/src/proxy/put_value.rs | 21 ---- .../src/common/dispatch/property_dispatch.rs | 5 +- crates/perry-stdlib/src/fetch/mod.rs | 15 ++- crates/perry-stdlib/src/streams.rs | 1 + crates/perry-stdlib/src/streams/tee.rs | 10 +- .../src/commands/compile/collect_modules.rs | 96 ++----------------- .../compile/collect_modules/script_string.rs | 75 +++++++++++++++ ..._compile_package_exports_subpath_source.sh | 6 +- 14 files changed, 127 insertions(+), 184 deletions(-) create mode 100644 crates/perry/src/commands/compile/collect_modules/script_string.rs diff --git a/crates/perry-hir/src/jsx.rs b/crates/perry-hir/src/jsx.rs index 1eae563aca..33bc73e0b9 100644 --- a/crates/perry-hir/src/jsx.rs +++ b/crates/perry-hir/src/jsx.rs @@ -185,26 +185,9 @@ pub(crate) fn lower_jsx_fragment( }), property: "Fragment".to_string(), }; - return Ok(Expr::Call { - callee: Box::new(Expr::PropertyGet { - object: Box::new(if let Some(id) = ctx.lookup_local(&react_local) { - Expr::LocalGet(id) - } else { - Expr::ExternFuncRef { - name: ctx - .lookup_imported_func(&react_local) - .unwrap_or(&react_local) - .to_string(), - param_types: Vec::new(), - return_type: Type::Any, - } - }), - property: "createElement".to_string(), - }), - args: vec![fragment_type, props_expr], - type_args: Vec::new(), - byte_offset: 0, - }); + if let Some(call) = react_create_element_call(ctx, fragment_type, &props_expr) { + return Ok(call); + } } Ok(Expr::Call { @@ -213,12 +196,17 @@ pub(crate) fn lower_jsx_fragment( param_types: Vec::new(), return_type: Type::Any, }), + // Fragment marker: inline "__Fragment" string. perry-react's jsx() checks + // `type === "__Fragment"` to detect fragment elements. args: vec![Expr::String("__Fragment".to_string()), props_expr], type_args: Vec::new(), byte_offset: 0, }) } +/// Lower a JSX element name to an HIR expression. +/// Lowercase tag names (HTML intrinsics) become string literals. +/// Uppercase tag names (components) are looked up as identifiers. pub(crate) fn lower_jsx_element_name( ctx: &mut LoweringContext, name: &ast::JSXElementName, diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index e81ceec762..d5268fd86c 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -673,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), @@ -686,7 +687,6 @@ pub fn lower_module_full( _ => None, }; if let Some(var_decl) = var_decl { - let mut builtin_aliases_in_decl = HashSet::new(); for decl in &var_decl.decls { // #4461: `var X = class { ... }` is lowered as a class // expression bound to the name `X` (see stmt.rs) — the class @@ -706,11 +706,11 @@ pub fn lower_module_full( || decl.init.as_deref().and_then(require_literal_specifier) == Some("node:util") { - builtin_aliases_in_decl.insert(name.clone()); + builtin_aliases_in_module_vars.insert(name.clone()); } if ctx.lookup_local(&name).is_none() { let ty = infer_hoisted_text_codec_var_type(decl, ident, |name| { - builtin_aliases_in_decl.contains(name) + builtin_aliases_in_module_vars.contains(name) || matches!( ctx.lookup_builtin_module_alias(name), Some("util" | "node:util") diff --git a/crates/perry-hir/src/lower_types/hoisted_text_codec.rs b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs index 8a790385eb..e4c41cf48e 100644 --- a/crates/perry-hir/src/lower_types/hoisted_text_codec.rs +++ b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs @@ -1,4 +1,4 @@ -use perry_types::Type; +use crate::types::Type; use swc_ecma_ast as ast; use super::extract_ts_type; diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 04dac3aba1..3b12973f24 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1424,15 +1424,17 @@ pub extern "C" fn js_object_get_field_by_name( let f = f64::from_bits(obj as u64); if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { let id = f as usize; - if let Some(probe) = crate::object::stream_handle_probe() { - unsafe { - if probe(id) { - if let Some(dispatch) = handle_property_dispatch() { - let key_ptr = - (key as *const u8).add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let bits = dispatch(id as i64, key_ptr, key_len); - return JSValue::from_bits(bits.to_bits()); + if crate::value::addr_class::is_stream_id_band(id) { + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = handle_property_dispatch() { + let key_ptr = (key as *const u8) + .add(std::mem::size_of::()); + let key_len = (*key).byte_len as usize; + let bits = dispatch(id as i64, key_ptr, key_len); + return JSValue::from_bits(bits.to_bits()); + } } } } diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 6cfc5effb1..5d5b0aa62c 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -870,30 +870,6 @@ pub extern "C" fn js_object_set_field_by_name( } } } - // #5756: Web Streams handles are represented as finite f64 ids in the - // stream-id band (not NaN-boxed pointers). Reads already route those ids - // through `handle_property_dispatch`; writes need the matching setter path - // so userland/React can attach expando fields like `stream.allReady`. - { - let f = f64::from_bits(obj as u64); - if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { - let id = f as usize; - if let Some(probe) = crate::object::stream_handle_probe() { - unsafe { - if probe(id) { - if let Some(dispatch) = handle_property_set_dispatch() { - let name_ptr = - (key as *const u8).add(std::mem::size_of::()); - let name_len = (*key).byte_len as usize; - dispatch(id as i64, name_ptr, name_len, value); - } - return; - } - } - } - } - } - // Strip NaN-boxing tags if present (defensive: handle POINTER_TAG, UNDEFINED, NULL, etc.) let obj = { let bits = obj as u64; diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 35558057c5..e686164c51 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -514,8 +514,6 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 { "RegExp" => 0xFFFF0021, "ArrayBuffer" => 0xFFFF0025, "DataView" => 0xFFFF002B, - "WeakMap" => 0xFFFF002C, - "WeakSet" => 0xFFFF002D, "Array" => 0xFFFF0024, "Object" => 0xFFFF0050, "Function" => CLASS_ID_FUNCTION, diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 083d0c33fb..235ee2c98f 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -167,27 +167,6 @@ pub extern "C" fn js_put_value_set( return value; } } - // Web Streams handles are finite f64 ids in the stream-id band, not - // heap objects. They still need ordinary expando property writes for - // userland fields such as ReactDOM's `stream.allReady`. Route through - // the registered handle setter so stdlib-owned handle storage remains - // consistent with stdlib-owned handle reads. - if let Some(name) = key_to_rust_string(property_key) { - if target.is_finite() && target > 0.0 && target.fract() == 0.0 { - let id = target as usize; - if let Some(probe) = crate::object::stream_handle_probe() { - unsafe { - if probe(id) { - if let Some(dispatch) = crate::object::handle_property_set_dispatch() { - dispatch(id as i64, name.as_ptr(), name.len(), value); - } - return value; - } - } - } - } - } - // Date / RegExp / Error exotic cells: route to the expando-aware // setter — the ordinary path below would bit-cast them. Throws on a // rejected strict write. (See `object::exotic_expando`.) diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index 9c8653bd2d..852e390154 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -53,10 +53,7 @@ pub unsafe extern "C" fn js_handle_property_dispatch( .contains(&(handle as usize)) && crate::streams::js_stream_handle_is_registered(handle as usize) { - let value = crate::streams::dispatch_stream_property(handle as f64, property_name); - if value.to_bits() != 0x7FFC_0000_0000_0001 { - return value; - } + return crate::streams::dispatch_stream_property(handle as f64, property_name); } if let Some(value) = diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 55bf77b8f7..ace33236c6 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -1419,13 +1419,20 @@ pub extern "C" fn js_response_get_headers(handle: f64) -> f64 { 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| { + 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(), @@ -1437,8 +1444,8 @@ pub extern "C" fn js_response_clone(handle: f64) -> f64 { url: resp.url.clone(), redirected: resp.redirected, cached_headers_id: None, - cached_body_stream_id: None, - body_stream_id: None, + cached_body_stream_id: cloned_stream_id, + body_stream_id: cloned_stream_id, } }) }; 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 36b32d8ce6..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; @@ -18,7 +13,6 @@ use perry_transform::{ }; use std::collections::{HashMap, HashSet}; use std::fs; -use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use crate::commands::progress::{ProgressSnapshot, VerboseProgress}; @@ -40,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; @@ -93,73 +88,6 @@ pub(super) fn is_nextjs_runtime_module(path: &std::path::Path) -> bool { .any(|w| w[0] == std::ffi::OsStr::new(".next") && w[1] == std::ffi::OsStr::new("server")) } -fn script_string_import_target(specifier: &str) -> Option<&str> { - let (path, query) = specifier.split_once('?')?; - if query.split('&').any(|part| part == "script-string") { - Some(path) - } else { - None - } -} - -fn compact_script_string_source(source: &str) -> String { - let lines: Vec<_> = source - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect(); - let mut out = String::new(); - for (idx, line) in lines.iter().enumerate() { - out.push_str(line); - let last = idx + 1 == lines.len(); - if !last && !line.ends_with('{') && !line.ends_with(',') { - out.push(';'); - } - } - out -} - -fn synthesize_script_string_module( - ctx: &CompilationContext, - importer_path: &std::path::Path, - specifier: &str, -) -> Result> { - let Some(target_specifier) = script_string_import_target(specifier) else { - return Ok(None); - }; - let resolved = if target_specifier.starts_with('/') { - super::resolve::resolve_absolute_import_paths(target_specifier) - .map(|path| path.canonical_path) - } else { - super::resolve::resolve_relative_import_path(target_specifier, importer_path) - }; - let Some(source_path) = resolved else { - return Ok(None); - }; - let raw = fs::read_to_string(&source_path) - .map_err(|e| anyhow!("Failed to read {}: {}", source_path.display(), e))?; - let script = compact_script_string_source(&raw); - 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); - specifier.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))?; - Ok(Some(synthetic_path)) -} - /// Collect all modules to compile (transitive closure of imports) pub(super) fn collect_modules( entry_path: &PathBuf, @@ -1371,19 +1299,7 @@ fn collect_module_one( continue; } - if let Some(synthetic_path) = - synthesize_script_string_module(ctx, entry_path, &import.source)? - { - let resolved_path = synthetic_path.canonicalize().map_err(|e| { - anyhow!( - "Failed to canonicalize synthetic module {}: {}", - synthetic_path.display(), - e - ) - })?; - import.resolved_path = Some(resolved_path.to_string_lossy().to_string()); - import.module_kind = ModuleKind::NativeCompiled; - pending.push(synthetic_path); + if script_string::resolve(ctx, &canonical, import, &mut pending)? { continue; } 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/tests/test_compile_package_exports_subpath_source.sh b/tests/test_compile_package_exports_subpath_source.sh index 93b3e29533..7375e33638 100755 --- a/tests/test_compile_package_exports_subpath_source.sh +++ b/tests/test_compile_package_exports_subpath_source.sh @@ -5,9 +5,9 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PERRY="$SCRIPT_DIR/../target/release/perry" -[ ! -f "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" -if [ ! -f "$PERRY" ]; then +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 From 73e1a466e66197eaa7662f858a59dc056d34347e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 13:18:06 +0200 Subject: [PATCH 7/9] test: cover audited TanStack stream and script behavior --- .../tests/issue_5756_response_stream_body.rs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs index 95f3dac7e1..0df5877942 100644 --- a/crates/perry/tests/issue_5756_response_stream_body.rs +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -101,6 +101,49 @@ console.log('done=' + first.done + ',len=' + (first.value ? first.value.byteLeng 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"); @@ -163,16 +206,22 @@ fn script_string_query_imports_compile_to_default_string_asset() { "import boot from './boot?script-string'\nexport function readBoot() { return boot }\n", ) .expect("write pkg index"); - std::fs::write(pkg.join("src/boot.ts"), "self.$_TSR = { buffer: [] }\n") - .expect("write script source"); + 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(boot === true)\n", + "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\nfalse\n"); + assert_eq!( + stdout, + "string\ntrue\n\"// preserve formatting\\nself.$_TSR = {\\n buffer: []\\n}\\n\"\n" + ); } #[test] From e49d0d3b24d587dfee284121d749b2c721dd0540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 13:19:07 +0200 Subject: [PATCH 8/9] chore: prepare v0.5.1276 landing for PR 5756 --- CLAUDE.md | 2 +- Cargo.lock | 152 +++++++++++----------- Cargo.toml | 2 +- changelog.d/5756-tanstack-start-compat.md | 8 ++ 4 files changed, 86 insertions(+), 78 deletions(-) create mode 100644 changelog.d/5756-tanstack-start-compat.md 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. From c76e6d9b7be42cc27aed5144bed0690377f0de30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 13:44:27 +0200 Subject: [PATCH 9/9] fix: close PR 5756 audit regressions --- crates/perry-codegen/src/expr/mod.rs | 6 + .../src/lower_call/extern_func.rs | 6 +- crates/perry-stdlib/src/fetch/mod.rs | 161 +----------------- .../perry-stdlib/src/fetch/response_ctor.rs | 156 +++++++++++++++++ crates/perry/src/commands/compile/resolve.rs | 50 +++--- .../src/commands/compile/resolve/tests.rs | 86 +--------- .../compile_package.rs | 84 +++++++++ 7 files changed, 281 insertions(+), 268 deletions(-) create mode 100644 crates/perry-stdlib/src/fetch/response_ctor.rs create mode 100644 crates/perry/src/commands/compile/resolve/tests/declaration_sidecar_tests/compile_package.rs 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 a00e90f85c..f38173d875 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1351,11 +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" - if !ctx.imported_vars.contains(name) - && !ctx.import_function_prefixes.contains_key(name) - && !ctx.import_function_v8_specifiers.contains_key(name) => - { + "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-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index ace33236c6..5560d2cd9f 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -45,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; @@ -1296,167 +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, - 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 { - 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) -} - /// 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 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/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index ebf9ab8afc..dbcdf23be9 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -858,37 +858,39 @@ fn prefer_ts_source_for_package_entry( package_dir: &Path, normal_entry: PathBuf, ) -> Option { - if is_js_file(&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); - } + 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 { - 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); - } + } + // 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); } } } } } - Some(normal_entry) + None } /// Resolve exports field from package.json diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index 5ac8c863af..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,90 +1561,6 @@ mod declaration_sidecar_tests { ); } - #[test] - fn compile_package_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 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") + ); +}