Skip to content

Commit 57853cb

Browse files
authored
feat(bundle): support browser field map in package.json (#34407)
Closes #30024 ## Summary Implements the object form of the npm `browser` field for `deno bundle --platform browser`. The simple string form was already supported; the mapped form was silently dropped. ```json "browser": { "./bar.js": "./bar.browser.js", "crypto": false, "foo": "./shims/foo.js" } ``` - relative-path keys remap the resolved file (`./bar.js` → `./bar.browser.js`) - bare-name keys remap an import specifier (`foo` → `./shims/foo.js`), with `node:` prefix stripped before lookup so `import "node:crypto"` matches a `"crypto"` key - a value of `false` disables the module — the bundler substitutes an empty stub Esbuild's built-in browser-map handling is bypassed because Deno's `on_resolve` plugin claims every resolve with filter `.*`, so we have to apply the mapping ourselves. ## Where the logic lives - **`libs/package_json`** — parses the object form into `browser_map: Option<IndexMap<String, BrowserMapEntry>>` (`Replace(String) | Disabled`). The string form keeps populating the existing `browser` field. - **`libs/node_resolver`** — substitution happens here, gated on the existing `prefer_browser_field` flag (only set when bundling with `--platform browser`). Hooks in `resolve`, `resolve_package`, and `resolve_package_subpath_from_deno_module`. A new `BrowserMapDisabledError` propagates `false` entries up to the caller. - **`cli/tools/bundle/mod.rs`** — only catches `BrowserMapDisabledError` from the resolver, returns a `\0deno-browser-disabled:<orig>` sentinel path, and the `on_load` hook turns it into `module.exports = {}`. ## Test plan - [x] New spec `tests/specs/bundle/browser_platform_map` covers all four cases (relative remap, bare remap, bare disabled, transitive within-package import) using new npm fixture `@denotest/browser-field-map` - [x] All 49 existing `bundle::*` spec tests still pass - [x] `node_resolver` unit tests still pass - [x] `--platform deno` bundles are unchanged (gate is `prefer_browser_field`)
1 parent 3cdfe7e commit 57853cb

15 files changed

Lines changed: 437 additions & 8 deletions

File tree

cli/tools/bundle/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,10 @@ pub struct DeferredResolveError {
851851
error: ResolveWithGraphError,
852852
}
853853

854+
/// Path prefix used to mark modules disabled via a `browser` map (`"foo": false`).
855+
/// `\0` keeps it from colliding with any real filesystem path.
856+
const BROWSER_DISABLED_PREFIX: &str = "\0deno-browser-disabled:";
857+
854858
pub struct DenoPluginHandler {
855859
file_fetcher: Arc<CliFileFetcher>,
856860
resolver: Arc<CliResolver>,
@@ -1013,6 +1017,17 @@ impl esbuild_client::PluginHandler for DenoPluginHandler {
10131017
let result = match result {
10141018
Ok(r) => r,
10151019
Err(e) => {
1020+
// A `browser` map entry of `false` disables the module: return a
1021+
// sentinel path that `on_load` recognizes and turns into an empty
1022+
// module, rather than surfacing the resolver error.
1023+
if let Some(orig) = browser_map_disabled_specifier(&e) {
1024+
return Ok(Some(esbuild_client::OnResolveResult {
1025+
path: Some(format!("{BROWSER_DISABLED_PREFIX}{orig}")),
1026+
namespace: Some("deno".into()),
1027+
plugin_name: Some("deno".to_string()),
1028+
..Default::default()
1029+
}));
1030+
}
10161031
return Ok(Some(esbuild_client::OnResolveResult {
10171032
errors: Some(vec![esbuild_client::protocol::PartialMessage {
10181033
id: "deno_error".into(),
@@ -1068,6 +1083,13 @@ impl esbuild_client::PluginHandler for DenoPluginHandler {
10681083
args: esbuild_client::OnLoadArgs,
10691084
) -> Result<Option<esbuild_client::OnLoadResult>, AnyError> {
10701085
log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_load"));
1086+
if args.path.starts_with(BROWSER_DISABLED_PREFIX) {
1087+
return Ok(Some(esbuild_client::OnLoadResult {
1088+
contents: Some(b"module.exports = {};\n".to_vec()),
1089+
loader: Some(esbuild_client::BuiltinLoader::Js),
1090+
..Default::default()
1091+
}));
1092+
}
10711093
if let Some(virtual_modules) = &self.virtual_modules
10721094
&& let Some(module) = virtual_modules.get(&args.path)
10731095
{
@@ -1215,6 +1237,29 @@ impl BundleLoadError {
12151237
}
12161238
}
12171239

1240+
/// Walk a `BundleError` looking for a `BrowserMapDisabled` error returned
1241+
/// by `node_resolver`. Returns the original specifier so the bundle can
1242+
/// emit an empty-module stub in place of the resolution.
1243+
fn browser_map_disabled_specifier(error: &BundleError) -> Option<String> {
1244+
let BundleErrorKind::Resolver(resolve_err) = error.as_kind() else {
1245+
return None;
1246+
};
1247+
let deno_resolver::graph::ResolveWithGraphErrorKind::Resolve(e) =
1248+
resolve_err.as_kind()
1249+
else {
1250+
return None;
1251+
};
1252+
let deno_resolver::DenoResolveErrorKind::Node(node_err) = e.as_kind() else {
1253+
return None;
1254+
};
1255+
let node_resolver::errors::NodeResolveErrorKind::BrowserMapDisabled(disabled) =
1256+
node_err.as_kind()
1257+
else {
1258+
return None;
1259+
};
1260+
Some(disabled.specifier.clone())
1261+
}
1262+
12181263
fn maybe_ignorable_resolution_error(
12191264
error: &ResolveWithGraphError,
12201265
) -> Option<String> {

libs/node_resolver/errors.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ impl NodeJsErrorCoded for PackageSubpathFromDenoModuleResolveError {
227227
PackageSubpathFromDenoModuleResolveErrorKind::FinalizeResolution(e) => {
228228
e.code()
229229
}
230+
PackageSubpathFromDenoModuleResolveErrorKind::BrowserMapDisabled(_) => {
231+
NodeJsErrorCode::ERR_MODULE_NOT_FOUND
232+
}
230233
}
231234
}
232235
}
@@ -240,6 +243,9 @@ impl PackageSubpathFromDenoModuleResolveError {
240243
PackageSubpathFromDenoModuleResolveErrorKind::FinalizeResolution(e) => {
241244
e.as_types_not_found()
242245
}
246+
PackageSubpathFromDenoModuleResolveErrorKind::BrowserMapDisabled(_) => {
247+
None
248+
}
243249
}
244250
}
245251

@@ -251,6 +257,9 @@ impl PackageSubpathFromDenoModuleResolveError {
251257
PackageSubpathFromDenoModuleResolveErrorKind::FinalizeResolution(e) => {
252258
e.maybe_specifier()
253259
}
260+
PackageSubpathFromDenoModuleResolveErrorKind::BrowserMapDisabled(_) => {
261+
None
262+
}
254263
}
255264
}
256265

@@ -264,6 +273,9 @@ impl PackageSubpathFromDenoModuleResolveError {
264273
PackageSubpathFromDenoModuleResolveErrorKind::FinalizeResolution(e) => {
265274
NodeResolveErrorKind::FinalizeResolution(e)
266275
}
276+
PackageSubpathFromDenoModuleResolveErrorKind::BrowserMapDisabled(e) => {
277+
NodeResolveErrorKind::BrowserMapDisabled(e)
278+
}
267279
}
268280
.into_box()
269281
}
@@ -277,6 +289,9 @@ pub enum PackageSubpathFromDenoModuleResolveErrorKind {
277289
#[class(inherit)]
278290
#[error(transparent)]
279291
FinalizeResolution(#[from] FinalizeResolutionError),
292+
#[class(inherit)]
293+
#[error(transparent)]
294+
BrowserMapDisabled(#[from] BrowserMapDisabledError),
280295
}
281296

282297
#[derive(Debug, Boxed, JsError)]
@@ -691,6 +706,17 @@ pub struct DataUrlReferrerError {
691706
pub source: url::ParseError,
692707
}
693708

709+
/// A module was disabled by the importer's `package.json` `browser` map
710+
/// (`{"name": false}`). The bundler turns this into an empty stub; other
711+
/// callers treat it as a resolution failure.
712+
#[derive(Debug, Error, JsError)]
713+
#[class(generic)]
714+
#[error("Module `{specifier}` is disabled by the `browser` field in `{}`.", pkg_json_path.display())]
715+
pub struct BrowserMapDisabledError {
716+
pub specifier: String,
717+
pub pkg_json_path: PathBuf,
718+
}
719+
694720
#[derive(Debug, Boxed, JsError)]
695721
pub struct NodeResolveError(pub Box<NodeResolveErrorKind>);
696722

@@ -714,7 +740,8 @@ impl NodeResolveError {
714740
NodeResolveErrorKind::FinalizeResolution(err) => err.maybe_specifier(),
715741
NodeResolveErrorKind::UnsupportedEsmUrlScheme(_)
716742
| NodeResolveErrorKind::DataUrlReferrer(_)
717-
| NodeResolveErrorKind::RelativeJoin(_) => None,
743+
| NodeResolveErrorKind::RelativeJoin(_)
744+
| NodeResolveErrorKind::BrowserMapDisabled(_) => None,
718745
}
719746
}
720747
}
@@ -751,6 +778,9 @@ pub enum NodeResolveErrorKind {
751778
#[class(inherit)]
752779
#[error(transparent)]
753780
FinalizeResolution(#[from] FinalizeResolutionError),
781+
#[class(inherit)]
782+
#[error(transparent)]
783+
BrowserMapDisabled(#[from] BrowserMapDisabledError),
754784
}
755785

756786
impl NodeResolveErrorKind {
@@ -769,6 +799,7 @@ impl NodeResolveErrorKind {
769799
| NodeResolveErrorKind::RelativeJoin(_)
770800
| NodeResolveErrorKind::PathToUrl(_)
771801
| NodeResolveErrorKind::UnknownBuiltInNodeModule(_)
802+
| NodeResolveErrorKind::BrowserMapDisabled(_)
772803
| NodeResolveErrorKind::UrlToFilePath(_) => None,
773804
}
774805
}
@@ -785,6 +816,7 @@ impl NodeResolveErrorKind {
785816
NodeResolveErrorKind::TypesNotFound(e) => Some(e.code()),
786817
NodeResolveErrorKind::UnknownBuiltInNodeModule(e) => Some(e.code()),
787818
NodeResolveErrorKind::FinalizeResolution(e) => Some(e.code()),
819+
NodeResolveErrorKind::BrowserMapDisabled(_) => None,
788820
}
789821
}
790822
}

0 commit comments

Comments
 (0)