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.
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