diff --git a/package-lock.json b/package-lock.json index 0b4f113c..980e5e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,7 +137,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2334,6 +2333,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", @@ -4832,7 +4841,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz", "integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -6017,7 +6025,6 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.81.4.tgz", "integrity": "sha512-aEXhRMsz6yN5X63Zk+cdKByQ0j3dsKv+ETRP9lLARdZ82fBOCMuK6IfmZMwK3A/3bI7gSvt2MFPn3QHy3WnByw==", "license": "MIT", - "peer": true, "dependencies": { "@react-native/js-polyfills": "0.81.4", "@react-native/metro-babel-transformer": "0.81.4", @@ -6641,7 +6648,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6735,7 +6741,6 @@ "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -7076,7 +7081,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7670,7 +7674,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -9071,7 +9074,6 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12264,8 +12266,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.5.0.tgz", "integrity": "sha512-Yi/FgnN8IU/Cd6KeLxyHkylBUvDTsSScT0Tna2zTrz8klmc8qF2ppj6Q1LHsmOueJWhigQwR4cO2p0XBGW5IaQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/node-int64": { "version": "0.4.0", @@ -13115,7 +13116,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13162,7 +13162,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -14600,7 +14599,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15179,7 +15177,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "license": "MIT", - "peer": true, "engines": { "node": ">=20" } diff --git a/packages/host/src/node/path-utils.test.ts b/packages/host/src/node/path-utils.test.ts index 7a65147b..52d1039e 100644 --- a/packages/host/src/node/path-utils.test.ts +++ b/packages/host/src/node/path-utils.test.ts @@ -491,6 +491,318 @@ describe("findNodeApiModulePathsByDependency", () => { }); }); +describe("findNodeApiModulePathsByConfiguration", () => { + it("should find Node-API paths by dependency in root package.json configuration (excluding certain packages)", async (context) => { + const packagesNames = ["lib-a", "lib-b", "lib-c", "lib-d", "lib-e"]; + const tempDir = setupTempDirectory(context, { + "app/package.json": JSON.stringify({ + name: "app", + dependencies: Object.fromEntries( + packagesNames + .slice(0, 2) + .map((packageName) => [packageName, "^1.0.0"]), + ), + reactNativeNodeApi: { + scan: { + dependencies: ["lib-e"], + }, + }, + }), + ...Object.fromEntries( + packagesNames.map((packageName) => [ + `app/node_modules/${packageName}`, + { + "package.json": JSON.stringify({ + name: packageName, + main: "index.js", + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ]), + ), + }); + + const result = await findNodeApiModulePathsByDependency({ + fromPath: path.join(tempDir, "app"), + platform: "apple", + includeSelf: false, + excludePackages: ["lib-a"], + }); + assert.deepEqual(result, { + "lib-b": { + path: path.join(tempDir, "app/node_modules/lib-b"), + modulePaths: ["addon.apple.node"], + }, + "lib-e": { + path: path.join(tempDir, "app/node_modules/lib-e"), + modulePaths: ["addon.apple.node"], + }, + }); + }); + + it("should find Node-API paths by dependency in child modules configuration (excluding certain packages)", async (context) => { + const packagesNames = ["lib-a", "lib-b", "lib-c", "lib-d", "lib-e"]; + const tempDir = setupTempDirectory(context, { + "app/package.json": JSON.stringify({ + name: "app", + dependencies: Object.fromEntries( + packagesNames + .slice(0, 3) + .map((packageName) => [packageName, "^1.0.0"]), + ), + }), + ...Object.fromEntries( + packagesNames.slice(1).map((packageName, i) => [ + `app/node_modules/${packageName}`, + { + "package.json": JSON.stringify({ + name: packageName, + main: "index.js", + reactNativeNodeApi: { + scan: { + dependencies: + packagesNames[i + 2] != null ? [packagesNames[i + 2]] : [], + }, + }, + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ]), + ), + }); + + const result = await findNodeApiModulePathsByDependency({ + fromPath: path.join(tempDir, "app"), + platform: "apple", + includeSelf: false, + excludePackages: ["lib-a"], + }); + assert.deepEqual(result, { + "lib-b": { + path: path.join(tempDir, "app/node_modules/lib-b"), + modulePaths: ["addon.apple.node"], + }, + "lib-c": { + path: path.join(tempDir, "app/node_modules/lib-c"), + modulePaths: ["addon.apple.node"], + }, + "lib-d": { + path: path.join(tempDir, "app/node_modules/lib-d"), + modulePaths: ["addon.apple.node"], + }, + "lib-e": { + path: path.join(tempDir, "app/node_modules/lib-e"), + modulePaths: ["addon.apple.node"], + }, + }); + }); + + it("shouldn't find Node-API paths that not set in any place", async (context) => { + const packagesNames = ["lib-a", "lib-b", "lib-c", "lib-d", "lib-e"]; + const tempDir = setupTempDirectory(context, { + "app/package.json": JSON.stringify({ + name: "app", + dependencies: Object.fromEntries( + packagesNames + .slice(0, -1) + .map((packageName) => [packageName, "^1.0.0"]), + ), + }), + ...Object.fromEntries( + packagesNames.slice(1).map((packageName) => [ + `app/node_modules/${packageName}`, + { + "package.json": JSON.stringify({ + name: packageName, + main: "index.js", + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ]), + ), + }); + + const result = await findNodeApiModulePathsByDependency({ + fromPath: path.join(tempDir, "app"), + platform: "apple", + includeSelf: false, + excludePackages: ["lib-a"], + }); + assert.deepEqual(result, { + "lib-b": { + path: path.join(tempDir, "app/node_modules/lib-b"), + modulePaths: ["addon.apple.node"], + }, + "lib-c": { + path: path.join(tempDir, "app/node_modules/lib-c"), + modulePaths: ["addon.apple.node"], + }, + "lib-d": { + path: path.join(tempDir, "app/node_modules/lib-d"), + modulePaths: ["addon.apple.node"], + }, + }); + }); + + it("shouldn't loop when searching Node-API paths", async (context) => { + const packagesNames = ["lib-a", "lib-b", "lib-c", "lib-d", "lib-e"]; + const tempDir = setupTempDirectory(context, { + "app/package.json": JSON.stringify({ + name: "app", + dependencies: Object.fromEntries( + packagesNames.map((packageName) => [packageName, "^1.0.0"]), + ), + }), + ...Object.fromEntries( + packagesNames.slice(1).map((packageName, i) => [ + `app/node_modules/${packageName}`, + { + "package.json": JSON.stringify({ + name: packageName, + main: "index.js", + reactNativeNodeApi: { + scan: { + dependencies: + packagesNames[i + ((i % 2) * 2 - 1)] != null + ? [packagesNames[i + ((i % 2) * 2 - 1)]] + : [], + }, + }, + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ]), + ), + }); + + const result = await findNodeApiModulePathsByDependency({ + fromPath: path.join(tempDir, "app"), + platform: "apple", + includeSelf: false, + excludePackages: ["lib-a"], + }); + assert.deepEqual(result, { + "lib-b": { + path: path.join(tempDir, "app/node_modules/lib-b"), + modulePaths: ["addon.apple.node"], + }, + "lib-c": { + path: path.join(tempDir, "app/node_modules/lib-c"), + modulePaths: ["addon.apple.node"], + }, + "lib-d": { + path: path.join(tempDir, "app/node_modules/lib-d"), + modulePaths: ["addon.apple.node"], + }, + "lib-e": { + path: path.join(tempDir, "app/node_modules/lib-e"), + modulePaths: ["addon.apple.node"], + }, + }); + }); + + it("should find Node-API paths by dependency in different layers config (excluding certain packages)", async (context) => { + // lib-e - default module without node-api + const packagesNames = [ + "lib-a", + "lib-b", + "lib-c", + "lib-d", + "lib-e", + "lib-f", + ]; + const tempDir = setupTempDirectory(context, { + "app/package.json": JSON.stringify({ + name: "app", + dependencies: Object.fromEntries( + packagesNames + .slice(0, 2) + .map((packageName) => [packageName, "^1.0.0"]), + ), + }), + "app/node_modules/lib-b": { + "package.json": JSON.stringify({ + name: "lib-b", + main: "index.js", + dependencies: packagesNames.slice(2, 4), + reactNativeNodeApi: { + scan: { + dependencies: packagesNames.slice(2, 4), + }, + }, + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ...Object.fromEntries( + packagesNames.slice(2, 4).map((packageName, i) => [ + `app/node_modules/${packageName}`, + { + "package.json": JSON.stringify({ + name: packageName, + main: "index.js", + dependencies: [packagesNames[i + 4]], + reactNativeNodeApi: { + scan: { + dependencies: [packagesNames[i + 4]], + }, + }, + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ]), + ), + ...Object.fromEntries( + packagesNames.slice(4, 6).map((packageName) => [ + `app/node_modules/${packageName}`, + { + "package.json": JSON.stringify({ + name: packageName, + main: "index.js", + }), + "index.js": "", + "addon.apple.node/react-native-node-api-module": "", + }, + ]), + ), + }); + + const result = await findNodeApiModulePathsByDependency({ + fromPath: path.join(tempDir, "app"), + platform: "apple", + includeSelf: false, + excludePackages: ["lib-a"], + }); + assert.deepEqual(result, { + "lib-b": { + path: path.join(tempDir, "app/node_modules/lib-b"), + modulePaths: ["addon.apple.node"], + }, + "lib-c": { + path: path.join(tempDir, "app/node_modules/lib-c"), + modulePaths: ["addon.apple.node"], + }, + "lib-d": { + path: path.join(tempDir, "app/node_modules/lib-d"), + modulePaths: ["addon.apple.node"], + }, + "lib-e": { + path: path.join(tempDir, "app/node_modules/lib-e"), + modulePaths: ["addon.apple.node"], + }, + "lib-f": { + path: path.join(tempDir, "app/node_modules/lib-f"), + modulePaths: ["addon.apple.node"], + }, + }); + }); +}); + describe("determineModuleContext", () => { it("should read package.json only once across multiple module paths for the same package", (context) => { const tempDir = setupTempDirectory(context, { diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index a2a954a6..c7d3950b 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import { packageDirectorySync } from "pkg-dir"; import { readPackageSync } from "read-pkg"; import { createRequire } from "node:module"; +import * as zod from "zod"; import { chalk, prettyPath } from "@react-native-node-api/cli-utils"; @@ -294,9 +295,49 @@ export function visualizeLibraryMap(libraryMap: LibraryMap) { return result.join("\n"); } +export const ReactNativeNodeAPIConfigurationSchema = zod.object({ + reactNativeNodeApi: zod + .object({ + scan: zod + .object({ + dependencies: zod.array(zod.string()).optional(), + }) + .optional(), + }) + .optional(), +}); + +export const PackageJsonDependenciesSchema = zod.object({ + dependencies: zod.record(zod.string(), zod.string()).optional(), +}); + +export type ReactNativeNodeAPIConfiguration = zod.infer< + typeof ReactNativeNodeAPIConfigurationSchema +>; +export type PackageJsonDependencies = zod.infer< + typeof PackageJsonDependenciesSchema +>; + +type PackageJsonWithNodeApi = PackageJsonDependencies & + ReactNativeNodeAPIConfiguration; + +export function findPackageConfigurationByPath( + fromPath: string, +): ReactNativeNodeAPIConfiguration { + const packageRoot = packageDirectorySync({ cwd: fromPath }); + assert(packageRoot, `Could not find package root from ${fromPath}`); + + const packageJson = readPackageSync({ + cwd: packageRoot, + }); + + return ReactNativeNodeAPIConfigurationSchema.parse(packageJson); +} + /** * Search upwards from a directory to find a package.json and - * return a record mapping from each dependencies of that package to their path on disk. + * return a record mapping from each dependency of that package to their path on disk. + * Also checks all dependencies from reactNativeNodeApi field in dependencies package.json */ export function findPackageDependencyPaths( fromPath: string, @@ -304,23 +345,54 @@ export function findPackageDependencyPaths( const packageRoot = packageDirectorySync({ cwd: fromPath }); assert(packageRoot, `Could not find package root from ${fromPath}`); - const requireFromPackageRoot = createRequire( + const requireFromRoot: NodeRequire = createRequire( path.join(packageRoot, "noop.js"), ); - const { dependencies = {} } = readPackageSync({ cwd: packageRoot }); + const packageJson = readPackageSync({ + cwd: packageRoot, + }) as PackageJsonWithNodeApi; - return Object.fromEntries( - Object.keys(dependencies).flatMap((dependencyName) => { - const resolvedDependencyRoot = resolvePackageRoot( - requireFromPackageRoot, - dependencyName, - ); - return resolvedDependencyRoot - ? [[dependencyName, resolvedDependencyRoot]] - : []; - }), + const { dependencies = {} } = + PackageJsonDependenciesSchema.parse(packageJson); + const { reactNativeNodeApi } = + ReactNativeNodeAPIConfigurationSchema.parse(packageJson); + + const initialDeps = Object.keys(dependencies).concat( + reactNativeNodeApi?.scan?.dependencies ?? [], ); + + const result: Record = {}; + const visited = new Set(); + const queue: Array = [...initialDeps]; + + while (queue.length > 0) { + const name = queue.shift()!; + + if (visited.has(name)) { + continue; + } + visited.add(name); + + const root = resolvePackageRoot(requireFromRoot, name); + if (!root) { + continue; + } + + result[name] = root; + + const config = findPackageConfigurationByPath(root); + const nestedDependencies = + config?.reactNativeNodeApi?.scan?.dependencies ?? []; + + for (const nestedName of nestedDependencies) { + if (!visited.has(nestedName)) { + queue.push(nestedName); + } + } + } + + return result; } export const MAGIC_FILENAME = "react-native-node-api-module";