From ad2a36b7758a92534d8a37e626c45cb6ef51f60f Mon Sep 17 00:00:00 2001 From: wanxiankai Date: Thu, 13 Aug 2026 11:14:02 +0800 Subject: [PATCH 1/2] fix: verify native prebuild fixtures --- .changeset/calm-bears-resolve.md | 5 ++++ .../host/src/node/babel-plugin/plugin.test.ts | 2 +- packages/host/src/node/babel-plugin/plugin.ts | 10 ++++++++ packages/node-addon-examples/package.json | 1 + .../scripts/verify-prebuilds.mts | 23 ++++++++++++++++++- pnpm-lock.yaml | 3 +++ 6 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .changeset/calm-bears-resolve.md diff --git a/.changeset/calm-bears-resolve.md b/.changeset/calm-bears-resolve.md new file mode 100644 index 00000000..94aa61c0 --- /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. 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/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 45e269e9..9829419f 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createRequire } from "node:module"; import path from "node:path"; import type { PluginObj, NodePath } from "@babel/core"; @@ -101,6 +102,7 @@ export function plugin(): PluginObj { } } else if ( !path.isAbsolute(id) && + !resolvesToNonNodeModule(id, this.filename) && isNodeApiModule(path.join(from, id)) ) { const relativePath = path.join(from, id); @@ -114,3 +116,11 @@ export function plugin(): PluginObj { }, }; } + +function resolvesToNonNodeModule(id: string, filename: string): boolean { + try { + return !createRequire(filename).resolve(id).endsWith(".node"); + } catch { + return false; + } +} diff --git a/packages/node-addon-examples/package.json b/packages/node-addon-examples/package.json index acfd70ec..47e9ff76 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:*", diff --git a/packages/node-addon-examples/scripts/verify-prebuilds.mts b/packages/node-addon-examples/scripts/verify-prebuilds.mts index 94b4e1bb..61ae9057 100644 --- a/packages/node-addon-examples/scripts/verify-prebuilds.mts +++ b/packages/node-addon-examples/scripts/verify-prebuilds.mts @@ -2,6 +2,7 @@ import fs from "node:fs"; import assert from "node:assert/strict"; import path from "node:path"; +import plistModule from "@expo/plist"; import { DIRS } from "./cmake-projects.mjs"; const EXPECTED_ANDROID_ARCHS = ["armeabi-v7a", "arm64-v8a", "x86_64", "x86"]; @@ -65,7 +66,27 @@ 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 + const libraryName = path.basename(frameworkDir, ".framework"); + const infoPlist: unknown = plistModule.default.parse( + await fs.promises.readFile( + path.join(frameworkDir, file.name), + "utf8", + ), + ); + assert( + typeof infoPlist === "object" && infoPlist !== null, + "Expected Info.plist to contain a dictionary", + ); + assert("CFBundleExecutable" in infoPlist); + assert("CFBundleIdentifier" in infoPlist); + assert.equal(infoPlist.CFBundleExecutable, libraryName); + assert.equal( + infoPlist.CFBundleIdentifier, + `com.callstackincubator.node-api.${libraryName}`.replace( + /[^A-Za-z0-9-.]/g, + "-", + ), + ); continue; } else { assert( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a99748a..5527e060 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,9 @@ importers: specifier: workspace:* version: link:../host devDependencies: + '@expo/plist': + specifier: 0.4.7 + version: 0.4.7 cmake-rn: specifier: workspace:* version: link:../cmake-rn From 7e212f5f7228616d27901fd0ab8048285d7a5e44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:24:33 +0000 Subject: [PATCH 2/2] Move .js/.node precedence fix into isNodeApiModule, dedupe plist checks - Replace the Babel-transform-time require.resolve() guard with a check inside isNodeApiModule itself, so the fix lives in the shared utility (also used by findNodeAddonForBindings) instead of duplicating Node's module resolution algorithm via a second, independent code path that could diverge from what Metro actually resolves at runtime. - Verify the Info.plist contents with a zod schema instead of ad hoc "in" checks on an untyped object, matching how the rest of the repo validates untrusted structured data. - Reuse the exported escapeBundleIdentifier instead of re-deriving the bundle-identifier escaping regex inline in the verify script, so the two can't silently drift apart. Closes #424 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Q1k6UQJPPaqKEKmnsRUatt --- .changeset/calm-bears-resolve.md | 2 +- packages/host/src/node/babel-plugin/plugin.ts | 10 ---- packages/host/src/node/index.ts | 1 + packages/host/src/node/path-utils.ts | 12 ++++ packages/node-addon-examples/package.json | 3 +- .../scripts/verify-prebuilds.mts | 57 ++++++++++++------- pnpm-lock.yaml | 23 ++------ 7 files changed, 58 insertions(+), 50 deletions(-) diff --git a/.changeset/calm-bears-resolve.md b/.changeset/calm-bears-resolve.md index 94aa61c0..b274b102 100644 --- a/.changeset/calm-bears-resolve.md +++ b/.changeset/calm-bears-resolve.md @@ -2,4 +2,4 @@ "react-native-node-api": patch --- -Preserve Node.js module resolution precedence when a JavaScript file and native addon share a basename. +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.ts b/packages/host/src/node/babel-plugin/plugin.ts index 9829419f..45e269e9 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { createRequire } from "node:module"; import path from "node:path"; import type { PluginObj, NodePath } from "@babel/core"; @@ -102,7 +101,6 @@ export function plugin(): PluginObj { } } else if ( !path.isAbsolute(id) && - !resolvesToNonNodeModule(id, this.filename) && isNodeApiModule(path.join(from, id)) ) { const relativePath = path.join(from, id); @@ -116,11 +114,3 @@ export function plugin(): PluginObj { }, }; } - -function resolvesToNonNodeModule(id: string, filename: string): boolean { - try { - return !createRequire(filename).resolve(id).endsWith(".node"); - } catch { - return false; - } -} 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 47e9ff76..c33f2122 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -39,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 61ae9057..62e521c9 100644 --- a/packages/node-addon-examples/scripts/verify-prebuilds.mts +++ b/packages/node-addon-examples/scripts/verify-prebuilds.mts @@ -3,8 +3,21 @@ 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 = [ @@ -38,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) { @@ -67,27 +101,10 @@ async function verifyApplePrebuild(dirent: fs.Dirent) { ); if (file.name === "Info.plist") { const libraryName = path.basename(frameworkDir, ".framework"); - const infoPlist: unknown = plistModule.default.parse( - await fs.promises.readFile( - path.join(frameworkDir, file.name), - "utf8", - ), - ); - assert( - typeof infoPlist === "object" && infoPlist !== null, - "Expected Info.plist to contain a dictionary", - ); - assert("CFBundleExecutable" in infoPlist); - assert("CFBundleIdentifier" in infoPlist); - assert.equal(infoPlist.CFBundleExecutable, libraryName); - assert.equal( - infoPlist.CFBundleIdentifier, - `com.callstackincubator.node-api.${libraryName}`.replace( - /[^A-Za-z0-9-.]/g, - "-", - ), + await verifyFrameworkInfoPlist( + path.join(frameworkDir, file.name), + libraryName, ); - continue; } else { assert( !file.name.endsWith(".node"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5527e060..c67fa1dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ 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 @@ -4098,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'} @@ -4415,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'} @@ -6793,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': {} @@ -8997,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 @@ -9381,8 +9370,6 @@ snapshots: sisteransi@1.0.5: {} - slash@3.0.0: {} - slice-ansi@2.1.0: dependencies: ansi-styles: 3.2.1