diff --git a/packages/node-addon-examples/scripts/build-examples.mts b/packages/node-addon-examples/scripts/build-examples.mts index 7f2d80d6..c569912f 100644 --- a/packages/node-addon-examples/scripts/build-examples.mts +++ b/packages/node-addon-examples/scripts/build-examples.mts @@ -6,9 +6,12 @@ const projectDirectories = findCMakeProjects(); for (const projectDirectory of projectDirectories) { console.log(`Running "react-native-node-api-cmake" in ${projectDirectory}`); - execSync("react-native-node-api-cmake --triplet arm64-apple-ios-sim", { - cwd: projectDirectory, - stdio: "inherit", - }); + execSync( + "react-native-node-api-cmake --triplet aarch64-linux-android --triplet arm64-apple-ios-sim", + { + cwd: projectDirectory, + stdio: "inherit", + } + ); console.log(); } diff --git a/packages/react-native-node-api-cmake/src/android.ts b/packages/react-native-node-api-cmake/src/android.ts index db8054c9..4a8b04e8 100644 --- a/packages/react-native-node-api-cmake/src/android.ts +++ b/packages/react-native-node-api-cmake/src/android.ts @@ -135,7 +135,7 @@ export async function createAndroidLibsDirectory({ const arch = ANDROID_ARCHITECTURES[triplet as AndroidTriplet]; const archOutputPath = path.join(outputPath, arch); await fs.promises.mkdir(archOutputPath, { recursive: true }); - const libraryName = path.basename(libraryPath, path.extname(libraryPath)); + const libraryName = path.basename(libraryPath); const libraryOutputPath = path.join(archOutputPath, libraryName); await fs.promises.copyFile(libraryPath, libraryOutputPath); } diff --git a/packages/react-native-node-api-cmake/src/weak-node-api.ts b/packages/react-native-node-api-cmake/src/weak-node-api.ts index ac3e288e..16b429b7 100644 --- a/packages/react-native-node-api-cmake/src/weak-node-api.ts +++ b/packages/react-native-node-api-cmake/src/weak-node-api.ts @@ -16,7 +16,7 @@ export function getWeakNodeApiPath(triplet: SupportedTriplet): string { ); assert(fs.existsSync(pathname), "Weak Node API path does not exist"); if (isAppleTriplet(triplet)) { - const xcframeworkPath = path.join(pathname, "weak-node-api.xcframework"); + const xcframeworkPath = path.join(pathname, "libweak-node-api.xcframework"); assert( fs.existsSync(xcframeworkPath), `Expected an XCFramework at ${xcframeworkPath}` @@ -25,9 +25,9 @@ export function getWeakNodeApiPath(triplet: SupportedTriplet): string { } else if (isAndroidTriplet(triplet)) { const libraryPath = path.join( pathname, - "weak-node-api.android.node", + "libweak-node-api.android.node", ANDROID_ARCHITECTURES[triplet], - "weak-node-api" + "libweak-node-api.so" ); assert(fs.existsSync(libraryPath), `Expected library at ${libraryPath}`); return libraryPath; diff --git a/packages/react-native-node-api-modules/.gitignore b/packages/react-native-node-api-modules/.gitignore index 26da3310..1759ce44 100644 --- a/packages/react-native-node-api-modules/.gitignore +++ b/packages/react-native-node-api-modules/.gitignore @@ -10,14 +10,14 @@ include/ **/android/build/ # iOS build artifacts -xcframeworks/ +/auto-linked/ # Android build artifacts android/.cxx/ android/build/ # Everything in weak-node-api is generated, except for the configurations -weak-node-api/build/ -weak-node-api/weak-node-api.xcframework -weak-node-api/weak-node-api.android.node -weak-node-api.cpp +/weak-node-api/build/ +/weak-node-api/*.xcframework +/weak-node-api/*.android.node +/weak-node-api/weak-node-api.cpp diff --git a/packages/react-native-node-api-modules/android/CMakeLists.txt b/packages/react-native-node-api-modules/android/CMakeLists.txt index fe1025b9..fa535e0c 100644 --- a/packages/react-native-node-api-modules/android/CMakeLists.txt +++ b/packages/react-native-node-api-modules/android/CMakeLists.txt @@ -16,7 +16,6 @@ target_include_directories(node-api-host PRIVATE find_package(ReactAndroid REQUIRED CONFIG) find_package(hermes-engine REQUIRED CONFIG) - target_link_libraries(node-api-host # android ReactAndroid::reactnative @@ -24,3 +23,15 @@ target_link_libraries(node-api-host hermes-engine::libhermes # react_codegen_NodeApiHostSpec ) + +add_subdirectory(../weak-node-api weak-node-api) + +target_compile_definitions(weak-node-api + PRIVATE + # NAPI_VERSION=8 + NODE_API_REEXPORT=1 +) +target_link_libraries(weak-node-api + node-api-host + hermes-engine::libhermes +) diff --git a/packages/react-native-node-api-modules/android/build.gradle b/packages/react-native-node-api-modules/android/build.gradle index 8cc61170..6201a2df 100644 --- a/packages/react-native-node-api-modules/android/build.gradle +++ b/packages/react-native-node-api-modules/android/build.gradle @@ -1,4 +1,5 @@ import java.nio.file.Paths +import groovy.json.JsonSlurper buildscript { ext.getExtOrDefault = {name -> @@ -64,7 +65,7 @@ android { externalNativeBuild { cmake { - targets "node-api-host" + targets "node-api-host", "weak-node-api" cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" arguments "-DANDROID_STL=c++_shared" abiFilters (*reactNativeArchitectures()) @@ -122,20 +123,6 @@ android { doNotStrip "**/libhermes.so" } - // sourceSets { - // main { - // jniLibs.srcDirs = [ 'src/main/jniLibs' ] - // } - // } - - // sourceSets { - // main { - // java.srcDirs += [ - // "generated/java", - // "generated/jni" - // ] - // } - // } } repositories { @@ -157,3 +144,21 @@ react { codegenJavaPackageName = "com.callstack.node_api_modules" } +// Custom task to fetch jniLibs paths via CLI +task linkNodeApiModules { + doLast { + exec { + // TODO: Support --strip-path-suffix + commandLine 'npx', 'react-native-node-api-modules', 'link', '--android', rootProject.rootDir.absolutePath + standardOutput = System.out + errorOutput = System.err + // Enable color output + environment "FORCE_COLOR", "1" + } + + android.sourceSets.main.jniLibs.srcDirs += file("../auto-linked/android").listFiles() + } +} + +preBuild.dependsOn linkNodeApiModules + diff --git a/packages/react-native-node-api-modules/android/src/main/java/com/callstack/node_api_modules/NodeApiModulesPackage.kt b/packages/react-native-node-api-modules/android/src/main/java/com/callstack/node_api_modules/NodeApiModulesPackage.kt index 3d46731d..89a38313 100644 --- a/packages/react-native-node-api-modules/android/src/main/java/com/callstack/node_api_modules/NodeApiModulesPackage.kt +++ b/packages/react-native-node-api-modules/android/src/main/java/com/callstack/node_api_modules/NodeApiModulesPackage.kt @@ -12,8 +12,8 @@ import java.util.HashMap class NodeApiModulesPackage : BaseReactPackage() { init { - // SoLoader.loadLibrary("node-api-host-bootstrap") SoLoader.loadLibrary("node-api-host") + SoLoader.loadLibrary("weak-node-api") } override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { diff --git a/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp b/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp index 0fd8ab59..91617105 100644 --- a/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp +++ b/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp @@ -53,7 +53,7 @@ bool CxxNodeApiHostModule::loadNodeAddon(NodeAddon &addon, std::string libraryPath = "@rpath/" + libraryName + ".framework/" + libraryName; #elif defined(__ANDROID__) - std::string libraryPath = libraryName + std::string libraryPath = "lib" + libraryName + ".so"; #else abort() #endif diff --git a/packages/react-native-node-api-modules/react-native-node-api-modules.podspec b/packages/react-native-node-api-modules/react-native-node-api-modules.podspec index 23006120..c4bb2eac 100644 --- a/packages/react-native-node-api-modules/react-native-node-api-modules.podspec +++ b/packages/react-native-node-api-modules/react-native-node-api-modules.podspec @@ -7,7 +7,7 @@ require_relative "./scripts/patch-hermes" NODE_PATH ||= `which node`.strip CLI_COMMAND ||= "'#{NODE_PATH}' '#{File.join(__dir__, "dist/node/cli/run.js")}'" STRIP_PATH_SUFFIX ||= ENV['NODE_API_MODULES_STRIP_PATH_SUFFIX'] === "true" -COPY_FRAMEWORKS_COMMAND ||= "#{CLI_COMMAND} xcframeworks copy --podfile '#{Pod::Config.instance.installation_root}' #{STRIP_PATH_SUFFIX ? '--strip-path-suffix' : ''}" +COPY_FRAMEWORKS_COMMAND ||= "#{CLI_COMMAND} link --apple '#{Pod::Config.instance.installation_root}' #{STRIP_PATH_SUFFIX ? '--strip-path-suffix' : ''}" # We need to run this now to ensure the xcframeworks are copied vendored_frameworks are considered XCFRAMEWORKS_DIR ||= File.join(__dir__, "xcframeworks") @@ -32,7 +32,7 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm}", "cpp/**/*.{hpp,cpp,c,h}", "include/*.h" s.public_header_files = "include/*.h" - s.vendored_frameworks = "xcframeworks/*.xcframework" + s.vendored_frameworks = "auto-linked/xcframeworks/*.xcframework" s.script_phase = { :name => 'Copy Node-API xcframeworks', :execution_position => :before_compile, diff --git a/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts b/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts index bbe75c3b..c57acc6b 100644 --- a/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts +++ b/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts @@ -12,23 +12,6 @@ import { z } from "zod"; export const WEAK_NODE_API_PATH = path.join(__dirname, "../weak-node-api"); -export function getNodeApiSymbols( - version: NodeApiVersion, - filter?: "js_native_api_symbols" | "node_api_symbols" -) { - const symbolsPerInterface = symbols[version]; - if (filter === "js_native_api_symbols") { - return symbolsPerInterface.js_native_api_symbols; - } else if (filter === "node_api_symbols") { - return symbolsPerInterface.node_api_symbols; - } else { - return [ - ...symbolsPerInterface.js_native_api_symbols, - ...symbolsPerInterface.node_api_symbols, - ]; - } -} - export function generateVersionScript( libraryName: string, globalSymbols: string[] @@ -88,6 +71,55 @@ export function getNodeApiHeaderAST(version: NodeApiVersion) { return clangAstDump.parse(parsed); } +type FunctionDecl = { + name: string; + returnType: string; + argumentTypes: string[]; + libraryPath: string; +}; + +export function generateNodeApiFunction({ + name, + returnType, + argumentTypes, + libraryPath, +}: FunctionDecl) { + const stubbedReturnStatement = + returnType === "void" + ? "abort();" + : "return napi_status::napi_generic_failure;"; + return ` +typedef ${returnType} (*${name}_t)(${argumentTypes.join(", ")}); + +${returnType} ${name}(${argumentTypes + .map((type, index) => `${type} arg${index}`) + .join(", ")}) { + #ifdef NODE_API_REEXPORT + static ${name}_t real_func = NULL; + + if (!real_func) { + void* handle = dlopen("${libraryPath}", RTLD_LAZY | RTLD_GLOBAL); + if (!handle) { + fprintf(stderr, "Failed to load ${libraryPath}: %s\\n", dlerror()); + ${stubbedReturnStatement} + } + + real_func = (${name}_t)dlsym(handle, "${name}"); + if (!real_func) { + fprintf(stderr, "Failed to find symbol: %s\\n", dlerror()); + ${stubbedReturnStatement} + } + } + + ${returnType === "void" ? "" : "return "}real_func(${argumentTypes + .map((t, index) => `arg${index}`) + .join(", ")}); // Call the real function + #else + ${stubbedReturnStatement} + #endif +}`; +} + /** * Generates source code for a version script for the given Node API version. * @param version @@ -95,21 +127,26 @@ export function getNodeApiHeaderAST(version: NodeApiVersion) { export function generateFakeNodeApiSource(version: NodeApiVersion) { const lines = [ "// This file is generated by react-native-node-api-modules", - "#include ", + "#include ", // Node-API + "#include ", // dlopen(), dlsym() + "#include ", // fprintf() + "#include ", // abort() ]; const root = getNodeApiHeaderAST(version); assert.equal(root.kind, "TranslationUnitDecl"); assert(Array.isArray(root.inner)); const foundSymbols = new Set(); - const nodeApiSymbols = new Set(getNodeApiSymbols(version)); - for (const node of root.inner) { - if ( - node.kind === "FunctionDecl" && - node.name && - nodeApiSymbols.has(node.name) - ) { - foundSymbols.add(node.name); + const symbolsPerInterface = symbols[version]; + const engineSymbols = new Set(symbolsPerInterface.js_native_api_symbols); + const runtimeSymbols = new Set(symbolsPerInterface.node_api_symbols); + const allSymbols = new Set([...engineSymbols, ...runtimeSymbols]); + + for (const node of root.inner) { + const { name, kind } = node; + if (kind === "FunctionDecl" && name && allSymbols.has(name)) { + assert(name, "Expected a name"); + foundSymbols.add(name); assert(node.type, `Expected type for ${node.name}`); const match = node.type.qualType.match( @@ -126,20 +163,27 @@ export function generateFakeNodeApiSource(version: NodeApiVersion) { ); assert( argumentTypes, - `Failed to get argument types from ${node.type.qualType}` + `Failed to get argument types from ${argumentTypes}` ); assert( returnType === "napi_status" || returnType === "void", `Expected return type to be napi_status, got ${returnType}` ); + lines.push( - `__attribute__((weak)) ${returnType} ${node.name}(${argumentTypes}) {`, - returnType === "void" ? "" : " napi_status::napi_generic_failure;", - "}" + generateNodeApiFunction({ + name, + returnType, + argumentTypes: argumentTypes.split(",").map((arg) => arg.trim()), + // Defer to the right library + libraryPath: engineSymbols.has(name) + ? "libhermes.so" + : "libnode-api-host.so", + }) ); } } - for (const knownSymbol of nodeApiSymbols) { + for (const knownSymbol of allSymbols) { if (!foundSymbols.has(knownSymbol)) { throw new Error( `Missing symbol '${knownSymbol}' in the AST for Node API ${version}` @@ -149,18 +193,6 @@ export function generateFakeNodeApiSource(version: NodeApiVersion) { return lines.join("\n"); } -export async function ensureNodeApiVersionScript(version: NodeApiVersion) { - const outputPath = path.join(WEAK_NODE_API_PATH, `fakenode-${version}.map`); - if (!fs.existsSync(outputPath)) { - // Make sure the output directory exists - fs.mkdirSync(WEAK_NODE_API_PATH, { recursive: true }); - const symbols = getNodeApiSymbols(version); - const content = generateVersionScript("libfakenode", symbols); - fs.writeFileSync(outputPath, content, "utf-8"); - } - return outputPath; -} - async function run() { const sourceCode = generateFakeNodeApiSource("v10"); await fs.promises.mkdir(WEAK_NODE_API_PATH, { recursive: true }); @@ -177,6 +209,8 @@ async function run() { "--apple", "--no-auto-link", "--no-weak-node-api-linkage", + // TODO: Add support for passing variables through to CMake + // "-D NODE_API_REEXPORT=1", "--source", WEAK_NODE_API_PATH, ], diff --git a/packages/react-native-node-api-modules/src/node/cli/android.ts b/packages/react-native-node-api-modules/src/node/cli/android.ts new file mode 100644 index 00000000..4acae601 --- /dev/null +++ b/packages/react-native-node-api-modules/src/node/cli/android.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { getLatestMtime, getLibraryName, MAGIC_FILENAME } from "../path-utils"; +import { + getLinkedModuleOutputPath, + LinkModuleResult, + type LinkModuleOptions, +} from "./link-modules"; + +const ANDROID_ARCHITECTURES = [ + "arm64-v8a", + "armeabi-v7a", + "x86_64", + "x86", +] as const; + +export async function linkAndroidDir({ + incremental, + modulePath, + naming, + platform, +}: LinkModuleOptions): Promise { + const libraryName = getLibraryName(modulePath, naming); + const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming); + + if (incremental && fs.existsSync(outputPath)) { + const moduleModified = getLatestMtime(modulePath); + const outputModified = getLatestMtime(outputPath); + if (moduleModified < outputModified) { + return { + originalPath: modulePath, + libraryName, + outputPath, + skipped: true, + }; + } + } + + await fs.promises.rm(outputPath, { recursive: true, force: true }); + await fs.promises.cp(modulePath, outputPath, { recursive: true }); + for (const arch of ANDROID_ARCHITECTURES) { + const archPath = path.join(outputPath, arch); + if (!fs.existsSync(archPath)) { + // Skip missing architectures + continue; + } + const libraryDirents = await fs.promises.readdir(archPath, { + withFileTypes: true, + }); + assert(libraryDirents.length === 1, "Expected exactly one library file"); + const [libraryDirent] = libraryDirents; + assert(libraryDirent.isFile(), "Expected a library file"); + const libraryPath = path.join(libraryDirent.parentPath, libraryDirent.name); + await fs.promises.rename( + libraryPath, + path.join(archPath, `lib${libraryName}.so`) + ); + } + await fs.promises.rm(path.join(outputPath, MAGIC_FILENAME), { + recursive: true, + }); + + // TODO: Update the DT_NEEDED entry in the .so files + + return { + originalPath: modulePath, + outputPath, + libraryName, + skipped: false, + }; +} diff --git a/packages/react-native-node-api-modules/src/node/cli/apple.ts b/packages/react-native-node-api-modules/src/node/cli/apple.ts new file mode 100644 index 00000000..d7191bd1 --- /dev/null +++ b/packages/react-native-node-api-modules/src/node/cli/apple.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +import { spawn } from "bufout"; + +import { getLatestMtime, getLibraryName } from "../path-utils.js"; +import { + getLinkedModuleOutputPath, + LinkModuleOptions, + LinkModuleResult, +} from "./link-modules.js"; + +type UpdateInfoPlistOptions = { + filePath: string; + oldLibraryName: string; + newLibraryName: string; +}; + +/** + * Update the Info.plist file of an xcframework to use the new library name. + */ +export async function updateInfoPlist({ + filePath, + oldLibraryName, + newLibraryName, +}: UpdateInfoPlistOptions) { + const infoPlistContents = await fs.promises.readFile(filePath, "utf-8"); + // TODO: Use a proper plist parser + const updatedContents = infoPlistContents.replaceAll( + oldLibraryName, + newLibraryName + ); + await fs.promises.writeFile(filePath, updatedContents, "utf-8"); +} + +export async function linkXcframework({ + platform, + modulePath, + incremental, + naming, +}: LinkModuleOptions): Promise { + // Copy the xcframework to the output directory and rename the framework and binary + const newLibraryName = getLibraryName(modulePath, naming); + const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming); + const tempPath = await fs.promises.mkdtemp( + path.join(os.tmpdir(), `react-native-node-api-modules-${newLibraryName}-`) + ); + try { + if (incremental && fs.existsSync(outputPath)) { + const moduleModified = getLatestMtime(modulePath); + const outputModified = getLatestMtime(outputPath); + if (moduleModified < outputModified) { + return { + originalPath: modulePath, + libraryName: newLibraryName, + outputPath, + skipped: true, + }; + } + } + // Delete any existing xcframework (or xcodebuild will try to amend it) + await fs.promises.rm(outputPath, { recursive: true, force: true }); + await fs.promises.cp(modulePath, tempPath, { recursive: true }); + + const frameworkPaths = await Promise.all( + fs + .readdirSync(tempPath, { + withFileTypes: true, + }) + .filter((tripletEntry) => tripletEntry.isDirectory()) + .flatMap((tripletEntry) => { + const tripletPath = path.join(tempPath, tripletEntry.name); + return fs + .readdirSync(tripletPath, { + withFileTypes: true, + }) + .filter( + (frameworkEntry) => + frameworkEntry.isDirectory() && + path.extname(frameworkEntry.name) === ".framework" + ) + .flatMap(async (frameworkEntry) => { + const frameworkPath = path.join(tripletPath, frameworkEntry.name); + const oldLibraryName = path.basename( + frameworkEntry.name, + ".framework" + ); + const oldLibraryPath = path.join(frameworkPath, oldLibraryName); + const newFrameworkPath = path.join( + tripletPath, + `${newLibraryName}.framework` + ); + const newLibraryPath = path.join( + newFrameworkPath, + newLibraryName + ); + assert( + fs.existsSync(oldLibraryPath), + `Expected a library at '${oldLibraryPath}'` + ); + // Rename the library + await fs.promises.rename( + oldLibraryPath, + // Cannot use newLibraryPath here, because the framework isn't renamed yet + path.join(frameworkPath, newLibraryName) + ); + // Rename the framework + await fs.promises.rename(frameworkPath, newFrameworkPath); + // Expect the library in the new location + assert(fs.existsSync(newLibraryPath)); + // Update the binary + await spawn( + "install_name_tool", + [ + "-id", + `@rpath/${newLibraryName}.framework/${newLibraryName}`, + newLibraryPath, + ], + { + outputMode: "buffered", + } + ); + // Update the Info.plist file for the framework + await updateInfoPlist({ + filePath: path.join(newFrameworkPath, "Info.plist"), + oldLibraryName, + newLibraryName, + }); + return newFrameworkPath; + }); + }) + ); + + // Create a new xcframework from the renamed frameworks + await spawn( + "xcodebuild", + [ + "-create-xcframework", + ...frameworkPaths.flatMap((frameworkPath) => [ + "-framework", + frameworkPath, + ]), + "-output", + outputPath, + ], + { + outputMode: "buffered", + } + ); + + return { + originalPath: modulePath, + libraryName: newLibraryName, + outputPath, + skipped: false, + }; + } finally { + await fs.promises.rm(tempPath, { recursive: true, force: true }); + } +} diff --git a/packages/react-native-node-api-modules/src/node/cli/helpers.test.ts b/packages/react-native-node-api-modules/src/node/cli/helpers.test.ts deleted file mode 100644 index aeb5426e..00000000 --- a/packages/react-native-node-api-modules/src/node/cli/helpers.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import path from "node:path"; - -import { findPackageDependencyPaths, findXCFrameworkPaths } from "./helpers"; -import { setupTempDirectory } from "../test-utils"; - -describe("findPackageDependencyPaths", () => { - it("should find package dependency paths", (context) => { - const tempDir = setupTempDirectory(context, { - "node_modules/lib-a/package.json": JSON.stringify({ - name: "lib-a", - main: "index.js", - }), - "node_modules/lib-a/index.js": "", - "test-package/node_modules/lib-b/package.json": JSON.stringify({ - name: "lib-b", - main: "index.js", - }), - "test-package/node_modules/lib-b/index.js": "", - "test-package/package.json": JSON.stringify({ - name: "test-package", - dependencies: { - "lib-a": "^1.0.0", - "lib-b": "^1.0.0", - }, - }), - "test-package/src/index.js": "console.log('Hello, world!')", - }); - - const result = findPackageDependencyPaths( - path.join(tempDir, "test-package/src/index.js") - ); - - assert.deepEqual(result, { - "lib-a": path.join(tempDir, "node_modules/lib-a"), - "lib-b": path.join(tempDir, "test-package/node_modules/lib-b"), - }); - }); -}); - -describe("findXCFrameworkPaths", () => { - it("should find xcframework paths", (context) => { - const tempDir = setupTempDirectory(context, { - "root.xcframework/react-native-node-api-module": "", - "sub-directory/lib-a.xcframework/react-native-node-api-module": "", - "sub-directory/lib-b.xcframework/react-native-node-api-module": "", - }); - const result = findXCFrameworkPaths(tempDir); - assert.deepEqual(result.sort(), [ - path.join(tempDir, "root.xcframework"), - path.join(tempDir, "sub-directory/lib-a.xcframework"), - path.join(tempDir, "sub-directory/lib-b.xcframework"), - ]); - }); - - it("respects default exclude patterns", (context) => { - const tempDir = setupTempDirectory(context, { - "root.xcframework/react-native-node-api-module": "", - "child-dir/dependency/lib.xcframework/react-native-node-api-module": "", - "child-dir/node_modules/dependency/lib.xcframework/react-native-node-api-module": - "", - }); - const result = findXCFrameworkPaths(tempDir); - assert.deepEqual(result.sort(), [ - path.join(tempDir, "child-dir/dependency/lib.xcframework"), - path.join(tempDir, "root.xcframework"), - ]); - }); - - it("respects explicit exclude patterns", (context) => { - const tempDir = setupTempDirectory(context, { - "root.xcframework/react-native-node-api-module": "", - "child-dir/dependency/lib.xcframework/react-native-node-api-module": "", - "child-dir/node_modules/dependency/lib.xcframework/react-native-node-api-module": - "", - }); - const result = findXCFrameworkPaths(tempDir, { excludePatterns: [/root/] }); - assert.deepEqual(result.sort(), [ - path.join(tempDir, "child-dir/dependency/lib.xcframework"), - path.join(tempDir, "child-dir/node_modules/dependency/lib.xcframework"), - ]); - }); - - it("disregards parts futher up in filesystem when excluding", (context) => { - const tempDir = setupTempDirectory(context, { - "node_modules/root.xcframework/react-native-node-api-module": "", - "node_modules/child-dir/node_modules/dependency/lib.xcframework/react-native-node-api-module": - "", - }); - const result = findXCFrameworkPaths(path.join(tempDir, "node_modules")); - assert.deepEqual(result, [ - path.join(tempDir, "node_modules/root.xcframework"), - ]); - }); -}); diff --git a/packages/react-native-node-api-modules/src/node/cli/helpers.ts b/packages/react-native-node-api-modules/src/node/cli/helpers.ts deleted file mode 100644 index a9f253ae..00000000 --- a/packages/react-native-node-api-modules/src/node/cli/helpers.ts +++ /dev/null @@ -1,337 +0,0 @@ -import assert from "node:assert/strict"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { readdirSync, existsSync, statSync } from "node:fs"; -import { createRequire } from "node:module"; - -import { spawn } from "bufout"; -import { packageDirectorySync } from "pkg-dir"; -import { readPackageSync } from "read-pkg"; - -import { NamingStrategy, getLibraryName } from "../path-utils.js"; -import chalk from "chalk"; - -// Must be in all xcframeworks to be considered as Node-API modules -export const MAGIC_FILENAME = "react-native-node-api-module"; -export const XCFRAMEWORKS_PATH = path.resolve( - __dirname, - "../../../xcframeworks" -); -export const DEFAULT_EXCLUDE_PATTERNS = [/\/node_modules\//, /\/.git\//]; - -export function prettyPath(p: string) { - return chalk.dim(path.relative(process.cwd(), p)); -} - -export function resolvePackageRoot( - requireFromPackageRoot: NodeJS.Require, - packageName: string -): string | undefined { - try { - const resolvedPath = requireFromPackageRoot.resolve(packageName); - return packageDirectorySync({ cwd: resolvedPath }); - } catch { - // TODO: Add a debug log here - return undefined; - } -} - -/** - * Get the latest modification time of all files in a directory and its subdirectories. - */ -function getLatestMtime(dir: string): number { - const entries = readdirSync(dir, { - withFileTypes: true, - recursive: true, - }); - - let latest = 0; - - for (const entry of entries) { - if (entry.isFile()) { - const fullPath = path.join(entry.parentPath, entry.name); - const stat = statSync(fullPath); - if (stat.mtimeMs > latest) { - latest = stat.mtimeMs; - } - } - } - - return latest; -} - -/** - * 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. - */ -export function findPackageDependencyPaths( - from: string -): Record { - const packageRoot = packageDirectorySync({ cwd: path.dirname(from) }); - assert(packageRoot, `Could not find package root from ${from}`); - - const requireFromPackageRoot = createRequire( - path.join(packageRoot, "noop.js") - ); - - const { dependencies = {} } = readPackageSync({ cwd: packageRoot }); - - return Object.fromEntries( - Object.keys(dependencies) - .map((dependencyName) => { - const resolvedDependencyRoot = resolvePackageRoot( - requireFromPackageRoot, - dependencyName - ); - return resolvedDependencyRoot - ? [dependencyName, resolvedDependencyRoot] - : undefined; - }) - .filter((item) => typeof item !== "undefined") - ); -} - -export type FindXCFrameworkOptions = { - excludePatterns?: RegExp[]; -}; - -/** - * Recursively search into a directory for xcframeworks containing Node-API modules. - * TODO: Turn this asynchronous - */ -export function findXCFrameworkPaths( - fromPath: string, - options: FindXCFrameworkOptions = {}, - suffix = "" -): string[] { - const candidatePath = path.join(fromPath, suffix); - const { excludePatterns = DEFAULT_EXCLUDE_PATTERNS } = options; - return readdirSync(candidatePath, { withFileTypes: true }).flatMap((file) => { - if ( - file.isFile() && - file.name === MAGIC_FILENAME && - path.extname(candidatePath) === ".xcframework" - ) { - return [candidatePath]; - } else if (file.isDirectory()) { - const newSuffix = path.join(suffix, file.name); - if (!excludePatterns.some((pattern) => pattern.test(newSuffix))) { - // Traverse into the child directory - return findXCFrameworkPaths(fromPath, options, newSuffix); - } - } - return []; - }); -} - -/** - * Finds all dependencies of the app package and their xcframeworks. - */ -export function findPackageDependencyPathsAndXcframeworks( - installationRoot: string, - options: FindXCFrameworkOptions = {} -) { - // Find the location of each dependency - const dependencyPathsByName = findPackageDependencyPaths(installationRoot); - // Find all their xcframeworks - return Object.fromEntries( - Object.entries(dependencyPathsByName) - .map(([dependencyName, dependencyPath]) => { - // Make all the xcframeworks relative to the dependency path - const xcframeworkPaths = findXCFrameworkPaths( - dependencyPath, - options - ).map((p) => path.relative(dependencyPath, p)); - return [ - dependencyName, - { - path: dependencyPath, - xcframeworkPaths, - }, - ] as const; - }) - // Remove any dependencies without xcframeworks - .filter(([, { xcframeworkPaths }]) => xcframeworkPaths.length > 0) - ); -} - -type UpdateInfoPlistOptions = { - filePath: string; - oldLibraryName: string; - newLibraryName: string; -}; - -/** - * Update the Info.plist file of an xcframework to use the new library name. - */ -export async function updateInfoPlist({ - filePath, - oldLibraryName, - newLibraryName, -}: UpdateInfoPlistOptions) { - const infoPlistContents = await fs.readFile(filePath, "utf-8"); - // TODO: Use a proper plist parser - const updatedContents = infoPlistContents.replaceAll( - oldLibraryName, - newLibraryName - ); - await fs.writeFile(filePath, updatedContents, "utf-8"); -} - -type RebuildXcframeworkOptions = { - modulePath: string; - incremental: boolean; - naming: NamingStrategy; -}; - -type VendoredXcframework = { - originalPath: string; - outputPath: string; - libraryName: string; -}; - -type VendoredXcframeworkResult = VendoredXcframework & { - skipped: boolean; -}; - -export function determineVendoredXcframeworkDetails( - modulePath: string, - naming: NamingStrategy -): VendoredXcframework { - const packageRoot = packageDirectorySync({ cwd: modulePath }); - assert(packageRoot, `Could not find package root from ${modulePath}`); - const { name } = readPackageSync({ cwd: packageRoot }); - assert(name, `Could not find package name from ${packageRoot}`); - const libraryName = getLibraryName(modulePath, naming); - return { - originalPath: modulePath, - outputPath: path.join(XCFRAMEWORKS_PATH, `${libraryName}.xcframework`), - libraryName, - }; -} - -export function hasDuplicatesWhenVendored( - modulePaths: string[], - naming: NamingStrategy -): boolean { - const outputPaths = modulePaths.map((modulePath) => { - const { outputPath } = determineVendoredXcframeworkDetails( - modulePath, - naming - ); - return outputPath; - }); - const uniqueNames = new Set(outputPaths); - return uniqueNames.size !== outputPaths.length; -} - -export async function vendorXcframework({ - modulePath, - incremental, - naming, -}: RebuildXcframeworkOptions): Promise { - // Copy the xcframework to the output directory and rename the framework and binary - const details = determineVendoredXcframeworkDetails(modulePath, naming); - const { outputPath, libraryName: newLibraryName } = details; - const tempPath = path.join(XCFRAMEWORKS_PATH, `${newLibraryName}.temp`); - try { - if (incremental && existsSync(outputPath)) { - const moduleModified = getLatestMtime(modulePath); - const outputModified = getLatestMtime(outputPath); - if (moduleModified < outputModified) { - return { ...details, skipped: true }; - } - } - // Delete any existing xcframework (or xcodebuild will try to amend it) - await fs.rm(outputPath, { recursive: true, force: true }); - await fs.cp(modulePath, tempPath, { recursive: true }); - - const frameworkPaths = await Promise.all( - readdirSync(tempPath, { - withFileTypes: true, - }) - .filter((tripletEntry) => tripletEntry.isDirectory()) - .flatMap((tripletEntry) => { - const tripletPath = path.join(tempPath, tripletEntry.name); - return readdirSync(tripletPath, { - withFileTypes: true, - }) - .filter( - (frameworkEntry) => - frameworkEntry.isDirectory() && - path.extname(frameworkEntry.name) === ".framework" - ) - .flatMap(async (frameworkEntry) => { - const frameworkPath = path.join(tripletPath, frameworkEntry.name); - const oldLibraryName = path.basename( - frameworkEntry.name, - ".framework" - ); - const oldLibraryPath = path.join(frameworkPath, oldLibraryName); - const newFrameworkPath = path.join( - tripletPath, - `${newLibraryName}.framework` - ); - const newLibraryPath = path.join( - newFrameworkPath, - newLibraryName - ); - assert( - existsSync(oldLibraryPath), - `Expected a library at '${oldLibraryPath}'` - ); - // Rename the library - await fs.rename( - oldLibraryPath, - // Cannot use newLibraryPath here, because the framework isn't renamed yet - path.join(frameworkPath, newLibraryName) - ); - // Rename the framework - await fs.rename(frameworkPath, newFrameworkPath); - // Expect the library in the new location - assert(existsSync(newLibraryPath)); - // Update the binary - await spawn( - "install_name_tool", - [ - "-id", - `@rpath/${newLibraryName}.framework/${newLibraryName}`, - newLibraryPath, - ], - { - outputMode: "buffered", - } - ); - // Update the Info.plist file for the framework - await updateInfoPlist({ - filePath: path.join(newFrameworkPath, "Info.plist"), - oldLibraryName, - newLibraryName, - }); - return newFrameworkPath; - }); - }) - ); - - // Create a new xcframework from the renamed frameworks - await spawn( - "xcodebuild", - [ - "-create-xcframework", - ...frameworkPaths.flatMap((frameworkPath) => [ - "-framework", - frameworkPath, - ]), - "-output", - outputPath, - ], - { - outputMode: "buffered", - } - ); - - return { ...details, skipped: false }; - } finally { - await fs.rm(tempPath, { recursive: true, force: true }); - } -} diff --git a/packages/react-native-node-api-modules/src/node/cli/hermes.ts b/packages/react-native-node-api-modules/src/node/cli/hermes.ts index 7f5f158e..cddb506c 100644 --- a/packages/react-native-node-api-modules/src/node/cli/hermes.ts +++ b/packages/react-native-node-api-modules/src/node/cli/hermes.ts @@ -3,8 +3,8 @@ import path from "node:path"; import { Command } from "@commander-js/extra-typings"; import { spawn, SpawnFailure } from "bufout"; -import { prettyPath } from "./helpers"; import { oraPromise } from "ora"; +import { prettyPath } from "../path-utils"; const HERMES_PATH = path.resolve(__dirname, "../../../hermes"); const HERMES_GIT_URL = "https://github.com/kraenhansen/hermes.git"; diff --git a/packages/react-native-node-api-modules/src/node/cli/link-modules.ts b/packages/react-native-node-api-modules/src/node/cli/link-modules.ts new file mode 100644 index 00000000..7f5e2c20 --- /dev/null +++ b/packages/react-native-node-api-modules/src/node/cli/link-modules.ts @@ -0,0 +1,157 @@ +import path from "node:path"; +import fs from "node:fs"; + +import { SpawnFailure } from "bufout"; +import { + findNodeApiModulePathsByDependency, + getAutolinkPath, + getLibraryName, + logModulePaths, + NamingStrategy, + PLATFORM_EXTENSIONS, + PlatformName, + prettyPath, +} from "../path-utils"; +import chalk from "chalk"; + +export type ModuleLinker = ( + options: LinkModuleOptions +) => Promise; + +export type LinkModulesOptions = { + platform: PlatformName; + incremental: boolean; + naming: NamingStrategy; + fromPath: string; + linker: ModuleLinker; +}; + +export type LinkModuleOptions = Omit< + LinkModulesOptions, + "fromPath" | "linker" +> & { + modulePath: string; +}; + +export type ModuleDetails = { + originalPath: string; + outputPath: string; + libraryName: string; +}; + +export type LinkModuleResult = ModuleDetails & { + skipped: boolean; +}; + +export type ModuleOutputBase = { + originalPath: string; + skipped: boolean; +}; + +type ModuleOutput = ModuleOutputBase & + ( + | { outputPath: string; failure?: never } + | { outputPath?: never; failure: SpawnFailure } + ); + +export async function linkModules({ + fromPath, + incremental, + naming, + platform, + linker, +}: LinkModulesOptions): Promise { + // Find all their xcframeworks + const dependenciesByName = findNodeApiModulePathsByDependency({ + fromPath, + platform, + includeSelf: true, + }); + + // Find absolute paths to xcframeworks + const absoluteModulePaths = Object.entries(dependenciesByName).flatMap( + ([, dependency]) => { + return dependency.modulePaths.map((modulePath) => + path.join(dependency.path, modulePath) + ); + } + ); + + if (hasDuplicateLibraryNames(absoluteModulePaths, naming)) { + logModulePaths(absoluteModulePaths, naming); + throw new Error("Found conflicting library names"); + } + + return Promise.all( + Object.entries(dependenciesByName).flatMap(([, dependency]) => { + return dependency.modulePaths.map(async (modulePath) => { + const originalPath = path.join(dependency.path, modulePath); + try { + return await linker({ + modulePath: originalPath, + incremental, + naming, + platform, + }); + } catch (error) { + if (error instanceof SpawnFailure) { + return { + originalPath, + skipped: false, + failure: error, + }; + } else { + throw error; + } + } + }); + }) + ); +} + +export async function pruneLinkedModules( + platform: PlatformName, + linkedModules: ModuleOutput[] +) { + if (linkedModules.some(({ failure }) => failure)) { + // Don't prune if any of the modules failed to copy + return; + } + const platformOutputPath = getAutolinkPath(platform); + // Pruning only when all modules are copied successfully + const expectedPaths = new Set([...linkedModules.map((m) => m.outputPath)]); + await Promise.all( + fs.readdirSync(platformOutputPath).map(async (entry) => { + const candidatePath = path.resolve(platformOutputPath, entry); + if (!expectedPaths.has(candidatePath)) { + console.log( + "🧹Deleting", + prettyPath(candidatePath), + chalk.dim("(no longer linked)") + ); + await fs.promises.rm(candidatePath, { recursive: true, force: true }); + } + }) + ); +} + +export function hasDuplicateLibraryNames( + modulePaths: string[], + naming: NamingStrategy +): boolean { + const libraryNames = modulePaths.map((modulePath) => { + return getLibraryName(modulePath, naming); + }); + const uniqueNames = new Set(libraryNames); + return uniqueNames.size !== libraryNames.length; +} + +export function getLinkedModuleOutputPath( + platform: PlatformName, + modulePath: string, + naming: NamingStrategy +): string { + const libraryName = getLibraryName(modulePath, naming); + const extension = PLATFORM_EXTENSIONS[platform]; + return path.join(getAutolinkPath(platform), `${libraryName}${extension}`); +} diff --git a/packages/react-native-node-api-modules/src/node/cli/options.ts b/packages/react-native-node-api-modules/src/node/cli/options.ts new file mode 100644 index 00000000..84a32acd --- /dev/null +++ b/packages/react-native-node-api-modules/src/node/cli/options.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; + +import { Option } from "@commander-js/extra-typings"; + +const { NODE_API_MODULES_STRIP_PATH_SUFFIX } = process.env; +assert( + typeof NODE_API_MODULES_STRIP_PATH_SUFFIX === "undefined" || + NODE_API_MODULES_STRIP_PATH_SUFFIX === "true" || + NODE_API_MODULES_STRIP_PATH_SUFFIX === "false", + "Expected NODE_API_MODULES_STRIP_PATH_SUFFIX to be either 'true' or 'false'" +); + +export const stripPathSuffixOption = new Option( + "--strip-path-suffix", + "Don't append escaped relative path to the library names (entails one Node-API module per package)" +).default(NODE_API_MODULES_STRIP_PATH_SUFFIX === "true"); diff --git a/packages/react-native-node-api-modules/src/node/cli/program.ts b/packages/react-native-node-api-modules/src/node/cli/program.ts index b0f800d2..62c1ef27 100644 --- a/packages/react-native-node-api-modules/src/node/cli/program.ts +++ b/packages/react-native-node-api-modules/src/node/cli/program.ts @@ -1,13 +1,246 @@ +import assert from "node:assert/strict"; +import path from "node:path"; import { EventEmitter } from "node:stream"; import { Command } from "@commander-js/extra-typings"; +import { SpawnFailure } from "bufout"; +import chalk from "chalk"; +import { oraPromise } from "ora"; + +import { + determineModuleContext, + findNodeApiModulePathsByDependency, + getAutolinkPath, + getLibraryName, + logModulePaths, + normalizeModulePath, + PlatformName, + PLATFORMS, + prettyPath, +} from "../path-utils"; -import { command as xcframeworks } from "./xcframeworks"; import { command as vendorHermes } from "./hermes"; +import { stripPathSuffixOption } from "./options"; +import { linkModules, pruneLinkedModules, ModuleLinker } from "./link-modules"; +import { linkXcframework } from "./apple"; +import { linkAndroidDir } from "./android"; // We're attaching a lot of listeners when spawning in parallel EventEmitter.defaultMaxListeners = 100; -export const program = new Command("react-native-node-api-modules") - .addCommand(xcframeworks) - .addCommand(vendorHermes); +export const program = new Command("react-native-node-api-modules").addCommand( + vendorHermes +); + +function getLinker(platform: PlatformName): ModuleLinker { + if (platform === "android") { + return linkAndroidDir; + } else if (platform === "apple") { + return linkXcframework; + } else { + throw new Error(`Unknown platform: ${platform}`); + } +} + +function getPlatformDisplayName(platform: PlatformName) { + if (platform === "android") { + return "Android"; + } else if (platform === "apple") { + return "Apple"; + } else { + throw new Error(`Unknown platform: ${platform}`); + } +} + +program + .command("link") + .argument("[path]", "Some path inside the app package", process.cwd()) + .option( + "--force", + "Don't check timestamps of input files to skip unnecessary rebuilds", + false + ) + .option( + "--prune", + "Delete vendored modules that are no longer auto-linked", + true + ) + .option("--android", "Link Android modules") + .option("--apple", "Link Apple modules") + .addOption(stripPathSuffixOption) + .action( + async (pathArg, { force, prune, stripPathSuffix, android, apple }) => { + console.log("Auto-linking Node-API modules from", chalk.dim(pathArg)); + + if (stripPathSuffix) { + console.log( + chalk.yellowBright("Warning:"), + "Stripping path suffixes, which might lead to name collisions" + ); + } + const platforms: PlatformName[] = []; + if (android) { + platforms.push("android"); + } + if (apple) { + platforms.push("apple"); + } + + if (platforms.length === 0) { + console.error( + `No platform specified, pass one or more of:`, + ...PLATFORMS.map((platform) => chalk.bold(`\n --${platform}`)) + ); + process.exitCode = 1; + return; + } + + for (const platform of platforms) { + const platformDisplayName = getPlatformDisplayName(platform); + const platformOutputPath = getAutolinkPath(platform); + const modules = await oraPromise( + () => + linkModules({ + platform, + fromPath: path.resolve(pathArg), + incremental: !force, + naming: { stripPathSuffix }, + linker: getLinker(platform), + }), + { + text: `Linking ${platformDisplayName} Node-API modules into ${prettyPath( + platformOutputPath + )}`, + successText: `Linked ${platformDisplayName} Node-API modules into ${prettyPath( + platformOutputPath + )}`, + failText: (error) => + `Failed to link ${platformDisplayName} Node-API modules into ${prettyPath( + platformOutputPath + )}: ${error.message}`, + } + ); + + if (modules.length === 0) { + console.log("Found no Node-API modules 🤷"); + } + + const failures = modules.filter((result) => "failure" in result); + const linked = modules.filter((result) => "outputPath" in result); + + for (const { originalPath, outputPath, skipped } of linked) { + const prettyOutputPath = outputPath + ? "→ " + prettyPath(path.basename(outputPath)) + : ""; + if (skipped) { + console.log( + chalk.greenBright("-"), + "Skipped", + prettyPath(originalPath), + prettyOutputPath, + "(up to date)" + ); + } else { + console.log( + chalk.greenBright("⚭"), + "Linked", + prettyPath(originalPath), + prettyOutputPath + ); + } + } + + for (const { originalPath, failure } of failures) { + assert(failure instanceof SpawnFailure); + console.error( + "\n", + chalk.redBright("✖"), + "Failed to copy", + prettyPath(originalPath) + ); + console.error(failure.message); + failure.flushOutput("both"); + process.exitCode = 1; + } + + if (prune) { + await pruneLinkedModules(platform, modules); + } + } + } + ); + +program + .command("list") + .description("Lists Node-API modules") + .argument("[from-path]", "Some path inside the app package", process.cwd()) + .option("--json", "Output as JSON", false) + .addOption(stripPathSuffixOption) + .action(async (fromArg, { json, stripPathSuffix }) => { + if (stripPathSuffix) { + console.log( + chalk.yellowBright("Warning:"), + "Stripping path suffixes might lead to name collisions" + ); + } + + const rootPath = path.resolve(fromArg); + const dependencies = findNodeApiModulePathsByDependency({ + fromPath: rootPath, + platform: PLATFORMS, + includeSelf: true, + }); + + if (json) { + console.log(JSON.stringify(dependencies, null, 2)); + } else { + const dependencyCount = Object.keys(dependencies).length; + const xframeworkCount = Object.values(dependencies).reduce( + (acc, { modulePaths }) => acc + modulePaths.length, + 0 + ); + console.log( + "Found", + chalk.greenBright(xframeworkCount), + "Node-API modules in", + chalk.greenBright(dependencyCount), + dependencyCount === 1 ? "package" : "packages", + "from", + prettyPath(rootPath) + ); + for (const [dependencyName, dependency] of Object.entries(dependencies)) { + console.log( + chalk.blueBright(dependencyName), + "→", + prettyPath(dependency.path) + ); + logModulePaths( + dependency.modulePaths.map((p) => path.join(dependency.path, p)), + { stripPathSuffix } + ); + } + } + }); + +program + .command("info ") + .description( + "Utility to print, module path, the hash of a single Android library" + ) + .addOption(stripPathSuffixOption) + .action((pathInput, { stripPathSuffix }) => { + const resolvedModulePath = path.resolve(pathInput); + const normalizedModulePath = normalizeModulePath(resolvedModulePath); + const { packageName, relativePath } = + determineModuleContext(resolvedModulePath); + const libraryName = getLibraryName(resolvedModulePath, { + stripPathSuffix, + }); + console.log({ + resolvedModulePath, + normalizedModulePath, + packageName, + relativePath, + libraryName, + }); + }); diff --git a/packages/react-native-node-api-modules/src/node/cli/xcframeworks.ts b/packages/react-native-node-api-modules/src/node/cli/xcframeworks.ts deleted file mode 100644 index dfa7fa22..00000000 --- a/packages/react-native-node-api-modules/src/node/cli/xcframeworks.ts +++ /dev/null @@ -1,377 +0,0 @@ -import assert from "node:assert/strict"; -import path from "node:path"; -import fs from "node:fs"; -import { EventEmitter } from "node:stream"; - -import { Command, Option } from "@commander-js/extra-typings"; -import { SpawnFailure } from "bufout"; -import chalk from "chalk"; -import { oraPromise } from "ora"; - -import { - findPackageDependencyPaths, - findPackageDependencyPathsAndXcframeworks, - findXCFrameworkPaths, - hasDuplicatesWhenVendored, - prettyPath, - vendorXcframework, - XCFRAMEWORKS_PATH, -} from "./helpers"; -import { - NamingStrategy, - determineModuleContext, - getLibraryName, - normalizeModulePath, -} from "../path-utils"; - -// We're attaching a lot of listeners when spawning in parallel -EventEmitter.defaultMaxListeners = 100; - -export const command = new Command("xcframeworks").description( - "Working with Node-API xcframeworks" -); - -type CopyXCFrameworksOptions = { - installationRoot: string; - incremental: boolean; - naming: NamingStrategy; -}; - -type XCFrameworkOutputBase = { - originalPath: string; - skipped: boolean; -}; - -type XCFrameworkOutput = XCFrameworkOutputBase & - ( - | { outputPath: string; failure?: never } - | { outputPath?: never; failure: SpawnFailure } - ); - -async function copyXCFrameworks({ - installationRoot, - incremental, - naming, -}: CopyXCFrameworksOptions): Promise { - // Find the location of each dependency - const dependencyPathsByName = findPackageDependencyPaths(installationRoot); - // Find all their xcframeworks - const dependenciesByName = Object.fromEntries( - Object.entries(dependencyPathsByName) - .map(([dependencyName, dependencyPath]) => { - // Make all the xcframeworks relative to the dependency path - const xcframeworkPaths = findXCFrameworkPaths(dependencyPath).map((p) => - path.relative(dependencyPath, p) - ); - return [ - dependencyName, - { - path: dependencyPath, - xcframeworkPaths, - }, - ] as const; - }) - // Remove any dependencies without xcframeworks - .filter(([, { xcframeworkPaths }]) => xcframeworkPaths.length > 0) - ); - - // Create or clean the output directory - fs.mkdirSync(XCFRAMEWORKS_PATH, { recursive: true }); - // Create vendored copies of xcframework found in dependencies - - const xcframeworksPaths = Object.entries(dependenciesByName).flatMap( - ([, dependency]) => { - return dependency.xcframeworkPaths.map((xcframeworkPath) => - path.join(dependency.path, xcframeworkPath) - ); - } - ); - - if (hasDuplicatesWhenVendored(xcframeworksPaths, naming)) { - // TODO: Make this prettier - logXcframeworkPaths(xcframeworksPaths, naming); - throw new Error("Found conflicting xcframeworks"); - } - - return oraPromise( - Promise.all( - Object.entries(dependenciesByName).flatMap(([, dependency]) => { - return dependency.xcframeworkPaths.map(async (xcframeworkPath) => { - const originalPath = path.join(dependency.path, xcframeworkPath); - try { - return await vendorXcframework({ - modulePath: originalPath, - incremental, - naming, - }); - } catch (error) { - if (error instanceof SpawnFailure) { - return { - originalPath, - skipped: false, - failure: error, - }; - } else { - throw error; - } - } - }); - }) - ), - { - text: `Copying Node-API xcframeworks into ${prettyPath( - XCFRAMEWORKS_PATH - )}`, - successText: `Copied Node-API xcframeworks into ${prettyPath( - XCFRAMEWORKS_PATH - )}`, - failText: (err) => - `Failed to copy Node-API xcframeworks into ${prettyPath( - XCFRAMEWORKS_PATH - )}: ${err.message}`, - } - ); -} - -// TODO: Consider adding a flag to drive the build of the original xcframeworks too - -const { NODE_API_MODULES_STRIP_PATH_SUFFIX } = process.env; -assert( - typeof NODE_API_MODULES_STRIP_PATH_SUFFIX === "undefined" || - NODE_API_MODULES_STRIP_PATH_SUFFIX === "true" || - NODE_API_MODULES_STRIP_PATH_SUFFIX === "false", - "Expected NODE_API_MODULES_STRIP_PATH_SUFFIX to be either 'true' or 'false'" -); - -const stripPathSuffixOption = new Option( - "--strip-path-suffix", - "Don't append escaped relative path to the library names (entails one Node-API module per package)" -).default(NODE_API_MODULES_STRIP_PATH_SUFFIX === "true"); - -command - .command("copy") - .option( - "--podfile ", - "Path to the Podfile", - path.resolve("./ios/Podfile") - ) - .option( - "--force", - "Don't check timestamps of input files to skip unnecessary rebuilds", - false - ) - .option("--prune", "Delete xcframeworks that are no longer auto-linked", true) - .addOption(stripPathSuffixOption) - .action(async ({ podfile, force, prune, stripPathSuffix }) => { - if (stripPathSuffix) { - console.log( - chalk.yellowBright("Warning:"), - "Stripping path suffixes, which might lead to name collisions" - ); - } - const xcframeworks = await copyXCFrameworks({ - installationRoot: path.resolve(podfile), - incremental: !force, - naming: { stripPathSuffix }, - }); - - const failures = xcframeworks.filter((result) => "failure" in result); - const rebuilds = xcframeworks.filter((result) => "outputPath" in result); - - for (const xcframework of rebuilds) { - const { originalPath, outputPath, skipped } = xcframework; - const outputPart = outputPath - ? "→ " + prettyPath(path.basename(outputPath)) - : ""; - if (skipped) { - console.log( - chalk.greenBright("✓"), - "Skipped", - prettyPath(originalPath), - outputPart, - "(already up to date)" - ); - } else { - console.log( - chalk.greenBright("✓"), - "Recreated", - prettyPath(originalPath), - outputPart - ); - } - } - - for (const { originalPath, failure } of failures) { - assert(failure instanceof SpawnFailure); - console.error( - "\n", - chalk.redBright("✖"), - "Failed to copy", - prettyPath(originalPath) - ); - console.error(failure.message); - failure.flushOutput("both"); - process.exitCode = 1; - } - - if (prune && failures.length === 0) { - // Pruning only when all xcframeworks are copied successfully - const expectedPaths = new Set([ - ...rebuilds.map((xcframework) => xcframework.outputPath), - ]); - for (const entry of fs.readdirSync(XCFRAMEWORKS_PATH)) { - const candidatePath = path.resolve(XCFRAMEWORKS_PATH, entry); - if (!expectedPaths.has(candidatePath)) { - console.log( - "🧹Deleting extroneous xcframework", - prettyPath(candidatePath) - ); - fs.rmSync(candidatePath, { recursive: true, force: true }); - } - } - } - }); - -command - .command("info ") - .description( - "Utility to print, module path, the hash of a single xcframework" - ) - .addOption(stripPathSuffixOption) - .action((pathInput, { stripPathSuffix }) => { - const resolvedModulePath = path.resolve(pathInput); - const normalizedModulePath = normalizeModulePath(resolvedModulePath); - const { packageName, relativePath } = - determineModuleContext(resolvedModulePath); - const libraryName = getLibraryName(resolvedModulePath, { - stripPathSuffix, - }); - console.log({ - resolvedModulePath, - normalizedModulePath, - packageName, - relativePath, - libraryName, - }); - }); - -function findDuplicates(values: string[]) { - const seen = new Set(); - const duplicates = new Set(); - for (const value of values) { - if (seen.has(value)) { - duplicates.add(value); - } else { - seen.add(value); - } - } - return duplicates; -} - -function logXcframeworkPaths( - xcframeworkPaths: string[], - // TODO: Default to iterating and printing for all supported naming strategies - naming: NamingStrategy -) { - const libraryNamesPerPath = Object.fromEntries( - xcframeworkPaths.map((xcframeworkPath) => [ - xcframeworkPath, - getLibraryName(xcframeworkPath, naming), - ]) - ); - const duplicates = findDuplicates(Object.values(libraryNamesPerPath)); - for (const [xcframeworkPath, libraryName] of Object.entries( - libraryNamesPerPath - )) { - const duplicated = duplicates.has(libraryName); - console.log( - " ↳", - prettyPath(xcframeworkPath), - duplicated - ? chalk.redBright(`(${libraryName})`) - : chalk.greenBright(`(${libraryName})`) - ); - } -} - -command - .command("list") - .description("Lists Node-API module XCFrameworks") - .option( - "--podfile ", - "List all Node-API frameworks of an app, based off the Podfile" - ) - .option( - "--dependency ", - "List all Node-API frameworks of a single dependency" - ) - .option("--json", "Output as JSON", false) - .addOption(stripPathSuffixOption) - .action( - async ({ - podfile: podfileArg, - dependency: dependencyArg, - json, - stripPathSuffix, - }) => { - if (stripPathSuffix) { - console.log( - chalk.yellowBright("Warning:"), - "Stripping path suffixes might lead to name collisions" - ); - } - if (podfileArg) { - const rootPath = path.dirname(path.resolve(podfileArg)); - const dependencies = - findPackageDependencyPathsAndXcframeworks(rootPath); - - if (json) { - console.log(JSON.stringify(dependencies, null, 2)); - } else { - const dependencyCount = Object.keys(dependencies).length; - const xframeworkCount = Object.values(dependencies).reduce( - (acc, { xcframeworkPaths }) => acc + xcframeworkPaths.length, - 0 - ); - console.log( - "Found", - chalk.greenBright(xframeworkCount), - "xcframeworks in", - chalk.greenBright(dependencyCount), - dependencyCount === 1 ? "dependency of" : "dependencies of", - prettyPath(rootPath) - ); - for (const [dependencyName, dependency] of Object.entries( - dependencies - )) { - console.log(dependencyName, "→", prettyPath(dependency.path)); - logXcframeworkPaths( - dependency.xcframeworkPaths.map((p) => - path.join(dependency.path, p) - ), - { stripPathSuffix } - ); - } - } - } else if (dependencyArg) { - const dependencyPath = path.resolve(dependencyArg); - const xcframeworkPaths = findXCFrameworkPaths(dependencyPath).map((p) => - path.relative(dependencyPath, p) - ); - - if (json) { - console.log(JSON.stringify(xcframeworkPaths, null, 2)); - } else { - console.log( - "Found", - chalk.greenBright(xcframeworkPaths.length), - "of", - prettyPath(dependencyPath) - ); - logXcframeworkPaths(xcframeworkPaths, { stripPathSuffix }); - } - } else { - throw new Error("Expected either --podfile or --package option"); - } - } - ); diff --git a/packages/react-native-node-api-modules/src/node/duplicates.ts b/packages/react-native-node-api-modules/src/node/duplicates.ts new file mode 100644 index 00000000..5e8be7f2 --- /dev/null +++ b/packages/react-native-node-api-modules/src/node/duplicates.ts @@ -0,0 +1,12 @@ +export function findDuplicates(values: string[]) { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) { + duplicates.add(value); + } else { + seen.add(value); + } + } + return duplicates; +} diff --git a/packages/react-native-node-api-modules/src/node/path-utils.test.ts b/packages/react-native-node-api-modules/src/node/path-utils.test.ts index ae8052ce..1605a1fc 100644 --- a/packages/react-native-node-api-modules/src/node/path-utils.test.ts +++ b/packages/react-native-node-api-modules/src/node/path-utils.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { determineModuleContext, + findNodeApiModulePaths, + findPackageDependencyPaths, getLibraryName, isNodeApiModule, replaceWithNodeExtension, @@ -27,6 +29,8 @@ describe("stripExtension", () => { it("strips extension", () => { assert.equal(stripExtension("./addon"), "./addon"); assert.equal(stripExtension("./addon.node"), "./addon"); + assert.equal(stripExtension("./addon.android.node"), "./addon"); + // assert.equal(stripExtension("./addon.apple.node"), "./addon"); assert.equal(stripExtension("./addon.xcframework"), "./addon"); }); }); @@ -67,7 +71,7 @@ describe("determineModuleContext", () => { { const { packageName, relativePath } = determineModuleContext( - path.join(tempDirectoryPath, "some-dir/some-file.js") + path.join(tempDirectoryPath, "some-dir/some-file.node") ); assert.equal(packageName, "my-package"); assert.equal(relativePath, "some-dir/some-file"); @@ -75,7 +79,7 @@ describe("determineModuleContext", () => { { const { packageName, relativePath } = determineModuleContext( - path.join(tempDirectoryPath, "sub-package-a/some-file.js") + path.join(tempDirectoryPath, "sub-package-a/some-file.node") ); assert.equal(packageName, "my-sub-package"); assert.equal(relativePath, "some-file"); @@ -83,7 +87,7 @@ describe("determineModuleContext", () => { { const { packageName, relativePath } = determineModuleContext( - path.join(tempDirectoryPath, "sub-package-b/some-file.js") + path.join(tempDirectoryPath, "sub-package-b/some-file.node") ); assert.equal(packageName, "my-sub-package"); assert.equal(relativePath, "some-file"); @@ -136,3 +140,106 @@ describe("getLibraryName", () => { ); }); }); + +describe("findPackageDependencyPaths", () => { + it("should find package dependency paths", (context) => { + const tempDir = setupTempDirectory(context, { + "node_modules/lib-a/package.json": JSON.stringify({ + name: "lib-a", + main: "index.js", + }), + "node_modules/lib-a/index.js": "", + "test-package/node_modules/lib-b/package.json": JSON.stringify({ + name: "lib-b", + main: "index.js", + }), + "test-package/node_modules/lib-b/index.js": "", + "test-package/package.json": JSON.stringify({ + name: "test-package", + dependencies: { + "lib-a": "^1.0.0", + "lib-b": "^1.0.0", + }, + }), + "test-package/src/index.js": "console.log('Hello, world!')", + }); + + const result = findPackageDependencyPaths( + path.join(tempDir, "test-package/src/index.js") + ); + + assert.deepEqual(result, { + "lib-a": path.join(tempDir, "node_modules/lib-a"), + "lib-b": path.join(tempDir, "test-package/node_modules/lib-b"), + }); + }); +}); + +describe("findNodeApiModulePaths", () => { + it("should find xcframework paths", (context) => { + const tempDir = setupTempDirectory(context, { + "root.xcframework/react-native-node-api-module": "", + "sub-directory/lib-a.xcframework/react-native-node-api-module": "", + "sub-directory/lib-b.xcframework/react-native-node-api-module": "", + }); + const result = findNodeApiModulePaths({ + fromPath: tempDir, + platform: "apple", + }); + assert.deepEqual(result.sort(), [ + path.join(tempDir, "root.xcframework"), + path.join(tempDir, "sub-directory/lib-a.xcframework"), + path.join(tempDir, "sub-directory/lib-b.xcframework"), + ]); + }); + + it("respects default exclude patterns", (context) => { + const tempDir = setupTempDirectory(context, { + "root.xcframework/react-native-node-api-module": "", + "child-dir/dependency/lib.xcframework/react-native-node-api-module": "", + "child-dir/node_modules/dependency/lib.xcframework/react-native-node-api-module": + "", + }); + const result = findNodeApiModulePaths({ + fromPath: tempDir, + platform: "apple", + }); + assert.deepEqual(result.sort(), [ + path.join(tempDir, "child-dir/dependency/lib.xcframework"), + path.join(tempDir, "root.xcframework"), + ]); + }); + + it("respects explicit exclude patterns", (context) => { + const tempDir = setupTempDirectory(context, { + "root.xcframework/react-native-node-api-module": "", + "child-dir/dependency/lib.xcframework/react-native-node-api-module": "", + "child-dir/node_modules/dependency/lib.xcframework/react-native-node-api-module": + "", + }); + const result = findNodeApiModulePaths({ + fromPath: tempDir, + platform: "apple", + excludePatterns: [/root/], + }); + assert.deepEqual(result.sort(), [ + path.join(tempDir, "child-dir/dependency/lib.xcframework"), + path.join(tempDir, "child-dir/node_modules/dependency/lib.xcframework"), + ]); + }); + + it("disregards parts futher up in filesystem when excluding", (context) => { + const tempDir = setupTempDirectory(context, { + "node_modules/root.xcframework/react-native-node-api-module": "", + "node_modules/child-dir/node_modules/dependency/lib.xcframework/react-native-node-api-module": + "", + }); + const result = findNodeApiModulePaths({ + fromPath: path.join(tempDir, "node_modules"), + platform: "apple", + }); + assert.deepEqual(result, [ + path.join(tempDir, "node_modules/root.xcframework"), + ]); + }); +}); diff --git a/packages/react-native-node-api-modules/src/node/path-utils.ts b/packages/react-native-node-api-modules/src/node/path-utils.ts index e5acc56b..a2433290 100644 --- a/packages/react-native-node-api-modules/src/node/path-utils.ts +++ b/packages/react-native-node-api-modules/src/node/path-utils.ts @@ -1,11 +1,32 @@ import assert from "node:assert/strict"; import path from "node:path"; -import fs from "node:fs"; +import fs, { readdirSync } from "node:fs"; +import { findDuplicates } from "./duplicates"; +import chalk from "chalk"; +import { packageDirectorySync } from "pkg-dir"; +import { readPackageSync } from "read-pkg"; +import { createRequire } from "node:module"; + +// TODO: Change to .apple.node +export const PLATFORMS = ["android", "apple"] as const; +export type PlatformName = "android" | "apple"; + +export const PLATFORM_EXTENSIONS = { + android: ".android.node", + // TODO: Change to .apple.node + apple: ".xcframework", +} as const satisfies Record; +export type PlatformExtentions = (typeof PLATFORM_EXTENSIONS)[PlatformName]; export type NamingStrategy = { stripPathSuffix: boolean; }; +/** + * @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. + * 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 @@ -13,21 +34,25 @@ export function isNodeApiModule(modulePath: string): boolean { path.dirname(modulePath), path.basename(modulePath, ".node") ); - return [ - candidateBasePath + ".xcframework", - // TODO: Add Android support - ].some(fs.existsSync); + return Object.values(PLATFORM_EXTENSIONS) + .map((extension) => candidateBasePath + extension) + .some(fs.existsSync); } /** - * Replaces any platform specific extensions with the common .node extension. + * Strip of any platform specific extensions from a module path. */ export function stripExtension(modulePath: string) { - return path.format({ - ...path.parse(modulePath), - base: undefined, - ext: undefined, - }); + return [...Object.values(PLATFORM_EXTENSIONS), ".node"].reduce( + (modulePath, extension) => { + if (modulePath.endsWith(extension)) { + return modulePath.slice(0, -extension.length); + } else { + return modulePath; + } + }, + modulePath + ); } /** @@ -91,26 +116,217 @@ export function escapePath(modulePath: string) { return modulePath.replace(/[^a-zA-Z0-9]/g, "-"); } -// export async function updateLibraryInstallPathInXCFramework( -// xcframeworkPath: string -// ) { -// for (const file of fs.readdirSync(xcframeworkPath, { -// withFileTypes: true, -// recursive: true, -// })) { -// if (file.isDirectory() && path.extname(file.name) === ".framework") { -// const libraryName = path.basename(file.name, ".framework"); -// const libraryPath = path.join(file.parentPath, file.name, libraryName); -// assert(fs.existsSync(libraryPath), `Expected library at: ${libraryPath}`); -// const newInstallName = getLibraryInstallName(xcframeworkPath); -// await spawn("install_name_tool", ["-id", newInstallName, libraryPath]); -// } -// } -// } - export function getLibraryName(modulePath: string, naming: NamingStrategy) { const { packageName, relativePath } = determineModuleContext(modulePath); return naming.stripPathSuffix ? packageName : `${packageName}--${escapePath(relativePath)}`; } + +export function prettyPath(p: string) { + return chalk.dim( + path.relative(process.cwd(), p) || chalk.italic("current directory") + ); +} + +export function resolvePackageRoot( + requireFromPackageRoot: NodeJS.Require, + packageName: string +): string | undefined { + try { + const resolvedPath = requireFromPackageRoot.resolve(packageName); + return packageDirectorySync({ cwd: resolvedPath }); + } catch { + // TODO: Add a debug log here + return undefined; + } +} + +export function logModulePaths( + modulePaths: string[], + // TODO: Default to iterating and printing for all supported naming strategies + naming: NamingStrategy +) { + const pathsPerName = new Map(); + for (const modulePath of modulePaths) { + const libraryName = getLibraryName(modulePath, naming); + const existingPaths = pathsPerName.get(libraryName) ?? []; + existingPaths.push(modulePath); + pathsPerName.set(libraryName, existingPaths); + } + + const allModulePaths = modulePaths.map((modulePath) => modulePath); + const duplicatePaths = findDuplicates(allModulePaths); + for (const [libraryName, modulePaths] of pathsPerName) { + console.log( + chalk.greenBright(`${libraryName}`), + ...modulePaths.flatMap((modulePath) => { + const line = duplicatePaths.has(modulePath) + ? chalk.redBright(prettyPath(modulePath)) + : prettyPath(modulePath); + return `\n ↳ ${line}`; + }) + ); + } +} + +/** + * 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. + */ +export function findPackageDependencyPaths( + fromPath: string +): Record { + const packageRoot = packageDirectorySync({ cwd: fromPath }); + assert(packageRoot, `Could not find package root from ${fromPath}`); + + const requireFromPackageRoot = createRequire( + path.join(packageRoot, "noop.js") + ); + + const { dependencies = {} } = readPackageSync({ cwd: packageRoot }); + + return Object.fromEntries( + Object.keys(dependencies) + .map((dependencyName) => { + const resolvedDependencyRoot = resolvePackageRoot( + requireFromPackageRoot, + dependencyName + ); + return resolvedDependencyRoot + ? [dependencyName, resolvedDependencyRoot] + : undefined; + }) + .filter((item) => typeof item !== "undefined") + ); +} + +export const MAGIC_FILENAME = "react-native-node-api-module"; + +/** + * Default patterns to use when excluding paths from the search for Node-API modules. + */ +export const DEFAULT_EXCLUDE_PATTERNS = [ + /\/react-native-node-api-modules\//, + /\/node_modules\//, + /\/.git\//, +]; + +export function hasPlatformExtension( + platform: PlatformName | Readonly, + fileName: string +): boolean { + if (typeof platform === "string") { + return fileName.endsWith(PLATFORM_EXTENSIONS[platform]); + } else { + return platform.some((p) => hasPlatformExtension(p, fileName)); + } +} + +export type FindNodeApiModuleOptions = { + fromPath: string; + excludePatterns?: RegExp[]; + platform: PlatformName | Readonly; +}; + +/** + * Recursively search into a directory for xcframeworks containing Node-API modules. + * TODO: Turn this asynchronous + */ +export function findNodeApiModulePaths( + options: FindNodeApiModuleOptions, + suffix = "" +): string[] { + const { + fromPath, + platform, + excludePatterns = DEFAULT_EXCLUDE_PATTERNS, + } = options; + if (excludePatterns.some((pattern) => pattern.test(suffix))) { + return []; + } + const candidatePath = path.join(fromPath, suffix); + return readdirSync(candidatePath, { withFileTypes: true }).flatMap((file) => { + if ( + file.isFile() && + file.name === MAGIC_FILENAME && + hasPlatformExtension(platform, candidatePath) + ) { + return [candidatePath]; + } else if (file.isDirectory()) { + // Traverse into the child directory + return findNodeApiModulePaths(options, path.join(suffix, file.name)); + } + return []; + }); +} + +/** + * Finds all dependencies of the app package and their xcframeworks. + */ +export function findNodeApiModulePathsByDependency({ + fromPath, + includeSelf, + ...options +}: FindNodeApiModuleOptions & { + includeSelf: boolean; +}) { + // Find the location of each dependency + const packagePathsByName = findPackageDependencyPaths(fromPath); + if (includeSelf) { + const packageRoot = packageDirectorySync({ cwd: fromPath }); + assert(packageRoot, `Could not find package root from ${fromPath}`); + const { name } = readPackageSync({ cwd: packageRoot }); + packagePathsByName[name] = packageRoot; + } + // Find all their xcframeworks + return Object.fromEntries( + Object.entries(packagePathsByName) + .map(([dependencyName, dependencyPath]) => { + // Make all the xcframeworks relative to the dependency path + const modulePaths = findNodeApiModulePaths({ + fromPath: dependencyPath, + ...options, + }).map((p) => path.relative(dependencyPath, p)); + return [ + dependencyName, + { + path: dependencyPath, + modulePaths, + }, + ] as const; + }) + // Remove any dependencies without module paths + .filter(([, { modulePaths }]) => modulePaths.length > 0) + ); +} + +export function getAutolinkPath(platform: PlatformName) { + const result = path.resolve(__dirname, "../../auto-linked", platform); + fs.mkdirSync(result, { recursive: true }); + return result; +} + +/** + * Get the latest modification time of all files in a directory and its subdirectories. + */ +export function getLatestMtime(fromPath: string): number { + const entries = fs.readdirSync(fromPath, { + withFileTypes: true, + recursive: true, + }); + + let latest = 0; + + for (const entry of entries) { + if (entry.isFile()) { + const fullPath = path.join(entry.parentPath, entry.name); + const stat = fs.statSync(fullPath); + if (stat.mtimeMs > latest) { + latest = stat.mtimeMs; + } + } + } + + return latest; +} diff --git a/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt b/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt index 918b666f..63374f2d 100644 --- a/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt +++ b/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt @@ -1,9 +1,7 @@ cmake_minimum_required(VERSION 3.15) project(weak-node-api) -add_compile_definitions(-DNAPI_VERSION=8) - add_library(${PROJECT_NAME} SHARED weak-node-api.cpp ${CMAKE_JS_SRC}) -set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node") target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_SOURCE_DIR}/../include) target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_17) +target_compile_definitions(${PROJECT_NAME} PRIVATE NAPI_VERSION=8)