use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use rolldown::{BundlerBuilder, BundlerOptions, InputItem, OutputFormat};
use rolldown_common::{Output, Platform, ResolvedExternal};
use rolldown_plugin::__inner::SharedPluginable;
use rolldown_plugin::{
HookResolveIdArgs, HookResolveIdOutput, HookResolveIdReturn, HookUsage, Plugin, PluginContext,
};
use oj_resolver::OjResolver;
use crate::{dep_serve_url, hex_encode, is_node_builtin, normalize, package_root};
pub fn enabled() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
crate::partial_bundle_enabled()
&& std::env::var("OJ_PB_ROLLDOWN").is_ok_and(|v| !v.is_empty() && v != "0")
})
}
const BUILTIN_FORCE: &[&str] = &["object-inspect"];
fn force_set() -> &'static std::collections::HashSet<String> {
static S: OnceLock<std::collections::HashSet<String>> = OnceLock::new();
S.get_or_init(|| {
let mut set: std::collections::HashSet<String> =
BUILTIN_FORCE.iter().map(|s| s.to_string()).collect();
if let Ok(v) = std::env::var("OJ_PB_ROLLDOWN_FORCE") {
for name in v.split(',').map(str::trim).filter(|s| !s.is_empty()) {
set.insert(name.to_string());
}
}
set
})
}
pub fn is_forced(entry: &Path) -> bool {
crate::pkg_bundle::package_name(entry).is_some_and(|n| force_set().contains(&n))
|| crate::pkg_bundle::is_include_forced(entry)
}
fn chunk_cache() -> &'static Mutex<std::collections::HashMap<String, Arc<String>>> {
static C: OnceLock<Mutex<std::collections::HashMap<String, Arc<String>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
pub fn cached_chunk(path: &str) -> Option<Arc<String>> {
chunk_cache().lock().unwrap().get(path).cloned()
}
fn store_chunk(path: String, code: Arc<String>) {
chunk_cache().lock().unwrap().insert(path, code);
}
pub async fn build(entry: &Path, root: &Path, resolver: Arc<OjResolver>) -> Option<Arc<String>> {
let hex = hex_encode(&entry.to_string_lossy());
let pkg_root = package_root(entry);
let plugin = ExternalizePlugin {
pkg_root,
root: root.to_path_buf(),
resolver,
};
let plugins: Vec<SharedPluginable> = vec![Arc::new(plugin)];
let mut bundler = BundlerBuilder::default()
.with_plugins(plugins)
.with_options(BundlerOptions {
input: Some(vec![InputItem {
name: Some(hex.clone()),
import: entry.to_string_lossy().into_owned(),
..Default::default()
}]),
cwd: Some(root.to_path_buf()),
platform: Some(Platform::Browser),
format: Some(OutputFormat::Esm),
entry_filenames: Some("[name].js".to_string().into()),
chunk_filenames: Some("[name]-[hash].js".to_string().into()),
define: Some(
[
("process.env.NODE_ENV", "\"development\""),
("import.meta.env.DEV", "true"),
("import.meta.env.PROD", "false"),
("import.meta.env.SSR", "false"),
("import.meta.env.MODE", "\"development\""),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
),
..Default::default()
})
.build()
.ok()?;
let outcome = bundler.generate().await;
let _ = bundler.close().await;
let output = outcome.ok()?;
let prefix = crate::pkg_bundle::PKG_PREFIX;
let mut entry_code: Option<Arc<String>> = None;
for asset in &output.assets {
if let Output::Chunk(c) = asset {
let code = Arc::new(c.code.clone());
let served = format!("{prefix}{}", c.filename);
store_chunk(served, Arc::clone(&code));
if c.is_entry {
entry_code = Some(Arc::clone(&code));
}
}
}
let entry_code = entry_code?;
store_chunk(format!("{prefix}{hex}"), Arc::clone(&entry_code));
Some(entry_code)
}
struct ExternalizePlugin {
pkg_root: PathBuf,
root: PathBuf,
resolver: Arc<OjResolver>,
}
impl std::fmt::Debug for ExternalizePlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExternalizePlugin")
.field("pkg_root", &self.pkg_root)
.finish()
}
}
fn inside_package(pkg_root: &Path, resolved: &Path) -> bool {
let root_c = std::fs::canonicalize(pkg_root).unwrap_or_else(|_| pkg_root.to_path_buf());
let res_c = std::fs::canonicalize(resolved).unwrap_or_else(|_| resolved.to_path_buf());
match res_c.strip_prefix(&root_c) {
Ok(rel) => !rel.components().any(|c| c.as_os_str() == "node_modules"),
Err(_) => false,
}
}
fn external(id: String) -> HookResolveIdOutput {
HookResolveIdOutput {
id: id.into(),
external: Some(ResolvedExternal::Bool(true)),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::is_forced;
use std::path::Path;
#[test]
fn builtin_hard_packages_are_forced() {
assert!(is_forced(Path::new("/x/node_modules/object-inspect/index.js")));
assert!(!is_forced(Path::new("/x/node_modules/lodash-es/index.js")));
}
}
impl Plugin for ExternalizePlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("oj:pkg-externalize")
}
fn register_hook_usage(&self) -> HookUsage {
HookUsage::ResolveId
}
fn resolve_id(
&self,
_ctx: &PluginContext,
args: &HookResolveIdArgs<'_>,
) -> impl std::future::Future<Output = HookResolveIdReturn> + Send {
let spec = args.specifier.to_string();
let importer = args.importer.map(str::to_string);
let pkg_root = self.pkg_root.clone();
let root = self.root.clone();
let resolver = Arc::clone(&self.resolver);
async move {
if spec.starts_with('.') || spec.starts_with('/') {
return Ok(None);
}
if is_node_builtin(&spec) {
return Ok(Some(external(format!("/@id/{}", hex_encode(&spec)))));
}
let dir = importer
.as_deref()
.map(Path::new)
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or_else(|| root.clone());
match resolver.resolve(&dir, &spec) {
Ok(resolved) => {
let resolved = normalize(&resolved);
if inside_package(&pkg_root, &resolved) {
Ok(None)
} else {
Ok(Some(external(dep_serve_url(&resolved, &root))))
}
}
Err(e) if e.ignored => Ok(Some(external("/@oj-empty".to_string()))),
Err(_) => Ok(None),
}
}
}
}