char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
1c8ffed
main
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::{ 19HookLoadArgs , HookLoadOutput , HookLoadReturn , HookResolveIdArgs , HookResolveIdOutput , 20HookResolveIdReturn , HookTransformArgs , HookTransformOutput , HookTransformOutputMap , 21HookTransformReturn , HookUsage , Plugin , PluginContext , SharedLoadPluginContext , 22SharedTransformPluginContext , 23}; 24use rolldown_common:: side_effects:: HookSideEffects ; 25use rolldown_common::{ ImportKind , ModuleType }; 26use tokio:: sync::{ mpsc, oneshot}; 27 28# [ derive ( Default )] 29pub struct Options { 30pub minify : bool , 31pub sourcemap : bool , 32pub no_splitting : bool , 33/// The ECMAScript year to lower syntax to; `None` is esnext. 34pub target : Option < u32 >, 35pub 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 { 43pub name : Option < String >, 44pub import : String , 45} 46 47/// An in-memory bundle plus the tracking its caller persists. 48pub struct Generated { 49pub files : Vec <( String , Vec < u8 >)>, 50pub warnings : Vec < String >, 51pub lock_keys : BTreeSet < String >, 52pub 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 ( 63graph : & crate :: modules:: ModuleGraph , 64entries : & [ Entry ], 65file_name : & str , 66directory : & Path , 67options : & Options , 68) ->Result < Generated , Error > { 69let input = entries 70. iter () 71. map ( |entry| rolldown:: InputItem { 72name : entry. name . clone (), 73import : entry. import . clone (), 74}) 75. collect (); 76let host =Rc :: new ( Host { 77project_root : graph. store . options (). project_root . clone (), 78target : options. target , 79modules : Arc :: default (), 80lock_keys : RefCell :: default (), 81warnings : RefCell :: default (), 82error : RefCell :: default (), 83graph : graph. clone (), 84}); 85let files =generate_bundle ( & host, input, directory, file_name, options). await ?; 86let warnings = std:: mem:: take ( & mut * host. warnings . borrow_mut ()); 87Ok ( Generated { 88 files, 89 warnings, 90lock_keys : host. lock_keys . borrow (). clone (), 91modules : std:: mem:: take ( & mut * host. modules . lock (). unwrap ()), 92}) 93} 94 95async fn generate_bundle ( 96host : & Rc < Host >, 97input : Vec < rolldown:: InputItem >, 98directory : & Path , 99file_name : & str , 100options : & Options , 101) ->Result < Vec <( String , Vec < u8 >)>, Error > { 102use rolldown:: * ; 103let entry_names: HashSet < String > = input 104. iter () 105. filter_map ( |entry| entry. name . clone ()) 106. collect (); 107let target = options 108. target 109. map_or ( "esnext" . to_owned (), |year|format! ( "es{year}" )); 110let options =BundlerOptions { 111input : Some ( input), 112// Stable ids, and so chunk hashes, depend only on the project layout. 113cwd : Some ( host. project_root . clone ()), 114dir : Some ( directory. to_string_lossy (). into_owned ()), 115entry_filenames : Some ( file_name. to_owned (). into ()), 116chunk_filenames : Some ( "[name]-[hash].js" . to_owned (). into ()), 117// Explicit entry names must not be silently sanitized or deconflicted. 118sanitize_filename : Some ( SanitizeFilename :: Fn ( Arc :: new ( move |name|{ 119let sanitize = !entry_names. contains ( name); 120let name = name. to_owned (); 121Box :: pin ( async move { 122Ok ( SanitizeFilename :: Boolean ( sanitize) 123. call ( & name) 124. await ? 125. to_string ()) 126}) 127}))), 128format : Some ( OutputFormat :: Esm ), 129// `Browser` would implicitly define `process.env.NODE_ENV`. 130platform : Some ( Platform :: Neutral ), 131code_splitting : Some ( CodeSplittingMode :: Bool ( !options. no_splitting )), 132minify : options. minify . then_some ( RawMinifyOptions :: Bool ( true )), 133sourcemap : options. sourcemap . then_some ( SourceMapType :: File ), 134sourcemap_path_transform : Some ( SourceMapPathTransform :: new ( Arc :: new ({ 135let modules = host. modules . clone (); 136move |sources, map|{ 137// Rolldown relativizes sources as paths, mangling remote URLs. 138use sugar_path:: SugarPath ; 139let directory =Path :: new ( map). parent (). unwrap (); 140let remote: HashMap < String , String > = modules 141. lock () 142. unwrap () 143. iter () 144. filter ( |name|{ 145crate :: 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 (); 158let sources = sources 159. into_iter () 160. map ( |source|match remote. get ( & source) { 161Some ( url) => url. clone (), 162None =>canonical ( & source). to_owned (), 163}) 164. collect (); 165Box :: pin ( async move { Ok ( sources) }) 166} 167}))), 168define : Some ( options. defines . iter (). cloned (). collect ()), 169// Rolldown's transformer, minifier, and helpers all lower to this. 170transform : Some ( BundlerTransformOptions { 171target : Some ( Either :: Left ( target. clone ())), 172 ..Default :: default () 173}), 174// Modules arrive compiled with their own options; never read other configs. 175tsconfig : Some ( TsConfig :: Auto ( false )), 176experimental : Some ( ExperimentalOptions { 177// Region comments would carry module ids into the output. 178attach_debug_info : Some ( AttachDebugInfo :: None ), 179// Surfaces asset references to `resolve` so they are rejected. 180resolve_new_url_to_asset : Some ( true ), 181 ..Default :: default () 182}), 183 ..Default :: default () 184}; 185let ( sender, mut receiver) = mpsc:: unbounded_channel ::< Job >(); 186let bundle =async move { 187let mut bundler =Bundler :: with_plugins ( options, vec! [ Plugin :: new_shared ( Bridge ( sender))]) ?; 188 bundler. generate (). await 189}; 190let serve = futures:: stream:: poll_fn ( |context| receiver. poll_recv ( context)) 191. for_each_concurrent ( None , |job|job ( host. clone ())); 192let 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}) ?; 202for warningin & output. warnings { 203// Oxc tolerates syntax it cannot lower and would leave it in place. 204if matches! ( 205 warning. kind (), 206 rolldown_error:: EventKind :: ToleratedTransform 207) { 208return 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 ( 219Error :: 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 { 237fn call < T : Send +' static >( 238& self , 239task : impl FnOnce ( Rc < Host >) ->LocalBoxFuture < ' static , Result < T , Error >> +Send +' static , 240) ->impl Future < Output = anyhow:: Result < T >> +Send +' static { 241let ( reply, response) = oneshot:: channel (); 242// A stopped host drops the job, and with it the reply sender. 243let _ =self . 0 . send ( Box :: new ( move |host|{ 244Box :: pin ( async move { 245let result =task ( host. clone ()). await . map_err ( |error|{ 246let message = error. to_string (); 247// Rolldown reports plugin errors without their message. 248 host. error . borrow_mut (). get_or_insert ( error); 249 message 250}); 251let _ = reply. send ( result); 252}) 253})); 254async move { response. await ?. map_err ( anyhow:: Error :: msg) } 255} 256} 257 258impl Plugin for Bridge { 259fn name ( & self ) -> std:: borrow:: Cow < ' static , str > { 260"avril" . into () 261} 262 263fn register_hook_usage ( & self ) ->HookUsage { 264HookUsage :: ResolveId |HookUsage :: Load |HookUsage :: Transform 265} 266 267fn resolve_id ( 268& self , 269 _: & PluginContext , 270args : & HookResolveIdArgs < ' _ >, 271) ->impl Future < Output =HookResolveIdReturn > +Send { 272let specifier = args. specifier . to_owned (); 273let importer = args. importer . map ( str:: to_owned); 274let kind = args. kind ; 275let resolved =self . call ( move |host|{ 276Box :: pin ( async move { host. resolve ( & specifier, importer. as_deref (), kind). await }) 277}); 278async move { resolved. await . map ( Some ) } 279} 280 281fn load ( 282& self , 283 _: SharedLoadPluginContext , 284args : & HookLoadArgs < ' _ >, 285) ->impl Future < Output =HookLoadReturn > +Send { 286let id = args. id . to_owned (); 287let loaded =self . call ( move |host|Box :: pin ( async move { host. load ( & id). await })); 288async 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. 294fn transform ( 295& self , 296 _: SharedTransformPluginContext , 297args : & HookTransformArgs < ' _ >, 298) ->impl Future < Output =HookTransformReturn > +Send { 299let code =format! ( "{}\nexport {{}};\n" , args. code ); 300let id = args. id . to_owned (); 301let format =( matches! ( args. module_type , ModuleType :: Js ) 302 && !id. starts_with ( '\0' ) 303 && !id. ends_with ( BROWSER_EXCLUDED )) 304. then ( ||{ 305self . call ( move |host|Box :: pin ( async move { host. graph . module_format ( canonical ( & id)) })) 306}); 307async move { 308let Some ( format) = formatelse { 309return Ok ( None ); 310}; 311Ok ( 312( format. await ? ==ModuleFormat :: Esm ). then ( ||HookTransformOutput { 313code : Some ( code), 314map : HookTransformOutputMap :: Null , 315 ..Default :: default () 316}), 317) 318} 319} 320} 321 322struct Host { 323graph : crate :: modules:: ModuleGraph , 324project_root : PathBuf , 325target : Option < u32 >, 326/// Shared with the source map path transform. 327modules : Arc < Mutex < HashSet < String >>>, 328lock_keys : RefCell < BTreeSet < String >>, 329warnings : RefCell < Vec < String >>, 330error : RefCell < Option < Error >>, 331} 332 333impl Host { 334async fn resolve ( 335& self , 336specifier : & str , 337importer : Option < & str >, 338kind : ImportKind , 339) ->Result < HookResolveIdOutput , Error > { 340let name =match importer{ 341None =>crate :: modules:: data_url ( specifier) 342. map_or_else ( || specifier. to_owned (), String :: from), 343Some ( importer) =>self 344. resolve_from ( specifier, canonical ( importer), kind) 345. await 346. map_err ( |error|{ 347Error :: Module ( format! ( "{error} (imported from {})" , canonical ( importer))) 348}) ?, 349}; 350let 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{ 356format! ( "{name}{COMMONJS}" ) 357} else { 358 name. clone () 359}; 360Ok ( HookResolveIdOutput { 361id : id. into (), 362side_effects : self . graph . side_effects ( & name). map ( |declared|{ 363if declared{ 364HookSideEffects :: True 365} else { 366HookSideEffects :: False 367} 368}), 369 ..Default :: default () 370}) 371} 372 373async fn resolve_from ( 374& self , 375specifier : & str , 376importer : & str , 377kind : ImportKind , 378) ->Result < String , Error > { 379let name =match kind{ 380ImportKind :: Import |ImportKind :: DynamicImport =>{ 381let ( name, key) =self . graph . resolve_tracked ( specifier, importer). await ?; 382self . lock_keys . borrow_mut (). extend ( key); 383 name 384} 385ImportKind :: Require if self . graph . module_format ( importer) ? ==ModuleFormat :: Esm =>{ 386return Err ( Error :: Module ( format! ( 387"require({specifier:?}) is unavailable in ES modules; import it instead" 388))); 389} 390ImportKind :: Require =>self . graph . require_resolve ( specifier, importer) ?, 391ImportKind :: NewUrl =>{ 392return Err ( Error :: Module ( format! ( 393"Asset references are not supported: new URL({specifier:?}, import.meta.url)" 394))); 395} 396 kind =>{ 397return Err ( Error :: Module ( format! ( 398"Unsupported {kind:?} reference: {specifier}" 399))); 400} 401}; 402if name. starts_with ( "avril:" ) { 403return Err ( Error :: Module ( format! ( 404"{name} has no browser implementation" 405))); 406} 407Ok ( name) 408} 409 410async fn load ( & self , id : & str ) ->Result < HookLoadOutput , Error > { 411if id. ends_with ( BROWSER_EXCLUDED ) { 412return Ok ( HookLoadOutput { 413code : "module.exports = {};" . into (), 414module_type : Some ( ModuleType :: Js ), 415 ..Default :: default () 416}); 417} 418let name =canonical ( id); 419self . graph . check_access ( name) ?; 420self . graph . initialize_package ( name). await ?; 421let format =self . graph . module_format ( name) ?; 422let source =self . graph . original_source ( name). await ?; 423self . modules . lock (). unwrap (). insert ( name. to_owned ()); 424if format ==ModuleFormat :: Json { 425return Ok ( HookLoadOutput { 426code : source. into (), 427module_type : Some ( ModuleType :: Json ), 428 ..Default :: default () 429}); 430} 431let source_type =self . graph . source_type ( name) ?; 432let ( code, map) =if source_type. is_typescript () || source_type. is_jsx () { 433let options =if source_type. is_jsx () { 434self . graph 435. jsx_config ( name) ? 436. transform_options () 437. map_err ( |error|Error :: Module ( format! ( "{name}: {error}" ))) ? 438} else { 439Default :: default () 440}; 441// JSX development metadata names local files relative to the project. 442let path =match Path :: new ( name) { 443 pathif path. is_absolute () =>{ 444 sugar_path:: SugarPath :: relative ( path, & self . project_root ) 445} 446 path => path. into (), 447}; 448let ( code, mut map) = 449crate :: 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}; 455let specifiers =analyze ( name, & code, format ==ModuleFormat :: CommonJs , self . target ) ?; 456let keys =self 457. graph 458. prepare_npm_roots ( name, format, & specifiers) 459. await ?; 460self . lock_keys . borrow_mut (). extend ( keys); 461Ok ( HookLoadOutput { 462code : code. into (), 463 map, 464module_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 ( 479name : & str , 480code : & str , 481commonjs : bool , 482target : Option < u32 >, 483) ->Result < Vec < String >, Error > { 484use oxc:: allocator:: Allocator ; 485use oxc:: span:: SourceType ; 486let allocator =Allocator :: default (); 487let source_type =if commonjs{ 488SourceType :: cjs () 489} else { 490SourceType :: mjs () 491}; 492let parsed = oxc:: parser:: Parser :: new ( & allocator, code, source_type). parse (); 493if let Some ( error) = parsed. diagnostics . into_iter (). next () { 494let error = error. render_with_source_code ( oxc:: diagnostics:: NamedSource :: new ( name, code)); 495return Err ( Error :: Syntax ( format! ( "{name}\n{error}" ))); 496} 497let semantic = oxc:: semantic:: SemanticBuilder :: new () 498. with_build_nodes ( true ) 499. build ( & parsed. program ) 500. semantic ; 501let ( scoping, nodes) =( semantic. scoping (), semantic. nodes ()); 502let fail = |message : & str |Err ( Error :: Module ( format! ( "{name}: {message}" ))); 503let literal = |argument : Option < & Argument > |match argument{ 504Some ( Argument :: StringLiteral ( _)) =>true , 505Some ( Argument :: TemplateLiteral ( template)) => template. is_no_substitution_template (), 506 _ =>false , 507}; 508let unresolved = scoping. root_unresolved_references (); 509for & referencein unresolved. get ( "require" ). into_iter (). flatten () { 510let node = scoping. get_reference ( reference). node_id (); 511let span = nodes. get_node ( node). span (); 512let bundleable =match nodes. parent_kind ( node) { 513AstKind :: CallExpression ( call) =>{ 514 call. callee . span () == span 515 && !call. optional 516 && call. arguments . len () ==1 517 &&literal ( call. arguments . first ()) 518} 519AstKind :: UnaryExpression ( unary) => unary. operator ==UnaryOperator :: Typeof , 520 _ =>false , 521}; 522if !bundleable{ 523return fail ( "only direct require('literal') calls can be bundled" ); 524} 525} 526for nodein nodes. iter () { 527let awaits =match node. kind () { 528AstKind :: ImportExpression ( import) =>{ 529if !matches! ( & import. source , Expression :: StringLiteral ( _)) 530 && !matches! ( & import. source , Expression :: TemplateLiteral ( template) if template. is_no_substitution_template ()) 531{ 532return fail ( "computed import() cannot be bundled; use a string literal" ); 533} 534false 535} 536AstKind :: AwaitExpression ( _) =>true , 537AstKind :: ForOfStatement ( statement) => statement. r#await , 538AstKind :: VariableDeclaration ( declaration) =>{ 539 declaration. kind ==VariableDeclarationKind :: AwaitUsing 540} 541 _ =>false , 542}; 543if 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{ 549return fail ( & format! ( 550"top-level await requires es2022 or later, not es{}" , 551 target. unwrap () 552)); 553} 554} 555Ok ( parsed 556. module_record 557. requested_modules 558. keys () 559. map ( ToString :: to_string) 560. collect ()) 561}