use rolldown_common::ModuleType;
use rolldown_plugin::{HookLoadArgs, HookLoadOutput, HookLoadReturn, Plugin, PluginContext};
use rustc_hash::FxHashMap;
use std::borrow::Cow;
use std::sync::Arc;
const MAX_VIRTUAL_FILE_SIZE: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct VirtualFilePlugin {
files: Arc<FxHashMap<String, String>>,
}
impl VirtualFilePlugin {
pub fn new(files: FxHashMap<String, String>) -> crate::Result<Self> {
for (module_id, content) in &files {
validate_module_id(module_id)?;
validate_content_size(content)?;
}
Ok(Self {
files: Arc::new(files),
})
}
}
impl Plugin for VirtualFilePlugin {
fn name(&self) -> Cow<'static, str> {
"joy-virtual-files".into()
}
fn register_hook_usage(&self) -> rolldown_plugin::HookUsage {
use rolldown_plugin::HookUsage;
HookUsage::ResolveId | HookUsage::Load
}
fn resolve_id(
&self,
_ctx: &PluginContext,
args: &rolldown_plugin::HookResolveIdArgs,
) -> impl std::future::Future<Output = rolldown_plugin::HookResolveIdReturn> + Send {
let specifier = args.specifier.to_string();
let files = self.files.clone();
async move {
if files.contains_key(&specifier) {
use rolldown_plugin::HookResolveIdOutput;
Ok(Some(HookResolveIdOutput {
id: specifier.into(),
..Default::default()
}))
} else {
Ok(None)
}
}
}
fn load(
&self,
_ctx: &PluginContext,
args: &HookLoadArgs<'_>,
) -> impl std::future::Future<Output = HookLoadReturn> + Send {
let id = args.id.to_string();
let files = self.files.clone();
async move {
if let Some(content) = files.get(&id) {
let module_type = infer_module_type(&id);
Ok(Some(HookLoadOutput {
code: content.clone().into(),
module_type: Some(module_type),
..Default::default()
}))
} else {
Ok(None)
}
}
}
}
fn validate_module_id(module_id: &str) -> crate::Result<()> {
if module_id.contains('\0') {
return Err(crate::Error::InvalidOutputPath(
"Virtual file module ID contains null byte".to_string(),
));
}
if module_id.len() > 4096 {
return Err(crate::Error::InvalidOutputPath(format!(
"Virtual file module ID too long: {} bytes (max 4096)",
module_id.len()
)));
}
Ok(())
}
fn validate_content_size(content: &str) -> crate::Result<()> {
if content.len() > MAX_VIRTUAL_FILE_SIZE {
return Err(crate::Error::WriteFailure(format!(
"Virtual file content too large: {} bytes (max {} bytes)",
content.len(),
MAX_VIRTUAL_FILE_SIZE
)));
}
Ok(())
}
fn infer_module_type(module_id: &str) -> ModuleType {
use std::path::Path;
let ext = Path::new(module_id)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"jsx" => ModuleType::Jsx,
"ts" => ModuleType::Ts,
"tsx" => ModuleType::Tsx,
"json" => ModuleType::Json,
"js" | "mjs" | "cjs" => ModuleType::Js,
_ => ModuleType::Js,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_module_id_normal() {
assert!(validate_module_id("virtual:config.js").is_ok());
assert!(validate_module_id("/virtual/generated/index.ts").is_ok());
}
#[test]
fn test_validate_module_id_null_byte() {
assert!(validate_module_id("file\0name.js").is_err());
}
#[test]
fn test_validate_module_id_too_long() {
let long_id = "a".repeat(5000);
assert!(validate_module_id(&long_id).is_err());
}
#[test]
fn test_validate_content_size_ok() {
let content = "a".repeat(1000);
assert!(validate_content_size(&content).is_ok());
}
#[test]
fn test_validate_content_size_too_large() {
let content = "a".repeat(MAX_VIRTUAL_FILE_SIZE + 1);
assert!(validate_content_size(&content).is_err());
}
#[test]
fn test_infer_module_type() {
assert!(matches!(infer_module_type("file.js"), ModuleType::Js));
assert!(matches!(infer_module_type("file.jsx"), ModuleType::Jsx));
assert!(matches!(infer_module_type("file.ts"), ModuleType::Ts));
assert!(matches!(infer_module_type("file.tsx"), ModuleType::Tsx));
assert!(matches!(infer_module_type("file.json"), ModuleType::Json));
}
#[test]
fn test_plugin_creation_valid() {
let mut files = FxHashMap::default();
files.insert(
"virtual:test.js".to_string(),
"export const x = 1;".to_string(),
);
let result = VirtualFilePlugin::new(files);
assert!(result.is_ok());
}
#[test]
fn test_plugin_creation_invalid_size() {
let mut files = FxHashMap::default();
let huge_content = "a".repeat(MAX_VIRTUAL_FILE_SIZE + 1);
files.insert("virtual:test.js".to_string(), huge_content);
let result = VirtualFilePlugin::new(files);
assert!(result.is_err());
}
}