From 8daf5e27f19a80ae6f6631221655cb01b5a03199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 12:03:25 +0200 Subject: [PATCH 1/2] feat(bun): map extracted standalone filesystem roots (#9598) --- crates/perry-runtime/src/bun_compat/mod.rs | 32 +++- crates/perry-runtime/src/embedded.rs | 17 +- crates/perry/src/commands/compile.rs | 2 +- .../src/commands/compile/asset_manifest.rs | 6 +- .../perry/src/commands/compile/build_cache.rs | 4 + .../src/commands/compile/collect_modules.rs | 40 ++++- .../compile/collect_modules/import_helpers.rs | 43 +++++ .../static_require_transform.rs | 27 ++- crates/perry/src/commands/compile/embed.rs | 71 ++++++++ .../perry/src/commands/compile/init_order.rs | 8 +- crates/perry/src/commands/compile/resolve.rs | 64 ++++++- .../src/commands/compile/run_pipeline.rs | 79 ++++++--- crates/perry/src/commands/compile/types.rs | 11 ++ crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + crates/perry/tests/issue_9598_bunfs_root.rs | 160 ++++++++++++++++++ docs/src/cli/flags.md | 16 ++ 17 files changed, 535 insertions(+), 47 deletions(-) create mode 100644 crates/perry/tests/issue_9598_bunfs_root.rs diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 545d27af23..60c370aff6 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -281,6 +281,13 @@ fn mime_type_for_path(path: &str) -> &'static str { } fn read_file_or_reject(path: &str) -> Result, f64> { + if let Some(bytes) = crate::embedded::lookup(path) { + return Ok(bytes.to_vec()); + } + if crate::embedded::is_virtual_path(path) { + let error = std::io::Error::new(std::io::ErrorKind::NotFound, "virtual file not found"); + return Err(unsafe { crate::fs::build_fs_error_value(&error, "open", path) }); + } std::fs::read(path) .map_err(|err| unsafe { crate::fs::build_fs_error_value(&err, "open", path) }) } @@ -319,11 +326,16 @@ extern "C" fn bun_file_bytes(closure: *const ClosureHeader) -> f64 { extern "C" fn bun_file_exists(closure: *const ClosureHeader) -> f64 { let path = value_to_string(captured(closure)); - promise_value(bool_value( + let exists = if crate::embedded::lookup(&path).is_some() { + true + } else if crate::embedded::is_virtual_path(&path) { + false + } else { std::fs::metadata(&path) - .map(|m| m.is_file()) - .unwrap_or(false), - )) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + }; + promise_value(bool_value(exists)) } fn json_parse_promise(bytes: &[u8]) -> f64 { @@ -368,9 +380,15 @@ pub extern "C" fn js_bun_file(path: f64) -> f64 { let obj = js_object_alloc(0, 9); set_field(obj, BUN_FILE_PATH_KEY, path_value); set_field(obj, b"name", path_value); - let size = std::fs::metadata(&path_string) - .map(|m| m.len()) - .unwrap_or(0); + let size = if let Some(bytes) = crate::embedded::lookup(&path_string) { + bytes.len() as u64 + } else if crate::embedded::is_virtual_path(&path_string) { + 0 + } else { + std::fs::metadata(&path_string) + .map(|metadata| metadata.len()) + .unwrap_or(0) + }; set_field(obj, b"size", size as f64); set_field( obj, diff --git a/crates/perry-runtime/src/embedded.rs b/crates/perry-runtime/src/embedded.rs index 7dcc6dad38..e5380f9ce7 100644 --- a/crates/perry-runtime/src/embedded.rs +++ b/crates/perry-runtime/src/embedded.rs @@ -30,6 +30,10 @@ use crate::value::{js_nanbox_pointer, JSValue, TAG_TRUE}; /// Bun's `$bunfs/`. `fs` and `readEmbedded` strip it before lookup; the /// import-attribute lowering hands user code a `$perryfs/` string. pub const VIRTUAL_PREFIX: &str = "$perryfs/"; +/// Bun standalone executables expose extracted files through this absolute +/// virtual prefix. Perry retains the full path as the registry key so user +/// code can keep passing the original string to `node:fs` and `Bun.file()`. +pub const BUNFS_ROOT_PREFIX: &str = "/$bunfs/root/"; /// One embedded file. `bytes` points into the binary's read-only data and is /// valid for the life of the process. @@ -95,12 +99,14 @@ pub fn lookup(path: &str) -> Option<&'static [u8]> { reg.iter().find(|a| a.name == key).map(|a| a.bytes) } -/// True if `path` is an embedded-asset *virtual* path (carries the `$perryfs/` -/// prefix), independent of whether it actually resolves. `fs` uses this to treat -/// an unresolved `$perryfs/...` path as missing rather than attempting a real -/// disk read of the literal string. Actual presence is [`lookup`]. +/// True if `path` is an embedded-asset virtual path (carries the `$perryfs/` +/// or `/$bunfs/root/` prefix), independent of whether it actually resolves. +/// `fs` uses this to treat an unresolved virtual path as missing rather than +/// attempting a real disk read of the literal string. Actual presence is +/// [`lookup`]. pub fn is_virtual_path(path: &str) -> bool { - path.replace('\\', "/").starts_with(VIRTUAL_PREFIX) + let unified = path.replace('\\', "/"); + unified.starts_with(VIRTUAL_PREFIX) || unified.starts_with(BUNFS_ROOT_PREFIX) } /// Snapshot of `(name, size)` for every embedded asset, in registration order. @@ -311,6 +317,7 @@ mod tests { assert_eq!(lookup("$perryfs\\embed-test\\asset.txt"), Some(DATA)); // `is_virtual_path` is a pure prefix test; presence is `lookup`. assert!(is_virtual_path("$perryfs/anything")); + assert!(is_virtual_path("/$bunfs/root/assets/help.zst")); assert!(!is_virtual_path("not/registered.txt")); assert!(lookup("not/registered.txt").is_none()); assert!(lookup("$perryfs/not-registered").is_none()); diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 181480a67e..453556b499 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -113,7 +113,7 @@ use resolve::{ ergonomic_export_alias, extract_compile_package_dir, has_perry_native_library, is_declaration_file, is_in_compile_package, is_in_perry_native_package, is_js_file, is_recognized_text_asset, parse_native_library_manifest, parse_package_specifier, - resolve_import, + resolve_import_with_bunfs, }; pub(crate) use runtime_compat::{ ensure_runtime_library_compatible, runtime_library_diagnostic, runtime_library_status, diff --git a/crates/perry/src/commands/compile/asset_manifest.rs b/crates/perry/src/commands/compile/asset_manifest.rs index 23bf5173c1..173a738bd8 100644 --- a/crates/perry/src/commands/compile/asset_manifest.rs +++ b/crates/perry/src/commands/compile/asset_manifest.rs @@ -81,7 +81,11 @@ pub(super) fn write( ctx, path, kind, - format!("$perryfs/{packaged_name}"), + if packaged_name.starts_with(super::resolve::BUNFS_ROOT_PREFIX) { + packaged_name.clone() + } else { + format!("$perryfs/{packaged_name}") + }, generated_owner(ctx, path), )?, ); diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index e5d7b9b4fb..285918a5f9 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -709,6 +709,10 @@ impl BuildCacheProbe { runtime_inputs: &[PathBuf], ) -> Result { let mut source_paths = ctx.native_modules.keys().cloned().collect::>(); + // Graph-discovered assets (including `/$bunfs/root/...` literals) are + // not necessarily modules. Fingerprint their source bytes alongside + // modules so changing an embedded file cannot reuse a stale binary. + source_paths.extend(ctx.embedded_assets.iter().map(|(_, path)| path.clone())); for addon in ctx.native_addons.values() { source_paths.extend(super::native_addon_sidecar::addon_payload_files(addon)); } diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 684a0ec4e6..e37b6c2f6b 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -53,18 +53,34 @@ use dynamic_glob::expand_dynamic_import_glob; use eval_worker::materialize_eval_worker_source; pub(super) use import_helpers::known_node_submodule_key; use import_helpers::{ - cached_resolve_import_with_lexical_base, collect_js_module_imports, env_defines_for_lowering, + cached_resolve_import_with_lexical_base, collect_js_module_imports, + ensure_bunfs_import_resolves, env_defines_for_lowering, }; use json_module::synthesize_json_module; pub(super) use native_addon::package_has_unsupported_node_addon; use native_addon::{collect_or_refuse_node_addon, refuse_compile_package_native_addon}; use parse_error::annotate_parse_error; -use static_require_transform::transform_static_literal_requires; +use static_require_transform::transform_static_literal_requires_with_bunfs; pub(super) use walk::collect_modules; use wasm_asset::{is_wasm_asset, synthesize_wasm_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; +fn register_bunfs_literal_assets(source: &str, ctx: &mut CompilationContext) { + let Some(root) = ctx.bunfs_root.clone() else { + return; + }; + for (name, path) in super::embed::resolve_bunfs_literal_assets(source, &root) { + if !ctx + .embedded_assets + .iter() + .any(|(existing_name, _)| existing_name == &name) + { + ctx.embedded_assets.push((name, path)); + } + } +} + enum VisitState { InProgress, Done, @@ -142,7 +158,17 @@ fn collect_module_one( .components() .any(|c| c.as_os_str() == "node_modules"); let is_perry_native = is_in_node_modules && is_in_perry_native_package(&canonical); - let is_in_compiled_pkg = ctx.aot_discovered_modules.contains(&canonical) + // `--bunfs-root` describes source extracted from a self-contained Bun + // executable. Compile every module below that opted-in tree natively, + // including paths with a `node_modules` component; otherwise Perry's + // ordinary dependency classification can route those mapped modules to + // the removed JS fallback despite the resolver selecting NativeCompiled. + let is_in_bunfs_root = ctx + .bunfs_root + .as_ref() + .is_some_and(|root| canonical.starts_with(root)); + let is_in_compiled_pkg = is_in_bunfs_root + || ctx.aot_discovered_modules.contains(&canonical) || (is_in_node_modules && is_in_compile_package(&canonical, &ctx.compile_packages)) || ctx.compile_package_dirs.iter().any(|dir| { if canonical.starts_with(dir) { @@ -203,6 +229,7 @@ fn collect_module_one( let source = fs::read_to_string(&canonical) .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))?; + register_bunfs_literal_assets(&source, ctx); progress.record(ProgressSnapshot { stage: "collect-js-module", module_path: Some(&canonical), @@ -311,10 +338,12 @@ fn collect_module_one( fs::read_to_string(&canonical) .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))? }; + register_bunfs_literal_assets(&raw_source, ctx); // CJS wrapping consumes literal `require()` sites and replaces them with // generated loader calls. Queue native targets before that rewrite so the // graph still authenticates and packages the selected `.node` binary. for specifier in super::cjs_wrap::extract_require_specifiers(&raw_source) { + ensure_bunfs_import_resolves(&specifier, &canonical, ctx)?; if let Some(target) = super::resolve::resolve_relative_import_path(&specifier, &canonical) { if target.extension().and_then(|extension| extension.to_str()) == Some("node") { pending.push(target); @@ -399,10 +428,11 @@ fn collect_module_one( // delta to the prefix so the wrapped-line → original-line subtraction is // computed against the FINAL parsed source. let lines_before_transform = source.bytes().filter(|&b| b == b'\n').count(); - let source = transform_static_literal_requires( + let source = transform_static_literal_requires_with_bunfs( &source, &ctx.compile_packages, canonical.parent().unwrap_or_else(|| Path::new(".")), + ctx.bunfs_root.as_deref(), ); // #8547: a builtin reached through `require("http")` never appears in the @@ -1030,6 +1060,7 @@ fn collect_module_one( // Process imports and update their resolved paths and module kinds for import in &mut hir_module.imports { + ensure_bunfs_import_resolves(&import.source, &canonical, ctx)?; // Resolve TypeScript type-only imports for metadata, but never queue // their target as a runtime module. The final graph may already // contain the target through a value import elsewhere; retaining its @@ -1667,6 +1698,7 @@ fn collect_module_one( perry_hir::Export::Named { .. } => None, }; if let Some(src) = source { + ensure_bunfs_import_resolves(src, &canonical, ctx)?; progress.record(ProgressSnapshot { stage: "resolve-re-export", module_path: Some(&canonical), diff --git a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs index a76b88369e..0f775ffa9b 100644 --- a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs +++ b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs @@ -6,6 +6,7 @@ //! mapping for HIR lowering, JS-module import scanning, lexical-vs-canonical //! import resolution, and the known-node-submodule classifier. +use anyhow::{anyhow, Result}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -13,6 +14,48 @@ use perry_hir::ModuleKind; use super::{cached_resolve_import, CompilationContext}; +/// Reject an unresolved Bun virtual module edge with a diagnostic that names +/// both the original contract path and its configured extracted-tree target. +/// Ordinary resolver failures retain their existing behavior. +pub(super) fn ensure_bunfs_import_resolves( + import_source: &str, + importer_path: &Path, + ctx: &CompilationContext, +) -> Result<()> { + if !import_source.starts_with(super::super::resolve::BUNFS_ROOT_PREFIX) { + return Ok(()); + } + let root = ctx.bunfs_root.as_deref().ok_or_else(|| { + anyhow!( + "cannot resolve Bun virtual path `{}` imported from `{}`: pass \ + `--bunfs-root ` pointing at the extracted standalone root", + import_source, + importer_path.display() + ) + })?; + let mapped = + super::super::resolve::bunfs_mapped_path(import_source, root).ok_or_else(|| { + anyhow!( + "cannot resolve Bun virtual path `{}` imported from `{}`: the path escapes \ + configured --bunfs-root `{}`", + import_source, + importer_path.display(), + root.display() + ) + })?; + if super::super::resolve::resolve_bunfs_import_path(import_source, root).is_none() { + return Err(anyhow!( + "cannot resolve Bun virtual path `{}` imported from `{}`: mapped target `{}` \ + is absent under --bunfs-root `{}`", + import_source, + importer_path.display(), + mapped.display(), + root.display() + )); + } + Ok(()) +} + /// #5009: build the bare-name → literal map perry-hir lowering consults to fold /// `process.env.` reads (`perry_hir::env_define_lookup`). Strips the /// `process.env.` prefix the `perry.define` keys carry and converts each diff --git a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs index 225be9d3d5..e196d647f8 100644 --- a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs +++ b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs @@ -9,10 +9,20 @@ use serde::Deserialize; use super::parse_package_specifier; use crate::commands::compile::cjs_wrap::detect::strip_comments_and_strings; -pub(super) fn transform_static_literal_requires( +#[cfg(test)] +fn transform_static_literal_requires( source: &str, compile_packages: &HashSet, module_dir: &Path, +) -> String { + transform_static_literal_requires_with_bunfs(source, compile_packages, module_dir, None) +} + +pub(super) fn transform_static_literal_requires_with_bunfs( + source: &str, + compile_packages: &HashSet, + module_dir: &Path, + bunfs_root: Option<&Path>, ) -> String { let create_require_aliases = collect_create_require_aliases(source); let mut require_aliases = @@ -73,7 +83,7 @@ pub(super) fn transform_static_literal_requires( continue; } let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); - if let Some(target) = resolve_static_require(module_dir, specifier) { + if let Some(target) = resolve_static_require(module_dir, specifier, bunfs_root) { if discovered_side_effects.insert(target.clone()) { let binding = unique_lazy_require_name(source, &mut next_id); imports.push(format!( @@ -86,7 +96,7 @@ pub(super) fn transform_static_literal_requires( let call_re = literal_require_call_re(&alias); for cap in call_re.captures_iter(source) { let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); - let require_target = resolve_static_require(module_dir, specifier); + let require_target = resolve_static_require(module_dir, specifier, bunfs_root); if should_leave_runtime_require(specifier, compile_packages) { if let Some(target) = require_target.as_ref() { if discovered_side_effects.insert(target.clone()) { @@ -176,7 +186,16 @@ pub(super) fn transform_static_literal_requires( prepend_imports_preserving_shebang(&transformed, &imports) } -fn resolve_static_require(module_dir: &Path, specifier: &str) -> Option { +fn resolve_static_require( + module_dir: &Path, + specifier: &str, + bunfs_root: Option<&Path>, +) -> Option { + if specifier.starts_with(crate::commands::compile::resolve::BUNFS_ROOT_PREFIX) { + return bunfs_root.and_then(|root| { + crate::commands::compile::resolve::resolve_bunfs_import_path(specifier, root) + }); + } if is_relative_or_absolute_specifier(specifier) { let base = if std::path::Path::new(specifier).is_absolute() { std::path::PathBuf::from(specifier) diff --git a/crates/perry/src/commands/compile/embed.rs b/crates/perry/src/commands/compile/embed.rs index 922517c666..d06a8e2cde 100644 --- a/crates/perry/src/commands/compile/embed.rs +++ b/crates/perry/src/commands/compile/embed.rs @@ -30,6 +30,51 @@ use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::OnceLock; + +/// Find literal Bun virtual paths in one source module and return every mapped +/// file that should retain that exact runtime name in the standalone binary. +/// Missing literals are left to the calling API's normal ENOENT behavior; +/// module edges receive a focused compile-time diagnostic in the resolver. +pub(super) fn resolve_bunfs_literal_assets( + source: &str, + bunfs_root: &Path, +) -> Vec<(String, PathBuf)> { + static LITERAL_RES: OnceLock> = OnceLock::new(); + let literal_res = LITERAL_RES.get_or_init(|| { + vec![ + regex::Regex::new(r#"\"(/\$bunfs/root/[^\"\\\r\n]+)\""#) + .expect("double-quoted bunfs path"), + regex::Regex::new(r#"'(/\$bunfs/root/[^'\\\r\n]+)'"#) + .expect("single-quoted bunfs path"), + regex::Regex::new(r#"`(/\$bunfs/root/[^`\\$\r\n]+)`"#).expect("template bunfs path"), + ] + }); + let canonical_root = match bunfs_root.canonicalize() { + Ok(root) => root, + Err(_) => return Vec::new(), + }; + let mut assets = std::collections::BTreeMap::new(); + for literal_re in literal_res { + for captures in literal_re.captures_iter(source) { + let Some(path_match) = captures.get(1) else { + continue; + }; + let virtual_path = path_match.as_str(); + let Some(mapped) = super::resolve::bunfs_mapped_path(virtual_path, &canonical_root) + else { + continue; + }; + let Ok(canonical) = mapped.canonicalize() else { + continue; + }; + if canonical.is_file() && canonical.starts_with(&canonical_root) { + assets.insert(virtual_path.to_string(), canonical); + } + } + } + assets.into_iter().collect() +} /// Collect the embed patterns from the CLI flag plus `perry.embed` /// (package.json) and `[compile] embed` (perry.toml) under `project_root`, @@ -511,6 +556,32 @@ mod tests { assert_eq!(merged.len(), 3); } + #[test] + fn bunfs_literals_keep_names_and_cannot_escape_the_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("root"); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::write(root.join("assets/a.bin"), b"A").unwrap(); + fs::write(root.join("assets/b.bin"), b"B").unwrap(); + + let source = r#" +const a = "/$bunfs/root/assets/a.bin"; +const duplicate = '/$bunfs/root/assets/a.bin'; +const b = `/$bunfs/root/assets/b.bin`; +const missing = "/$bunfs/root/assets/missing.bin"; +const escape = "/$bunfs/root/../outside.bin"; +"#; + let assets = resolve_bunfs_literal_assets(source, &root); + let names: Vec<_> = assets.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!( + names, + vec!["/$bunfs/root/assets/a.bin", "/$bunfs/root/assets/b.bin"] + ); + assert!(assets + .iter() + .all(|(_, path)| path.starts_with(root.canonicalize().unwrap()))); + } + #[test] fn asm_line_escapes_and_appends_newline() { assert_eq!(asm_line(".globl foo"), " \".globl foo\\n\"\n"); diff --git a/crates/perry/src/commands/compile/init_order.rs b/crates/perry/src/commands/compile/init_order.rs index f5f22368e7..dfea825bd8 100644 --- a/crates/perry/src/commands/compile/init_order.rs +++ b/crates/perry/src/commands/compile/init_order.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use crate::OutputFormat; -use super::resolve::resolve_import; +use super::resolve::resolve_import_with_bunfs; use super::CompilationContext; /// Issue #753: reachability classification for eager vs deferred init. @@ -79,12 +79,13 @@ pub(super) fn classify_eager_modules(ctx: &mut CompilationContext, entry_path: & } } for src in reexport_sources { - if let Some((resolved_path, _)) = resolve_import( + if let Some((resolved_path, _)) = resolve_import_with_bunfs( &src, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { if ctx.native_modules.contains_key(&resolved_path) && !eager.contains(&resolved_path) @@ -182,12 +183,13 @@ pub(super) fn topo_sort_non_entry_modules( perry_hir::Export::Named { .. } => None, }; if let Some(src) = source { - if let Some((resolved_path, _)) = resolve_import( + if let Some((resolved_path, _)) = resolve_import_with_bunfs( src, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { if resolved_path != *entry_path && ctx.native_modules.contains_key(&resolved_path) diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index f5a7aa534c..b52d96d8ee 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -147,6 +147,11 @@ mod tests; const PERRY_NATIVE_EXTENSION_PACKAGES: &[&str] = &["ioredis", "ethers", "mysql2", "ws", "dotenv", "undici"]; +/// Absolute virtual prefix used by files extracted from a Bun standalone +/// executable. `--bunfs-root` maps the suffix below this prefix to a real +/// directory without requiring a host-level `/$bunfs` mount or symlink. +pub(super) const BUNFS_ROOT_PREFIX: &str = "/$bunfs/root/"; + /// Check if a file path is inside a Perry native extension package (has built-in stdlib support) /// or a package that has perry.nativeLibrary in its package.json. pub(super) fn is_in_perry_native_package(path: &Path) -> bool { @@ -1289,6 +1294,32 @@ pub(super) fn resolve_absolute_import_paths(import_source: &str) -> Option Option { + let suffix = import_source.strip_prefix(BUNFS_ROOT_PREFIX)?; + let relative = Path::new(suffix); + if relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + Some(root.join(relative)) +} + +/// Resolve a Bun virtual module specifier through the configured extracted +/// root, using Perry's ordinary extension/index lookup while retaining one +/// canonical identity for real-path and virtual-path imports of the same file. +pub(super) fn resolve_bunfs_import_path(import_source: &str, root: &Path) -> Option { + let canonical_root = root.canonicalize().ok()?; + let mapped = bunfs_mapped_path(import_source, &canonical_root)?; + let source_path = resolve_with_extensions(&mapped)?; + let canonical = source_path.canonicalize().ok()?; + canonical.starts_with(&canonical_root).then_some(canonical) +} + fn normalize_path_lexically(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { @@ -1350,6 +1381,7 @@ pub(super) fn is_relative_specifier(import_source: &str) -> bool { } /// Resolve an import specifier to a file path +#[cfg(test)] pub(super) fn resolve_import( import_source: &str, importer_path: &Path, @@ -1357,6 +1389,32 @@ pub(super) fn resolve_import( compile_packages: &HashSet, compile_package_dirs: &BTreeSet, ) -> Option<(PathBuf, ModuleKind)> { + resolve_import_with_bunfs( + import_source, + importer_path, + project_root, + compile_packages, + compile_package_dirs, + None, + ) +} + +/// Context-aware resolver entry point used by compile passes that must retain +/// `--bunfs-root` semantics after the initial module walk (export flattening, +/// init ordering, and dynamic-import metadata construction). +pub(super) fn resolve_import_with_bunfs( + import_source: &str, + importer_path: &Path, + project_root: &Path, + compile_packages: &HashSet, + compile_package_dirs: &BTreeSet, + bunfs_root: Option<&Path>, +) -> Option<(PathBuf, ModuleKind)> { + if import_source.starts_with(BUNFS_ROOT_PREFIX) { + return bunfs_root + .and_then(|root| resolve_bunfs_import_path(import_source, root)) + .map(|path| (path, ModuleKind::NativeCompiled)); + } // Check if it's a native Rust stdlib module. Refs #665: when the user has // explicitly opted the package into `perry.compilePackages`, they want // their `node_modules` copy compiled from source (cjs_wrap + native @@ -1389,12 +1447,13 @@ pub(super) fn resolve_import( // specifier, which resolves through node_modules per spec (or the // stdlib for `node:` builtins). Ok(SubpathImportOutcome::External(spec)) => { - return resolve_import( + return resolve_import_with_bunfs( &spec, importer_path, project_root, compile_packages, compile_package_dirs, + bunfs_root, ); } // Not covered by an `imports` map — fall through (the tsconfig @@ -1737,12 +1796,13 @@ pub(super) fn cached_resolve_import( if let Some(cached) = ctx.resolve_cache.get(&cache_key) { return cached.clone(); } - let result = resolve_import( + let result = resolve_import_with_bunfs( import_source, importer_path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ); ctx.resolve_cache.insert(cache_key, result.clone()); result diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 2cd21a99d5..27de5d7d8a 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -735,6 +735,25 @@ pub fn run_with_parse_cache( let mut ctx = CompilationContext::new(project_root.clone()); ctx.cache_root = object_cache_project_root(&args.input, &project_root); + ctx.bunfs_root = match args.bunfs_root.as_deref() { + Some(root) => { + let canonical = root.canonicalize().map_err(|error| { + anyhow::anyhow!( + "failed to resolve --bunfs-root `{}`: {}", + root.display(), + error + ) + })?; + if !canonical.is_dir() { + anyhow::bail!( + "invalid --bunfs-root `{}`: expected an extracted root directory", + root.display() + ); + } + Some(canonical) + } + None => None, + }; let explain_lowering = if args.explain_lowering { Some(lowering_report::ExplainLoweringRun::prepare( &ctx.cache_root, @@ -1079,12 +1098,13 @@ pub fn run_with_parse_cache( _ => None, }; if let Some((source, re_export_names)) = source_str { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, enum_name), members) in &exported_enums { @@ -1208,12 +1228,13 @@ pub fn run_with_parse_cache( let Some((source, names)) = re_export else { continue; }; - let Some((resolved_source, _)) = resolve_import( + let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) else { continue; }; @@ -1773,12 +1794,13 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); if let Some(source_exports) = all_module_exports.get(&source_path_str) { @@ -1827,12 +1849,13 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); if let Some(source_exports) = all_module_exports.get(&source_path_str) { @@ -1880,12 +1903,13 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( &import.source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); @@ -1975,12 +1999,13 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), ¶m_count) in &exported_func_param_counts @@ -2003,12 +2028,13 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), ¶m_count) in &exported_func_param_counts @@ -2039,12 +2065,13 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( &import.source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); @@ -2092,12 +2119,13 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), return_type) in &exported_func_return_types @@ -2124,12 +2152,13 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), return_type) in &exported_func_return_types @@ -2163,12 +2192,13 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( &import.source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); @@ -2218,12 +2248,13 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, class_name), class) in &exported_classes { @@ -2241,12 +2272,13 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, class_name), class) in &exported_classes { @@ -2272,12 +2304,13 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import( + if let Some((resolved_source, _)) = resolve_import_with_bunfs( &import.source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { let source_path_str = resolved_source.to_string_lossy().to_string(); @@ -2568,12 +2601,13 @@ pub fn run_with_parse_cache( perry_hir::Export::ReExport { source, .. } | perry_hir::Export::ExportAll { source } | perry_hir::Export::NamespaceReExport { source, .. } => { - if let Some((resolved_path, _)) = resolve_import( + if let Some((resolved_path, _)) = resolve_import_with_bunfs( source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { if let Some(name) = path_to_module_name.get(&resolved_path) { *source = name.clone(); @@ -2595,12 +2629,13 @@ pub fn run_with_parse_cache( if import.is_native { continue; } - if let Some((resolved_path, _)) = resolve_import( + if let Some((resolved_path, _)) = resolve_import_with_bunfs( &import.source, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { if let Some(name) = path_to_module_name.get(&resolved_path) { import.source = name.clone(); @@ -3243,12 +3278,13 @@ pub fn run_with_parse_cache( perry_hir::Export::Named { .. } => None, }; if let Some(src) = src { - if let Some((resolved_path, _)) = resolve_import( + if let Some((resolved_path, _)) = resolve_import_with_bunfs( &src, path, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) { if let Some(src_mod) = ctx.native_modules.get(&resolved_path) { push_dep(&mut deps, &mut seen, sanitize_name(&src_mod.name)); @@ -4089,12 +4125,13 @@ pub fn run_with_parse_cache( let perry_hir::Export::ExportAll { source } = e else { return None; }; - let (target_path, _) = resolve_import( + let (target_path, _) = resolve_import_with_bunfs( source, std::path::Path::new(&ns_scan_path), &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), )?; let target = target_path.to_string_lossy().to_string(); all_module_exports @@ -4106,12 +4143,13 @@ pub fn run_with_parse_cache( let Some((hop_src, hop_imported)) = named_hop.or_else(export_all_hop) else { break; }; - let Some((hop_path, _)) = resolve_import( + let Some((hop_path, _)) = resolve_import_with_bunfs( &hop_src, std::path::Path::new(&ns_scan_path), &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) else { break; }; @@ -4167,12 +4205,13 @@ pub fn run_with_parse_cache( break; } let importer = std::path::Path::new(&ns_scan_path); - let Some((ns_target, _)) = resolve_import( + let Some((ns_target, _)) = resolve_import_with_bunfs( ns_src, importer, &ctx.project_root, &ctx.compile_packages, &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), ) else { break; }; diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 6e61df85ee..6bb2a61bd9 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -163,6 +163,13 @@ pub struct CompileArgs { #[arg(long)] pub embed: Vec, + /// Map Bun standalone-executable virtual paths below `/$bunfs/root/` to + /// an extracted filesystem tree. Static module edges resolve through the + /// mapping, while referenced files are embedded under their original Bun + /// paths so `node:fs` and `Bun.file()` keep working after relocation. + #[arg(long, value_name = "DIR")] + pub bunfs_root: Option, + /// Generate a deterministic TypeScript module that maps asset-relative /// names to Bun-compatible `{ type: "file" }` imports. The value is /// `=`; paths are relative to the @@ -672,6 +679,9 @@ pub struct CompilationContext { #[allow(dead_code)] // #5731 embed-assets context contract; pub field populated on the embed path, not read here pub embedded_assets: Vec<(String, PathBuf)>, + /// Canonical extracted root mounted at Bun's `/$bunfs/root/` virtual path. + /// Set only by the compile CLI's `--bunfs-root` option. + pub bunfs_root: Option, /// Canonical paths whose import attributes explicitly requested Bun's /// `{ type: "file" }` loader. Kept separate from `embedded_assets` because /// automatic wasm imports also register bytes there but must still lower @@ -1178,6 +1188,7 @@ impl CompilationContext { auto_skipped_node_addon_packages: HashSet::new(), aot_discovered_modules: HashSet::new(), embedded_assets: Vec::new(), + bunfs_root: None, file_loader_asset_paths: HashSet::new(), file_loader_asset_names: HashMap::new(), generated_asset_modules: BTreeMap::new(), diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index ba1b63dcb8..a042a36581 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -295,6 +295,7 @@ fn build_once( output_type: "executable".to_string(), bundle_extensions: None, embed: Vec::new(), + bunfs_root: None, asset_module: Vec::new(), type_check: false, minify: false, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index a7e420f56e..d2e077592e 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -207,6 +207,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> output_type: "executable".to_string(), bundle_extensions: None, embed: Vec::new(), + bunfs_root: None, asset_module: Vec::new(), type_check: args.type_check, minify: target.as_deref() == Some("web"), diff --git a/crates/perry/tests/issue_9598_bunfs_root.rs b/crates/perry/tests/issue_9598_bunfs_root.rs new file mode 100644 index 0000000000..2ca74f1bf6 --- /dev/null +++ b/crates/perry/tests/issue_9598_bunfs_root.rs @@ -0,0 +1,160 @@ +//! #9598 — compile source extracted from Bun standalone executables without a +//! host `/$bunfs` mount, while preserving Bun's original runtime path strings. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn write_fixture(root: &std::path::Path) { + std::fs::create_dir_all(root.join("assets")).expect("mkdir assets"); + std::fs::create_dir_all(root.join("node_modules/fixture-pkg")).expect("mkdir nested package"); + std::fs::write( + root.join("entry.js"), + r#" +import { answer, token as virtualToken } from "/$bunfs/root/chunk.js"; +import { token as realToken } from "./chunk.js"; +import { reexported } from "/$bunfs/root/barrel.js"; +import { nestedValue } from "/$bunfs/root/node_modules/fixture-pkg/value.js"; +import { readFileSync } from "node:fs"; + +const required = require("/$bunfs/root/required.js"); +const dynamicModule = await import("/$bunfs/root/dynamic.js"); +const assetPath = "/$bunfs/root/assets/help.zst"; +const bunFile = Bun.file(assetPath); + +console.log([ + answer, + reexported, + required.required, + dynamicModule.dynamicValue, + nestedValue, + virtualToken === realToken, + readFileSync(assetPath).length, + await bunFile.text(), + bunFile.size, + await bunFile.exists(), +].join("|")); +"#, + ) + .expect("write entry"); + std::fs::write( + root.join("chunk.js"), + "export const answer = 42; export const token = {};\n", + ) + .expect("write chunk"); + std::fs::write( + root.join("barrel.js"), + "export { reexported } from \"/$bunfs/root/reexported.js\";\n", + ) + .expect("write barrel"); + std::fs::write(root.join("reexported.js"), "export const reexported = 7;\n") + .expect("write re-export"); + std::fs::write(root.join("required.js"), "export const required = 9;\n") + .expect("write required"); + std::fs::write(root.join("dynamic.js"), "export const dynamicValue = 11;\n") + .expect("write dynamic"); + std::fs::write( + root.join("node_modules/fixture-pkg/value.js"), + "export const nestedValue = 13;\n", + ) + .expect("write nested package module"); + std::fs::write(root.join("assets/help.zst"), b"HELP").expect("write asset"); +} + +#[test] +fn resolves_modules_and_reads_assets_after_extracted_root_moves() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path(); + let root = project.join("root"); + write_fixture(&root); + std::fs::write( + project.join("package.json"), + r#"{"name":"bunfs-root-regression","private":true}"#, + ) + .expect("write package.json"); + + let output = project.join("app"); + let compile = Command::new(perry_bin()) + .current_dir(project) + .arg("compile") + .arg("--bunfs-root") + .arg(&root) + .arg(root.join("entry.js")) + .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 compile_stdout = String::from_utf8_lossy(&compile.stdout); + let manifest_path = compile_stdout + .lines() + .find_map(|line| line.strip_prefix("Asset manifest: ")) + .expect("compile output names the asset manifest"); + let manifest = std::fs::read_to_string(manifest_path).expect("read asset manifest"); + assert!( + manifest.contains(r#""packaged_path": "/$bunfs/root/assets/help.zst""#), + "manifest did not preserve the Bun virtual path:\n{manifest}" + ); + assert!( + !manifest.contains("$perryfs//$bunfs/root/assets/help.zst"), + "manifest incorrectly rebased the Bun virtual path:\n{manifest}" + ); + + std::fs::rename(&root, project.join("root-moved-away")).expect("move extracted root"); + let run = Command::new(&output) + .current_dir(project) + .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) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "42|7|9|11|13|true|4|HELP|4|true\n" + ); +} + +#[test] +fn missing_virtual_module_names_mapping_in_diagnostic() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("root"); + std::fs::create_dir_all(&root).expect("mkdir root"); + let entry = root.join("entry.js"); + std::fs::write( + &entry, + "import { missing } from \"/$bunfs/root/missing.js\"; console.log(missing);\n", + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg("--bunfs-root") + .arg(&root) + .arg(&entry) + .arg("-o") + .arg(dir.path().join("app")) + .output() + .expect("run perry compile"); + assert!(!compile.status.success(), "missing mapped module compiled"); + let stderr = String::from_utf8_lossy(&compile.stderr); + assert!(stderr.contains("/$bunfs/root/missing.js"), "{stderr}"); + assert!( + stderr.contains(&root.join("missing.js").display().to_string()), + "{stderr}" + ); + assert!(stderr.contains("--bunfs-root"), "{stderr}"); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 2958671b48..be99c8e415 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -94,6 +94,7 @@ executable so it runs with no external files on disk (#5731). | Flag | Description | |------|-------------| | `--embed ` | Embed a file, directory, or `*`/`**` glob (relative to the project root). Repeatable. Merged with `perry.embed` (package.json) and `[compile] embed` (perry.toml). | +| `--bunfs-root ` | Map an extracted Bun standalone root to `/$bunfs/root/`; mapped module paths resolve natively and literal file paths remain readable from the relocated executable. | | `--asset-module ` | Generate a virtual module whose default export maps every file below `dir` to a stable `$perryfs` handle. Repeatable; source maps are excluded. | ```bash @@ -128,6 +129,21 @@ and bind the default import to its `$perryfs` path: import sound from "./sound.mp3" with { type: "file" }; ``` +For source extracted from a Bun standalone executable, mount its extracted +`root/` directory at Bun's original virtual prefix: + +```bash +perry compile --bunfs-root ./fixture/root ./fixture/root/entry.js -o app +``` + +Static imports, re-exports, literal dynamic imports, and literal `require()` +calls below `/$bunfs/root/` resolve against that directory. Perry canonicalizes +their real targets, so importing the same module through `./chunk.js` and +`/$bunfs/root/chunk.js` still initializes one module. Literal mapped file paths +are embedded under their original names and work through both `node:fs` and +`Bun.file()` after the extracted directory is removed. No host-level +`/$bunfs` directory or compatibility symlink is needed. + Some build pipelines inject a generated module rather than writing it into the source checkout. Reproduce that file-map step with `--asset-module`. Perry sorts the directory walk, preserves each `{ type: "file" }` edge, and keeps the From 66be1f529895965cce16bfdd61d80139a04e60e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 12:04:31 +0200 Subject: [PATCH 2/2] changelog: fragment for #9614 (#9598 BunFS root) --- changelog.d/9614-bunfs-root.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 changelog.d/9614-bunfs-root.md diff --git a/changelog.d/9614-bunfs-root.md b/changelog.d/9614-bunfs-root.md new file mode 100644 index 0000000000..f9c51eac52 --- /dev/null +++ b/changelog.d/9614-bunfs-root.md @@ -0,0 +1,13 @@ +### Bun compatibility + +- **`perry compile --bunfs-root ` now compiles source extracted from a + Bun standalone executable without a host `/$bunfs` mount.** Static imports, + re-exports, literal dynamic imports, and literal `require()` calls retain + their Bun virtual paths while resolving against the extracted directory. + Canonical real paths keep a module imported through both spellings to one + identity, including modules below an extracted `node_modules` directory. + + Literal mapped files are embedded under their original `/$bunfs/root/...` + names, so `node:fs` and `Bun.file()` reads keep working after the source tree + moves. Missing module mappings produce a focused diagnostic, and mapped + paths cannot traverse or follow symlinks outside the configured root.