Skip to content
Open
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
49 changes: 22 additions & 27 deletions cli/standalone/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ use deno_resolver::file_fetcher::FetchPermissionsOptionRef;
use deno_resolver::loader::GraphCjsAnalysisSourceProvider;
use deno_resolver::workspace::WorkspaceResolver;
use deno_semver::npm::NpmPackageReqReference;
use deno_semver::package::PackageReq;
use indexmap::IndexMap;
use node_resolver::analyze::CjsAnalysisSourceProvider;
use node_resolver::analyze::ResolvedCjsAnalysis;
Expand Down Expand Up @@ -664,17 +665,19 @@ impl<'a> DenoCompileBinaryWriter<'a> {
vfs.add_path(&pkg_json.path)?;
}
let progress_bar = ProgressBar::new(ProgressBarStyle::ProgressBars);
// With --bundle the JS graph is self-contained, so the whole npm tree
// is intentionally left out of the binary. The exception is packages
// that ship native (.node) addons: the package JS is still bundled, but
// its `.node` file imports stay external (`external = ["*.node"]` in
// compile.rs) so the addon loader resolves them against the embedded VFS
// at runtime. For that to work the package's installed folder, plus the
// closure of its dependencies, must be embedded in the VFS.
let npm_snapshot = if compile_flags.bundle {
let external_npm_reqs = if compile_flags.external.is_empty() {
Vec::new()
} else {
graph
.modules()
.filter_map(|module| module.npm())
.map(|module| module.pkg_req_ref.req())
.collect()
};
self
.fill_bundle_native_addon_vfs(&mut vfs, &progress_bar)
.context("Embedding native addon packages.")?
.fill_bundle_npm_vfs(&mut vfs, &progress_bar, &external_npm_reqs)
.context("Embedding npm packages for the bundle.")?
} else {
match &self.npm_resolver {
CliNpmResolver::Managed(managed) => {
Expand Down Expand Up @@ -1295,23 +1298,13 @@ impl<'a> DenoCompileBinaryWriter<'a> {
.await
}

/// Decide what to embed for `deno compile --bundle`. The bundle is
/// always shipped; this controls the npm portion. We need it when
/// either the CJS-from-ESM wrapper pointed at on-disk paths during
/// rewriting, or the resolved tree has a native (`.node`) addon — in
/// both cases the compiled binary will do node-module resolution at
/// runtime. Pure-ESM bundles with no native addons skip this and ship
/// nothing npm-related.
///
/// When embedding is needed, we ship only the packages actually
/// reached: the rewriter recorded every absolute path it pointed at,
/// and we map each path back to its owning npm package and walk that
/// closure. The full resolution snapshot still goes in the metadata
/// so denort can resolve packages by name at runtime.
fn fill_bundle_native_addon_vfs(
/// Embed packages needed by native addons, rewritten CJS paths, and
/// npm imports that remain in the graph after bundling.
fn fill_bundle_npm_vfs(
&self,
builder: &mut VfsBuilder,
progress_bar: &ProgressBar,
external_npm_reqs: &[&PackageReq],
) -> Result<Option<ValidSerializedNpmResolutionSnapshot>, AnyError> {
let needs_for_cjs_wrapper =
self.cli_options.compile_bundle_embed_node_modules();
Expand All @@ -1324,14 +1317,15 @@ impl<'a> DenoCompileBinaryWriter<'a> {
.root_dir_url()
.to_file_path()
.ok();
let needs_for_native_addons = !needs_for_cjs_wrapper
&& !super::native_addons::find_native_addon_packages(
if !needs_for_cjs_wrapper
&& external_npm_reqs.is_empty()
&& super::native_addons::find_native_addon_packages(
self.npm_resolver,
&self.npm_system_info,
workspace_root.as_deref(),
)?
.is_empty();
if !needs_for_cjs_wrapper && !needs_for_native_addons {
.is_empty()
{
return Ok(None);
}

Expand All @@ -1352,6 +1346,7 @@ impl<'a> DenoCompileBinaryWriter<'a> {
self.npm_resolver,
&self.npm_system_info,
referenced_paths,
external_npm_reqs,
)?
else {
unreachable!(
Expand Down
9 changes: 8 additions & 1 deletion cli/standalone/native_addons.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2018-2026 the Deno authors. MIT license.

//! Discovery of npm packages that need to ship in the binary built by
//! `deno compile --bundle`. Two complementary signals:
//! `deno compile --bundle`.
//!
//! 1. Packages with `.node` native addons can't be tree-shaken into the
//! bundle, so we mark them for inclusion eagerly. For managed npm we
Expand All @@ -10,6 +10,7 @@
//! 2. Absolute paths the bundle path-rewriter pointed at — captured at
//! bundle time — are mapped back to their owning npm package so the
//! binary writer ships only what's actually reached at runtime.
//! 3. npm imports remaining in the bundled module graph.

use std::collections::HashMap;
use std::collections::HashSet;
Expand All @@ -21,6 +22,7 @@ use std::path::PathBuf;
use deno_core::error::AnyError;
use deno_npm::NpmPackageId;
use deno_npm::NpmSystemInfo;
use deno_semver::package::PackageReq;

use crate::npm::CliNpmResolver;

Expand Down Expand Up @@ -114,6 +116,7 @@ pub fn collect_bundle_required_packages(
npm_resolver: &CliNpmResolver,
npm_system_info: &NpmSystemInfo,
referenced_paths: &[PathBuf],
external_npm_reqs: &[&PackageReq],
) -> Result<Option<HashSet<NpmPackageId>>, AnyError> {
let CliNpmResolver::Managed(managed) = npm_resolver else {
return Ok(None);
Expand Down Expand Up @@ -183,6 +186,10 @@ pub fn collect_bundle_required_packages(
path_owner_cache.insert(path, owner);
}

for req in external_npm_reqs {
seeds.insert(managed.resolution().resolve_pkg_id_from_pkg_req(req)?);
}

// BFS through dependency edges from each seed so runtime requires that
// happen inside an already-embedded package can resolve.
let mut closure: HashSet<NpmPackageId> = HashSet::new();
Expand Down
23 changes: 16 additions & 7 deletions cli/tools/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ async fn run_bundle_for_compile(
let main_bytes = bundle_one_for_compile(
bundle_flags.clone(),
compile_flags.source_file.clone(),
compile_flags.minify,
compile_flags,
)
.await?;
let main_rewrite = rewrite_absolute_bundle_paths(&main_bytes, &initial_cwd)?;
Expand All @@ -318,7 +318,7 @@ async fn run_bundle_for_compile(
let worker_bytes = bundle_one_for_compile(
bundle_flags.clone(),
worker_abs.display().to_string(),
compile_flags.minify,
compile_flags,
)
.await?;
let worker_rewrite =
Expand Down Expand Up @@ -372,16 +372,22 @@ async fn run_bundle_for_compile(
async fn bundle_one_for_compile(
flags: Arc<Flags>,
entrypoint: String,
minify: bool,
compile_flags: &CompileFlags,
) -> Result<Vec<u8>, AnyError> {
// Always leave `.node` files external. esbuild has no loader for them
// and would error if it tried to inline a native binary; with this
// pattern the require() calls are emitted verbatim and resolved at
// runtime against the embedded VFS by the native addon loader.
let external = vec!["*.node".to_string()];
super::bundle::bundle_for_compile(flags, entrypoint, external, minify)
.boxed_local()
.await
let mut external = compile_flags.external.clone();
external.push("*.node".to_string());
super::bundle::bundle_for_compile(
flags,
entrypoint,
external,
compile_flags.minify,
)
.boxed_local()
.await
}

/// Find every `new URL("X.{ts,js,…}", import.meta.url)` in the bundle whose
Expand Down Expand Up @@ -1410,6 +1416,7 @@ mod test {
eszip: false,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down Expand Up @@ -1441,6 +1448,7 @@ mod test {
eszip: true,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down Expand Up @@ -1479,6 +1487,7 @@ mod test {
eszip: true,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down
1 change: 1 addition & 0 deletions cli/tools/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ async fn compile_desktop(
eszip: false,
self_extracting,
bundle: false,
external: Vec::new(),
minify: false,
exclude_unused_npm: desktop_flags.exclude_unused_npm,
engine: desktop_flags.engine,
Expand Down
1 change: 1 addition & 0 deletions cli/tools/installer/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ async fn install_global_compiled(
eszip: false,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down
4 changes: 4 additions & 0 deletions libs/cli_parser/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2127,6 +2127,10 @@ fn compile_parse(
eszip,
self_extracting,
bundle: result.get_bool("bundle"),
external: result
.get_many("external")
.map(|values| values.iter().map(|value| value.to_string()).collect())
.unwrap_or_default(),
app_name: result.get_one("app-name").map(|s| s.to_string()),
minify: result.get_bool("minify"),
exclude_unused_npm: result.get_bool("exclude-unused-npm"),
Expand Down
6 changes: 6 additions & 0 deletions libs/cli_parser/src/defs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,12 @@ pub static COMPILE_SUBCOMMAND: CommandDef = CommandDef {
.set_true()
.requires(&["bundle"])
.help("Experimental. Minify the bundled output. Only meaningful with --bundle.\n Reduces both the embedded bundle size and runtime memory use, at the cost of less readable stack traces."),
ArgDef::new("external")
.long("external")
.action(ArgAction::Append)
.num_args(NumArgs::Exact(1))
.requires(&["bundle"])
.help("Experimental. Leave a matching import specifier unresolved in the bundle. Can be specified more than once."),
ArgDef::new("app-name")
.long("app-name")
.action(ArgAction::Set)
Expand Down
2 changes: 2 additions & 0 deletions libs/cli_parser/src/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ pub struct CompileFlags {
/// Bundle the entrypoint with esbuild before embedding it, instead of
/// shipping the entire node_modules tree. Experimental.
pub bundle: bool,
/// Specifiers to leave unresolved in the bundle.
pub external: Vec<String>,
/// Stable identity for the compiled app. Determines where origin-bound
/// storage (default `Deno.openKv()`, `localStorage`, `caches`) is persisted.
/// Defaults to the output file name when not set.
Expand Down
26 changes: 26 additions & 0 deletions libs/cli_parser/src/tests_full.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5065,6 +5065,7 @@ fn compile() {
eszip: false,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down Expand Up @@ -5173,6 +5174,28 @@ fn desktop_exclude_unused_npm() {
assert!(desktop.exclude_unused_npm);
}

#[test]
fn compile_bundle_external() {
let r = flags_from_vec(svec![
"deno",
"compile",
"--bundle",
"--external",
"chromium-bidi/*",
"--external=node:sea",
"main.ts"
]);
let DenoSubcommand::Compile(compile) = r.unwrap().subcommand else {
panic!("expected compile subcommand");
};
assert_eq!(compile.external, svec!["chromium-bidi/*", "node:sea"]);
assert_eq!(compile.source_file, "main.ts");
assert!(
flags_from_vec(svec!["deno", "compile", "--external=node:sea", "main.ts"])
.is_err()
);
}

#[test]
fn compile_watch_with_no_clear_screen() {
let r = flags_from_vec(svec![
Expand All @@ -5197,6 +5220,7 @@ fn compile_watch_with_no_clear_screen() {
eszip: false,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down Expand Up @@ -5234,6 +5258,7 @@ fn compile_with_flags() {
eszip: false,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down Expand Up @@ -8224,6 +8249,7 @@ fn preload_flag_test() {
eszip: false,
self_extracting: false,
bundle: false,
external: Vec::new(),
app_name: None,
minify: false,
exclude_unused_npm: false,
Expand Down
24 changes: 24 additions & 0 deletions tests/specs/compile/bundle/external/__test__.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"tempDir": true,
"steps": [{
"if": "unix",
"args": "compile --bundle --external=node:sea --no-check --output main main.ts",
"output": "compile.out"
}, {
"if": "unix",
"commandName": "./main",
"args": [],
"output": "run.out",
"exitCode": 0
}, {
"if": "windows",
"args": "compile --bundle --external=node:sea --no-check --output main.exe main.ts",
"output": "compile.out"
}, {
"if": "windows",
"commandName": "./main.exe",
"args": [],
"output": "run.out",
"exitCode": 0
}]
}
1 change: 1 addition & 0 deletions tests/specs/compile/bundle/external/compile.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[WILDCARD]deno compile --bundle is experimental[WILDCARD]
2 changes: 2 additions & 0 deletions tests/specs/compile/bundle/external/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const sea = await import("node:sea").catch(() => null);
console.log(sea === null ? "not a SEA" : "SEA");
1 change: 1 addition & 0 deletions tests/specs/compile/bundle/external/run.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
not a SEA
20 changes: 20 additions & 0 deletions tests/specs/compile/bundle/npm_cjs_default_export/__test__.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,25 @@
"args": [],
"output": "run.out",
"exitCode": 0
}, {
"if": "unix",
"args": "compile --bundle --external=npm:@denotest/cjs-default-export --no-check --output external main.ts",
"output": "[WILDCARD]"
}, {
"if": "unix",
"commandName": "./external",
"args": [],
"output": "run.out",
"exitCode": 0
}, {
"if": "windows",
"args": "compile --bundle --external=npm:@denotest/cjs-default-export --no-check --output external.exe main.ts",
"output": "[WILDCARD]"
}, {
"if": "windows",
"commandName": "./external.exe",
"args": [],
"output": "run.out",
"exitCode": 0
}]
}