oxedyne/fe2o3/fe2o3_steel/src/srv/dev/js.rs
15.2 KiB, 42 runs
created by r1870400018:979, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! A JavaScript and TypeScript module bundler based on the SWC compiler. |
| 2 | //! |
| 3 | //! This module provides functionality to bundle JavaScript and TypeScript files into a single |
| 4 | //! minified output file. It handles module dependencies, import resolution, and code transformations. |
| 5 | use oxedyne_fe2o3_core::{ |
| 6 | prelude::*, |
| 7 | }; |
| 8 | |
| 9 | use std::{ |
| 10 | collections::{ |
| 11 | HashMap, |
| 12 | HashSet, |
| 13 | }, |
| 14 | path::{ |
| 15 | Path, |
| 16 | PathBuf, |
| 17 | }, |
| 18 | str::FromStr, |
| 19 | sync::Arc, |
| 20 | }; |
| 21 | |
| 22 | use swc_bundler::{ |
| 23 | Bundler, |
| 24 | Hook, |
| 25 | Load, |
| 26 | ModuleData, |
| 27 | ModuleRecord, |
| 28 | ModuleType, |
| 29 | Resolve, |
| 30 | }; |
| 31 | use swc_common::{ |
| 32 | FileName, |
| 33 | GLOBALS, |
| 34 | Span, |
| 35 | SourceMap, |
| 36 | }; |
| 37 | use swc_ecma_ast::{ |
| 38 | EsVersion, |
| 39 | KeyValueProp, |
| 40 | PropName, |
| 41 | Expr, |
| 42 | Lit, |
| 43 | Str, |
| 44 | IdentName, |
| 45 | }; |
| 46 | use swc_ecma_codegen::{ |
| 47 | text_writer::JsWriter, |
| 48 | Emitter, |
| 49 | }; |
| 50 | use swc_ecma_loader::{ |
| 51 | resolve::Resolution, |
| 52 | resolvers::{ |
| 53 | lru::CachingResolver, |
| 54 | node::NodeModulesResolver, |
| 55 | }, |
| 56 | TargetEnv, |
| 57 | }; |
| 58 | use swc_ecma_parser::{ |
| 59 | parse_file_as_module, |
| 60 | Syntax, |
| 61 | TsSyntax, |
| 62 | }; |
| 63 | |
| 64 | |
| 65 | /// Supported file extensions for bundling. |
| 66 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 67 | pub enum FileType { |
| 68 | JavaScript, |
| 69 | JavaScriptModule, |
| 70 | TypeScript, |
| 71 | TypeScriptModule, |
| 72 | TypeScriptReact, |
| 73 | } |
| 74 | |
| 75 | impl FromStr for FileType { |
| 76 | type Err = Error<ErrTag>; |
| 77 | |
| 78 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 79 | match s { |
| 80 | "js" => Ok(Self::JavaScript), |
| 81 | "mjs" => Ok(Self::JavaScriptModule), |
| 82 | "ts" => Ok(Self::TypeScript), |
| 83 | "mts" => Ok(Self::TypeScriptModule), |
| 84 | "tsx" => Ok(Self::TypeScriptReact), |
| 85 | _ => Err(err!( |
| 86 | "Unknown file type extension: {}", s; |
| 87 | Invalid, Input, String, Conversion)) |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /// Module resolution allowing the use of javascript import aliases. |
| 93 | #[derive(Debug)] |
| 94 | pub struct AliasResolver { |
| 95 | aliases: Arc<Vec<(String, PathBuf)>>, |
| 96 | } |
| 97 | |
| 98 | impl AliasResolver { |
| 99 | pub fn new( |
| 100 | aliases: Vec<(String, PathBuf)>, |
| 101 | ) |
| 102 | -> Self |
| 103 | { |
| 104 | Self { |
| 105 | aliases: Arc::new(aliases), |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | fn try_resolve_with_extensions(&self, path: &Path) -> Option<PathBuf> { |
| 110 | // First try exact path. |
| 111 | if path.exists() { |
| 112 | return Some(path.to_path_buf()); |
| 113 | } |
| 114 | |
| 115 | // Try file extensions if path doesn't have one |
| 116 | if path.extension().is_none() { |
| 117 | // Regular file extensions |
| 118 | let file_exts = [".mjs", ".js", ".ts"]; |
| 119 | for ext in file_exts { |
| 120 | let with_ext = path.with_extension(ext.trim_start_matches('.')); |
| 121 | if with_ext.exists() { |
| 122 | return Some(with_ext); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // Check for index files if path is directory |
| 127 | if path.is_dir() { |
| 128 | for ext in file_exts { |
| 129 | let index_path = path.join(fmt!("index{}", ext)); |
| 130 | if index_path.exists() { |
| 131 | return Some(index_path); |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | None |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | impl Resolve for AliasResolver { |
| 142 | |
| 143 | fn resolve( |
| 144 | &self, |
| 145 | base: &FileName, |
| 146 | module_specifier: &str, |
| 147 | ) |
| 148 | -> Result<Resolution, anyhow::Error> |
| 149 | { |
| 150 | // First try to resolve using aliases. |
| 151 | for (alias, path) in self.aliases.iter() { |
| 152 | if module_specifier.starts_with(alias) { |
| 153 | // Remove alias prefix and combine with alias path. |
| 154 | if let Some(rel_path) = module_specifier.strip_prefix(alias) { |
| 155 | let full_path = path.join(rel_path.trim_start_matches('/')); |
| 156 | |
| 157 | // Try with extensions. |
| 158 | if let Some(resolved_path) = self.try_resolve_with_extensions(&full_path) { |
| 159 | // Resolved alias paths. |
| 160 | return Ok(Resolution { |
| 161 | filename: FileName::Real(resolved_path), |
| 162 | slug: None, |
| 163 | }); |
| 164 | } |
| 165 | |
| 166 | // If no extension matches but the specified path exists, use it. |
| 167 | if full_path.exists() { |
| 168 | // Exact alias paths. |
| 169 | return Ok(Resolution { |
| 170 | filename: FileName::Real(full_path), |
| 171 | slug: None, |
| 172 | }); |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // If not an alias, fallback to node module resolution. |
| 179 | let resolver = NodeModulesResolver::new( |
| 180 | TargetEnv::Browser, |
| 181 | Default::default(), |
| 182 | true, |
| 183 | ); |
| 184 | |
| 185 | match resolver.resolve(base, module_specifier) { |
| 186 | // Successful node resolution. |
| 187 | Ok(resolution) => Ok(resolution), |
| 188 | // Failed resolution. |
| 189 | Err(_) => Ok(Resolution { |
| 190 | filename: FileName::Real(PathBuf::new()), |
| 191 | slug: None, |
| 192 | }), |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | /// Configuration for the bundler. |
| 198 | #[derive(Debug, Clone)] |
| 199 | pub struct BundleConfig { |
| 200 | /// Primary entry point files (if empty, all found files are entries). |
| 201 | pub entry_points: Vec<PathBuf>, |
| 202 | /// Supported file types to bundle. |
| 203 | pub file_types: HashSet<FileType>, |
| 204 | /// Target ECMAScript version. |
| 205 | pub target_version: EsVersion, |
| 206 | /// Enable TypeScript support. |
| 207 | pub typescript: bool, |
| 208 | /// Enable minification. |
| 209 | pub minify: bool, |
| 210 | } |
| 211 | |
| 212 | impl Default for BundleConfig { |
| 213 | fn default() -> Self { |
| 214 | let mut file_types = HashSet::new(); |
| 215 | file_types.insert(FileType::JavaScript); |
| 216 | file_types.insert(FileType::JavaScriptModule); |
| 217 | file_types.insert(FileType::TypeScript); |
| 218 | file_types.insert(FileType::TypeScriptModule); |
| 219 | file_types.insert(FileType::TypeScriptReact); |
| 220 | |
| 221 | Self { |
| 222 | entry_points: Vec::new(), |
| 223 | file_types, |
| 224 | target_version: EsVersion::Es2020, |
| 225 | typescript: true, |
| 226 | minify: true, |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | /// Custom hook for SWC bundler to handle imports. |
| 232 | struct BundleHook; |
| 233 | |
| 234 | impl Hook for BundleHook { |
| 235 | |
| 236 | fn get_import_meta_props( |
| 237 | &self, |
| 238 | span: Span, |
| 239 | record: &ModuleRecord, |
| 240 | ) |
| 241 | -> Result<Vec<KeyValueProp>, anyhow::Error> |
| 242 | { |
| 243 | info!("Processing imports for module: {}", record.file_name); |
| 244 | |
| 245 | // Convert filesystem path to web path |
| 246 | let web_path = match &record.file_name { |
| 247 | FileName::Real(path) => { |
| 248 | // Find the "www" directory in the path and take everything after it |
| 249 | path.to_string_lossy() |
| 250 | .split("/www/") |
| 251 | .nth(1) |
| 252 | .map(|p| fmt!("/{}", p)) |
| 253 | .unwrap_or_else(|| "/".to_string()) |
| 254 | }, |
| 255 | _ => "/".to_string(), |
| 256 | }; |
| 257 | |
| 258 | let mut props = Vec::new(); |
| 259 | props.push(KeyValueProp { |
| 260 | key: PropName::Ident(IdentName::new("url".into(), span)), |
| 261 | value: Box::new(Expr::Lit(Lit::Str(Str { |
| 262 | span, |
| 263 | value: web_path.into(), |
| 264 | raw: None, |
| 265 | }))), |
| 266 | }); |
| 267 | |
| 268 | Ok(props) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | /// Loads JavaScript/TypeScript modules for the bundler. |
| 273 | struct ModuleLoader { |
| 274 | /// Source map for code generation and error reporting. |
| 275 | cm: Arc<SourceMap>, |
| 276 | /// Configuration for parsing modules. |
| 277 | config: BundleConfig, |
| 278 | } |
| 279 | |
| 280 | impl ModuleLoader { |
| 281 | fn new(cm: Arc<SourceMap>, config: BundleConfig) -> Self { |
| 282 | Self { cm, config } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | impl Load for ModuleLoader { |
| 287 | |
| 288 | fn load(&self, filename: &FileName) -> Result<ModuleData, anyhow::Error> { |
| 289 | let module_path = match filename { |
| 290 | FileName::Real(path) => path.display().to_string(), |
| 291 | _ => "<unknown>".to_string(), |
| 292 | }; |
| 293 | info!("Attempting to load module: {}", module_path); |
| 294 | |
| 295 | // Load the source file. |
| 296 | let fm = match filename { |
| 297 | FileName::Real(path) => { |
| 298 | if !path.exists() { |
| 299 | return Err(anyhow::anyhow!(err!( |
| 300 | "Module not found: {:?}. Check import paths in requesting module.", |
| 301 | path; |
| 302 | IO, File, Missing))); |
| 303 | } |
| 304 | match self.cm.load_file(path) { |
| 305 | Ok(fm) => { |
| 306 | debug!("Successfully loaded file: {}", module_path); |
| 307 | fm |
| 308 | }, |
| 309 | Err(e) => return Err(anyhow::anyhow!(err!(e, |
| 310 | "Failed to load file: {:?}", path; |
| 311 | IO, File, Read))), |
| 312 | } |
| 313 | }, |
| 314 | _ => return Err(anyhow::anyhow!(err!( |
| 315 | "Unsupported module type: {:?}", filename; |
| 316 | IO, File))), |
| 317 | }; |
| 318 | |
| 319 | // Determine syntax based on file extension and config |
| 320 | let syntax = if self.config.typescript { |
| 321 | Syntax::Typescript(TsSyntax { |
| 322 | tsx: fm.name.to_string().ends_with(".tsx"), |
| 323 | decorators: true, |
| 324 | dts: false, |
| 325 | no_early_errors: false, |
| 326 | disallow_ambiguous_jsx_like: false, |
| 327 | }) |
| 328 | } else { |
| 329 | Syntax::Es(Default::default()) |
| 330 | }; |
| 331 | |
| 332 | debug!("Parsing module with syntax: {:?}", syntax); |
| 333 | let module = match parse_file_as_module( |
| 334 | &fm, |
| 335 | syntax, |
| 336 | self.config.target_version, |
| 337 | None, |
| 338 | &mut vec![], |
| 339 | ) { |
| 340 | Ok(m) => { |
| 341 | debug!("Successfully parsed module"); |
| 342 | m |
| 343 | }, |
| 344 | Err(e) => return Err(anyhow::anyhow!(err!( |
| 345 | "Failed to parse module {}: {:?}", fm.name, e; |
| 346 | IO, Format))), |
| 347 | }; |
| 348 | |
| 349 | Ok(ModuleData { |
| 350 | fm, |
| 351 | module, |
| 352 | helpers: Default::default(), |
| 353 | }) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | /// Handles bundling of JavaScript and TypeScript files. |
| 358 | pub struct JsBundle { |
| 359 | /// Bundle path map. |
| 360 | bundles_map: Vec<(PathBuf, PathBuf)>, |
| 361 | /// Path aliases for module resolution. |
| 362 | path_aliases: Vec<(String, PathBuf)>, |
| 363 | /// Bundling configuration. |
| 364 | config: BundleConfig, |
| 365 | } |
| 366 | |
| 367 | impl JsBundle { |
| 368 | |
| 369 | /// Entry points must be specified to ensure correct bundling regardless of project structure. |
| 370 | /// The bundler will follow imports from these entry points to build the dependency graph. |
| 371 | pub fn new( |
| 372 | bundles_map: Vec<(PathBuf, PathBuf)>, |
| 373 | path_aliases: Vec<(String, PathBuf)>, |
| 374 | ) |
| 375 | -> Self |
| 376 | { |
| 377 | let entry_points = bundles_map |
| 378 | .clone() |
| 379 | .into_iter() |
| 380 | .map(|jsbm| jsbm.0) |
| 381 | .collect(); |
| 382 | Self { |
| 383 | bundles_map, |
| 384 | path_aliases, |
| 385 | config: BundleConfig { |
| 386 | entry_points, |
| 387 | ..Default::default() |
| 388 | }, |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | pub fn bundle_entries( |
| 393 | &self, |
| 394 | src_dir: &Path, |
| 395 | ) |
| 396 | -> Outcome<()> |
| 397 | { |
| 398 | info!("Starting JS bundling from directory: {:?}", src_dir); |
| 399 | |
| 400 | for js_bundle_map in &self.bundles_map { |
| 401 | info!("Bundling entry point: {:?} -> {:?}", |
| 402 | js_bundle_map.0, js_bundle_map.1); |
| 403 | |
| 404 | // Verify entry file exists |
| 405 | if !js_bundle_map.0.exists() { |
| 406 | return Err(err!( |
| 407 | "Entry point file not found: {:?}", js_bundle_map.0; |
| 408 | IO, File, Missing)); |
| 409 | } |
| 410 | |
| 411 | let mut entries = HashMap::new(); |
| 412 | entries.insert( |
| 413 | fmt!("entry"), |
| 414 | FileName::Real(js_bundle_map.0.clone()), |
| 415 | ); |
| 416 | |
| 417 | // Create output directory if needed |
| 418 | if let Some(parent) = js_bundle_map.1.parent() { |
| 419 | if !parent.exists() { |
| 420 | res!(std::fs::create_dir_all(parent)); |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | let result = GLOBALS.set(&swc_common::Globals::new(), || { |
| 425 | let cm = Arc::new(SourceMap::default()); |
| 426 | let globals = GLOBALS.set(&swc_common::Globals::new(), || { |
| 427 | swc_common::Globals::new() |
| 428 | }); |
| 429 | |
| 430 | // Create custom resolver with aliases |
| 431 | let resolver = CachingResolver::new( |
| 432 | 4096, |
| 433 | AliasResolver::new( |
| 434 | self.path_aliases.clone(), |
| 435 | ), |
| 436 | ); |
| 437 | |
| 438 | let loader = ModuleLoader::new(cm.clone(), self.config.clone()); |
| 439 | |
| 440 | let mut bundler = Bundler::new( |
| 441 | &globals, |
| 442 | cm.clone(), |
| 443 | loader, |
| 444 | resolver, |
| 445 | swc_bundler::Config { |
| 446 | require: false, |
| 447 | disable_inliner: false, |
| 448 | external_modules: vec![], |
| 449 | module: ModuleType::Es, |
| 450 | disable_fixer: false, |
| 451 | disable_hygiene: false, |
| 452 | disable_dce: false, |
| 453 | }, |
| 454 | Box::new(BundleHook), |
| 455 | ); |
| 456 | |
| 457 | let bundles = catch_other!(bundler.bundle(entries), IO, Format); |
| 458 | info!("Bundle generation complete, processing {} modules", bundles.len()); |
| 459 | |
| 460 | let mut combined = String::new(); |
| 461 | for (idx, bundle) in bundles.iter().enumerate() { |
| 462 | info!("Processing bundle module {}/{}", idx + 1, bundles.len()); |
| 463 | let mut buf = vec![]; |
| 464 | { |
| 465 | let writer = JsWriter::new( |
| 466 | cm.clone(), |
| 467 | "\n", |
| 468 | &mut buf, |
| 469 | None, |
| 470 | ); |
| 471 | let mut emitter = Emitter { |
| 472 | cfg: swc_ecma_codegen::Config::default(), |
| 473 | comments: None, |
| 474 | cm: cm.clone(), |
| 475 | wr: Box::new(writer), |
| 476 | }; |
| 477 | |
| 478 | if let Err(e) = emitter.emit_module(&bundle.module) { |
| 479 | return Err(err!(e, |
| 480 | "Failed to emit bundle module {} for entry point {:?}", |
| 481 | idx, js_bundle_map.0; |
| 482 | IO, Format)); |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | match String::from_utf8(buf) { |
| 487 | Ok(s) => combined.push_str(&s), |
| 488 | Err(e) => return Err(err!(e, |
| 489 | "Generated invalid UTF-8 in bundle module {}", idx; |
| 490 | IO, Format)), |
| 491 | }; |
| 492 | combined.push('\n'); |
| 493 | } |
| 494 | |
| 495 | // Write bundle with error context |
| 496 | if let Err(e) = std::fs::write(&js_bundle_map.1, combined) { |
| 497 | return Err(err!(e, |
| 498 | "Failed to write bundle to {:?}", js_bundle_map.1; |
| 499 | IO, File, Write)); |
| 500 | } |
| 501 | |
| 502 | info!("Successfully bundled {:?}", js_bundle_map.0); |
| 503 | Ok(()) |
| 504 | }); |
| 505 | |
| 506 | res!(result); |
| 507 | } |
| 508 | |
| 509 | info!("JavaScript bundling completed successfully"); |
| 510 | Ok(()) |
| 511 | } |
| 512 | |
| 513 | } |