diff --git a/.changeset/calm-bears-resolve.md b/.changeset/calm-bears-resolve.md new file mode 100644 index 00000000..b274b102 --- /dev/null +++ b/.changeset/calm-bears-resolve.md @@ -0,0 +1,5 @@ +--- +"react-native-node-api": patch +--- + +Preserve Node.js module resolution precedence when a JavaScript file and native addon share a basename: `require('./foo')` no longer gets rewritten to load a Node-API addon when a same-named `foo.js`/`.cjs`/`.mjs`/`.json` file exists alongside it, since that source file is what `require()` actually resolves to. An explicit `require('./foo.node')` is unaffected. diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 7dae2979..d3fe5fac 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -129,7 +129,7 @@ describe("plugin", () => { itTransforms("and does not touch required JS files", { files: { "package.json": `{ "name": "my-package" }`, - // TODO: Add a ./my-addon.node to make this test complete + "my-addon.node": "// This is supposed to be a binary file", "my-addon.js": "// Some JS file", "index.js": ` const addon = require('./my-addon'); diff --git a/packages/host/src/node/index.ts b/packages/host/src/node/index.ts index 1c4c69a9..baee642c 100644 --- a/packages/host/src/node/index.ts +++ b/packages/host/src/node/index.ts @@ -20,6 +20,7 @@ export { createXCframework, createUniversalAppleLibrary, determineXCFrameworkFilename, + escapeBundleIdentifier, } from "./prebuilds/apple.js"; export { diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index 0cb4506a..6b255404 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -59,6 +59,9 @@ export type NamingStrategy = { // Cache mapping package directory to package name across calls const packageNameCache = new Map(); +// Extensions Node's own require() resolves before ever trying `.node`. +const COLLIDING_SOURCE_EXTENSIONS = [".js", ".cjs", ".mjs", ".json"]; + /** * @param modulePath Batch-scans the path to the module to check (must be extensionless or end in .node) * @returns True if a platform specific prebuild exists for the module path, warns on unreadable modules. @@ -66,6 +69,15 @@ const packageNameCache = new Map(); * TODO: Consider checking for a specific platform extension. */ export function isNodeApiModule(modulePath: string): boolean { + if ( + !modulePath.endsWith(".node") && + COLLIDING_SOURCE_EXTENSIONS.some((extension) => + fs.existsSync(modulePath + extension), + ) + ) { + // An explicit require('./foo.node') has no such ambiguity to defer to. + return false; + } { // HACK: Take a shortcut (if applicable): existing `.node` files are addons try { diff --git a/packages/node-addon-examples/package.json b/packages/node-addon-examples/package.json index acfd70ec..c33f2122 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -30,6 +30,7 @@ "bootstrap": "node --run copy-and-build" }, "devDependencies": { + "@expo/plist": "0.4.7", "cmake-rn": "workspace:*", "node-addon-examples": "github:nodejs/node-addon-examples#4b7dd86a85644610e6de80154df9acac9329b509", "gyp-to-cmake": "workspace:*", @@ -38,6 +39,7 @@ }, "dependencies": { "assert": "^2.1.0", - "react-native-node-api": "workspace:*" + "react-native-node-api": "workspace:*", + "zod": "^4.1.11" } } diff --git a/packages/node-addon-examples/scripts/verify-prebuilds.mts b/packages/node-addon-examples/scripts/verify-prebuilds.mts index 94b4e1bb..62e521c9 100644 --- a/packages/node-addon-examples/scripts/verify-prebuilds.mts +++ b/packages/node-addon-examples/scripts/verify-prebuilds.mts @@ -2,8 +2,22 @@ import fs from "node:fs"; import assert from "node:assert/strict"; import path from "node:path"; +import plistModule from "@expo/plist"; +import { escapeBundleIdentifier } from "react-native-node-api"; +import { z } from "zod"; + import { DIRS } from "./cmake-projects.mjs"; +// @expo/plist is CJS with an `export default`; under this genuine ESM +// (.mts) module's interop, the default import binds to the whole +// `module.exports`, nesting the real API one `.default` deeper. +const plist = plistModule.default; + +const FrameworkInfoPlistSchema = z.object({ + CFBundleExecutable: z.string(), + CFBundleIdentifier: z.string(), +}); + const EXPECTED_ANDROID_ARCHS = ["armeabi-v7a", "arm64-v8a", "x86_64", "x86"]; const EXPECTED_XCFRAMEWORK_PLATFORMS = [ @@ -37,6 +51,27 @@ async function verifyAndroidPrebuild(dirent: fs.Dirent) { } } +async function verifyFrameworkInfoPlist( + infoPlistPath: string, + libraryName: string, +) { + const contents = await fs.promises.readFile(infoPlistPath, "utf8"); + const parsed = FrameworkInfoPlistSchema.parse(plist.parse(contents)); + assert.equal( + parsed.CFBundleExecutable, + libraryName, + `Unexpected CFBundleExecutable in ${infoPlistPath}`, + ); + assert.equal( + parsed.CFBundleIdentifier, + // Mirrors the default writeFrameworkInfoPlist derives in + // packages/host/src/node/prebuilds/apple.ts, since none of the + // examples pass --apple-bundle-identifier. + escapeBundleIdentifier(`com.callstackincubator.node-api.${libraryName}`), + `Unexpected CFBundleIdentifier in ${infoPlistPath}`, + ); +} + async function verifyApplePrebuild(dirent: fs.Dirent) { console.log("Verifying Apple prebuild", dirent.name, "in", dirent.parentPath); for (const arch of EXPECTED_XCFRAMEWORK_PLATFORMS) { @@ -65,8 +100,11 @@ async function verifyApplePrebuild(dirent: fs.Dirent) { "Expected only directory and files in framework", ); if (file.name === "Info.plist") { - // TODO: Verify the contents of the Info.plist file - continue; + const libraryName = path.basename(frameworkDir, ".framework"); + await verifyFrameworkInfoPlist( + path.join(frameworkDir, file.name), + libraryName, + ); } else { assert( !file.name.endsWith(".node"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a99748a..c67fa1dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,7 +280,13 @@ importers: react-native-node-api: specifier: workspace:* version: link:../host + zod: + specifier: ^4.1.11 + version: 4.4.3 devDependencies: + '@expo/plist': + specifier: 0.4.7 + version: 0.4.7 cmake-rn: specifier: workspace:* version: link:../cmake-rn @@ -4095,14 +4101,6 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - pkg-dir@8.0.0: resolution: {integrity: sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==} engines: {node: '>=18'} @@ -4412,10 +4410,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@2.1.0: resolution: {integrity: sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==} engines: {node: '>=6'} @@ -6790,7 +6784,9 @@ snapshots: metro-runtime: 0.87.0 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.88.0-nightly-20260809-db662caea': {} @@ -8994,10 +8990,6 @@ snapshots: picomatch@4.0.5: {} - pify@4.0.1: {} - - pirates@4.0.7: {} - pkg-dir@8.0.0: dependencies: find-up-simple: 1.0.1 @@ -9378,8 +9370,6 @@ snapshots: sisteransi@1.0.5: {} - slash@3.0.0: {} - slice-ansi@2.1.0: dependencies: ansi-styles: 3.2.1