diff --git a/apps/test-app/babel.config.js b/apps/test-app/babel.config.js index 8b5623ab..f412b4d6 100644 --- a/apps/test-app/babel.config.js +++ b/apps/test-app/babel.config.js @@ -1,5 +1,5 @@ module.exports = { presets: ['module:@react-native/babel-preset'], - // plugins: [['module:react-native-node-api-modules/babel-plugin', { naming: "hash" }]], - plugins: [['module:react-native-node-api-modules/babel-plugin', { naming: "package-name" }]], + // plugins: [['module:react-native-node-api-modules/babel-plugin', { stripPathSuffix: true }]], + plugins: ['module:react-native-node-api-modules/babel-plugin'], }; diff --git a/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp b/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp index 7db1ce52..0fd8ab59 100644 --- a/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp +++ b/packages/react-native-node-api-modules/cpp/CxxNodeApiHostModule.cpp @@ -23,16 +23,17 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, return jsi::Value::undefined(); } -jsi::Value CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, - const jsi::String path) { - const std::string pathStr = path.utf8(rt); +jsi::Value +CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, + const jsi::String libraryName) { + const std::string libraryNameStr = libraryName.utf8(rt); - auto [it, inserted] = nodeAddons_.emplace(pathStr, NodeAddon()); + auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); NodeAddon &addon = it->second; // Check if this module has been loaded already, if not then load it... if (inserted) { - if (!loadNodeAddon(addon, pathStr)) { + if (!loadNodeAddon(addon, libraryNameStr)) { return jsi::Value::undefined(); } } @@ -47,10 +48,19 @@ jsi::Value CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, } bool CxxNodeApiHostModule::loadNodeAddon(NodeAddon &addon, - const std::string &path) const { + const std::string &libraryName) const { +#if defined(__APPLE__) + std::string libraryPath = + "@rpath/" + libraryName + ".framework/" + libraryName; +#elif defined(__ANDROID__) + std::string libraryPath = libraryName +#else + abort() +#endif + typename LoaderPolicy::Symbol initFn = NULL; typename LoaderPolicy::Module library = - LoaderPolicy::loadLibrary(path.c_str()); + LoaderPolicy::loadLibrary(libraryPath.c_str()); if (NULL != library) { addon.moduleHandle = library; 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 7023a0a0..23006120 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 @@ -6,11 +6,13 @@ require_relative "./scripts/patch-hermes" NODE_PATH ||= `which node`.strip CLI_COMMAND ||= "'#{NODE_PATH}' '#{File.join(__dir__, "dist/node/cli/run.js")}'" -COPY_FRAMEWORKS_COMMAND ||= "#{CLI_COMMAND} copy-xcframeworks '#{Pod::Config.instance.installation_root}'" +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' : ''}" # We need to run this now to ensure the xcframeworks are copied vendored_frameworks are considered XCFRAMEWORKS_DIR ||= File.join(__dir__, "xcframeworks") unless defined?(@xcframeworks_copied) + puts "Executing #{COPY_FRAMEWORKS_COMMAND}" system(COPY_FRAMEWORKS_COMMAND) or raise "Failed to copy xcframeworks" # Setting a flag to avoid running this command on every require @xcframeworks_copied = true diff --git a/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.test.ts b/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.test.ts index cb7b7eb4..d8fbcf01 100644 --- a/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.test.ts +++ b/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.test.ts @@ -6,7 +6,7 @@ import { transformFileSync } from "@babel/core"; import { plugin } from "./plugin.js"; import { setupTempDirectory } from "../test-utils.js"; -import { getLibraryInstallName } from "../path-utils.js"; +import { getLibraryName } from "../path-utils.js"; describe("plugin", () => { it("transforms require calls, regardless", (context) => { @@ -38,19 +38,19 @@ describe("plugin", () => { `, }); - const ADDON_1_REQUIRE_ARG = getLibraryInstallName( + const ADDON_1_REQUIRE_ARG = getLibraryName( path.join(tempDirectoryPath, "addon-1"), - "hash" + { stripPathSuffix: false } ); - const ADDON_2_REQUIRE_ARG = getLibraryInstallName( + const ADDON_2_REQUIRE_ARG = getLibraryName( path.join(tempDirectoryPath, "addon-2"), - "hash" + { stripPathSuffix: false } ); { const result = transformFileSync( path.join(tempDirectoryPath, "./addon-1.js"), - { plugins: [[plugin, { naming: "hash" }]] } + { plugins: [[plugin, { stripPathSuffix: false }]] } ); assert(result); const { code } = result; diff --git a/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.ts b/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.ts index a19a6aee..1644fd6d 100644 --- a/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.ts +++ b/packages/react-native-node-api-modules/src/node/babel-plugin/plugin.ts @@ -5,24 +5,22 @@ import type { PluginObj, NodePath } from "@babel/core"; import * as t from "@babel/types"; import { - getLibraryInstallName, + getLibraryName, isNodeApiModule, replaceWithNodeExtension, NamingStrategy, - NAMING_STATEGIES, } from "../path-utils"; type PluginOptions = { - naming?: NamingStrategy; + stripPathSuffix?: boolean; }; function assertOptions(opts: unknown): asserts opts is PluginOptions { assert(typeof opts === "object" && opts !== null, "Expected an object"); - if ("naming" in opts) { - assert(typeof opts.naming === "string", "Expected 'naming' to be a string"); + if ("stripPathSuffix" in opts) { assert( - NAMING_STATEGIES.includes(opts.naming as NamingStrategy), - "Expected 'naming' to be either 'hash' or 'package-name'" + typeof opts.stripPathSuffix === "boolean", + "Expected 'stripPathSuffix' to be a boolean" ); } } @@ -32,7 +30,7 @@ export function replaceWithRequireNodeAddon( modulePath: string, naming: NamingStrategy ) { - const requireCallArgument = getLibraryInstallName( + const requireCallArgument = getLibraryName( replaceWithNodeExtension(modulePath), naming ); @@ -54,7 +52,7 @@ export function plugin(): PluginObj { visitor: { CallExpression(p) { assertOptions(this.opts); - const { naming = "package-name" } = this.opts; + const { stripPathSuffix = false } = this.opts; if (typeof this.filename !== "string") { // This transformation only works when the filename is known return; @@ -77,7 +75,9 @@ export function plugin(): PluginObj { const relativePath = path.join(from, id); // TODO: Support traversing the filesystem to find the Node-API module if (isNodeApiModule(relativePath)) { - replaceWithRequireNodeAddon(p.parentPath, relativePath, naming); + replaceWithRequireNodeAddon(p.parentPath, relativePath, { + stripPathSuffix, + }); } } } else if ( @@ -85,7 +85,7 @@ export function plugin(): PluginObj { isNodeApiModule(path.join(from, id)) ) { const relativePath = path.join(from, id); - replaceWithRequireNodeAddon(p, relativePath, naming); + replaceWithRequireNodeAddon(p, relativePath, { stripPathSuffix }); } } }, 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 index 8685ddeb..e890b2ce 100644 --- a/packages/react-native-node-api-modules/src/node/cli/helpers.ts +++ b/packages/react-native-node-api-modules/src/node/cli/helpers.ts @@ -8,7 +8,7 @@ import { spawn } from "bufout"; import { packageDirectorySync } from "pkg-dir"; import { readPackageSync } from "read-pkg"; -import { NamingStrategy, hashModulePath } from "../path-utils.js"; +import { NamingStrategy, getLibraryName } from "../path-utils.js"; // Must be in all xcframeworks to be considered as Node-API modules export const MAGIC_FILENAME = "react-native-node-api-module"; @@ -182,16 +182,8 @@ type RebuildXcframeworkOptions = { type VendoredXcframework = { originalPath: string; outputPath: string; -} & ( - | { - hash: string; - packageName?: never; - } - | { - hash?: never; - packageName: string; - } -); + libraryName: string; +}; type VendoredXcframeworkResult = VendoredXcframework & { skipped: boolean; @@ -201,24 +193,16 @@ export function determineVendoredXcframeworkDetails( modulePath: string, naming: NamingStrategy ): VendoredXcframework { - if (naming === "hash") { - const hash = hashModulePath(modulePath); - return { - hash, - originalPath: modulePath, - outputPath: path.join(XCFRAMEWORKS_PATH, `node-api-${hash}.xcframework`), - }; - } else { - 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}`); - return { - packageName: name, - originalPath: modulePath, - outputPath: path.join(XCFRAMEWORKS_PATH, `${name}.xcframework`), - }; - } + 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( @@ -243,13 +227,8 @@ export async function vendorXcframework({ }: RebuildXcframeworkOptions): Promise { // Copy the xcframework to the output directory and rename the framework and binary const details = determineVendoredXcframeworkDetails(modulePath, naming); - const { outputPath } = details; - const discriminator = - typeof details.hash === "string" ? details.hash : details.packageName; - const tempPath = path.join( - XCFRAMEWORKS_PATH, - `node-api-${discriminator}-temp` - ); + const { outputPath, libraryName: newLibraryName } = details; + const tempPath = path.join(XCFRAMEWORKS_PATH, `${newLibraryName}.temp`); try { if (incremental && existsSync(outputPath)) { const moduleModified = getLatestMtime(modulePath); @@ -284,10 +263,6 @@ export async function vendorXcframework({ ".framework" ); const oldLibraryPath = path.join(frameworkPath, oldLibraryName); - const newLibraryName = path.basename( - details.outputPath, - ".xcframework" - ); const newFrameworkPath = path.join( tripletPath, `${newLibraryName}.framework` 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 7b69e850..b483d93e 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,343 +1,12 @@ -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 { Command } from "@commander-js/extra-typings"; -import { - findPackageDependencyPaths, - findPackageDependencyPathsAndXcframeworks, - findXCFrameworkPaths, - hasDuplicatesWhenVendored, - vendorXcframework, - XCFRAMEWORKS_PATH, -} from "./helpers"; -import { - NamingStrategy, - determineModuleContext, - getLibraryDiscriminator, - hashModulePath, -} from "../path-utils"; +import { command as xcframeworks } from "./xcframeworks"; // We're attaching a lot of listeners when spawning in parallel EventEmitter.defaultMaxListeners = 100; export const program = new Command("react-native-node-api-modules"); -function prettyPath(p: string) { - return chalk.dim(path.relative(process.cwd(), p)); -} - -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_NAMING = "package-name" } = process.env; -assert( - typeof NODE_API_MODULES_NAMING === "undefined" || - NODE_API_MODULES_NAMING === "hash" || - NODE_API_MODULES_NAMING === "package-name", - "Expected NODE_API_MODULES_NAMING to be either 'hash' or 'package-name'" -); - -const namingStrategyOption = new Option( - "--naming ", - "Naming strategy to use when copying the xcframeworks" -) - .choices(["hash", "package-name"] as const satisfies NamingStrategy[]) - .default(NODE_API_MODULES_NAMING as NamingStrategy); - -program - .command("copy-xcframeworks") - .argument("", "Parent directory of the Podfile", (p) => - path.resolve(process.cwd(), p) - ) - .option( - "--force", - "Don't check timestamps of input files to skip unnecessary rebuilds", - false - ) - .addOption(namingStrategyOption) - .option("--prune", "Delete xcframeworks that are no longer auto-linked", true) - .action(async (installationRoot: string, { force, prune, naming }) => { - console.log(`Using ${naming} naming strategy`); - const xcframeworks = await copyXCFrameworks({ - installationRoot, - incremental: !force, - naming, - }); - - 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 }); - } - } - } - }); - -program - .command("hash-xcframework ") - .description("Utility to print the hash of xcframeworks") - .action((pathInput) => { - const resolvedModulePath = path.resolve(pathInput); - const { packageName, relativePath } = - determineModuleContext(resolvedModulePath); - const hash = hashModulePath(resolvedModulePath); - console.log({ resolvedModulePath, packageName, relativePath, hash }); - }); - -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 discriminatorsPerPath = Object.fromEntries( - xcframeworkPaths.map((xcframeworkPath) => [ - xcframeworkPath, - naming ? getLibraryDiscriminator(xcframeworkPath, naming) : undefined, - ]) - ); - const duplicates = findDuplicates( - Object.values(discriminatorsPerPath).filter((p) => typeof p === "string") - ); - for (const [xcframeworkPath, discriminator] of Object.entries( - discriminatorsPerPath - )) { - const duplicated = discriminator && duplicates.has(discriminator); - console.log( - " ↳", - prettyPath(xcframeworkPath), - discriminator - ? duplicated - ? chalk.redBright(`(${discriminator})`) - : chalk.greenBright(`(${discriminator})`) - : "" - ); - } -} - -program - .command("print-xcframeworks") - .description("Lists Node-API module XCFrameworks") - .option("--podfile ", "Path of the App's Podfile") - .option("--dependency ", "Path of some dependency directory") - .option("--json", "Output as JSON", false) - .action(async ({ podfile: podfileArg, dependency: dependencyArg, json }) => { - 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) - ) - ); - } - } - } 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); - } - } else { - throw new Error("Expected either --podfile or --package option"); - } - }); +program.addCommand(xcframeworks); 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 new file mode 100644 index 00000000..74921e11 --- /dev/null +++ b/packages/react-native-node-api-modules/src/node/cli/xcframeworks.ts @@ -0,0 +1,380 @@ +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, + 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" +); + +function prettyPath(p: string) { + return chalk.dim(path.relative(process.cwd(), p)); +} + +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/path-utils.test.ts b/packages/react-native-node-api-modules/src/node/path-utils.test.ts index ef91986e..ae8052ce 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,7 +4,7 @@ import path from "node:path"; import { determineModuleContext, - hashModulePath, + getLibraryName, isNodeApiModule, replaceWithNodeExtension, stripExtension, @@ -70,7 +70,7 @@ describe("determineModuleContext", () => { path.join(tempDirectoryPath, "some-dir/some-file.js") ); assert.equal(packageName, "my-package"); - assert.equal(relativePath, "some-dir/some-file.js"); + assert.equal(relativePath, "some-dir/some-file"); } { @@ -78,7 +78,7 @@ describe("determineModuleContext", () => { path.join(tempDirectoryPath, "sub-package-a/some-file.js") ); assert.equal(packageName, "my-sub-package"); - assert.equal(relativePath, "some-file.js"); + assert.equal(relativePath, "some-file"); } { @@ -86,63 +86,53 @@ describe("determineModuleContext", () => { path.join(tempDirectoryPath, "sub-package-b/some-file.js") ); assert.equal(packageName, "my-sub-package"); - assert.equal(relativePath, "some-file.js"); + assert.equal(relativePath, "some-file"); } }); }); -describe("hashModulePath", () => { - it("produce the same hash for sub-packages of equal names", (context) => { +describe("getLibraryName", () => { + it("works when including relative path", (context) => { const tempDirectoryPath = setupTempDirectory(context, { "package.json": `{ "name": "my-package" }`, - "some-dir/addon.xcframework/react-native-node-api-module": "", - // Two sub-packages with the same name - "sub-package-a/package.json": `{ "name": "my-sub-package" }`, - "sub-package-a/addon.xcframework/react-native-node-api-module": "", - "sub-dir/sub-package-b/package.json": `{ "name": "my-sub-package" }`, - "sub-dir/sub-package-b/addon.xcframework/react-native-node-api-module": - "", + "addon.xcframework/addon.node": "// This is supposed to be a binary file", + "sub-directory/addon.xcframework/addon.node": + "// This is supposed to be a binary file", }); - - const hashInRoot = hashModulePath( - path.join(tempDirectoryPath, "some-dir/addon") - ); - - const hashInRootAgain = hashModulePath( - path.join(tempDirectoryPath, "some-dir/../some-dir/addon") + assert.equal( + getLibraryName(path.join(tempDirectoryPath, "addon"), { + stripPathSuffix: false, + }), + "my-package--addon" ); - const hashInSubPackageA = hashModulePath( - path.join(tempDirectoryPath, "sub-package-a/addon") - ); - const hashInSubPackageB = hashModulePath( - path.join(tempDirectoryPath, "sub-dir/sub-package-b/addon") + assert.equal( + getLibraryName(path.join(tempDirectoryPath, "sub-directory/addon"), { + stripPathSuffix: false, + }), + "my-package--sub-directory-addon" ); - - assert.equal(hashInRoot, hashInRootAgain); - assert.notEqual(hashInRoot, hashInSubPackageA); - assert.notEqual(hashInRoot, hashInSubPackageB); - // Because they both reference the same file in packages of equal names - assert.equal(hashInSubPackageA, hashInSubPackageB); }); - it("produce the same hash from different cwds", (context) => { + it("works when stripping relative path", (context) => { const tempDirectoryPath = setupTempDirectory(context, { "package.json": `{ "name": "my-package" }`, - "some-dir/addon.xcframework/react-native-node-api-module": "", + "addon.xcframework/addon.node": "// This is supposed to be a binary file", + "sub-directory/addon.xcframework/addon.node": + "// This is supposed to be a binary file", }); - const hashInRoot = hashModulePath( - path.join(tempDirectoryPath, "some-dir/addon") + assert.equal( + getLibraryName(path.join(tempDirectoryPath, "addon"), { + stripPathSuffix: true, + }), + "my-package" + ); + + assert.equal( + getLibraryName(path.join(tempDirectoryPath, "sub-directory-addon"), { + stripPathSuffix: true, + }), + "my-package" ); - const previousCwd = process.cwd(); - try { - process.chdir(tempDirectoryPath); - const hashInRootAgain = hashModulePath( - path.join(tempDirectoryPath, "some-dir/../some-dir/addon") - ); - assert.equal(hashInRoot, hashInRootAgain); - } finally { - process.chdir(previousCwd); - } }); }); 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 60a8afc1..e5acc56b 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,12 +1,10 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; -import crypto from "node:crypto"; -// import { spawn } from "bufout"; - -export const NAMING_STATEGIES = ["hash", "package-name"] as const; -export type NamingStrategy = (typeof NAMING_STATEGIES)[number]; +export type NamingStrategy = { + stripPathSuffix: boolean; +}; export function isNodeApiModule(modulePath: string): boolean { // Determine if we're trying to load a Node-API module @@ -73,7 +71,9 @@ export function determineModuleContext( ); return { packageName: packageJson.name, - relativePath: path.relative(modulePath, originalPath), + relativePath: normalizeModulePath( + path.relative(modulePath, originalPath) + ), }; } else if (parentDirectoryPath === modulePath) { // We've reached the root of the filesystem @@ -84,12 +84,11 @@ export function determineModuleContext( } export function normalizeModulePath(modulePath: string) { - // Transforming platform specific paths to a common path - if (path.extname(modulePath) !== ".node") { - modulePath = replaceWithNodeExtension(modulePath); - } - const { packageName, relativePath } = determineModuleContext(modulePath); - return path.normalize(path.join(packageName, relativePath)); + return path.normalize(stripExtension(modulePath)); +} + +export function escapePath(modulePath: string) { + return modulePath.replace(/[^a-zA-Z0-9]/g, "-"); } // export async function updateLibraryInstallPathInXCFramework( @@ -109,53 +108,9 @@ export function normalizeModulePath(modulePath: string) { // } // } -type HashModulePathOptions = { - verify?: boolean; -}; - -export function hashModulePath( - modulePath: string, - { verify = true }: HashModulePathOptions = {} -) { - const hash = crypto.createHash("sha256"); - assert( - path.isAbsolute(modulePath), - `Expected absolute path when hashing, got: ${modulePath}` - ); - const strippedModulePath = stripExtension(modulePath); - if (verify) { - assert( - isNodeApiModule(strippedModulePath), - `Expected a Node-API module at ${strippedModulePath}` - ); - } - hash.update(normalizeModulePath(strippedModulePath)); - return hash.digest("hex").slice(0, 8); -} - -export function getLibraryDiscriminator( - modulePath: string, - naming: NamingStrategy -) { - if (naming === "package-name") { - const { packageName } = determineModuleContext(modulePath); - return packageName; - } else if (naming === "hash") { - return hashModulePath(modulePath); - } else { - throw new Error(`Unknown naming strategy: ${naming}`); - } -} - export function getLibraryName(modulePath: string, naming: NamingStrategy) { - const discriminator = getLibraryDiscriminator(modulePath, naming); - return naming === "hash" ? `node-api-${discriminator}` : discriminator; -} - -export function getLibraryInstallName( - modulePath: string, - naming: NamingStrategy -) { - const libraryName = getLibraryName(modulePath, naming); - return `@rpath/${libraryName}.framework/${libraryName}`; + const { packageName, relativePath } = determineModuleContext(modulePath); + return naming.stripPathSuffix + ? packageName + : `${packageName}--${escapePath(relativePath)}`; } diff --git a/packages/react-native-node-api-modules/src/react-native/NativeNodeApiHost.ts b/packages/react-native-node-api-modules/src/react-native/NativeNodeApiHost.ts index 3d579524..fdf04dc8 100644 --- a/packages/react-native-node-api-modules/src/react-native/NativeNodeApiHost.ts +++ b/packages/react-native-node-api-modules/src/react-native/NativeNodeApiHost.ts @@ -2,7 +2,7 @@ import type { TurboModule } from "react-native"; import { TurboModuleRegistry } from "react-native"; export interface Spec extends TurboModule { - requireNodeAddon(path: string): void; + requireNodeAddon(libraryName: string): void; } export default TurboModuleRegistry.getEnforcing("NodeApiHost"); diff --git a/packages/react-native-node-api-modules/tsconfig.json b/packages/react-native-node-api-modules/tsconfig.json index ef4f6ff6..1cfb08e3 100644 --- a/packages/react-native-node-api-modules/tsconfig.json +++ b/packages/react-native-node-api-modules/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.node-tests.json" }, { "path": "./tsconfig.react-native.json" } ] } diff --git a/packages/react-native-node-api-modules/tsconfig.node-tests.json b/packages/react-native-node-api-modules/tsconfig.node-tests.json new file mode 100644 index 00000000..e1a79553 --- /dev/null +++ b/packages/react-native-node-api-modules/tsconfig.node-tests.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": true + }, + "include": ["src/node/**/*.test.ts"], + "exclude": [], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +}