Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"build": "tsc --build",
"clean": "tsc --build --clean",
"dev": "tsc --build --watch",
"lint": "eslint ."
"lint": "eslint .",
"test": "npm run test --workspace react-native-node-api-modules --workspace react-native-node-api-cmake --workspace gyp-to-cmake --workspace node-addon-examples"
},
"author": {
"name": "Callstack",
Expand Down
101 changes: 87 additions & 14 deletions packages/react-native-node-api-modules/src/node/path-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import path from "node:path";
import fs from "node:fs";

import {
determineModuleContext,
Expand All @@ -23,6 +24,66 @@ describe("isNodeApiModule", () => {
assert(isNodeApiModule(path.join(tempDirectoryPath, "addon")));
assert(isNodeApiModule(path.join(tempDirectoryPath, "addon.node")));
});

it("returns false when directory cannot be read due to permissions", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"addon.android.node": "",
});
// remove read permissions on directory
fs.chmodSync(tempDirectoryPath, 0);
try {
assert.equal(
isNodeApiModule(path.join(tempDirectoryPath, "addon")),
false
);
} finally {
fs.chmodSync(tempDirectoryPath, 0o700);
}
});

it("throws when module file exists but is not readable", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"addon.android.node": "",
});
const candidate = path.join(tempDirectoryPath, "addon.android.node");
// remove read permission on file
fs.chmodSync(candidate, 0);
try {
assert.throws(() => isNodeApiModule(path.join(tempDirectoryPath, "addon")), /Found an unreadable module addon\.android\.node/);
} finally {
fs.chmodSync(candidate, 0o600);
}
});

it("returns false when parent directory does not exist", () => {
// Path to a non-existent directory
const fakePath = path.join(process.cwd(), "no-such-dir", "addon");
assert.equal(isNodeApiModule(fakePath), false);
});

it("recognize .xcframeworks", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"addon.xcframework/addon.node": "// This is supposed to be a binary file",
});
assert.equal(isNodeApiModule(path.join(tempDirectoryPath, "addon")), true);
assert.equal(
isNodeApiModule(path.join(tempDirectoryPath, "addon.node")),
true
);
assert.equal(isNodeApiModule(path.join(tempDirectoryPath, "nope")), false);
});

it("throws when one module unreadable but another readable", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"addon.android.node": "",
"addon.xcframework": "",
});
const unreadable = path.join(tempDirectoryPath, "addon.android.node");
// only android module is unreadable
fs.chmodSync(unreadable, 0);
assert.throws(() => isNodeApiModule(path.join(tempDirectoryPath, "addon")), /Found an unreadable module addon\.android\.node/);
fs.chmodSync(unreadable, 0o600);
});
});

describe("stripExtension", () => {
Expand All @@ -46,20 +107,6 @@ describe("replaceExtensionWithNode", () => {
});
});

describe("isNodeApiModule", () => {
it("recognize .xcframeworks", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"addon.xcframework/addon.node": "// This is supposed to be a binary file",
});
assert.equal(isNodeApiModule(path.join(tempDirectoryPath, "addon")), true);
assert.equal(
isNodeApiModule(path.join(tempDirectoryPath, "addon.node")),
true
);
assert.equal(isNodeApiModule(path.join(tempDirectoryPath, "nope")), false);
});
});

describe("determineModuleContext", () => {
it("works", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
Expand Down Expand Up @@ -243,3 +290,29 @@ describe("findNodeApiModulePaths", () => {
]);
});
});

