From 15cf9307bdb8e16c1c685fda5956a4a03cbe6381 Mon Sep 17 00:00:00 2001 From: jipeng6036-del Date: Fri, 6 Nov 2026 14:58:57 -0800 Subject: [PATCH] feat(cli): support --external with deno compile --bundle --- cli/standalone/binary.rs | 49 +++++++++---------- cli/standalone/native_addons.rs | 9 +++- cli/tools/compile.rs | 23 ++++++--- cli/tools/desktop.rs | 1 + cli/tools/installer/global.rs | 1 + libs/cli_parser/src/convert.rs | 4 ++ libs/cli_parser/src/defs.rs | 6 +++ libs/cli_parser/src/flags.rs | 2 + libs/cli_parser/src/tests_full.rs | 26 ++++++++++ .../compile/bundle/external/__test__.jsonc | 24 +++++++++ .../specs/compile/bundle/external/compile.out | 1 + tests/specs/compile/bundle/external/main.ts | 2 + tests/specs/compile/bundle/external/run.out | 1 + .../npm_cjs_default_export/__test__.jsonc | 20 ++++++++ 14 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 tests/specs/compile/bundle/external/__test__.jsonc create mode 100644 tests/specs/compile/bundle/external/compile.out create mode 100644 tests/specs/compile/bundle/external/main.ts create mode 100644 tests/specs/compile/bundle/external/run.out diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index 3e7a79de535bee..13ea2606770b74 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -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; @@ -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) => { @@ -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, AnyError> { let needs_for_cjs_wrapper = self.cli_options.compile_bundle_embed_node_modules(); @@ -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); } @@ -1352,6 +1346,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { self.npm_resolver, &self.npm_system_info, referenced_paths, + external_npm_reqs, )? else { unreachable!( diff --git a/cli/standalone/native_addons.rs b/cli/standalone/native_addons.rs index 8403cd4ca845da..71f8a9b7a66c25 100644 --- a/cli/standalone/native_addons.rs +++ b/cli/standalone/native_addons.rs @@ -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 @@ -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; @@ -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; @@ -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>, AnyError> { let CliNpmResolver::Managed(managed) = npm_resolver else { return Ok(None); @@ -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 = HashSet::new(); diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index 1fc9c67c1225a2..1292ad38890540 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -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)?; @@ -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 = @@ -372,16 +372,22 @@ async fn run_bundle_for_compile( async fn bundle_one_for_compile( flags: Arc, entrypoint: String, - minify: bool, + compile_flags: &CompileFlags, ) -> Result, 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 @@ -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, @@ -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, @@ -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, diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 1ccf7816bd3174..b40b178e10e93c 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -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, diff --git a/cli/tools/installer/global.rs b/cli/tools/installer/global.rs index 01d3408cc72914..6c234f869bea7c 100644 --- a/cli/tools/installer/global.rs +++ b/cli/tools/installer/global.rs @@ -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, diff --git a/libs/cli_parser/src/convert.rs b/libs/cli_parser/src/convert.rs index 8ab0551be5350a..a8e1e1e952339f 100644 --- a/libs/cli_parser/src/convert.rs +++ b/libs/cli_parser/src/convert.rs @@ -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"), diff --git a/libs/cli_parser/src/defs.rs b/libs/cli_parser/src/defs.rs index 7047926e437948..1bad4aaebd16ae 100644 --- a/libs/cli_parser/src/defs.rs +++ b/libs/cli_parser/src/defs.rs @@ -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) diff --git a/libs/cli_parser/src/flags.rs b/libs/cli_parser/src/flags.rs index aa1354c434e88b..9dea999d9faf72 100644 --- a/libs/cli_parser/src/flags.rs +++ b/libs/cli_parser/src/flags.rs @@ -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, /// 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. diff --git a/libs/cli_parser/src/tests_full.rs b/libs/cli_parser/src/tests_full.rs index ea0577d21ae76e..37abd5e66869fc 100644 --- a/libs/cli_parser/src/tests_full.rs +++ b/libs/cli_parser/src/tests_full.rs @@ -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, @@ -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![ @@ -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, @@ -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, @@ -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, diff --git a/tests/specs/compile/bundle/external/__test__.jsonc b/tests/specs/compile/bundle/external/__test__.jsonc new file mode 100644 index 00000000000000..1f202860ec2a5c --- /dev/null +++ b/tests/specs/compile/bundle/external/__test__.jsonc @@ -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 + }] +} diff --git a/tests/specs/compile/bundle/external/compile.out b/tests/specs/compile/bundle/external/compile.out new file mode 100644 index 00000000000000..0fd791b6075f03 --- /dev/null +++ b/tests/specs/compile/bundle/external/compile.out @@ -0,0 +1 @@ +[WILDCARD]deno compile --bundle is experimental[WILDCARD] diff --git a/tests/specs/compile/bundle/external/main.ts b/tests/specs/compile/bundle/external/main.ts new file mode 100644 index 00000000000000..4632c6dd1f93ff --- /dev/null +++ b/tests/specs/compile/bundle/external/main.ts @@ -0,0 +1,2 @@ +const sea = await import("node:sea").catch(() => null); +console.log(sea === null ? "not a SEA" : "SEA"); diff --git a/tests/specs/compile/bundle/external/run.out b/tests/specs/compile/bundle/external/run.out new file mode 100644 index 00000000000000..46824be1f3ad23 --- /dev/null +++ b/tests/specs/compile/bundle/external/run.out @@ -0,0 +1 @@ +not a SEA diff --git a/tests/specs/compile/bundle/npm_cjs_default_export/__test__.jsonc b/tests/specs/compile/bundle/npm_cjs_default_export/__test__.jsonc index 06dcce79c32be3..5e185e21918b11 100644 --- a/tests/specs/compile/bundle/npm_cjs_default_export/__test__.jsonc +++ b/tests/specs/compile/bundle/npm_cjs_default_export/__test__.jsonc @@ -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 }] }