alef 0.115.7

Opinionated polyglot binding generator for Rust libraries
Documentation
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) {
        // ~keep NAPI's trailing named exports must execute before the shim wraps their functions.
        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(())
}

/// Patch only recognized scaffold shapes after both generated shim files exist. ~keep
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
        );
    }
}