use std::{borrow::Cow, path::Path};
use deno_ast::{parse_module, EmitOptions, ImportsNotUsedAsValues, MediaType, ModuleSpecifier, ParseParams, SourceMapOption, TranspileOptions};
use deno_core::{
anyhow::Context, error::generic_error, futures::FutureExt, resolve_import, ModuleLoadResponse, ModuleLoader, ModuleSource, ModuleSourceCode, ModuleType, RequestedModuleType
};
pub const NPM_LOADER_CDN: &str = "https://cdn.jsdelivr.net/npm/";
const TRANSPILE_EXTENSIONS: [&str; 7] = [".ts", ".cts", ".mts", ".tsx", ".jsx", ".ctsx", ".mtsx"];
const TRANSPILE_REMOTE_EXTENSIONS: [&str; 1] = ["application/typescript"];
const REMOTE_MODULES: [&str; 3] = ["http://", "https://", "npm:"];
pub struct TanxiumModuleLoader {
cwd: String,
}
impl TanxiumModuleLoader {
pub fn new(cwd: String) -> Self {
Self {
cwd,
}
}
pub fn is_remote_module(&self, specifier: &str) -> bool {
REMOTE_MODULES.iter().any(|prefix| specifier.starts_with(prefix))
}
pub fn should_transpile(&self, specifier: &str) -> bool {
TRANSPILE_EXTENSIONS.iter().any(|ext| specifier.ends_with(ext))
}
pub fn should_transpile_remote(&self, specifier: &str) -> bool {
TRANSPILE_REMOTE_EXTENSIONS.iter().any(|ext| specifier.eq_ignore_ascii_case(ext))
}
pub fn hash_module_specifier(&self, specifier: &str) -> String {
let hash = md5::compute(specifier);
format!("{:x}", hash)
}
pub fn get_module_cache_path(&self, specifier: &str) -> String {
let hash = self.hash_module_specifier(specifier);
format!("{}/.yasumu_modules/{}.js", self.cwd, hash)
}
}
impl ModuleLoader for TanxiumModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
_kind: deno_core::ResolutionKind,
) -> Result<deno_core::ModuleSpecifier, deno_core::anyhow::Error> {
Ok(resolve_import(specifier, referrer)?)
}
fn load(
&self,
module_specifier: &deno_core::ModuleSpecifier,
_maybe_referrer: Option<&deno_core::ModuleSpecifier>,
_is_dyn_import: bool,
requested_module_type: deno_core::RequestedModuleType,
) -> deno_core::ModuleLoadResponse {
let module_specifier = module_specifier.clone();
let cache_path = self.get_module_cache_path(module_specifier.as_str());
let cwd = self.cwd.clone();
let fut = async move {
let mod_str = module_specifier.as_str();
let is_remote_module = REMOTE_MODULES.iter().any(|prefix| mod_str.starts_with(prefix));
if is_remote_module {
tokio::fs::create_dir_all(format!("{}/.yasumu_modules", cwd))
.await
.unwrap_or(());
if Path::new(&cache_path).exists() {
let code = tokio::fs::read(cache_path).await.with_context(|| {
format!("Failed to load {}", module_specifier.as_str())
})?;
let module = ModuleSource::new(
ModuleType::JavaScript,
ModuleSourceCode::Bytes(code.into_boxed_slice().into()),
&module_specifier,
None,
);
return Ok(module);
}
let final_str = if mod_str.starts_with("npm:") {
format!("{}{}/+esm", NPM_LOADER_CDN, mod_str.replace("npm:", ""))
} else {
mod_str.to_string()
};
let res = reqwest::get(&final_str).await.with_context(|| {
format!("Failed to load {}", module_specifier.as_str())
})?;
if res.status().is_success() {
let is_typescript = match res.headers().get("content-type") {
Some(content_type) => {
TRANSPILE_REMOTE_EXTENSIONS.iter().any(|ext| content_type.to_str().unwrap_or("").eq_ignore_ascii_case(ext))
},
None => false,
};
let code = res.text().await.with_context(|| {
format!("Failed to load {}", module_specifier.as_str())
})?;
let code = if is_typescript {
let parsed = parse_module(ParseParams {
specifier: ModuleSpecifier::parse(module_specifier.as_str()).unwrap(),
text: code.into(),
media_type: MediaType::TypeScript,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})?;
let transpiled_source = parsed
.transpile(
&TranspileOptions {
imports_not_used_as_values: ImportsNotUsedAsValues::Remove,
..Default::default()
},
&EmitOptions {
source_map: SourceMapOption::None,
..Default::default()
},
)
.unwrap()
.into_source();
let transpiled = String::from_utf8(transpiled_source.source).with_context(|| {
format!("Failed to transpile {}", module_specifier.as_str())
})?;
transpiled
} else {
code
};
tokio::fs::write(cache_path, code.as_bytes()).await.unwrap_or(());
let code_bytes = code.into_bytes().into_boxed_slice().into();
let module = ModuleSource::new(ModuleType::JavaScript, ModuleSourceCode::Bytes(code_bytes), &module_specifier, None);
return Ok(module);
}
return Err(generic_error(format!("Failed to load remote module: {}", module_specifier.as_str())));
}
let path = module_specifier.to_file_path().map_err(|_| {
generic_error(format!(
"Provided module specifier \"{module_specifier}\" is not a file URL."
))
})?;
let ts_mod_type = Cow::Borrowed("typescript");
let mut module_type = if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if ext == "json" {
ModuleType::Json
} else if TRANSPILE_EXTENSIONS.iter().any(|ext| ext.eq(ext)) {
ModuleType::Other(ts_mod_type.clone())
} else {
match &requested_module_type {
RequestedModuleType::Other(ty) => ModuleType::Other(ty.clone()),
_ => ModuleType::JavaScript,
}
}
} else {
ModuleType::JavaScript
};
if module_type == ModuleType::Json
&& requested_module_type != RequestedModuleType::Json
{
return Err(generic_error("Attempted to load JSON module without specifying \"type\": \"json\" attribute in the import statement."));
}
let code = tokio::fs::read(path).await.with_context(|| {
format!("Failed to load {}", module_specifier.as_str())
})?;
let code = if module_type == ModuleType::Other(ts_mod_type) {
module_type = ModuleType::JavaScript;
transpile_typescript(String::from_utf8(code).with_context(|| {
format!("Failed to load {}", module_specifier.as_str())
})?, module_specifier.as_str().to_string())?.as_bytes().to_vec()
} else {
code
};
let module = ModuleSource::new(
module_type,
ModuleSourceCode::Bytes(code.into_boxed_slice().into()),
&module_specifier,
None,
);
Ok(module)
}
.boxed_local();
ModuleLoadResponse::Async(fut)
}
}
fn transpile_typescript(code: String, module_specifier: String) -> Result<String, deno_core::error::AnyError> {
let parsed = parse_module(ParseParams {
specifier: ModuleSpecifier::parse(module_specifier.as_str()).unwrap(),
text: code.into(),
media_type: MediaType::TypeScript,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})?;
let transpiled_source = parsed
.transpile(
&TranspileOptions {
imports_not_used_as_values: ImportsNotUsedAsValues::Remove,
..Default::default()
},
&EmitOptions {
source_map: SourceMapOption::None,
..Default::default()
},
)
.unwrap()
.into_source();
let transpiled = String::from_utf8(transpiled_source.source).with_context(|| {
format!("Failed to transpile {}", module_specifier.as_str())
})?;
Ok(transpiled)
}