use std::{
borrow::Cow,
env,
future::Future,
path::{Path, PathBuf},
pin::Pin,
sync::Arc,
};
use anyhow::anyhow;
use arcstr::ArcStr;
use derive_more::Debug;
use owo_colors::OwoColorize;
use rustc_hash::FxHashSet;
use sugar_path::SugarPath;
use rolldown_common::{ImportKind, WatcherChangeKind, side_effects::HookSideEffects};
use rolldown_plugin::{
HookLoadArgs, HookLoadOutput, HookLoadReturn, HookResolveIdArgs, HookResolveIdOutput,
HookResolveIdReturn, HookUsage, Plugin, PluginContext, typedmap::TypedMapKey,
};
use rolldown_std_utils::PathExt as _;
use rolldown_utils::{dataurl::is_data_url, pattern_filter::StringOrRegex};
use crate::{
ResolveOptionsExternal,
builtin::{BuiltinChecker, is_node_like_builtin},
external::{self, ExternalDecider, ExternalDeciderOptions},
file_url::file_url_str_to_path_and_postfix,
resolver::{self, AdditionalOptions, Resolvers},
utils::{
BROWSER_EXTERNAL_ID, OPTIONAL_PEER_DEP_ID, is_bare_import, is_windows_drive_path,
normalize_leading_slashes, normalize_path,
},
};
const FS_PREFIX: &str = "/@fs/";
#[derive(Debug)]
pub struct ViteResolveOptions {
pub resolve_options: ViteResolveResolveOptions,
pub environment_consumer: String,
pub environment_name: String,
pub builtins: Vec<StringOrRegex>,
pub external: external::ResolveOptionsExternal,
pub no_external: external::ResolveOptionsNoExternal,
pub dedupe: Vec<String>,
pub disable_cache: bool,
pub legacy_inconsistent_cjs_interop: bool,
#[debug(skip)]
pub finalize_bare_specifier: Option<Arc<FinalizeBareSpecifierCallback>>,
#[debug(skip)]
pub finalize_other_specifiers: Option<Arc<FinalizeOtherSpecifiersCallback>>,
#[debug(skip)]
pub resolve_subpath_imports: Arc<ResolveSubpathImportsCallback>,
#[debug(skip)]
pub on_warn: Option<Arc<OnLogCallback>>,
#[debug(skip)]
pub on_debug: Option<Arc<OnLogCallback>>,
pub yarn_pnp: bool,
}
pub type FinalizeBareSpecifierCallback = dyn (Fn(
&str,
&str,
Option<&str>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<String>>> + Send + Sync>>)
+ Send
+ Sync;
pub type FinalizeOtherSpecifiersCallback = dyn (Fn(&str, &str) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<String>>> + Send + Sync>>)
+ Send
+ Sync;
pub type ResolveSubpathImportsCallback = dyn (Fn(
&str,
Option<&str>,
bool,
bool,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<String>>> + Send + Sync>>)
+ Send
+ Sync;
pub type OnLogCallback =
dyn (Fn(String) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + Sync>>) + Send + Sync;
#[derive(Debug)]
pub struct ViteResolveResolveOptions {
pub is_build: bool,
pub is_production: bool,
pub as_src: bool,
pub prefer_relative: bool,
pub is_require: Option<bool>,
pub root: String,
pub scan: bool,
pub main_fields: Vec<String>,
pub conditions: Vec<String>,
pub external_conditions: Vec<String>,
pub extensions: Vec<String>,
pub try_index: bool,
pub try_prefix: Option<String>,
pub preserve_symlinks: bool,
pub tsconfig_paths: bool,
}
#[derive(Hash, PartialEq, Eq)]
pub struct ResolveIdOptionsScan;
impl TypedMapKey for ResolveIdOptionsScan {
type Value = bool;
}
#[derive(Debug)]
pub struct ViteResolvePlugin {
resolve_options: ViteResolveResolveOptions,
external: external::ResolveOptionsExternal,
no_external: Arc<external::ResolveOptionsNoExternal>,
dedupe: Arc<FxHashSet<String>>,
disable_cache: bool,
legacy_inconsistent_cjs_interop: bool,
environment_consumer: String,
environment_name: String,
#[debug(skip)]
finalize_bare_specifier: Option<Arc<FinalizeBareSpecifierCallback>>,
#[debug(skip)]
finalize_other_specifiers: Option<Arc<FinalizeOtherSpecifiersCallback>>,
#[debug(skip)]
resolve_subpath_imports: Arc<ResolveSubpathImportsCallback>,
#[debug(skip)]
on_warn: Option<Arc<OnLogCallback>>,
#[debug(skip)]
on_debug: Option<Arc<OnLogCallback>>,
resolvers: Resolvers,
external_decider: ExternalDecider,
builtin_checker: Arc<BuiltinChecker>,
}
impl ViteResolvePlugin {
pub fn new(options: ViteResolveOptions) -> Self {
let base_options = resolver::BaseOptions {
main_fields: &options.resolve_options.main_fields,
conditions: &options.resolve_options.conditions,
extensions: &options.resolve_options.extensions,
is_production: options.resolve_options.is_production,
try_index: options.resolve_options.try_index,
try_prefix: &options.resolve_options.try_prefix,
as_src: options.resolve_options.as_src,
root: PathBuf::from(&options.resolve_options.root),
preserve_symlinks: options.resolve_options.preserve_symlinks,
tsconfig_paths: options.resolve_options.tsconfig_paths,
yarn_pnp: options.yarn_pnp,
};
let builtin_checker = Arc::new(BuiltinChecker::new(options.builtins));
let resolvers = Resolvers::new(
&base_options,
&options.resolve_options.external_conditions,
Arc::clone(&builtin_checker),
);
let no_external = Arc::new(options.no_external);
let dedupe = Arc::new(options.dedupe.into_iter().collect());
Self {
external: options.external.clone(),
no_external: Arc::clone(&no_external),
dedupe: Arc::clone(&dedupe),
disable_cache: options.disable_cache,
legacy_inconsistent_cjs_interop: options.legacy_inconsistent_cjs_interop,
environment_consumer: options.environment_consumer,
environment_name: options.environment_name,
finalize_bare_specifier: options.finalize_bare_specifier,
finalize_other_specifiers: options.finalize_other_specifiers,
resolve_subpath_imports: options.resolve_subpath_imports,
on_warn: options.on_warn,
on_debug: options.on_debug,
external_decider: ExternalDecider::new(
ExternalDeciderOptions {
external: options.external,
no_external: Arc::clone(&no_external),
dedupe,
legacy_inconsistent_cjs_interop: options.legacy_inconsistent_cjs_interop,
is_build: options.resolve_options.is_build,
},
resolvers.get_for_external(),
Arc::clone(&builtin_checker),
),
builtin_checker,
resolvers,
resolve_options: options.resolve_options,
}
}
async fn warn(&self, ctx: &PluginContext, message: String) -> anyhow::Result<()> {
if let Some(on_warn) = &self.on_warn {
on_warn(message).await
} else {
ctx.warn(rolldown_common::LogWithoutPlugin { message, ..Default::default() });
Ok(())
}
}
#[inline]
async fn debug_log<T: FnOnce() -> String>(&self, message: T) -> anyhow::Result<()> {
if let Some(on_debug) = &self.on_debug { on_debug(message()).await } else { Ok(()) }
}
}
impl Plugin for ViteResolvePlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("rolldown:vite-resolve")
}
async fn resolve_id(
&self,
ctx: &PluginContext,
args: &HookResolveIdArgs<'_>,
) -> HookResolveIdReturn {
let scan =
args.custom.get(&ResolveIdOptionsScan).is_some_and(|v| *v) || self.resolve_options.scan;
if args.specifier.starts_with('\0')
|| args.specifier.starts_with("virtual:")
|| args.specifier.starts_with("/virtual:")
{
return Ok(None);
}
if args.specifier.starts_with(BROWSER_EXTERNAL_ID) {
return Ok(Some(HookResolveIdOutput { id: args.specifier.into(), ..Default::default() }));
}
if self.disable_cache {
self.resolvers.clear_cache();
}
let mut id = {
#[cfg(windows)]
{
args
.specifier
.strip_prefix('/')
.filter(|p| is_windows_drive_path(p))
.map_or(Cow::Borrowed(args.specifier), Cow::Borrowed)
}
#[cfg(not(windows))]
{
Cow::Borrowed(args.specifier)
}
};
if args.specifier.starts_with('#')
&& let Some(resolved_imports) =
(self.resolve_subpath_imports)(&id, args.importer, args.kind == ImportKind::Require, scan)
.await?
{
id = Cow::Owned(resolved_imports);
if args
.custom
.get(&rolldown_plugin_utils::constants::ViteImportGlob)
.is_some_and(|v| v.is_sub_imports_pattern())
{
let base_dir = args
.importer
.and_then(|importer| Path::new(importer).parent())
.unwrap_or(Path::new(&self.resolve_options.root));
let joined = base_dir.join(id.as_ref());
let path = joined.normalize();
let package_json_path = (!self.legacy_inconsistent_cjs_interop)
.then(|| {
self
.resolvers
.get_nearest_package_json(path.to_str().unwrap())
.map(|pj| pj.realpath().to_str().unwrap().to_string())
})
.flatten();
return Ok(Some(HookResolveIdOutput {
id: path.to_slash_lossy().into(),
package_json_path,
..Default::default()
}));
}
}
if self.resolve_options.as_src && id.starts_with(FS_PREFIX) {
let mut res = fs_path_from_id(&id);
if let Some(finalize_other_specifiers) = &self.finalize_other_specifiers
&& let Some(finalized) = finalize_other_specifiers(&res, &id).await?
{
res = finalized.into();
}
self.debug_log(|| format!("[@fs] {} -> {}", id.cyan(), res.dimmed())).await?;
return Ok(Some(HookResolveIdOutput { id: res.into(), ..Default::default() }));
}
if id.starts_with("file://") {
let (path, postfix) = file_url_str_to_path_and_postfix(&id)?;
let mut res = normalize_path(&path).into_owned() + &postfix;
if let Some(finalize_other_specifiers) = &self.finalize_other_specifiers
&& let Some(finalized) = finalize_other_specifiers(&res, &id).await?
{
res = finalized;
}
let package_json_path = (!self.legacy_inconsistent_cjs_interop)
.then(|| {
self
.resolvers
.get_nearest_package_json(&path)
.map(|pj| pj.realpath().to_str().unwrap().to_string())
})
.flatten();
return Ok(Some(HookResolveIdOutput {
id: res.into(),
package_json_path,
..Default::default()
}));
}
if is_data_url(&id) {
return Ok(None);
}
let additional_options = AdditionalOptions::new(
self.resolve_options.is_require.unwrap_or(args.kind == ImportKind::Require),
self.resolve_options.prefer_relative
|| args.is_entry
|| args.importer.is_some_and(|i| i.ends_with(".html")),
);
let resolver = self.resolvers.get(additional_options);
if is_bare_import(&id) {
let external = self.resolve_options.is_build
&& self.environment_consumer == "server"
&& self.external_decider.is_external(&id, args.importer);
let result = resolver.resolve_bare_import(
&id,
args.importer,
external,
&self.dedupe,
self.legacy_inconsistent_cjs_interop,
)?;
if let Some(mut result) = result {
if let Some(finalize_bare_specifier) = &self.finalize_bare_specifier
&& !scan
&& result.id.as_path().is_in_node_modules()
{
let finalized = finalize_bare_specifier(&result.id, &id, args.importer)
.await?
.map(Into::into)
.unwrap_or(result.id);
result.id = finalized;
}
self.debug_log(|| format!("[bare] {} -> {}", id.cyan(), result.id.dimmed())).await?;
return Ok(Some(result));
}
if self.environment_consumer == "server" && self.builtin_checker.is_builtin(&id) {
return Ok(Some(HookResolveIdOutput {
id: id.into(),
external: Some(true.into()),
side_effects: Some(HookSideEffects::False),
..Default::default()
}));
} else if self.environment_consumer == "server" && is_node_like_builtin(&id) {
if !(matches!(self.external, ResolveOptionsExternal::True)
|| self.external.is_external_explicitly(&id))
{
let mut message = format!("Automatically externalized node built-in module \"{}\"", &id);
if let Some(importer) = args.importer {
let current_dir =
env::current_dir().unwrap_or(PathBuf::from(&self.resolve_options.root));
message.push_str(&format!(
" imported from \"{}\"",
Path::new(importer).relative(current_dir).to_string_lossy()
));
}
message.push_str(&format!(
". Consider adding it to environments.{}.external if it is intended.",
self.environment_name
));
self.warn(ctx, message).await?;
}
return Ok(Some(HookResolveIdOutput {
id: id.into(),
external: Some(true.into()),
side_effects: Some(HookSideEffects::False),
..Default::default()
}));
} else if self.environment_consumer == "client" && is_node_like_builtin(&id) {
if self.no_external.is_true()
&&(matches!(self.external, ResolveOptionsExternal::True)
|| !self.external.is_external_explicitly(&id))
{
let mut message = format!("Cannot bundle Node.js built-in \"{id}\"");
if let Some(importer) = args.importer {
let current_dir =
env::current_dir().unwrap_or(PathBuf::from(&self.resolve_options.root));
message.push_str(&format!(
" imported from \"{}\"",
Path::new(importer).relative(current_dir).to_string_lossy()
));
}
message.push_str(&format!(
". Consider disabling environments.{}.noExternal or remove the built-in dependency.",
self.environment_name
));
return Err(anyhow!(message));
}
if !self.resolve_options.as_src {
self
.debug_log(|| {
let mut message = format!("externalized node built-in \"{id}\" to empty module.");
if let Some(importer) = args.importer {
message.push_str(&format!(" (imported by: {})", importer.dimmed().white()));
}
message
})
.await?;
} else if self.resolve_options.is_production {
let mut message =
format!("Module \"{id}\" has been externalized for browser compatibility");
if let Some(importer) = args.importer {
message.push_str(&format!(", imported by \"{importer}\"",));
}
message.push_str(
". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details."
);
self.warn(ctx, message).await?;
}
return Ok(Some(HookResolveIdOutput {
id: if self.resolve_options.is_production {
arcstr::literal!(BROWSER_EXTERNAL_ID)
} else {
format!("{BROWSER_EXTERNAL_ID}:{id}").into()
},
..Default::default()
}));
}
}
let specifier = normalize_leading_slashes(&id);
let resolved = resolver.normalize_oxc_resolver_result(
specifier,
args.importer,
&self.dedupe,
self.legacy_inconsistent_cjs_interop,
&resolver.resolve_raw(specifier, args.importer, false),
)?;
if let Some(mut resolved) = resolved {
if !scan
&& let Some(finalize_other_specifiers) = &self.finalize_other_specifiers
&& let Some(finalized) = finalize_other_specifiers(&resolved.id, &id).await?
{
resolved.id = finalized.into();
}
self.debug_log(|| format!("[other] {} -> {}", id.cyan(), resolved.id.dimmed())).await?;
return Ok(Some(resolved));
}
if is_external_url(&id) {
return Ok(Some(HookResolveIdOutput {
id: id.into(),
external: Some(true.into()),
..Default::default()
}));
}
self.debug_log(|| format!("[fallthrough] {}", id.dimmed())).await?;
Ok(None)
}
async fn load(
&self,
_ctx: rolldown_plugin::SharedLoadPluginContext,
args: &HookLoadArgs<'_>,
) -> HookLoadReturn {
if let Some(id_without_prefix) = args.id.strip_prefix(BROWSER_EXTERNAL_ID) {
if self.resolve_options.is_build {
if self.resolve_options.is_production {
return Ok(Some(HookLoadOutput {
code: arcstr::literal!("module.exports = {}"),
..Default::default()
}));
} else {
return Ok(Some(HookLoadOutput {
code: get_development_build_browser_external_module_code(
if id_without_prefix.is_empty() {
id_without_prefix
} else {
&id_without_prefix[1..]
},
),
..Default::default()
}));
}
} else if self.resolve_options.is_production {
return Ok(Some(HookLoadOutput {
code: arcstr::literal!("export default {}"),
..Default::default()
}));
} else {
return Ok(Some(HookLoadOutput {
code: get_development_dev_browser_external_module_code(
if id_without_prefix.is_empty() { id_without_prefix } else { &id_without_prefix[1..] },
),
..Default::default()
}));
}
}
if args.id.starts_with(OPTIONAL_PEER_DEP_ID) {
let [_, peer_dep, parent_dep] = args.id.splitn(3, ":").collect::<Vec<&str>>()[..] else {
unreachable!()
};
return Ok(Some(HookLoadOutput {
code: get_optional_peer_dep_module_code(
peer_dep,
parent_dep,
self.resolve_options.is_production,
),
..Default::default()
}));
}
Ok(None)
}
async fn watch_change(
&self,
_ctx: &PluginContext,
_path: &str,
event: WatcherChangeKind,
) -> rolldown_plugin::HookNoopReturn {
match event {
WatcherChangeKind::Create | WatcherChangeKind::Delete => {
self.resolvers.clear_cache();
}
WatcherChangeKind::Update => {}
};
Ok(())
}
fn register_hook_usage(&self) -> HookUsage {
HookUsage::ResolveId | HookUsage::Load | HookUsage::WatchChange
}
}
fn get_development_build_browser_external_module_code(id_without_prefix: &str) -> ArcStr {
arcstr::format!(
"\
module.exports = Object.create(new Proxy({{}}, {{
get(_, key) {{
if (
key !== '__esModule' &&
key !== '__proto__' &&
key !== 'constructor' &&
key !== 'splice'
) {{
throw new Error(`Module \"{id_without_prefix}\" has been externalized for browser compatibility. Cannot access \"{id_without_prefix}.${{key}}\" in client code. See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`)
}}
}}
}}))\
"
)
}
fn get_development_dev_browser_external_module_code(id_without_prefix: &str) -> ArcStr {
arcstr::format!(
"\
export default new Proxy({{}}, {{
get(_, key) {{
throw new Error(`Module \"{id_without_prefix}\" has been externalized for browser compatibility. Cannot access \"{id_without_prefix}.${{key}}\" in client code. See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`)
}}
}})\
"
)
}
fn get_optional_peer_dep_module_code(
peer_dep: &str,
parent_dep: &str,
is_production: bool,
) -> ArcStr {
let additional_message = if is_production { " Is it installed?" } else { "" };
arcstr::format!(
"\
export default {{}};
throw new Error(`Could not resolve \"{peer_dep}\" imported by \"{parent_dep}\".{additional_message}`)\
"
)
}
fn fs_path_from_id(id: &str) -> Cow<'_, str> {
let fs_path = normalize_path(id.strip_prefix(FS_PREFIX).unwrap_or(id));
if fs_path.starts_with('/') || is_windows_drive_path(&fs_path) {
return fs_path;
}
format!("/{fs_path}").into()
}
fn is_external_url(id: &str) -> bool {
if let Some(double_slash_pos) = id.find("//") {
if double_slash_pos == 0 {
true
} else {
let protocol = &id[0..double_slash_pos];
protocol.strip_suffix(':').map(|p| p.bytes().all(|c| c.is_ascii_alphabetic())).is_some()
}
} else {
false
}
}