Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions changelog.d/9614-bunfs-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
### Bun compatibility

- **`perry compile --bunfs-root <DIR>` 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.
32 changes: 25 additions & 7 deletions crates/perry-runtime/src/bun_compat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,13 @@ fn mime_type_for_path(path: &str) -> &'static str {
}

fn read_file_or_reject(path: &str) -> Result<Vec<u8>, 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) })
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 12 additions & 5 deletions crates/perry-runtime/src/embedded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion crates/perry/src/commands/compile/asset_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)?,
);
Expand Down
4 changes: 4 additions & 0 deletions crates/perry/src/commands/compile/build_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,10 @@ impl BuildCacheProbe {
runtime_inputs: &[PathBuf],
) -> Result<BuildCacheManifest, String> {
let mut source_paths = ctx.native_modules.keys().cloned().collect::<BTreeSet<_>>();
// 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));
}
Expand Down
40 changes: 36 additions & 4 deletions crates/perry/src/commands/compile/collect_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,56 @@
//! 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};

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 <DIR>` 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.<NAME>` reads (`perry_hir::env_define_lookup`). Strips the
/// `process.env.` prefix the `perry.define` keys carry and converts each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
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<String>,
module_dir: &Path,
bunfs_root: Option<&Path>,
) -> String {
let create_require_aliases = collect_create_require_aliases(source);
let mut require_aliases =
Expand Down Expand Up @@ -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!(
Expand All @@ -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()) {
Expand Down Expand Up @@ -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<std::path::PathBuf> {
fn resolve_static_require(
module_dir: &Path,
specifier: &str,
bunfs_root: Option<&Path>,
) -> Option<std::path::PathBuf> {
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)
Expand Down
Loading
Loading