use anyhow::Context as _;
use std::path::Path;
const STALE_INDEX_EXPORTS: [(&str, &str); 2] = [
(
"module.exports = nativeBinding;",
"module.exports = require(\"./binding.cjs\")(nativeBinding);",
),
(
"module.exports = { ...nativeBinding, ..._service };",
"module.exports = { ...require(\"./binding.cjs\")(nativeBinding), ..._service };",
),
];
fn export_fragment(entrypoint: &str, include_import: bool) -> String {
crate::scaffold::template_env::render(
"node_error_shim_export.json.jinja",
crate::alef_context! { entrypoint => entrypoint, include_import => include_import },
)
.trim_end_matches('\n')
.to_string()
}
fn add_shim_files_to_files_array(package_json: &str) -> Option<String> {
let files_start = package_json.find("\"files\"")?;
let array_start = files_start + package_json[files_start..].find('[')?;
let array_end = array_start + package_json[array_start..].find(']')?;
let array = &package_json[array_start..array_end];
if array.contains("\"binding.cjs\"") || array.contains("\"index.mjs\"") {
return None;
}
let entry = array.find("\"index.js\"")?;
let after_entry = &array[entry + "\"index.js\"".len()..];
let replacement = if let Some(rest) = after_entry.strip_prefix(",\n") {
let indent: String = rest.chars().take_while(|c| c.is_whitespace() && *c != '\n').collect();
crate::scaffold::template_env::render(
"node_error_shim_files.json.jinja",
crate::alef_context! { indent => indent },
)
.trim_end_matches('\n')
.to_string()
} else {
"\"index.js\", \"binding.cjs\", \"index.mjs\"".to_string()
};
let mut migrated = String::with_capacity(package_json.len() + replacement.len());
migrated.push_str(&package_json[..array_start + entry]);
migrated.push_str(&replacement);
migrated.push_str(&package_json[array_start + entry + "\"index.js\"".len()..]);
Some(migrated)
}
fn napi_export_tail(index: &str) -> bool {
if !index
.lines()
.take(5)
.any(|line| line.contains("auto-generated by NAPI-RS"))
{
return false;
}
let is_alias = |line: &str| {
matches!(
line.trim(),
"module.exports = nativeBinding" | "module.exports = nativeBinding;"
)
};
if index.lines().filter(|line| is_alias(line)).count() != 1 {
return false;
}
index.lines().skip_while(|line| !is_alias(line)).skip(1).all(|line| {
line.trim().is_empty()
|| (line.starts_with("module.exports.") && line.contains(" = nativeBinding."))
|| line.trim() == "module.exports = require(\"./binding.cjs\")(nativeBinding);"
})
}
fn refresh_napi_version_guards(index: &str) -> String {
if !napi_export_tail(index) {
return index.to_string();
}
let mut updated = index.to_string();
for line in index.lines() {
let Some((_, suffix)) = line.split_once("bindingPackageVersion !== '") else {
continue;
};
let Some((version, _)) = suffix.split_once('\'') else {
continue;
};
if semver::Version::parse(version).is_err() {
continue;
}
let diagnostic =
format!("Native binding package version mismatch, expected {version} but got ${{bindingPackageVersion}}");
if !index.contains(&diagnostic) {
continue;
}
updated = updated
.replace(&format!("bindingPackageVersion !== '{version}'"), "bindingPackageVersion !== require('./package.json').version")
.replace(&diagnostic, "Native binding package version mismatch, expected ${require('./package.json').version} but got ${bindingPackageVersion}");
}
updated
}
fn updated_index(index: &str) -> Option<String> {
let refreshed = refresh_napi_version_guards(index);
let index = refreshed.as_str();
if index.contains("binding.cjs") {
return Some(index.to_string());
}
if napi_export_tail(index) {
return Some(format!(
"{index}\nmodule.exports = require(\"./binding.cjs\")(nativeBinding);\n"
));
}
let (stale, fixed) = STALE_INDEX_EXPORTS
.iter()
.find(|(stale, _)| index.matches(stale).count() == 1)?;
Some(index.replacen(stale, fixed, 1))
}
fn protect_build_script(package: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(package).ok()?;
let command = parsed.get("scripts")?.get("build")?.as_str()?;
if !command.contains("napi build") || command.contains("--no-js") {
return None;
}
let original = serde_json::to_string(command).ok()?;
if package.matches(&original).count() != 1 {
return None;
}
let replacement = serde_json::to_string(&command.replacen("napi build", "napi build --no-js", 1)).ok()?;
Some(package.replacen(&original, &replacement, 1))
}
fn updated_package(package: &str) -> Option<String> {
let mut migrated = package.to_string();
if !package.contains("\"./index.mjs\"") {
let (entrypoint, stale) = ["index.js", "index-wrapper.cjs"].into_iter().find_map(|entrypoint| {
let stale = export_fragment(entrypoint, false);
(package.matches(&stale).count() == 1).then_some((entrypoint, stale))
})?;
migrated = package.replacen(&stale, &export_fragment(entrypoint, true), 1);
}
let migrated = add_shim_files_to_files_array(&migrated).unwrap_or(migrated);
Some(protect_build_script(&migrated).unwrap_or(migrated))
}
fn persist_repair(path: &Path, content: &str) -> anyhow::Result<()> {
let parent = path.parent().context("node package path has no parent directory")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temporary file in {}", parent.display()))?;
std::io::Write::write_all(&mut temporary, content.as_bytes())
.with_context(|| format!("failed to write temporary file for {}", path.display()))?;
temporary
.persist(path)
.map_err(|error| error.error)
.with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
pub(crate) fn migrate_node_error_shim(base_dir: &Path, relative_path: &Path) -> anyhow::Result<bool> {
let package_json_path = crate::cli::pipeline::generate::write::contained_output_path(base_dir, relative_path)?;
let Some(crate_root) = package_json_path.parent() else {
return Ok(false);
};
if !crate_root.join("binding.cjs").is_file() || !crate_root.join("index.mjs").is_file() {
return Ok(false);
}
let index_path = crate_root.join("index.js");
let (Ok(package_json), Ok(index_js)) = (
std::fs::read_to_string(&package_json_path),
std::fs::read_to_string(&index_path),
) else {
return Ok(false);
};
let (Some(migrated_index), Some(migrated_package)) = (updated_index(&index_js), updated_package(&package_json))
else {
return Ok(false);
};
if migrated_index == index_js && migrated_package == package_json {
return Ok(false);
}
persist_repair(&index_path, &migrated_index)?;
persist_repair(&package_json_path, &migrated_package)?;
tracing::info!(
path = %package_json_path.display(),
"repaired pre-existing crates/*-node package: routed index.js through binding.cjs and added the ESM entry"
);
Ok(true)
}
#[cfg(test)]
mod error_shim_migration_tests {
use super::*;
const OLD_PACKAGE_JSON: &str = "{\n \"name\": \"@scope/example\",\n \"main\": \"index.js\",\n \"exports\": {\n \".\": {\n \"types\": \"./index.d.ts\",\n \"require\": \"./index.js\",\n \"default\": \"./index.js\"\n }\n },\n \"files\": [\n \"index.js\",\n \"index.d.ts\",\n \"*.node\"\n ]\n}\n";
const OLD_INDEX_JS: &str = "\"use strict\";\nlet nativeBinding = null;\nmodule.exports = nativeBinding;\n";
fn package_dir(with_shim_files: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("crates/example-node");
std::fs::create_dir_all(&root).expect("create crate dir");
std::fs::write(root.join("package.json"), OLD_PACKAGE_JSON).expect("write package.json");
std::fs::write(root.join("index.js"), OLD_INDEX_JS).expect("write index.js");
if with_shim_files {
std::fs::write(root.join("binding.cjs"), "").expect("write binding.cjs");
std::fs::write(root.join("index.mjs"), "").expect("write index.mjs");
}
dir
}
#[test]
fn routes_index_js_through_the_shim_and_adds_the_esm_entry() {
let dir = package_dir(true);
let relative = Path::new("crates/example-node/package.json");
assert!(migrate_node_error_shim(dir.path(), relative).expect("migration runs"));
let root = dir.path().join("crates/example-node");
let index = std::fs::read_to_string(root.join("index.js")).unwrap();
assert!(
index.contains("module.exports = require(\"./binding.cjs\")(nativeBinding);"),
"{index}"
);
let package = std::fs::read_to_string(root.join("package.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&package).expect("package.json stays valid JSON");
assert_eq!(parsed["exports"]["."]["import"], "./index.mjs", "{package}");
assert_eq!(parsed["exports"]["."]["require"], "./index.js", "{package}");
let files: Vec<&str> = parsed["files"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(files, ["index.js", "binding.cjs", "index.mjs", "index.d.ts", "*.node"]);
assert!(
!migrate_node_error_shim(dir.path(), relative).expect("second run"),
"the migration must be idempotent"
);
}
fn napi_package_dir() -> tempfile::TempDir {
let dir = package_dir(true);
let root = dir.path().join("crates/example-node");
std::fs::write(
root.join("binding.cjs"),
include_str!("../../../backends/napi/templates/binding.cjs"),
)
.unwrap();
let index = r#"/* auto-generated by NAPI-RS */
const nativeBinding = {
scrape() { throw new Error("request failed", { cause: new Error('alef:error-info:{"kind":"forbidden","statusCode":403}') }); },
Engine: class Engine {},
};
module.exports = nativeBinding
module.exports.scrape = nativeBinding.scrape
module.exports.Engine = nativeBinding.Engine
"#;
std::fs::write(root.join("index.js"), index).unwrap();
let package = OLD_PACKAGE_JSON.replace(
"\"files\": [",
"\"scripts\": {\"build\": \"pnpm exec napi build --platform --release\", \"custom\": \"echo keep\"},\n \"files\": [",
);
std::fs::write(root.join("package.json"), package).unwrap();
dir
}
#[test]
fn routes_napi_exports_through_the_shim_after_native_assignments() {
let dir = napi_package_dir();
let root = dir.path().join("crates/example-node");
let relative = Path::new("crates/example-node/package.json");
assert!(migrate_node_error_shim(dir.path(), relative).expect("NAPI migration runs"));
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(root.join("package.json")).unwrap()).unwrap();
assert_eq!(
parsed["scripts"]["build"],
"pnpm exec napi build --no-js --platform --release"
);
assert_eq!(parsed["scripts"]["custom"], "echo keep");
assert_eq!(parsed["exports"]["."]["import"], "./index.mjs");
assert_eq!(
parsed["files"],
serde_json::json!(["index.js", "binding.cjs", "index.mjs", "index.d.ts", "*.node"])
);
assert_node_runtime(&root);
assert!(!migrate_node_error_shim(dir.path(), relative).expect("second migration is idempotent"));
}
fn assert_node_runtime(root: &Path) {
let output = std::process::Command::new("node")
.current_dir(root)
.args([
"-e",
r#"
const assert = require('node:assert/strict');
const binding = require('./index.js');
assert(new binding.Engine() instanceof binding.Engine);
assert.throws(() => binding.scrape(), error => error.kind === 'forbidden' && error.statusCode === 403);
console.log('two runtime assertions passed');
"#,
])
.output()
.expect("Node is required for this runtime regression");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"two runtime assertions passed"
);
}
#[test]
fn keeps_napi_version_enforcement_aligned_with_the_package_manifest() {
let dir = napi_package_dir();
let root = dir.path().join("crates/example-node");
let index = r#"/* auto-generated by NAPI-RS */
const bindingPackageVersion = process.env.TEST_BINDING_VERSION;
if (bindingPackageVersion !== '1.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 1.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
const nativeBinding = { version: bindingPackageVersion };
module.exports = nativeBinding
module.exports.version = nativeBinding.version
"#;
std::fs::write(root.join("index.js"), index).unwrap();
let package = std::fs::read_to_string(root.join("package.json")).unwrap();
std::fs::write(
root.join("package.json"),
package.replace("\"name\":", "\"version\": \"1.12.1\", \"name\":"),
)
.unwrap();
let relative = Path::new("crates/example-node/package.json");
assert!(migrate_node_error_shim(dir.path(), relative).expect("migration runs"));
for (version, succeeds) in [("1.12.1", true), ("1.12.0", false)] {
let output = std::process::Command::new("node")
.current_dir(&root)
.env("NAPI_RS_ENFORCE_VERSION_CHECK", "1")
.env("TEST_BINDING_VERSION", version)
.args(["-e", "require('./index.js')"])
.output()
.expect("Node is required for this runtime regression");
assert_eq!(
output.status.success(),
succeeds,
"{}",
String::from_utf8_lossy(&output.stderr)
);
if !succeeds {
assert!(String::from_utf8_lossy(&output.stderr).contains("expected 1.12.1 but got 1.12.0"));
}
}
assert!(!migrate_node_error_shim(dir.path(), relative).expect("second migration is idempotent"));
}
#[test]
fn waits_until_the_shim_files_exist() {
let dir = package_dir(false);
let relative = Path::new("crates/example-node/package.json");
assert!(!migrate_node_error_shim(dir.path(), relative).expect("migration runs"));
let root = dir.path().join("crates/example-node");
assert_eq!(std::fs::read_to_string(root.join("index.js")).unwrap(), OLD_INDEX_JS);
assert_eq!(
std::fs::read_to_string(root.join("package.json")).unwrap(),
OLD_PACKAGE_JSON
);
}
#[test]
fn leaves_a_hand_edited_index_js_alone() {
let dir = package_dir(true);
let root = dir.path().join("crates/example-node");
std::fs::write(root.join("index.js"), "module.exports = { custom: true };\n").unwrap();
let relative = Path::new("crates/example-node/package.json");
assert!(!migrate_node_error_shim(dir.path(), relative).expect("migration runs"));
assert_eq!(
std::fs::read_to_string(root.join("package.json")).unwrap(),
OLD_PACKAGE_JSON
);
}
}