char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsupport data urls1c8ffed

main
20.9 KiB561 linesraw
1//! `avril bundle`: Rolldown discovers, links, lowers, and chunks the graph,
2//! asking avril to resolve and load every module exactly as `run` would, with
3//! npm packages in browser mode. No application code runs.
4
5use std::cell::RefCell;
6use std::collections::{BTreeSet, HashMap, HashSet};
7use std::path::{Path, PathBuf};
8use std::rc::Rc;
9use std::sync::{Arc, Mutex};
10
11use crate::registry::npm::resolution::BROWSER_EXCLUDED;
12use avril_core::{Error, ModuleFormat, ModuleLoader};
13use futures::StreamExt;
14use futures::future::LocalBoxFuture;
15use oxc::ast::AstKind;
16use oxc::ast::ast::{Argument, Expression, UnaryOperator, VariableDeclarationKind};
17use oxc::span::GetSpan;
18use rolldown::plugin::{
19    HookLoadArgs, HookLoadOutput, HookLoadReturn, HookResolveIdArgs, HookResolveIdOutput,
20    HookResolveIdReturn, HookTransformArgs, HookTransformOutput, HookTransformOutputMap,
21    HookTransformReturn, HookUsage, Plugin, PluginContext, SharedLoadPluginContext,
22    SharedTransformPluginContext,
23};
24use rolldown_common::side_effects::HookSideEffects;
25use rolldown_common::{ImportKind, ModuleType};
26use tokio::sync::{mpsc, oneshot};
27
28#[derive(Default)]
29pub struct Options {
30    pub minify: bool,
31    pub sourcemap: bool,
32    pub no_splitting: bool,
33    /// The ECMAScript year to lower syntax to; `None` is esnext.
34    pub target: Option<u32>,
35    pub defines: Vec<(String, String)>,
36}
37
38/// Marks CommonJS that Rolldown would otherwise infer to be ESM from its name.
39const COMMONJS: &str = "\0.cjs";
40
41/// One bundler input: an entry module with an optional chunk name.
42pub struct Entry {
43    pub name: Option<String>,
44    pub import: String,
45}
46
47/// An in-memory bundle plus the tracking its caller persists.
48pub struct Generated {
49    pub files: Vec<(String, Vec<u8>)>,
50    pub warnings: Vec<String>,
51    pub lock_keys: BTreeSet<String>,
52    pub modules: HashSet<String>,
53}
54
55/// Generate using a fresh graph from [`crate::modules::ModuleGraph::open_browser`].
56/// `file_name` is the entry filename template (`[name].js` for named entries);
57/// `directory` is its absolute output location for source maps and need not exist.
58/// Outputs and lockfile changes are returned, not published; package acquisition
59/// may still populate caches. Diagnostics are returned without printing.
60///
61/// Poll within a Tokio runtime on the thread owning `graph`; the future is `!Send`.
62pub async fn generate(
63    graph: &crate::modules::ModuleGraph,
64    entries: &[Entry],
65    file_name: &str,
66    directory: &Path,
67    options: &Options,
68) -> Result<Generated, Error> {
69    let input = entries
70        .iter()
71        .map(|entry| rolldown::InputItem {
72            name: entry.name.clone(),
73            import: entry.import.clone(),
74        })
75        .collect();
76    let host = Rc::new(Host {
77        project_root: graph.store.options().project_root.clone(),
78        target: options.target,
79        modules: Arc::default(),
80        lock_keys: RefCell::default(),
81        warnings: RefCell::default(),
82        error: RefCell::default(),
83        graph: graph.clone(),
84    });
85    let files = generate_bundle(&host, input, directory, file_name, options).await?;
86    let warnings = std::mem::take(&mut *host.warnings.borrow_mut());
87    Ok(Generated {
88        files,
89        warnings,
90        lock_keys: host.lock_keys.borrow().clone(),
91        modules: std::mem::take(&mut *host.modules.lock().unwrap()),
92    })
93}
94
95async fn generate_bundle(
96    host: &Rc<Host>,
97    input: Vec<rolldown::InputItem>,
98    directory: &Path,
99    file_name: &str,
100    options: &Options,
101) -> Result<Vec<(String, Vec<u8>)>, Error> {
102    use rolldown::*;
103    let entry_names: HashSet<String> = input
104        .iter()
105        .filter_map(|entry| entry.name.clone())
106        .collect();
107    let target = options
108        .target
109        .map_or("esnext".to_owned(), |year| format!("es{year}"));
110    let options = BundlerOptions {
111        input: Some(input),
112        // Stable ids, and so chunk hashes, depend only on the project layout.
113        cwd: Some(host.project_root.clone()),
114        dir: Some(directory.to_string_lossy().into_owned()),
115        entry_filenames: Some(file_name.to_owned().into()),
116        chunk_filenames: Some("[name]-[hash].js".to_owned().into()),
117        // Explicit entry names must not be silently sanitized or deconflicted.
118        sanitize_filename: Some(SanitizeFilename::Fn(Arc::new(move |name| {
119            let sanitize = !entry_names.contains(name);
120            let name = name.to_owned();
121            Box::pin(async move {
122                Ok(SanitizeFilename::Boolean(sanitize)
123                    .call(&name)
124                    .await?
125                    .to_string())
126            })
127        }))),
128        format: Some(OutputFormat::Esm),
129        // `Browser` would implicitly define `process.env.NODE_ENV`.
130        platform: Some(Platform::Neutral),
131        code_splitting: Some(CodeSplittingMode::Bool(!options.no_splitting)),
132        minify: options.minify.then_some(RawMinifyOptions::Bool(true)),
133        sourcemap: options.sourcemap.then_some(SourceMapType::File),
134        sourcemap_path_transform: Some(SourceMapPathTransform::new(Arc::new({
135            let modules = host.modules.clone();
136            move |sources, map| {
137                // Rolldown relativizes sources as paths, mangling remote URLs.
138                use sugar_path::SugarPath;
139                let directory = Path::new(map).parent().unwrap();
140                let remote: HashMap<String, String> = modules
141                    .lock()
142                    .unwrap()
143                    .iter()
144                    .filter(|name| {
145                        crate::modules::http_url(name).is_some()
146                            || crate::modules::data_url(name).is_some()
147                    })
148                    .map(|url| {
149                        (
150                            url.as_path()
151                                .relative(directory)
152                                .to_string_lossy()
153                                .into_owned(),
154                            url.clone(),
155                        )
156                    })
157                    .collect();
158                let sources = sources
159                    .into_iter()
160                    .map(|source| match remote.get(&source) {
161                        Some(url) => url.clone(),
162                        None => canonical(&source).to_owned(),
163                    })
164                    .collect();
165                Box::pin(async move { Ok(sources) })
166            }
167        }))),
168        define: Some(options.defines.iter().cloned().collect()),
169        // Rolldown's transformer, minifier, and helpers all lower to this.
170        transform: Some(BundlerTransformOptions {
171            target: Some(Either::Left(target.clone())),
172            ..Default::default()
173        }),
174        // Modules arrive compiled with their own options; never read other configs.
175        tsconfig: Some(TsConfig::Auto(false)),
176        experimental: Some(ExperimentalOptions {
177            // Region comments would carry module ids into the output.
178            attach_debug_info: Some(AttachDebugInfo::None),
179            // Surfaces asset references to `resolve` so they are rejected.
180            resolve_new_url_to_asset: Some(true),
181            ..Default::default()
182        }),
183        ..Default::default()
184    };
185    let (sender, mut receiver) = mpsc::unbounded_channel::<Job>();
186    let bundle = async move {
187        let mut bundler = Bundler::with_plugins(options, vec![Plugin::new_shared(Bridge(sender))])?;
188        bundler.generate().await
189    };
190    let serve = futures::stream::poll_fn(|context| receiver.poll_recv(context))
191        .for_each_concurrent(None, |job| job(host.clone()));
192    let output = match futures::future::select(std::pin::pin!(bundle), std::pin::pin!(serve)).await
193    {
194        futures::future::Either::Left((output, _)) => output,
195        futures::future::Either::Right(_) => unreachable!("the bundler holds the sender"),
196    }
197    .map_err(|errors| {
198        host.error
199            .take()
200            .unwrap_or_else(|| Error::Module(format!("Bundling failed:\n{errors}")))
201    })?;
202    for warning in &output.warnings {
203        // Oxc tolerates syntax it cannot lower and would leave it in place.
204        if matches!(
205            warning.kind(),
206            rolldown_error::EventKind::ToleratedTransform
207        ) {
208            return Err(Error::Module(format!(
209                "Cannot bundle for {target}:\n{warning}"
210            )));
211        }
212        host.warnings.borrow_mut().push(warning.to_string());
213    }
214    output
215        .assets
216        .iter()
217        .map(|asset| match asset {
218            rolldown_common::Output::Asset(map) if !map.filename.ends_with(".map") => Err(
219                Error::Module(format!("Unexpected bundle asset: {}", map.filename)),
220            ),
221            asset => Ok((
222                asset.filename().to_owned(),
223                asset.content_as_bytes().to_vec(),
224            )),
225        })
226        .collect()
227}
228
229/// Rolldown's hooks must be `Send`, but the module graph is not: each hook
230/// call becomes a job serviced by the thread that owns the graph.
231type Job = Box<dyn FnOnce(Rc<Host>) -> LocalBoxFuture<'static, ()> + Send>;
232
233#[derive(Debug)]
234struct Bridge(mpsc::UnboundedSender<Job>);
235
236impl Bridge {
237    fn call<T: Send + 'static>(
238        &self,
239        task: impl FnOnce(Rc<Host>) -> LocalBoxFuture<'static, Result<T, Error>> + Send + 'static,
240    ) -> impl Future<Output = anyhow::Result<T>> + Send + 'static {
241        let (reply, response) = oneshot::channel();
242        // A stopped host drops the job, and with it the reply sender.
243        let _ = self.0.send(Box::new(move |host| {
244            Box::pin(async move {
245                let result = task(host.clone()).await.map_err(|error| {
246                    let message = error.to_string();
247                    // Rolldown reports plugin errors without their message.
248                    host.error.borrow_mut().get_or_insert(error);
249                    message
250                });
251                let _ = reply.send(result);
252            })
253        }));
254        async move { response.await?.map_err(anyhow::Error::msg) }
255    }
256}
257
258impl Plugin for Bridge {
259    fn name(&self) -> std::borrow::Cow<'static, str> {
260        "avril".into()
261    }
262
263    fn register_hook_usage(&self) -> HookUsage {
264        HookUsage::ResolveId | HookUsage::Load | HookUsage::Transform
265    }
266
267    fn resolve_id(
268        &self,
269        _: &PluginContext,
270        args: &HookResolveIdArgs<'_>,
271    ) -> impl Future<Output = HookResolveIdReturn> + Send {
272        let specifier = args.specifier.to_owned();
273        let importer = args.importer.map(str::to_owned);
274        let kind = args.kind;
275        let resolved = self.call(move |host| {
276            Box::pin(async move { host.resolve(&specifier, importer.as_deref(), kind).await })
277        });
278        async move { resolved.await.map(Some) }
279    }
280
281    fn load(
282        &self,
283        _: SharedLoadPluginContext,
284        args: &HookLoadArgs<'_>,
285    ) -> impl Future<Output = HookLoadReturn> + Send {
286        let id = args.id.to_owned();
287        let loaded = self.call(move |host| Box::pin(async move { host.load(&id).await }));
288        async move { loaded.await.map(Some) }
289    }
290
291    /// Rolldown treats modules using a free `module` or `exports` as CommonJS
292    /// whatever their name says, unless they have an export, so avril's ES
293    /// modules get an empty one. It moves no code, so maps stay as they were.
294    fn transform(
295        &self,
296        _: SharedTransformPluginContext,
297        args: &HookTransformArgs<'_>,
298    ) -> impl Future<Output = HookTransformReturn> + Send {
299        let code = format!("{}\nexport {{}};\n", args.code);
300        let id = args.id.to_owned();
301        let format = (matches!(args.module_type, ModuleType::Js)
302            && !id.starts_with('\0')
303            && !id.ends_with(BROWSER_EXCLUDED))
304        .then(|| {
305            self.call(move |host| Box::pin(async move { host.graph.module_format(canonical(&id)) }))
306        });
307        async move {
308            let Some(format) = format else {
309                return Ok(None);
310            };
311            Ok(
312                (format.await? == ModuleFormat::Esm).then(|| HookTransformOutput {
313                    code: Some(code),
314                    map: HookTransformOutputMap::Null,
315                    ..Default::default()
316                }),
317            )
318        }
319    }
320}
321
322struct Host {
323    graph: crate::modules::ModuleGraph,
324    project_root: PathBuf,
325    target: Option<u32>,
326    /// Shared with the source map path transform.
327    modules: Arc<Mutex<HashSet<String>>>,
328    lock_keys: RefCell<BTreeSet<String>>,
329    warnings: RefCell<Vec<String>>,
330    error: RefCell<Option<Error>>,
331}
332
333impl Host {
334    async fn resolve(
335        &self,
336        specifier: &str,
337        importer: Option<&str>,
338        kind: ImportKind,
339    ) -> Result<HookResolveIdOutput, Error> {
340        let name = match importer {
341            None => crate::modules::data_url(specifier)
342                .map_or_else(|| specifier.to_owned(), String::from),
343            Some(importer) => self
344                .resolve_from(specifier, canonical(importer), kind)
345                .await
346                .map_err(|error| {
347                    Error::Module(format!("{error} (imported from {})", canonical(importer)))
348                })?,
349        };
350        let id = if name.ends_with(BROWSER_EXCLUDED) {
351            name.clone()
352        } else if self.graph.module_format(&name)? == ModuleFormat::CommonJs
353            && !name.ends_with(".cjs")
354            && !name.ends_with(".cts")
355        {
356            format!("{name}{COMMONJS}")
357        } else {
358            name.clone()
359        };
360        Ok(HookResolveIdOutput {
361            id: id.into(),
362            side_effects: self.graph.side_effects(&name).map(|declared| {
363                if declared {
364                    HookSideEffects::True
365                } else {
366                    HookSideEffects::False
367                }
368            }),
369            ..Default::default()
370        })
371    }
372
373    async fn resolve_from(
374        &self,
375        specifier: &str,
376        importer: &str,
377        kind: ImportKind,
378    ) -> Result<String, Error> {
379        let name = match kind {
380            ImportKind::Import | ImportKind::DynamicImport => {
381                let (name, key) = self.graph.resolve_tracked(specifier, importer).await?;
382                self.lock_keys.borrow_mut().extend(key);
383                name
384            }
385            ImportKind::Require if self.graph.module_format(importer)? == ModuleFormat::Esm => {
386                return Err(Error::Module(format!(
387                    "require({specifier:?}) is unavailable in ES modules; import it instead"
388                )));
389            }
390            ImportKind::Require => self.graph.require_resolve(specifier, importer)?,
391            ImportKind::NewUrl => {
392                return Err(Error::Module(format!(
393                    "Asset references are not supported: new URL({specifier:?}, import.meta.url)"
394                )));
395            }
396            kind => {
397                return Err(Error::Module(format!(
398                    "Unsupported {kind:?} reference: {specifier}"
399                )));
400            }
401        };
402        if name.starts_with("avril:") {
403            return Err(Error::Module(format!(
404                "{name} has no browser implementation"
405            )));
406        }
407        Ok(name)
408    }
409
410    async fn load(&self, id: &str) -> Result<HookLoadOutput, Error> {
411        if id.ends_with(BROWSER_EXCLUDED) {
412            return Ok(HookLoadOutput {
413                code: "module.exports = {};".into(),
414                module_type: Some(ModuleType::Js),
415                ..Default::default()
416            });
417        }
418        let name = canonical(id);
419        self.graph.check_access(name)?;
420        self.graph.initialize_package(name).await?;
421        let format = self.graph.module_format(name)?;
422        let source = self.graph.original_source(name).await?;
423        self.modules.lock().unwrap().insert(name.to_owned());
424        if format == ModuleFormat::Json {
425            return Ok(HookLoadOutput {
426                code: source.into(),
427                module_type: Some(ModuleType::Json),
428                ..Default::default()
429            });
430        }
431        let source_type = self.graph.source_type(name)?;
432        let (code, map) = if source_type.is_typescript() || source_type.is_jsx() {
433            let options = if source_type.is_jsx() {
434                self.graph
435                    .jsx_config(name)?
436                    .transform_options()
437                    .map_err(|error| Error::Module(format!("{name}: {error}")))?
438            } else {
439                Default::default()
440            };
441            // JSX development metadata names local files relative to the project.
442            let path = match Path::new(name) {
443                path if path.is_absolute() => {
444                    sugar_path::SugarPath::relative(path, &self.project_root)
445                }
446                path => path.into(),
447            };
448            let (code, mut map) =
449                crate::modules::compile(name, &path, &source, source_type, options)?;
450            map.set_source_contents(vec![Some(&source)]);
451            (code, Some(map))
452        } else {
453            (source, None)
454        };
455        let specifiers = analyze(name, &code, format == ModuleFormat::CommonJs, self.target)?;
456        let keys = self
457            .graph
458            .prepare_npm_roots(name, format, &specifiers)
459            .await?;
460        self.lock_keys.borrow_mut().extend(keys);
461        Ok(HookLoadOutput {
462            code: code.into(),
463            map,
464            module_type: Some(ModuleType::Js),
465            ..Default::default()
466        })
467    }
468}
469
470fn canonical(id: &str) -> &str {
471    id.split_once('\0').map_or(id, |(name, _)| name)
472}
473
474/// Rejects what Rolldown would otherwise turn into broken browser code: a
475/// free `require` that is not a literal call (left behind as a throwing
476/// shim), computed `import()`, and top-level await below ES2022. Returns
477/// the static import specifiers.
478fn analyze(
479    name: &str,
480    code: &str,
481    commonjs: bool,
482    target: Option<u32>,
483) -> Result<Vec<String>, Error> {
484    use oxc::allocator::Allocator;
485    use oxc::span::SourceType;
486    let allocator = Allocator::default();
487    let source_type = if commonjs {
488        SourceType::cjs()
489    } else {
490        SourceType::mjs()
491    };
492    let parsed = oxc::parser::Parser::new(&allocator, code, source_type).parse();
493    if let Some(error) = parsed.diagnostics.into_iter().next() {
494        let error = error.render_with_source_code(oxc::diagnostics::NamedSource::new(name, code));
495        return Err(Error::Syntax(format!("{name}\n{error}")));
496    }
497    let semantic = oxc::semantic::SemanticBuilder::new()
498        .with_build_nodes(true)
499        .build(&parsed.program)
500        .semantic;
501    let (scoping, nodes) = (semantic.scoping(), semantic.nodes());
502    let fail = |message: &str| Err(Error::Module(format!("{name}: {message}")));
503    let literal = |argument: Option<&Argument>| match argument {
504        Some(Argument::StringLiteral(_)) => true,
505        Some(Argument::TemplateLiteral(template)) => template.is_no_substitution_template(),
506        _ => false,
507    };
508    let unresolved = scoping.root_unresolved_references();
509    for &reference in unresolved.get("require").into_iter().flatten() {
510        let node = scoping.get_reference(reference).node_id();
511        let span = nodes.get_node(node).span();
512        let bundleable = match nodes.parent_kind(node) {
513            AstKind::CallExpression(call) => {
514                call.callee.span() == span
515                    && !call.optional
516                    && call.arguments.len() == 1
517                    && literal(call.arguments.first())
518            }
519            AstKind::UnaryExpression(unary) => unary.operator == UnaryOperator::Typeof,
520            _ => false,
521        };
522        if !bundleable {
523            return fail("only direct require('literal') calls can be bundled");
524        }
525    }
526    for node in nodes.iter() {
527        let awaits = match node.kind() {
528            AstKind::ImportExpression(import) => {
529                if !matches!(&import.source, Expression::StringLiteral(_))
530                    && !matches!(&import.source, Expression::TemplateLiteral(template) if template.is_no_substitution_template())
531                {
532                    return fail("computed import() cannot be bundled; use a string literal");
533                }
534                false
535            }
536            AstKind::AwaitExpression(_) => true,
537            AstKind::ForOfStatement(statement) => statement.r#await,
538            AstKind::VariableDeclaration(declaration) => {
539                declaration.kind == VariableDeclarationKind::AwaitUsing
540            }
541            _ => false,
542        };
543        if awaits
544            && target.is_some_and(|year| year < 2022)
545            && !scoping
546                .scope_ancestors(node.scope_id())
547                .any(|scope| scoping.scope_flags(scope).is_function())
548        {
549            return fail(&format!(
550                "top-level await requires es2022 or later, not es{}",
551                target.unwrap()
552            ));
553        }
554    }
555    Ok(parsed
556        .module_record
557        .requested_modules
558        .keys()
559        .map(ToString::to_string)
560        .collect())
561}