Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-addon-precedence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-native-node-api": patch
---

Preserve JavaScript file resolution when an extensionless require has a sibling Node-API addon.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"publint": "node scripts/run-in-published.ts pnpm exec publint --strict",
"prettier:check": "prettier --experimental-cli --check .",
"prettier:write": "prettier --experimental-cli --write .",
"test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake run test",
"test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake run test && pnpm --filter @react-native-node-api/node-addon-examples run test:verify",
"bootstrap": "node --run build && pnpm --recursive run bootstrap",
"changeset": "changeset",
"release": "node --run prerelease && changeset publish",
Expand Down
2 changes: 1 addition & 1 deletion packages/host/src/node/babel-plugin/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ 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.node": "// This is supposed to be a binary file",
"index.js": `
const addon = require('./my-addon');
console.log(addon);
Expand Down
12 changes: 11 additions & 1 deletion packages/host/src/node/babel-plugin/plugin.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -66,6 +67,14 @@ export function replaceWithRequireNodeAddon(
);
}

function resolvesToNonAddon(id: string, filename: string): boolean {
try {
return !createRequire(path.resolve(filename)).resolve(id).endsWith(".node");
} catch {
return false;
}
}

export function plugin(): PluginObj {
return {
visitor: {
Expand Down Expand Up @@ -101,7 +110,8 @@ export function plugin(): PluginObj {
}
} else if (
!path.isAbsolute(id) &&
isNodeApiModule(path.join(from, id))
isNodeApiModule(path.join(from, id)) &&
!resolvesToNonAddon(id, this.filename)
) {
const relativePath = path.join(from, id);
replaceWithRequireNodeAddon(p, relativePath, {
Expand Down
2 changes: 2 additions & 0 deletions packages/node-addon-examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
"gyp-to-cmake": "gyp-to-cmake --weak-node-api .",
"build": "tsx scripts/build-examples.mts",
"copy-and-build": "node --run copy-examples && node --run gyp-to-cmake && node --run build",
"test:verify": "tsx --test scripts/verify-prebuilds.test.mts",
"verify": "tsx scripts/verify-prebuilds.mts",
"test": "node --run copy-and-build && node --run verify",
"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:*",
Expand Down
68 changes: 55 additions & 13 deletions packages/node-addon-examples/scripts/verify-prebuilds.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import fs from "node:fs";
import assert from "node:assert/strict";
import path from "node:path";
import { pathToFileURL } from "node:url";

import { parse } from "@expo/plist/build/parse.js";

import { EXAMPLES_DIR } from "./cmake-projects.mjs";

Expand All @@ -16,6 +19,32 @@ const EXPECTED_XCFRAMEWORK_PLATFORMS = [
"xros-arm64-simulator",
];

export async function verifyFrameworkInfoPlist(
infoPlistPath: string,
libraryName: string,
) {
const parsed: unknown = parse(
await fs.promises.readFile(infoPlistPath, "utf8"),
);
assert(
typeof parsed === "object" && parsed !== null,
`Expected an object in ${infoPlistPath}`,
);
assert.equal(
Reflect.get(parsed, "CFBundleExecutable"),
libraryName,
`Unexpected CFBundleExecutable in ${infoPlistPath}`,
);
assert.equal(
Reflect.get(parsed, "CFBundleIdentifier"),
`com.callstackincubator.node-api.${libraryName}`.replace(
/[^A-Za-z0-9-.]/g,
"-",
),
`Unexpected CFBundleIdentifier in ${infoPlistPath}`,
);
}

async function verifyAndroidPrebuild(dirent: fs.Dirent) {
console.log(
"Verifying Android prebuild",
Expand Down Expand Up @@ -65,7 +94,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
const libraryName = path.basename(frameworkDir, ".framework");
await verifyFrameworkInfoPlist(
path.join(frameworkDir, file.name),
libraryName,
);
continue;
} else {
assert(
Expand All @@ -82,17 +115,26 @@ async function verifyApplePrebuild(dirent: fs.Dirent) {
}
}

for await (const dirent of fs.promises.glob("**/*.*.node", {
cwd: EXAMPLES_DIR,
withFileTypes: true,
})) {
if (dirent.name.endsWith(".android.node")) {
await verifyAndroidPrebuild(dirent);
} else if (dirent.name.endsWith(".apple.node")) {
await verifyApplePrebuild(dirent);
} else {
throw new Error(
`Unexpected prebuild file: ${dirent.name} in ${dirent.parentPath}`,
);
async function main() {
for await (const dirent of fs.promises.glob("**/*.*.node", {
cwd: EXAMPLES_DIR,
withFileTypes: true,
})) {
if (dirent.name.endsWith(".android.node")) {
await verifyAndroidPrebuild(dirent);
} else if (dirent.name.endsWith(".apple.node")) {
await verifyApplePrebuild(dirent);
} else {
throw new Error(
`Unexpected prebuild file: ${dirent.name} in ${dirent.parentPath}`,
);
}
}
}

if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}
65 changes: 65 additions & 0 deletions packages/node-addon-examples/scripts/verify-prebuilds.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";

import { build } from "@expo/plist/build/build.js";

import { verifyFrameworkInfoPlist } from "./verify-prebuilds.mjs";

async function writeInfoPlist(
directory: string,
contents: Record<string, unknown>,
) {
const infoPlistPath = path.join(directory, "Info.plist");
await fs.promises.writeFile(infoPlistPath, build(contents), "utf8");
return infoPlistPath;
}

describe("verifyFrameworkInfoPlist", () => {
it("accepts the expected executable and escaped bundle identifier", async (context) => {
const directory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "verify-framework-plist-"),
);
context.after(() => fs.promises.rm(directory, { recursive: true }));
const infoPlistPath = await writeInfoPlist(directory, {
CFBundleExecutable: "my_addon",
CFBundleIdentifier: "com.callstackincubator.node-api.my-addon",
});

await verifyFrameworkInfoPlist(infoPlistPath, "my_addon");
});

it("rejects an unexpected executable", async (context) => {
const directory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "verify-framework-plist-"),
);
context.after(() => fs.promises.rm(directory, { recursive: true }));
const infoPlistPath = await writeInfoPlist(directory, {
CFBundleExecutable: "wrong-addon",
CFBundleIdentifier: "com.callstackincubator.node-api.my-addon",
});

await assert.rejects(
() => verifyFrameworkInfoPlist(infoPlistPath, "my-addon"),
/Unexpected CFBundleExecutable/,
);
});

it("rejects an unexpected bundle identifier", async (context) => {
const directory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "verify-framework-plist-"),
);
context.after(() => fs.promises.rm(directory, { recursive: true }));
const infoPlistPath = await writeInfoPlist(directory, {
CFBundleExecutable: "my-addon",
CFBundleIdentifier: "com.example.wrong",
});

await assert.rejects(
() => verifyFrameworkInfoPlist(infoPlistPath, "my-addon"),
/Unexpected CFBundleIdentifier/,
);
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.