Skip to content

fix(compile): handle CJS and native addons in --bundle#34529

Merged
bartlomieju merged 8 commits into
mainfrom
feat/compile-bundle-native-addons
Jun 1, 2026
Merged

fix(compile): handle CJS and native addons in --bundle#34529
bartlomieju merged 8 commits into
mainfrom
feat/compile-bundle-native-addons

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Stacks on top of #34527. Two things to make compile --bundle usable
with anything beyond pure-ESM trees.

First, the bundler's CJS-from-ESM wrapper hardcodes the absolute path
of each CJS module it imports. At runtime the VFS is mounted at a temp
dir under $TMPDIR, so those paths miss. Rewrite each literal in the
bundle output to a runtime-relative resolution against
import.meta.url so it survives the relocation.

Second, when those rewritten requires hit something at runtime —
either a CJS dep loaded through the wrapper or a .node native addon
— the npm tree has to ship in the binary. Detect both signals (the
rewrite fired; or find_native_addon_packages found a package with a
.node in the resolved snapshot) and embed the full snapshot in that
case. Pure-ESM bundles still skip everything npm and stay tiny.

Smoke-tested against chalk (pure ESM, ~17 KB user payload), lodash
(CJS, full npm), and @node-rs/crc32 (native addon, full npm; the
addon loads and runs). A spec test under
tests/specs/compile/bundle/native_addon/ exercises the addon path
against the existing @denotest/node-addon fixture, alongside PR 1's
basic test.

Bundles the entrypoint with esbuild before embedding it in the compiled
binary, instead of shipping the entire node_modules tree. Tree-shaken
output and a `createRequire(import.meta.url)` shim let CJS-style
`require()` keep working in the standalone runtime. Prints a warning
since this is the first cut and a number of cases (native .node addons,
worker entrypoints, dynamic require/import) aren't handled yet.
Stacks on top of #34527. Two things to make `compile --bundle` usable
with anything beyond pure-ESM trees.

First, the bundler's CJS-from-ESM wrapper hardcodes the absolute path of
each CJS module it imports. At runtime the VFS is mounted at a temp dir
under $TMPDIR, so those paths miss. Rewrite each literal in the bundle
output to a runtime-relative resolution against `import.meta.url` so it
survives relocation.

Second, when those rewritten requires hit something at runtime — either
a CJS dep loaded through the wrapper or a `.node` native addon — the
npm tree has to ship in the binary. Detect both signals (the rewrite
fired; or `find_native_addon_packages` found a package with a `.node`
in the resolved snapshot) and embed the full snapshot in that case.
Pure-ESM bundles still skip everything npm and stay tiny.

Tested with chalk (pure ESM, ~17 KB user payload), lodash (CJS, full
npm), and `@node-rs/crc32` (native addon, full npm — addon loads and
runs). Spec test under `tests/specs/compile/bundle/native_addon/`
exercises the addon path against the local `@denotest/node-addon`
fixture.
The bundle path-rewriter only matched POSIX absolute paths (a leading
`/`), so on Windows the drive-letter paths esbuild emits for the
CJS-from-ESM wrapper (e.g. "C:\\Users\\...\\index.js", with
JS-escaped backslashes) were never rewritten. They stayed as build-time
absolute paths, so the compiled binary's require() pointed at a location
that doesn't exist at runtime, failing with "NotFound: path not found"
in loadMaybeCjs. This broke compile/bundle/native_addon on Windows.

Broaden the regex to also match Windows drive-letter paths with either
separator style, collapse JS-escaped backslashes before touching the
filesystem, and factor the core out so it can be unit-tested with
synthetic paths on every platform.
The compile-only import.meta.main transform parsed each module with its
original media type. A CJS module imported from ESM reaches the transform
as the ESM facade the module loader generates (createRequire + export
default), but MediaType::Cjs makes deno_ast parse in script mode, which
rejects the facade's import/export statements with a parse error. deno run
and standalone deno bundle don't run this transform, so they accepted it.

Map Cjs to JavaScript before parsing so program mode auto-detects module
vs script, handling both the facade and genuine CJS scripts.

Closes the .cjs-in-static-imports blocker reported against the compile
bundle stack (affects jiti and anything depending on it).
Base automatically changed from feat/compile-bundle to main May 31, 2026 08:07
…ative-addons

# Conflicts:
#	cli/standalone/binary.rs
#	cli/tools/bundle/mod.rs
#	cli/tools/compile.rs
CJS npm packages with a default export are now inlined into the bundle
rather than externalized as a stripped runtime require(). The failure
mode shifted from a NotCapable read-access error to a TypeError on the
non-callable default import. Update run.out and the explanatory comment
to lock in the new (still exit-1) behavior.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review. Solid PR overall — the rewrite-to-relative + embed-npm-only-when-needed approach is sound and well-commented, and the experimental warning sets expectations. Gates pass (title format, focused scope, internal contributor). Confirm CI is green before merge — no checks are showing yet.

Findings inline, ordered by significance. None are blocking for an experimental feature; the regex-breadth (compile.rs) and the comment/impl mismatch (binary.rs) are the two I'd most want addressed before this stops being experimental.

