Skip to main content

ferrijs_bundle/
bundle.rs

1//! rolldown bundle + tree-shake + TypeScript -> one ESM module ->
2//! compiled to `QuickJS` bytecode once.
3//!
4//! rolldown (built on oxc) resolves the whole import graph including
5//! `node_modules`, transpiles `.ts`/`.tsx`, tree-shakes, and emits a
6//! single ESM chunk. That chunk is compiled to bytecode a single time;
7//! every realm that runs it links the bytecode (one `Module::load`, no
8//! parse, no resolver). A hidden source map is kept so a JS error in
9//! the bundled output is reported at the original `.ts`/`.js` location.
10//!
11//! The native modules a realm serves stay EXTERNAL: the chunk keeps the
12//! bare `import ... from 'node:fs'` and the written bytecode re-links by
13//! name against whatever realm loads it. The [`Bundler`] reads which
14//! specifiers those are from the same [`ModuleRegistry`] the runtime
15//! was built with, so the two cannot disagree.
16
17use 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
37/// Id prefix for host-declared virtual modules.
38const VIRTUAL_USER_PREFIX: &str = "\0ferrijs-virtual:";
39
40/// A bundle failure, rendered with the file and line it points at.
41///
42/// `BatchedBuildDiagnostic`'s `Debug` prints `BuildDiagnostic { kind:
43/// "PARSE_ERROR", message: "Unexpected token", .. }` — and the `..` is
44/// the label span, which is the only place the offending file appears.
45/// Reporting that verbatim leaves the reader bisecting an import graph
46/// by hand to find which of several hundred modules rolldown could not
47/// parse. `to_diagnostic()` resolves the labels against the source it
48/// read, so the file and line come back.
49fn 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
67/// Extensions a JS parser must not be pointed at, mapped to the module
68/// type that makes importing one a no-op.
69///
70/// A stylesheet import is a side effect of the bundler that built the
71/// A stylesheet import is a side effect of the bundler that built the
72/// package, not something the importing module reads. An analytics
73/// package shipping `require("./styles/guides.scss")` inside its dist
74/// is enough: with no rule for the extension rolldown hands the SCSS to
75/// oxc and reports `PARSE_ERROR: Unexpected token` against a file that
76/// is not JavaScript and was never going to be. There is no CSS in a
77/// headless QuickJS runtime for the import to mean anything, so `Empty`
78/// is the honest answer rather than a stub with a default export.
79///
80/// Images and fonts get `Empty` for the same reason; JSON and the text
81/// formats keep a real value, because code that imports one reads it.
82fn 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/// How a bundle resolves: shim aliases, inline virtual modules, the
99/// module resolution controls and the tsconfig selection.
100#[derive(Debug, Clone)]
101pub struct BundlerOptions {
102  /// `specifier -> absolute shim file path`. The shim is bundled and
103  /// transpiled like any other source (so `.ts` works) and lands in the
104  /// source map, which keeps the disk-cache freshness check covering it.
105  pub alias: Vec<(String, PathBuf)>,
106  /// `specifier -> inline ES-module source` (never touches the fs).
107  pub virtual_modules: Vec<(String, String)>,
108  /// Extra `exports`/`imports` condition names. The resolver appends
109  /// these to its own base set, so an empty list resolves exactly as it
110  /// did before any were configured.
111  pub conditions: Vec<String>,
112  /// `package.json` fields consulted when no `exports` entry matches.
113  /// rolldown's own default for a neutral platform is EMPTY, which
114  /// leaves a plain `"main": "index.js"` package unresolvable; the
115  /// default here is `["module", "main"]`.
116  pub main_fields: Vec<String>,
117  /// `package.json` field paths holding a legacy path-remapping object.
118  pub alias_fields: Vec<Vec<String>>,
119  /// The tsconfig whose `paths` / `baseUrl` govern resolution. `None`
120  /// leaves rolldown's per-module upward discovery in place; a value
121  /// pins one file for the whole graph, which is the only way to select
122  /// a config discovery would not find (`tsconfig.test.json`).
123  pub tsconfig: Option<PathBuf>,
124  /// Specifiers to keep external beyond the registry's own: a host that
125  /// serves modules of its own at load time (a package's bytecode
126  /// already evaluated under a specifier) names them here so the chunk
127  /// keeps the bare import instead of inlining a second copy.
128  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  /// Add a shim: `specifier` resolves to `target`, a file bundled and
147  /// transpiled like any other source. A relative target is taken
148  /// against `base`.
149  #[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  /// Add an inline ES module under `specifier`.
158  #[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  /// Pin the tsconfig governing resolution, resolved against `base` when
165  /// relative.
166  #[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  /// Stable content fingerprint, folded into every bundle cache key so
176  /// editing an alias mapping, a virtual module's source or a resolution
177  /// control invalidates cached bytecode. (Alias *target file* content is
178  /// already covered by the transitive input set; this covers the
179  /// mapping itself, the inline sources, and every knob that changes
180  /// output without changing a source byte. The tsconfig's CONTENT is
181  /// covered separately, through the bundle's input set.)
182  #[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
203/// Virtual id of the synthetic entry that fans out to every requested
204/// entry file. rolldown emits ONE entry chunk per input; feeding it N
205/// step/extension files as N inputs produces N entry chunks, of which
206/// [`bundle_source`] can only return one — every other file's
207/// registrations would be silently dropped. The synthetic entry
208/// side-effect-imports each file instead, so one chunk carries them all.
209const MULTI_ENTRY_ID: &str = "\0ferrijs-multi-entry.js";
210
211#[derive(Debug)]
212struct RuntimePlugin {
213  env: Arc<BundlerOptions>,
214  registry: Arc<ModuleRegistry>,
215  /// Source of the synthetic multi-entry module, when the bundle has
216  /// more than one entry file.
217  multi_entry: Option<String>,
218}
219
220impl Plugin for RuntimePlugin {
221  fn name(&self) -> Cow<'static, str> {
222    "ferrijs-runtime".into()
223  }
224
225  // rolldown's `Plugin` declares these `async`; an impl that happens to
226  // need no `.await` still cannot drop the keyword without failing to
227  // satisfy the trait. `unknown_lints` rides along because the lint
228  // itself only exists from 1.98, and this crate still compiles below it.
229  #[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    // Native modules stay EXTERNAL: the emitted chunk keeps the bare
235    // import and the bytecode re-links by name against the loading
236    // realm's ModuleDefs. Checked first so a host alias can never
237    // hijack the native surface. A specifier the host serves at load
238    // time stays external too: inlining it would give every consumer
239    // its own copy of the provider's state.
240    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      // Resolved to a concrete file: rolldown's default fs loader reads
255      // it and transpiles by extension, so `.ts` shims work.
256      return Ok(Some(HookResolveIdOutput::from_id(
257        target.to_string_lossy().into_owned(),
258      )));
259    }
260    Ok(None)
261  }
262
263  // rolldown's `Plugin` declares these `async`; an impl that happens to
264  // need no `.await` still cannot drop the keyword without failing to
265  // satisfy the trait. `unknown_lints` rides along because the lint
266  // itself only exists from 1.98, and this crate still compiles below it.
267  #[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
298/// The result of one rolldown bundle.
299pub struct BundledSource {
300  pub code: String,
301  /// Hidden source map JSON, for translating bundled positions back to
302  /// source in stack traces.
303  pub source_map_json: Option<String>,
304  /// Every module the entry chunk was built from, straight out of
305  /// rolldown's module graph.
306  ///
307  /// NOT derived from the source map: a module whose every binding is
308  /// inlined leaves no mapping tokens and vanishes from the map's
309  /// `sources`, so a source-map-derived input set silently omitted
310  /// exactly the small helper modules extensions are made of — and the
311  /// bytecode caches then treated an edited helper as unchanged.
312  pub modules: Vec<PathBuf>,
313  /// Non-module files the resolver read that can change the output —
314  /// the tsconfigs rolldown discovered or was pointed at. They are not
315  /// in `modules` (nothing imports them) but editing a `paths` mapping
316  /// changes what the same sources resolve to, so they belong in the
317  /// cache's input set.
318  pub config_inputs: Vec<PathBuf>,
319}
320
321/// The bundle front-end for one runtime configuration: its options,
322/// the module table whose specifiers stay external, and the cache its
323/// compiles land in.
324#[derive(Debug, Clone)]
325pub struct Bundler {
326  options: Arc<BundlerOptions>,
327  registry: Arc<ModuleRegistry>,
328  cache: BytecodeCache,
329}
330
331impl Bundler {
332  /// A bundler for realms built over `registry`.
333  #[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  /// Everything outside the entry files that can change a bundle's
358  /// output for byte-identical sources: the options and the native
359  /// module table. Every cache key folds this in.
360  #[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  /// A cache key for `entry_paths` bundled from `cwd` under `kind`.
370  #[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  /// rolldown-bundle + tree-shake + transpile the entry files (and their
376  /// `node_modules` / shared imports) into a single ESM module. Exposed
377  /// for diagnostics and tests; [`Self::compile`] is the production path.
378  ///
379  /// # Errors
380  ///
381  /// A bundle failure, rendered with the file and line it points at.
382  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    // ONE rolldown input, always. Each input produces its own entry
398    // chunk and only one chunk's code can be returned, so multiple entry
399    // files must be fanned out from a single synthetic entry module that
400    // side-effect-imports each of them (top-level `Given`/`defineTool`
401    // registrations are side effects, so nothing tree-shakes away).
402    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      // Neutral: no Node builtins are injected (QuickJS has none); pure
426      // ESM/CJS node_modules still resolve and bundle.
427      platform: Some(Platform::Neutral),
428      format: Some(OutputFormat::Esm),
429      // Hidden: emit the map but no `//# sourceMappingURL` trailer in the
430      // code we feed to QuickJS.
431      sourcemap: Some(SourceMapType::Hidden),
432      // Only `sources` paths and mappings are ever read back (`remap`);
433      // `sourcesContent` would inline every spec's full text, tripling the
434      // map and the cache blob it is stored in.
435      sourcemap_exclude_sources: Some(true),
436      // One chunk, always. Only the entry chunk is returned and compiled,
437      // so a split chunk would be a reference to code nobody wrote — and
438      // its modules would be missing from the cache's input set, making an
439      // edit to them invalidate nothing. Legal because there is exactly
440      // one input (MULTI_ENTRY fans the rest out).
441      code_splitting: Some(CodeSplittingMode::Bool(false)),
442      resolve: Some(ResolveOptions {
443        // `None` and an empty list are NOT the same to rolldown for main
444        // fields: `None` means "platform default", which is empty for
445        // Platform::Neutral. Always pass ours.
446        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      // Unset leaves rolldown's per-module upward discovery (its default).
452      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    // rolldown's generate future is large; box it so it doesn't bloat the
468    // enclosing future.
469    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    // Every tsconfig the resolver consulted, whether pinned or discovered
483    // per module. rolldown reports them alongside the modules it read.
484    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        // Assigned imperatively rather than through `Option::map`: the
508        // map's type lives in a transitive crate this one does not depend on
509        // directly, so it cannot be named for a method-path closure.
510        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  /// Bundle the entry files (TypeScript ok; `node_modules` and shared
526  /// helpers resolved + tree-shaken) into one ESM module and compile it
527  /// to bytecode under `module_name`, which is what error locations and
528  /// stack frames are labelled with. Done once; every realm links the
529  /// result.
530  ///
531  /// # Errors
532  ///
533  /// A bundle failure, or a module that fails to declare.
534  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    // Disk cache: an unchanged source tree skips rolldown AND the QuickJS
543    // compile. Validated against every transitive input's stamp. The
544    // module name participates in the key: it is baked into the written
545    // bytecode (QuickJS stores the module name), so two hosts bundling
546    // the same files under different labels must not share an entry.
547    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  /// Compile already-bundled ESM `code` to `QuickJS` bytecode.
611  ///
612  /// Split out of [`Self::compile`] because bundling and compiling can
613  /// happen in different processes: a client bundles (its working
614  /// directory is the one relative imports resolve against) and a host
615  /// compiles (its `QuickJS` build is the one that will load the
616  /// bytecode), so bytecode never crosses the wire between differently-
617  /// built binaries.
618  ///
619  /// Does not touch the disk cache: the caller owns the key, because
620  /// only it knows which inputs the code was built from.
621  ///
622  /// # Errors
623  ///
624  /// [`ScriptError`] if the module fails to declare (a syntax error, or
625  /// an import the native loader cannot resolve) or to serialize.
626  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    // QuickJS resolves the module graph EAGERLY at declare, and the
637    // bundle keeps native specifiers external — so even this throwaway
638    // compile runtime needs the native resolver/loader. The written
639    // bytecode stores the dependency by NAME and re-links against the
640    // loading realm's own ModuleDefs. Externals the host serves at load
641    // time are declared empty here: linking happens at eval, so an empty
642    // module is enough to let a consumer's import resolve while it is
643    // being compiled.
644    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        // The bundle's only remaining imports are the external native
656        // specifiers, resolved by the loader installed above.
657        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      // This path is handed code that was already bundled elsewhere, so
685      // only the caller knows what its map's paths are relative to; it
686      // sets `cwd` if the answer is not the process's.
687      cwd: None,
688    })
689  }
690}
691
692/// Resolver/loader for the host-served externals in a throwaway compile
693/// realm: each declares as an empty module so the entry links.
694#[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/// True when a path's extension marks it as TypeScript (`.ts`/`.tsx`/
729/// `.mts`/`.cts`) and so must be transpiled through the bundler.
730#[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/// Heuristic: the source begins a line with a static `import`/`export`
739/// and so must run as an ES module (bundled). Dynamic `import(...)` is
740/// intentionally NOT matched — it is valid in a plain script, so such a
741/// script keeps top-level `return`. A false positive only costs an
742/// unnecessary bundle, never wrong output.
743#[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}