Skip to main content

rolldown_plugin_asset_module/
lib.rs

1use std::{borrow::Cow, path::Path};
2
3use rolldown_common::{ModuleType, side_effects::HookSideEffects};
4use rolldown_plugin::{
5  HookLoadArgs, HookLoadOutput, HookLoadReturn, HookRenderChunkArgs, HookRenderChunkReturn,
6  HookUsage, Plugin, PluginHookMeta, PluginOrder, SharedLoadPluginContext,
7};
8use rolldown_plugin_utils::{emit_asset, rewrite_emitted_asset_references};
9use rolldown_utils::url::clean_url;
10use rustc_hash::FxHashSet;
11
12const PREFIX: &str = "__ROLLDOWN_ASSET__#";
13
14#[derive(Debug)]
15pub struct AssetModulePlugin {
16  asset_extensions: FxHashSet<String>,
17}
18
19impl AssetModulePlugin {
20  pub fn new(module_types: &rustc_hash::FxHashMap<Cow<'static, str>, ModuleType>) -> Self {
21    let mut asset_extensions = FxHashSet::default();
22    for (ext, module_type) in module_types {
23      if matches!(module_type, ModuleType::Asset) {
24        let ext = ext.strip_prefix('.').unwrap_or(ext);
25        asset_extensions.insert(ext.to_string());
26      }
27    }
28    Self { asset_extensions }
29  }
30}
31
32impl Plugin for AssetModulePlugin {
33  fn name(&self) -> Cow<'static, str> {
34    Cow::Borrowed("builtin:asset-module")
35  }
36
37  fn register_hook_usage(&self) -> HookUsage {
38    HookUsage::Load | HookUsage::RenderChunk
39  }
40
41  fn load_meta(&self) -> Option<PluginHookMeta> {
42    // Run after user plugins so they can override asset loading
43    Some(PluginHookMeta { order: Some(PluginOrder::Post) })
44  }
45
46  fn load(
47    &self,
48    ctx: SharedLoadPluginContext,
49    args: &HookLoadArgs<'_>,
50  ) -> impl std::future::Future<Output = HookLoadReturn> + Send {
51    self.load_impl(ctx, args)
52  }
53
54  fn render_chunk_meta(&self) -> Option<PluginHookMeta> {
55    // Run before user plugins so placeholders are resolved first
56    Some(PluginHookMeta { order: Some(PluginOrder::Pre) })
57  }
58
59  async fn render_chunk(
60    &self,
61    ctx: &rolldown_plugin::PluginContext,
62    args: &HookRenderChunkArgs<'_>,
63  ) -> HookRenderChunkReturn {
64    Ok(rewrite_emitted_asset_references(ctx, args, PREFIX))
65  }
66}
67
68impl AssetModulePlugin {
69  async fn load_impl(
70    &self,
71    ctx: SharedLoadPluginContext,
72    args: &HookLoadArgs<'_>,
73  ) -> HookLoadReturn {
74    // Determine if this module should be treated as an asset:
75    // 1. Via asserted_module_type (e.g. from `new URL('./file', import.meta.url)`)
76    // 2. Via file extension matching `moduleTypes` config, unless a `type` import attribute
77    //    selects a built-in module type
78    //
79    // Strip query/fragment (e.g. `file.png?url`) before extension check and path operations,
80    // consistent with CopyModulePlugin's handling.
81    let clean_id = clean_url(args.id);
82    let is_asset = args.asserted_module_type.is_some_and(|ty| matches!(ty, ModuleType::Asset))
83      || (args.attributes.module_type().is_none() && self.is_asset_by_extension(clean_id));
84
85    if !is_asset {
86      return Ok(None);
87    }
88
89    let reference_id = emit_asset(&ctx, clean_id, |e| {
90      anyhow::anyhow!("Failed to read asset module {clean_id}: {e}")
91    })
92    .await?;
93    ctx.associate_module_with_file_ref(args.id, &reference_id);
94    // Through LoadPluginContext, which also records the module's HMR transform dependency.
95    ctx.add_watch_file(clean_id);
96
97    // Return JS code that exports the asset placeholder via CJS.
98    // Using `module.exports` ensures `require()` returns the string directly.
99    // The placeholder will be resolved in renderChunk to the actual filename.
100    let code = format!("module.exports = \"{PREFIX}{reference_id}\"");
101
102    Ok(Some(HookLoadOutput {
103      code: code.into(),
104      module_type: Some(ModuleType::Js),
105      // Mark as side-effect-free so tree-shaking excludes the module's statements
106      // when nothing imports from it (e.g. `new URL()` only references).
107      side_effects: Some(HookSideEffects::False),
108      ..Default::default()
109    }))
110  }
111
112  fn is_asset_by_extension(&self, id: &str) -> bool {
113    if self.asset_extensions.is_empty() {
114      return false;
115    }
116    Path::new(id)
117      .extension()
118      .and_then(|e| e.to_str())
119      .is_some_and(|ext| self.asset_extensions.contains(ext))
120  }
121}