1use std::borrow::Cow;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::time::Instant;
21
22use ferrijs::ScriptError;
23use ferrijs::modules::ModuleRegistry;
24use ferrijs::source_map::{CompiledModule, LazyMap};
25use rolldown::{
26 Bundler as Rolldown, BundlerOptions as RolldownOptions, InputItem, OutputFormat, Platform, SourceMapType,
27};
28use rolldown_common::{CodeSplittingMode, ModuleType, Output, ResolveOptions, TsConfig};
29use rolldown_plugin::{
30 HookLoadArgs, HookLoadOutput, HookLoadReturn, HookResolveIdArgs, HookResolveIdOutput, HookResolveIdReturn, HookUsage,
31 Plugin, PluginContext, SharedLoadPluginContext,
32};
33use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Module, WriteOptions, WriteOptionsEndianness};
34
35use crate::cache::BytecodeCache;
36
37const VIRTUAL_USER_PREFIX: &str = "\0ferrijs-virtual:";
39
40fn render_bundle_diagnostics(err: &rolldown_error::BatchedBuildDiagnostic) -> String {
50 let rendered: Vec<String> = err
51 .iter()
52 .map(|d| {
53 let diagnostic = d.to_diagnostic();
54 let kind = diagnostic.kind();
55 match diagnostic.get_primary_location() {
56 Some((file, line, column, _)) => format!("{kind} at {file}:{line}:{column}: {d}"),
57 None => format!("{kind}: {d}"),
58 }
59 })
60 .collect();
61 if rendered.is_empty() {
62 return format!("rolldown bundle: {err}");
63 }
64 format!("rolldown bundle: {}", rendered.join("; "))
65}
66
67fn asset_module_types() -> rustc_hash::FxHashMap<String, ModuleType> {
83 let mut m = rustc_hash::FxHashMap::default();
84 for ext in ["css", "scss", "sass", "less", "styl", "stylus"] {
85 m.insert(ext.to_string(), ModuleType::Empty);
86 }
87 for ext in [
88 "png", "jpg", "jpeg", "gif", "webp", "avif", "ico", "woff", "woff2", "ttf", "eot", "mp4", "webm",
89 ] {
90 m.insert(ext.to_string(), ModuleType::Empty);
91 }
92 for ext in ["svg", "txt", "md", "graphql", "gql", "html"] {
93 m.insert(ext.to_string(), ModuleType::Text);
94 }
95 m
96}
97
98#[derive(Debug, Clone)]
101pub struct BundlerOptions {
102 pub alias: Vec<(String, PathBuf)>,
106 pub virtual_modules: Vec<(String, String)>,
108 pub conditions: Vec<String>,
112 pub main_fields: Vec<String>,
117 pub alias_fields: Vec<Vec<String>>,
119 pub tsconfig: Option<PathBuf>,
124 pub externals: Vec<String>,
129}
130
131impl Default for BundlerOptions {
132 fn default() -> Self {
133 Self {
134 alias: Vec::new(),
135 virtual_modules: Vec::new(),
136 conditions: Vec::new(),
137 main_fields: vec!["module".to_string(), "main".to_string()],
138 alias_fields: Vec::new(),
139 tsconfig: None,
140 externals: Vec::new(),
141 }
142 }
143}
144
145impl BundlerOptions {
146 #[must_use]
150 pub fn alias(mut self, specifier: impl Into<String>, target: impl AsRef<Path>, base: &Path) -> Self {
151 let p = target.as_ref();
152 let abs = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
153 self.alias.push((specifier.into(), abs));
154 self
155 }
156
157 #[must_use]
159 pub fn virtual_module(mut self, specifier: impl Into<String>, source: impl Into<String>) -> Self {
160 self.virtual_modules.push((specifier.into(), source.into()));
161 self
162 }
163
164 #[must_use]
167 pub fn with_tsconfig(mut self, tsconfig: Option<&str>, base: &Path) -> Self {
168 self.tsconfig = tsconfig.map(|t| {
169 let p = Path::new(t);
170 if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
171 });
172 self
173 }
174
175 #[must_use]
183 pub fn fingerprint(&self) -> u64 {
184 use std::hash::{Hash, Hasher};
185 let mut h = std::collections::hash_map::DefaultHasher::new();
186 for (spec, path) in &self.alias {
187 spec.hash(&mut h);
188 path.hash(&mut h);
189 }
190 for (spec, src) in &self.virtual_modules {
191 spec.hash(&mut h);
192 src.hash(&mut h);
193 }
194 self.conditions.hash(&mut h);
195 self.main_fields.hash(&mut h);
196 self.alias_fields.hash(&mut h);
197 self.tsconfig.hash(&mut h);
198 self.externals.hash(&mut h);
199 h.finish()
200 }
201}
202
203const MULTI_ENTRY_ID: &str = "\0ferrijs-multi-entry.js";
210
211#[derive(Debug)]
212struct RuntimePlugin {
213 env: Arc<BundlerOptions>,
214 registry: Arc<ModuleRegistry>,
215 multi_entry: Option<String>,
218}
219
220impl Plugin for RuntimePlugin {
221 fn name(&self) -> Cow<'static, str> {
222 "ferrijs-runtime".into()
223 }
224
225 #[allow(unknown_lints, clippy::unused_async_trait_impl)]
230 async fn resolve_id(&self, _ctx: &PluginContext, args: &HookResolveIdArgs<'_>) -> HookResolveIdReturn {
231 if args.specifier == MULTI_ENTRY_ID && self.multi_entry.is_some() {
232 return Ok(Some(HookResolveIdOutput::from_id(MULTI_ENTRY_ID)));
233 }
234 if self.registry.serves(args.specifier) || self.env.externals.iter().any(|e| e == args.specifier) {
241 return Ok(Some(HookResolveIdOutput {
242 id: args.specifier.into(),
243 external: Some(rolldown_common::ResolvedExternal::Bool(true)),
244 ..Default::default()
245 }));
246 }
247 if self.env.virtual_modules.iter().any(|(spec, _)| spec == args.specifier) {
248 return Ok(Some(HookResolveIdOutput::from_id(format!(
249 "{VIRTUAL_USER_PREFIX}{}",
250 args.specifier
251 ))));
252 }
253 if let Some((_, target)) = self.env.alias.iter().find(|(spec, _)| spec == args.specifier) {
254 return Ok(Some(HookResolveIdOutput::from_id(
257 target.to_string_lossy().into_owned(),
258 )));
259 }
260 Ok(None)
261 }
262
263 #[allow(unknown_lints, clippy::unused_async_trait_impl)]
268 async fn load(&self, _ctx: SharedLoadPluginContext, args: &HookLoadArgs<'_>) -> HookLoadReturn {
269 if args.id == MULTI_ENTRY_ID
270 && let Some(src) = &self.multi_entry
271 {
272 return Ok(Some(HookLoadOutput {
273 code: src.clone().into(),
274 module_type: Some(ModuleType::Js),
275 ..Default::default()
276 }));
277 }
278 let code: Option<Cow<'_, str>> = args.id.strip_prefix(VIRTUAL_USER_PREFIX).and_then(|spec| {
279 self
280 .env
281 .virtual_modules
282 .iter()
283 .find(|(s, _)| s == spec)
284 .map(|(_, src)| Cow::Owned(src.clone()))
285 });
286 Ok(code.map(|code| HookLoadOutput {
287 code: code.into_owned().into(),
288 module_type: Some(ModuleType::Js),
289 ..Default::default()
290 }))
291 }
292
293 fn register_hook_usage(&self) -> HookUsage {
294 HookUsage::ResolveId | HookUsage::Load
295 }
296}
297
298pub struct BundledSource {
300 pub code: String,
301 pub source_map_json: Option<String>,
304 pub modules: Vec<PathBuf>,
313 pub config_inputs: Vec<PathBuf>,
319}
320
321#[derive(Debug, Clone)]
325pub struct Bundler {
326 options: Arc<BundlerOptions>,
327 registry: Arc<ModuleRegistry>,
328 cache: BytecodeCache,
329}
330
331impl Bundler {
332 #[must_use]
334 pub fn new(options: BundlerOptions, registry: Arc<ModuleRegistry>, cache: BytecodeCache) -> Self {
335 Self {
336 options: Arc::new(options),
337 registry,
338 cache,
339 }
340 }
341
342 #[must_use]
343 pub fn options(&self) -> &BundlerOptions {
344 &self.options
345 }
346
347 #[must_use]
348 pub fn registry(&self) -> &Arc<ModuleRegistry> {
349 &self.registry
350 }
351
352 #[must_use]
353 pub fn cache(&self) -> &BytecodeCache {
354 &self.cache
355 }
356
357 #[must_use]
361 pub fn env_fingerprint(&self) -> u64 {
362 use std::hash::{Hash, Hasher};
363 let mut h = std::collections::hash_map::DefaultHasher::new();
364 self.options.fingerprint().hash(&mut h);
365 self.registry.fingerprint().hash(&mut h);
366 h.finish()
367 }
368
369 #[must_use]
371 pub fn cache_key(&self, kind: &str, entry_paths: &[PathBuf], cwd: &Path) -> u64 {
372 crate::cache::entry_key(kind, entry_paths, cwd, self.env_fingerprint())
373 }
374
375 pub async fn bundle(&self, entry_paths: &[PathBuf], cwd: &Path) -> Result<BundledSource, ScriptError> {
383 if entry_paths.is_empty() {
384 return Err(ScriptError::internal("no entry files".to_string()));
385 }
386
387 let env = Arc::clone(&self.options);
388 if let Some(ts) = &env.tsconfig
389 && !ts.is_file()
390 {
391 return Err(ScriptError::internal(format!(
392 "tsconfig points at {}, which is not a file",
393 ts.display()
394 )));
395 }
396
397 let multi_entry = (entry_paths.len() > 1).then(|| {
403 use std::fmt::Write as _;
404 entry_paths.iter().fold(String::new(), |mut acc, p| {
405 let _ = writeln!(
406 acc,
407 "import {};",
408 serde_json::to_string(&p.to_string_lossy()).unwrap_or_else(|_| String::from("\"\""))
409 );
410 acc
411 })
412 });
413 let input: Vec<InputItem> = vec![InputItem {
414 name: None,
415 import: if multi_entry.is_some() {
416 MULTI_ENTRY_ID.to_string()
417 } else {
418 entry_paths[0].to_string_lossy().into_owned()
419 },
420 }];
421
422 let options = RolldownOptions {
423 input: Some(input),
424 cwd: Some(cwd.to_path_buf()),
425 platform: Some(Platform::Neutral),
428 format: Some(OutputFormat::Esm),
429 sourcemap: Some(SourceMapType::Hidden),
432 sourcemap_exclude_sources: Some(true),
436 code_splitting: Some(CodeSplittingMode::Bool(false)),
442 resolve: Some(ResolveOptions {
443 main_fields: Some(env.main_fields.clone()),
447 condition_names: (!env.conditions.is_empty()).then(|| env.conditions.clone()),
448 alias_fields: (!env.alias_fields.is_empty()).then(|| env.alias_fields.clone()),
449 ..Default::default()
450 }),
451 tsconfig: env.tsconfig.clone().map(TsConfig::Manual),
453 module_types: Some(asset_module_types()),
454 ..Default::default()
455 };
456
457 let build_started = Instant::now();
458 let mut bundler = Rolldown::with_plugins(
459 options,
460 vec![Arc::new(RuntimePlugin {
461 env: Arc::clone(&env),
462 registry: Arc::clone(&self.registry),
463 multi_entry,
464 })],
465 )
466 .map_err(|e| ScriptError::internal(format!("rolldown init: {e:?}")))?;
467 let ctor_ms = build_started.elapsed().as_secs_f64() * 1000.0;
470 let gen_started = Instant::now();
471 let out = Box::pin(bundler.generate())
472 .await
473 .map_err(|e| ScriptError::internal(render_bundle_diagnostics(&e)))?;
474 tracing::debug!(
475 target: "ferrijs::bundle",
476 entries = entry_paths.len(),
477 ctor_ms,
478 generate_ms = gen_started.elapsed().as_secs_f64() * 1000.0,
479 "rolldown build"
480 );
481
482 let config_inputs: Vec<PathBuf> = bundler
485 .watch_files()
486 .iter()
487 .map(|f| PathBuf::from(f.as_str()))
488 .filter(|p| {
489 let named_tsconfig = p
490 .file_name()
491 .and_then(|n| n.to_str())
492 .is_some_and(|n| n.starts_with("tsconfig"));
493 named_tsconfig && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("json"))
494 })
495 .collect();
496
497 for asset in &out.assets {
498 if let Output::Chunk(chunk) = asset
499 && chunk.is_entry
500 {
501 let modules = chunk
502 .module_ids
503 .iter()
504 .map(|id| PathBuf::from(id.to_string()))
505 .filter(|p| p.is_file())
506 .collect();
507 let mut source_map_json = None;
511 if let Some(m) = chunk.map.as_ref() {
512 source_map_json = Some(m.to_json_string());
513 }
514 return Ok(BundledSource {
515 code: chunk.code.clone(),
516 source_map_json,
517 modules,
518 config_inputs,
519 });
520 }
521 }
522 Err(ScriptError::internal("rolldown produced no entry chunk".to_string()))
523 }
524
525 pub async fn compile(
535 &self,
536 entry_paths: &[PathBuf],
537 cwd: &Path,
538 module_name: &str,
539 ) -> Result<CompiledModule, ScriptError> {
540 let module_name = module_name.to_string();
541
542 let cache_key = self.cache_key(&format!("bundle:{module_name}"), entry_paths, cwd);
548 let probe_started = Instant::now();
549 let hit = self.cache.load(cache_key);
550 let probe_elapsed = probe_started.elapsed();
551 if let Some(hit) = hit {
552 let map_bytes = hit.source_map_json.as_ref().map_or(0, String::len);
553 let source_map = LazyMap::from_json(hit.source_map_json.as_deref());
554 tracing::debug!(
555 target: "ferrijs::bundle",
556 module = %module_name,
557 entries = entry_paths.len(),
558 map_bytes,
559 probe_ms = probe_elapsed.as_secs_f64() * 1000.0,
560 "bundle warm path"
561 );
562 return Ok(CompiledModule {
563 module_name,
564 bytecode: Arc::from(hit.bytecode.into_boxed_slice()),
565 source_map,
566 cwd: Some(cwd.to_path_buf()),
567 });
568 }
569
570 let bundle_started = Instant::now();
571 let bundled = Box::pin(self.bundle(entry_paths, cwd)).await?;
572 let bundle_elapsed = bundle_started.elapsed();
573 let (code, map_json, mut modules) = (bundled.code, bundled.source_map_json, bundled.modules);
574 modules.extend(bundled.config_inputs);
575
576 let compile_started = Instant::now();
577 let compiled = self.compile_source(&code, &module_name, map_json.as_deref()).await?;
578 let compile_elapsed = compile_started.elapsed();
579
580 let store_started = Instant::now();
581 let inputs = crate::cache::input_set(entry_paths, &modules);
582 self.cache.store(
583 cache_key,
584 &compiled.bytecode,
585 &module_name,
586 map_json.as_deref(),
587 None,
588 &inputs,
589 );
590 let store_elapsed = store_started.elapsed();
591
592 tracing::debug!(
593 target: "ferrijs::bundle",
594 module = %module_name,
595 entries = entry_paths.len(),
596 modules = modules.len(),
597 code_bytes = code.len(),
598 map_bytes = map_json.as_ref().map_or(0, String::len),
599 bytecode_bytes = compiled.bytecode.len(),
600 probe_ms = probe_elapsed.as_secs_f64() * 1000.0,
601 bundle_ms = bundle_elapsed.as_secs_f64() * 1000.0,
602 compile_ms = compile_elapsed.as_secs_f64() * 1000.0,
603 store_ms = store_elapsed.as_secs_f64() * 1000.0,
604 "bundle cold path"
605 );
606
607 Ok(compiled)
608 }
609
610 pub async fn compile_source(
627 &self,
628 code: &str,
629 module_name: &str,
630 source_map_json: Option<&str>,
631 ) -> Result<CompiledModule, ScriptError> {
632 let name = module_name.to_string();
633 let code = code.to_string();
634 let rt_started = Instant::now();
635 let runtime = AsyncRuntime::new().map_err(|e| ScriptError::internal(format!("bytecode runtime: {e}")))?;
636 let (resolver, loader) = self.registry.loader();
645 let externals = ExternalStubs(self.options.externals.clone());
646 runtime
647 .set_loader((resolver, externals.clone()), (loader, externals))
648 .await;
649 let ctx = AsyncContext::full(&runtime)
650 .await
651 .map_err(|e| ScriptError::internal(format!("bytecode context: {e}")))?;
652 let rt_elapsed = rt_started.elapsed();
653 let bytecode: Vec<u8> = ctx
654 .async_with(async |ctx| {
655 let declare_started = Instant::now();
658 let module = Module::declare(ctx.clone(), name.into_bytes(), code.into_bytes())
659 .catch(&ctx)
660 .map_err(|e| ScriptError::from_caught_unmapped(e, "", 0))?;
661 let declare_elapsed = declare_started.elapsed();
662 let write_started = Instant::now();
663 let out = module
664 .write(WriteOptions {
665 endianness: WriteOptionsEndianness::Native,
666 ..Default::default()
667 })
668 .map_err(|e| ScriptError::internal(format!("module write: {e}")));
669 tracing::debug!(
670 target: "ferrijs::bundle",
671 runtime_ms = rt_elapsed.as_secs_f64() * 1000.0,
672 declare_ms = declare_elapsed.as_secs_f64() * 1000.0,
673 write_ms = write_started.elapsed().as_secs_f64() * 1000.0,
674 "bundle compile split"
675 );
676 out
677 })
678 .await?;
679
680 Ok(CompiledModule {
681 module_name: module_name.to_string(),
682 bytecode: Arc::from(bytecode.into_boxed_slice()),
683 source_map: LazyMap::from_json(source_map_json),
684 cwd: None,
688 })
689 }
690}
691
692#[derive(Clone)]
695struct ExternalStubs(Vec<String>);
696
697impl rquickjs::loader::Resolver for ExternalStubs {
698 fn resolve<'js>(
699 &mut self,
700 _ctx: &rquickjs::Ctx<'js>,
701 base: &str,
702 name: &str,
703 _attributes: Option<rquickjs::loader::ImportAttributes<'js>>,
704 ) -> rquickjs::Result<String> {
705 if self.0.iter().any(|e| e == name) {
706 Ok(name.to_string())
707 } else {
708 Err(rquickjs::Error::new_resolving(base, name))
709 }
710 }
711}
712
713impl rquickjs::loader::Loader for ExternalStubs {
714 fn load<'js>(
715 &mut self,
716 ctx: &rquickjs::Ctx<'js>,
717 name: &str,
718 _attributes: Option<rquickjs::loader::ImportAttributes<'js>>,
719 ) -> rquickjs::Result<Module<'js>> {
720 if self.0.iter().any(|e| e == name) {
721 Module::declare(ctx.clone(), name, "export {};\n")
722 } else {
723 Err(rquickjs::Error::new_loading(name))
724 }
725 }
726}
727
728#[must_use]
731pub fn is_typescript_path(path: &Path) -> bool {
732 matches!(
733 path.extension().and_then(|e| e.to_str()),
734 Some("ts" | "tsx" | "mts" | "cts")
735 )
736}
737
738#[must_use]
744pub fn source_is_es_module(source: &str) -> bool {
745 source.lines().any(|line| {
746 let t = line.trim_start();
747 let static_import = t
748 .strip_prefix("import")
749 .is_some_and(|rest| matches!(rest.as_bytes().first(), Some(b' ' | b'\t' | b'{' | b'\'' | b'"')));
750 static_import
751 || t.starts_with("export ")
752 || t.starts_with("export\t")
753 || t.starts_with("export{")
754 || t.starts_with("export*")
755 })
756}