describe("determineModuleContext", () => {
it("should read package.json only once across multiple module paths for the same package", (context) => {
const tempDir = setupTempDirectory(context, {
"package.json": `{ "name": "cached-pkg" }`,
"subdir1/file1.node": "",
"subdir2/file2.node": "",
"subdir1/file1.xcframework": ""
});
let readCount = 0;
const orig = fs.readFileSync;
context.mock.method(fs, "readFileSync", (...args: Parameters<typeof fs.readFileSync>) => {
const [pathArg] = args;
if (typeof pathArg === "string" && pathArg.endsWith("package.json")) {
readCount++;
}
return orig(...args);
});

const ctx1 = determineModuleContext(path.join(tempDir, "subdir1/file1.node"));
const ctx2 = determineModuleContext(path.join(tempDir, "subdir2/file2.node"));
assert.equal(ctx1.packageName, "cached-pkg");
assert.equal(ctx2.packageName, "cached-pkg");
assert.equal(readCount, 1);
});
});
84 changes: 45 additions & 39 deletions packages/react-native-node-api-modules/src/node/path-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import path from "node:path";
import fs, { readdirSync } from "node:fs";
import fs from "node:fs";
import { findDuplicates } from "./duplicates";
import chalk from "chalk";
import { packageDirectorySync } from "pkg-dir";
Expand All @@ -22,21 +22,37 @@ export type NamingStrategy = {
stripPathSuffix: boolean;
};

// Cache mapping package directory to package name across calls
const packageNameCache = new Map<string, string>();

/**
* @param modulePath 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.
* @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.
* @throws If the parent directory cannot be read, or if a detected module is unreadable.
* TODO: Consider checking for a specific platform extension.
*/
export function isNodeApiModule(modulePath: string): boolean {
// Determine if we're trying to load a Node-API module
// Strip optional .node extension
const candidateBasePath = path.resolve(
path.dirname(modulePath),
path.basename(modulePath, ".node")
);
return Object.values(PLATFORM_EXTENSIONS)
.map((extension) => candidateBasePath + extension)
.some(fs.existsSync);
const dir = path.dirname(modulePath);
const baseName = path.basename(modulePath, ".node");
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch {
// Cannot read directory: treat as no module
return false;
}
return Object.values(PLATFORM_EXTENSIONS).some(extension => {
const fileName = baseName + extension;
if (!entries.includes(fileName)) {
return false;
}
try {
fs.accessSync(path.join(dir, fileName), fs.constants.R_OK);
return true;
} catch (cause) {
throw new Error(`Found an unreadable module ${fileName}: ${cause}`);
}
});
}

/**
Expand Down Expand Up @@ -78,34 +94,24 @@ export function determineModuleContext(
modulePath: string,
originalPath = modulePath
): ModuleContext {
const candidatePackageJsonPath = path.join(modulePath, "package.json");
const parentDirectoryPath = path.dirname(modulePath);
if (fs.existsSync(candidatePackageJsonPath)) {
const packageJsonContent = fs.readFileSync(
candidatePackageJsonPath,
"utf8"
);
const packageJson = JSON.parse(packageJsonContent) as unknown;
assert(
typeof packageJson === "object" && packageJson !== null,
"Expected package.json to be an object"
);
assert(
"name" in packageJson && typeof packageJson.name === "string",
"Expected package.json to have a name"
);
return {
packageName: packageJson.name,
relativePath: normalizeModulePath(
path.relative(modulePath, originalPath)
),
};
} else if (parentDirectoryPath === modulePath) {
// We've reached the root of the filesystem
// Locate nearest package directory
const pkgDir = packageDirectorySync({ cwd: modulePath });
if (!pkgDir) {
throw new Error("Could not find containing package");
} else {
return determineModuleContext(parentDirectoryPath, originalPath);
}
// Read and cache package name
let pkgName = packageNameCache.get(pkgDir);
if (!pkgName) {
const pkg = readPackageSync({ cwd: pkgDir });
assert(typeof pkg.name === "string", "Expected package.json to have a name");
pkgName = pkg.name;
packageNameCache.set(pkgDir, pkgName);
}
// Compute module-relative path
const relPath = normalizeModulePath(
path.relative(pkgDir, originalPath)
);
return { packageName: pkgName, relativePath: relPath };
}

export function normalizeModulePath(modulePath: string) {
Expand Down Expand Up @@ -246,7 +252,7 @@ export function findNodeApiModulePaths(
return [];
}
const candidatePath = path.join(fromPath, suffix);
return readdirSync(candidatePath, { withFileTypes: true }).flatMap((file) => {
return fs.readdirSync(candidatePath, { withFileTypes: true }).flatMap((file) => {
if (
file.isFile() &&
file.name === MAGIC_FILENAME &&
Expand Down