diff --git a/.changeset/loud-pandas-jump.md b/.changeset/loud-pandas-jump.md new file mode 100644 index 00000000..cdfcb8fb --- /dev/null +++ b/.changeset/loud-pandas-jump.md @@ -0,0 +1,11 @@ +--- +"react-native-node-api": patch +--- + +Fixed the Babel plugin rewriting `require('./foo')` to load a Node-API addon +even when a same-named `foo.js`/`.cjs`/`.mjs`/`.json` file exists alongside it +— that source file is what Node's own `require()` resolves to, so the addon +was never reachable at runtime through that specific call, only through an +explicit `require('./foo.node')`. + +Also exported `escapeBundleIdentifier` from the package's `node` entrypoint. diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 7dae2979..d076ced7 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -129,8 +129,9 @@ 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.js": "// Some JS file", + "my-addon.apple.node/my-addon.node": + "// This is supposed to be a binary file", "index.js": ` const addon = require('./my-addon'); console.log(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..9fd2aed8 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -37,7 +37,9 @@ "weak-node-api": "workspace:*" }, "dependencies": { + "@expo/plist": "0.4.7", "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..9ac9ae10 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 Node's ESM/CJS interop +// the default import binds to the whole `module.exports`, which nests 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..25aaaff2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,12 +274,18 @@ importers: packages/node-addon-examples: dependencies: + '@expo/plist': + specifier: 0.4.7 + version: 0.4.7 assert: specifier: ^2.1.0 version: 2.1.0 react-native-node-api: specifier: workspace:* version: link:../host + zod: + specifier: ^4.1.11 + version: 4.4.3 devDependencies: cmake-rn: specifier: workspace:* @@ -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