use std::io::Read;
use std::path::Path;
use fallow_config::{NapiConfig, PackageJson};
use rustc_hash::FxHashSet;
use super::{Plugin, PluginResult, ProvidedDependencyRule};
const LOADER_HEADER: &str = "auto-generated by NAPI-RS";
const LOADER_HEADER_SCAN_BYTES: u64 = 512;
const LOADER_EXTENSIONS: &[&str] = &["js", "cjs", "mjs"];
const WASI_WORKERS: &str = "wasi-worker*.mjs";
const WASM_RUNTIME: &str = "@napi-rs/wasm-runtime";
const GENERATED_BUILD_ARTIFACTS: &[&str] = &[".node", ".wasi.cjs", ".wasi-browser"];
const BROWSER_ENTRY: &str = "browser.js";
const WASM32_WASI_SUFFIX: &str = "wasm32-wasi";
const ALWAYS_USED: &[&str] = &[WASI_WORKERS];
pub struct NapiRsPlugin;
impl Plugin for NapiRsPlugin {
fn name(&self) -> &'static str {
"napi-rs"
}
fn is_enabled_with_package_json(&self, pkg: &PackageJson, _root: &Path) -> bool {
pkg.napi
.as_ref()
.is_some_and(|napi| package_base(pkg, napi).is_some() && !napi.targets.is_empty())
}
fn always_used(&self) -> &'static [&'static str] {
ALWAYS_USED
}
fn generated_import_patterns(&self) -> &'static [&'static str] {
GENERATED_BUILD_ARTIFACTS
}
fn package_json_referenced_dependencies(&self, pkg: &PackageJson, _root: &Path) -> Vec<String> {
referenced_optional_dependencies(pkg)
}
fn resolve_package_json(&self, pkg: &PackageJson, root: &Path) -> PluginResult {
let Some(base) = pkg.napi.as_ref().and_then(|napi| package_base(pkg, napi)) else {
return PluginResult::default();
};
let mut result = PluginResult::default();
result
.provided_dependencies
.push(ProvidedDependencyRule::new(
WASI_WORKERS,
[WASM_RUNTIME],
[format!("{WASM_RUNTIME}/")],
));
let platform_prefix = format!("{base}-");
for loader in generated_loaders(root) {
result.push_entry_pattern(loader.clone());
result
.provided_dependencies
.push(ProvidedDependencyRule::new(
loader,
[WASM_RUNTIME.to_string()],
[platform_prefix.clone(), format!("{WASM_RUNTIME}/")],
));
}
if is_generated_browser_entry(&root.join(BROWSER_ENTRY), base) {
result.push_entry_pattern(BROWSER_ENTRY);
result
.provided_dependencies
.push(ProvidedDependencyRule::new(
BROWSER_ENTRY,
[format!("{base}-{WASM32_WASI_SUFFIX}")],
std::iter::empty::<String>(),
));
}
result
}
}
fn is_generated_browser_entry(path: &Path, base: &str) -> bool {
let Ok(source) = std::fs::read_to_string(path) else {
return false;
};
let Some(specifier) = source
.trim()
.strip_prefix("export * from ")
.map(|rest| rest.trim_end_matches(';').trim())
else {
return false;
};
let expected = format!("{base}-{WASM32_WASI_SUFFIX}");
['\'', '"'].iter().any(|quote| {
specifier
.strip_prefix(*quote)
.and_then(|rest| rest.strip_suffix(*quote))
== Some(expected.as_str())
})
}
fn generated_loaders(root: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut loaders: Vec<String> = entries
.filter_map(Result::ok)
.filter(|entry| entry.path().is_file())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| {
Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| LOADER_EXTENSIONS.contains(&ext))
})
.filter(|name| has_loader_header(&root.join(name)))
.collect();
loaders.sort();
loaders
}
fn has_loader_header(path: &Path) -> bool {
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut head = Vec::new();
if file
.take(LOADER_HEADER_SCAN_BYTES)
.read_to_end(&mut head)
.is_err()
{
return false;
}
String::from_utf8_lossy(&head).contains(LOADER_HEADER)
}
fn package_base<'a>(pkg: &'a PackageJson, napi: &'a NapiConfig) -> Option<&'a str> {
napi.package_name
.as_deref()
.or(pkg.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
}
fn referenced_optional_dependencies(pkg: &PackageJson) -> Vec<String> {
let Some(napi) = &pkg.napi else {
return Vec::new();
};
let Some(base) = package_base(pkg, napi) else {
return Vec::new();
};
let optional_dependencies: FxHashSet<String> =
pkg.optional_dependency_names().into_iter().collect();
if optional_dependencies.is_empty() {
return Vec::new();
}
let mut referenced: Vec<String> = napi
.targets
.iter()
.filter_map(|target| target_suffix(target))
.map(|suffix| format!("{base}-{suffix}"))
.filter(|dependency| optional_dependencies.contains(dependency))
.collect();
referenced.sort();
referenced.dedup();
referenced
}
fn target_suffix(target: &str) -> Option<&'static str> {
match target.trim() {
"aarch64-apple-darwin" => Some("darwin-arm64"),
"x86_64-apple-darwin" => Some("darwin-x64"),
"aarch64-linux-android" => Some("android-arm64"),
"armv7-linux-androideabi" => Some("android-arm-eabi"),
"i686-linux-android" => Some("android-ia32"),
"x86_64-linux-android" => Some("android-x64"),
"aarch64-pc-windows-msvc" => Some("win32-arm64-msvc"),
"i686-pc-windows-msvc" => Some("win32-ia32-msvc"),
"x86_64-pc-windows-msvc" => Some("win32-x64-msvc"),
"aarch64-unknown-freebsd" => Some("freebsd-arm64"),
"x86_64-unknown-freebsd" => Some("freebsd-x64"),
"aarch64-unknown-linux-gnu" => Some("linux-arm64-gnu"),
"aarch64-unknown-linux-musl" => Some("linux-arm64-musl"),
"armv7-unknown-linux-gnueabihf" => Some("linux-arm-gnueabihf"),
"x86_64-unknown-linux-gnu" => Some("linux-x64-gnu"),
"x86_64-unknown-linux-musl" => Some("linux-x64-musl"),
"wasm32-wasi" | "wasm32-wasip1" => Some("wasm32-wasi"),
"wasm32-wasi-preview1-threads" | "wasm32-wasip1-threads" => {
Some("wasm32-wasi-singlethreaded")
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(raw: &str) -> PackageJson {
serde_json::from_str(raw).expect("package json")
}
fn resolve(raw: &str) -> Vec<String> {
referenced_optional_dependencies(&pkg(raw))
}
#[test]
fn maps_common_targets_to_napi_package_suffixes() {
for (target, suffix) in [
("aarch64-apple-darwin", "darwin-arm64"),
("x86_64-apple-darwin", "darwin-x64"),
("aarch64-unknown-linux-gnu", "linux-arm64-gnu"),
("aarch64-unknown-linux-musl", "linux-arm64-musl"),
("x86_64-unknown-linux-gnu", "linux-x64-gnu"),
("x86_64-unknown-linux-musl", "linux-x64-musl"),
("aarch64-pc-windows-msvc", "win32-arm64-msvc"),
("x86_64-pc-windows-msvc", "win32-x64-msvc"),
("wasm32-wasip1", "wasm32-wasi"),
("wasm32-wasip1-threads", "wasm32-wasi-singlethreaded"),
] {
assert_eq!(target_suffix(target), Some(suffix));
}
}
#[test]
fn credits_scoped_optional_packages_from_package_name() {
let referenced = resolve(
r#"{
"name": "@srcmap/codec",
"optionalDependencies": {
"@srcmap/codec-darwin-arm64": "1.0.0",
"@srcmap/codec-linux-x64-gnu": "1.0.0",
"unused-optional-pkg": "1.0.0"
},
"napi": {
"binaryName": "srcmap-codec",
"targets": [
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu"
]
}
}"#,
);
assert_eq!(
referenced,
vec![
"@srcmap/codec-darwin-arm64".to_string(),
"@srcmap/codec-linux-x64-gnu".to_string()
]
);
}
#[test]
fn package_name_overrides_manifest_name() {
let referenced = resolve(
r#"{
"name": "development-helper",
"optionalDependencies": {
"@oxc-coverage-instrument/binding-win32-arm64-msvc": "1.0.0",
"@oxc-coverage-instrument/binding-wasm32-wasi": "1.0.0",
"@oxc-coverage-instrument/binding-wasm32-wasi-singlethreaded": "1.0.0"
},
"napi": {
"packageName": "@oxc-coverage-instrument/binding",
"targets": [
"aarch64-pc-windows-msvc",
"wasm32-wasip1",
"wasm32-wasip1-threads"
]
}
}"#,
);
assert_eq!(
referenced,
vec![
"@oxc-coverage-instrument/binding-wasm32-wasi".to_string(),
"@oxc-coverage-instrument/binding-wasm32-wasi-singlethreaded".to_string(),
"@oxc-coverage-instrument/binding-win32-arm64-msvc".to_string()
]
);
}
#[test]
fn credits_unscoped_optional_packages() {
let referenced = resolve(
r#"{
"name": "snappy",
"optionalDependencies": {
"snappy-linux-x64-gnu": "1.0.0"
},
"napi": {
"targets": ["x86_64-unknown-linux-gnu"]
}
}"#,
);
assert_eq!(referenced, vec!["snappy-linux-x64-gnu"]);
}
#[test]
fn does_not_credit_binary_name_as_package_base() {
let referenced = resolve(
r#"{
"optionalDependencies": {
"srcmap-codec-darwin-arm64": "1.0.0"
},
"napi": {
"binaryName": "srcmap-codec",
"targets": ["aarch64-apple-darwin"]
}
}"#,
);
assert!(referenced.is_empty());
}
#[test]
fn ignores_missing_optional_dependencies_and_unknown_targets() {
let referenced = resolve(
r#"{
"name": "@scope/native",
"optionalDependencies": {
"@scope/native-linux-x64-gnu": "1.0.0"
},
"napi": {
"targets": [
"x86_64-unknown-linux-gnu",
"unknown-target"
]
}
}"#,
);
assert_eq!(referenced, vec!["@scope/native-linux-x64-gnu"]);
}
#[test]
fn activates_without_optional_dependencies() {
let root = Path::new("/");
let source_repo = pkg(r#"{
"name": "@scope/native",
"napi": { "targets": ["x86_64-unknown-linux-gnu"] }
}"#);
let no_targets = pkg(r#"{ "name": "@scope/native", "napi": { "binaryName": "native" } }"#);
assert!(NapiRsPlugin.is_enabled_with_package_json(&source_repo, root));
assert!(!NapiRsPlugin.is_enabled_with_package_json(&no_targets, root));
}
#[test]
fn only_files_with_the_napi_header_are_loaders() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
std::fs::write(
root.join("index.js"),
"// prettier-ignore\n/* auto-generated by NAPI-RS */\nrequire('x')\n",
)
.expect("write loader");
std::fs::write(
root.join("browser.cjs"),
"/* auto-generated by NAPI-RS */\n",
)
.expect("write browser loader");
std::fs::write(
root.join("manual.js"),
"require('@scope/native-linux-x64-gnu')\n",
)
.expect("write hand-written file");
std::fs::write(root.join("index.d.ts"), "/* auto-generated by NAPI-RS */\n")
.expect("write declarations");
assert_eq!(generated_loaders(root), ["browser.cjs", "index.js"]);
}
#[test]
fn only_the_generated_browser_reexport_is_a_browser_entry() {
let dir = tempfile::tempdir().expect("temp dir");
let entry = dir.path().join(BROWSER_ENTRY);
std::fs::write(&entry, "export * from '@scope/native-wasm32-wasi'\n")
.expect("write browser entry");
assert!(is_generated_browser_entry(&entry, "@scope/native"));
assert!(!is_generated_browser_entry(&entry, "@scope/other"));
std::fs::write(&entry, "export * from \"@scope/native-wasm32-wasi\";\n")
.expect("write browser entry");
assert!(is_generated_browser_entry(&entry, "@scope/native"));
std::fs::write(
&entry,
"export * from '@scope/native-wasm32-wasi'\nexport const x = 1\n",
)
.expect("write hand-written entry");
assert!(!is_generated_browser_entry(&entry, "@scope/native"));
}
#[test]
fn malformed_napi_config_is_ignored_by_package_parser() {
let referenced = resolve(
r#"{
"name": "native",
"optionalDependencies": {
"native-linux-x64-gnu": "1.0.0"
},
"napi": true
}"#,
);
assert!(referenced.is_empty());
}
}