Test coverage is good: unit tests for the rewrite (native path shape + skips-missing) and a real round-trip via cjs_import_from_esm. Two gaps to consider — no unit test asserting the rewrite is skipped for relative __commonJS-style keys (guards the regex-breadth concern), and the BYONM path is stubbed with a TODO and no test documenting that native addons under BYONM + bundle currently won't embed. The npm_cjs_unsupported .out flip from NotCapable to TypeError is legitimate — it reflects the real behavior change from the main merge and locks in a known failure mode rather than masking a regression.

Comment thread cli/tools/compile.rs
Comment thread cli/tools/compile.rs
Comment thread cli/standalone/binary.rs Outdated
Comment thread cli/standalone/binary.rs
Document the regex rewrite assumption (absolute literals only appear at
external require call sites) and the VFS cwd-relative layout invariant,
reword the native-addon prose to clarify only .node imports stay
external, and flag the snapshot re-walk in fill_bundle_native_addon_vfs.
find_native_addon_packages returned empty for BYONM, so a pure-ESM bundle
that imports a native addon under BYONM embedded nothing and the .node
failed to resolve at runtime. It only worked by accident when a CJS dep
also forced the wholesale node_modules embed. Now walk the workspace
node_modules trees (the same set fill_npm_vfs embeds wholesale) and report
folders that ship a .node so the embed fires. Hoisted vs nested layout is
irrelevant because the BYONM embed is wholesale, so a plain existence scan
is correct.

Also add the skip-relative-key rewrite unit test flagged in self-review,
asserting esbuild's relative __commonJS keys are left untouched.
@bartlomieju

Copy link
Copy Markdown
Member Author

Followed up on the two test/coverage gaps from the self-review (commit cf3a9a6).

1. Skip-relative-key rewrite test. Added test_rewrite_absolute_bundle_paths_skips_relative_key, which feeds an inlined __commonJS-style object key ({ "pkg/index.js"(exports, module) {...} }) through the rewriter with path_exists forced to true and asserts it is left untouched. That locks in the regex-breadth assumption documented above: the leading-separator anchor keeps esbuild's relative keys out of the match even when the path resolves on disk.

2. BYONM native addons. Implemented the detection rather than leaving it stubbed, and replaced the TODO. The key realization is that the TODO's stated blocker (hoisted-vs-nested monorepo layout) only matters for precise per-package embedding, which BYONM does not do: fill_npm_vfs embeds the workspace node_modules trees wholesale. So the embed decision only needs a boolean, "does any tree contain a .node," and layout is irrelevant. find_native_addon_packages now walks the same trees fill_npm_vfs embeds and reports the folders that ship an addon (following nested node_modules so transitive addons count). NativeAddonPackage.id became Option<NpmPackageId> since BYONM has no resolved snapshot id.

Without this, a pure-ESM bundle that imports a native addon under BYONM embedded nothing and the addon failed to resolve at runtime; it only worked by accident when a CJS dep also forced the wholesale embed.

Covered by two unit tests (byonm_detects_addon_in_workspace_node_modules, byonm_detects_transitive_addon) using a temp workspace. Full local lib test run is green; clippy clean.

@bartlomieju bartlomieju changed the title feat(compile): handle CJS and native addons in --bundle fix(compile): handle CJS and native addons in --bundle Jun 1, 2026
@bartlomieju
bartlomieju merged commit 152d722 into main Jun 1, 2026
137 checks passed
@bartlomieju
bartlomieju deleted the feat/compile-bundle-native-addons branch June 1, 2026 11:10
bartlomieju added a commit that referenced this pull request Jun 1, 2026
Resolves conflicts from the squashed landing of the prior stacked PRs
(#34527 --bundle flag, #34529 CJS/native addons). Keeps the worker
bundling logic (BundleForCompileResult, discover_worker_urls,
rewrite_worker_urls, per-worker bundling) while adopting main's
refactors: the testable rewrite_absolute_bundle_paths_inner with
Windows-path support, the up-front CleanupGuard, and the BYONM
native-addon scan (workspace_root parameter).
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
Two things to make `compile --bundle` usable
with anything beyond pure-ESM trees.

First, the bundler's CJS-from-ESM wrapper hardcodes the absolute path
of each CJS module it imports. At runtime the VFS is mounted at a temp
dir under `$TMPDIR`, so those paths miss. Rewrite each literal in the
bundle output to a runtime-relative resolution against
`import.meta.url` so it survives the relocation.

Second, when those rewritten requires hit something at runtime —
either a CJS dep loaded through the wrapper or a `.node` native addon
— the npm tree has to ship in the binary. Detect both signals (the
rewrite fired; or `find_native_addon_packages` found a package with a
`.node` in the resolved snapshot) and embed the full snapshot in that
case. Pure-ESM bundles still skip everything npm and stay tiny.

Smoke-tested against chalk (pure ESM, ~17 KB user payload), lodash
(CJS, full npm), and `@node-rs/crc32` (native addon, full npm; the
addon loads and runs). A spec test under
`tests/specs/compile/bundle/native_addon/` exercises the addon path
against the existing `@denotest/node-addon` fixture, alongside PR 1's
basic test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant