From 3c0ffc8cfd53dd70a5d972b41765c71379e82c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sun, 20 Jul 2025 11:13:30 +0200 Subject: [PATCH 01/11] Add isSupportedTriplet to host --- packages/host/src/node/index.ts | 5 +++-- packages/host/src/node/prebuilds/triplets.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/host/src/node/index.ts b/packages/host/src/node/index.ts index 4a9c0207..068ae373 100644 --- a/packages/host/src/node/index.ts +++ b/packages/host/src/node/index.ts @@ -1,10 +1,11 @@ export { + SUPPORTED_TRIPLETS, ANDROID_TRIPLETS, APPLE_TRIPLETS, - SUPPORTED_TRIPLETS, + type SupportedTriplet, type AndroidTriplet, type AppleTriplet, - type SupportedTriplet, + isSupportedTriplet, isAppleTriplet, isAndroidTriplet, } from "./prebuilds/triplets.js"; diff --git a/packages/host/src/node/prebuilds/triplets.ts b/packages/host/src/node/prebuilds/triplets.ts index fcfbf6a9..60bff46f 100644 --- a/packages/host/src/node/prebuilds/triplets.ts +++ b/packages/host/src/node/prebuilds/triplets.ts @@ -32,14 +32,20 @@ export const SUPPORTED_TRIPLETS = [ export type SupportedTriplet = (typeof SUPPORTED_TRIPLETS)[number]; +export function isSupportedTriplet( + triplet: unknown +): triplet is SupportedTriplet { + return (SUPPORTED_TRIPLETS as readonly unknown[]).includes(triplet); +} + export function isAndroidTriplet( triplet: SupportedTriplet ): triplet is AndroidTriplet { - return ANDROID_TRIPLETS.includes(triplet as AndroidTriplet); + return (ANDROID_TRIPLETS as readonly unknown[]).includes(triplet); } export function isAppleTriplet( triplet: SupportedTriplet ): triplet is AppleTriplet { - return APPLE_TRIPLETS.includes(triplet as AppleTriplet); + return (APPLE_TRIPLETS as readonly unknown[]).includes(triplet); } From 3d09aa657eb02121a14c108291fea098ea530306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sun, 20 Jul 2025 11:38:40 +0200 Subject: [PATCH 02/11] Export weak node api path --- packages/host/package.json | 2 +- packages/host/src/node/index.ts | 2 ++ packages/host/src/node/weak-node-api.ts | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/host/src/node/weak-node-api.ts diff --git a/packages/host/package.json b/packages/host/package.json index cd79a85b..ea5eb74b 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -20,7 +20,7 @@ }, "./babel-plugin": "./dist/node/babel-plugin/index.js", "./cli": "./dist/node/cli/run.js", - "./weak-node-api": "./weak-node-api" + "./weak-node-api": "./dist/node/weak-node-api.js" }, "files": [ "logo.svg", diff --git a/packages/host/src/node/index.ts b/packages/host/src/node/index.ts index 068ae373..878eeb01 100644 --- a/packages/host/src/node/index.ts +++ b/packages/host/src/node/index.ts @@ -23,3 +23,5 @@ export { } from "./prebuilds/apple.js"; export { determineLibraryBasename, prettyPath } from "./path-utils.js"; + +export { weakNodeApiPath } from "./weak-node-api.js"; diff --git a/packages/host/src/node/weak-node-api.ts b/packages/host/src/node/weak-node-api.ts new file mode 100644 index 00000000..df29834f --- /dev/null +++ b/packages/host/src/node/weak-node-api.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +export const weakNodeApiPath = path.resolve(__dirname, "../../weak-node-api"); + +assert( + fs.existsSync(weakNodeApiPath), + `Expected Weak Node API path to exist: ${weakNodeApiPath}` +); From 074a5d2a8d069009460989df325fa934d7c3d555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sun, 20 Jul 2025 11:39:51 +0200 Subject: [PATCH 03/11] Refactor into a platform abstraction --- packages/cmake-rn/src/android.ts | 103 ----- packages/cmake-rn/src/apple.ts | 113 ------ packages/cmake-rn/src/cli.ts | 427 ++++++--------------- packages/cmake-rn/src/platforms.ts | 23 ++ packages/cmake-rn/src/platforms/android.ts | 173 +++++++++ packages/cmake-rn/src/platforms/apple.ts | 194 ++++++++++ packages/cmake-rn/src/platforms/types.ts | 76 ++++ packages/cmake-rn/src/weak-node-api.ts | 15 +- 8 files changed, 601 insertions(+), 523 deletions(-) delete mode 100644 packages/cmake-rn/src/android.ts delete mode 100644 packages/cmake-rn/src/apple.ts create mode 100644 packages/cmake-rn/src/platforms.ts create mode 100644 packages/cmake-rn/src/platforms/android.ts create mode 100644 packages/cmake-rn/src/platforms/apple.ts create mode 100644 packages/cmake-rn/src/platforms/types.ts diff --git a/packages/cmake-rn/src/android.ts b/packages/cmake-rn/src/android.ts deleted file mode 100644 index af550cb1..00000000 --- a/packages/cmake-rn/src/android.ts +++ /dev/null @@ -1,103 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; - -import { AndroidTriplet } from "react-native-node-api"; - -export const DEFAULT_ANDROID_TRIPLETS = [ - "aarch64-linux-android", - "armv7a-linux-androideabi", - "i686-linux-android", - "x86_64-linux-android", -] as const satisfies AndroidTriplet[]; - -type AndroidArchitecture = "armeabi-v7a" | "arm64-v8a" | "x86" | "x86_64"; - -export const ANDROID_ARCHITECTURES = { - "armv7a-linux-androideabi": "armeabi-v7a", - "aarch64-linux-android": "arm64-v8a", - "i686-linux-android": "x86", - "x86_64-linux-android": "x86_64", -} satisfies Record; - -type AndroidConfigureOptions = { - triplet: AndroidTriplet; - ndkVersion: string; - sdkVersion: string; -}; - -export function getAndroidConfigureCmakeArgs({ - triplet, - ndkVersion, - sdkVersion, -}: AndroidConfigureOptions) { - const { ANDROID_HOME } = process.env; - assert(typeof ANDROID_HOME === "string", "Missing env variable ANDROID_HOME"); - assert( - fs.existsSync(ANDROID_HOME), - `Expected the Android SDK at ${ANDROID_HOME}` - ); - const installNdkCommand = `sdkmanager --install "ndk;${ndkVersion}"`; - const ndkPath = path.resolve(ANDROID_HOME, "ndk", ndkVersion); - assert( - fs.existsSync(ndkPath), - `Missing Android NDK v${ndkVersion} (at ${ndkPath}) - run: ${installNdkCommand}` - ); - - const toolchainPath = path.join( - ndkPath, - "build/cmake/android.toolchain.cmake" - ); - const architecture = ANDROID_ARCHITECTURES[triplet]; - - const linkerFlags: string[] = [ - // `--no-version-undefined`, - // `--whole-archive`, - // `--no-whole-archive`, - ]; - - return [ - // Use the XCode as generator for Apple platforms - "-G", - "Ninja", - "--toolchain", - toolchainPath, - "-D", - "CMAKE_SYSTEM_NAME=Android", - // "-D", - // `CPACK_SYSTEM_NAME=Android-${architecture}`, - // "-D", - // `CMAKE_INSTALL_PREFIX=${installPath}`, - // "-D", - // `CMAKE_BUILD_TYPE=${configuration}`, - "-D", - "CMAKE_MAKE_PROGRAM=ninja", - // "-D", - // "CMAKE_C_COMPILER_LAUNCHER=ccache", - // "-D", - // "CMAKE_CXX_COMPILER_LAUNCHER=ccache", - "-D", - `ANDROID_NDK=${ndkPath}`, - "-D", - `ANDROID_ABI=${architecture}`, - "-D", - "ANDROID_TOOLCHAIN=clang", - "-D", - `ANDROID_PLATFORM=${sdkVersion}`, - "-D", - "ANDROID_STL=c++_shared", - // Pass linker flags to avoid errors from undefined symbols - // TODO: Link against a weak-node-api to avoid this (or whatever other lib which will be providing the symbols) - // "-D", - // `CMAKE_SHARED_LINKER_FLAGS="-Wl,--allow-shlib-undefined"`, - "-D", - `CMAKE_SHARED_LINKER_FLAGS=${linkerFlags - .map((flag) => `-Wl,${flag}`) - .join(" ")}`, - ]; -} - -export function isAndroidSupported() { - const { ANDROID_HOME } = process.env; - return typeof ANDROID_HOME === "string" && fs.existsSync(ANDROID_HOME); -} diff --git a/packages/cmake-rn/src/apple.ts b/packages/cmake-rn/src/apple.ts deleted file mode 100644 index 1b92409b..00000000 --- a/packages/cmake-rn/src/apple.ts +++ /dev/null @@ -1,113 +0,0 @@ -import assert from "node:assert/strict"; - -import { AppleTriplet, isAppleTriplet } from "react-native-node-api"; - -export const DEFAULT_APPLE_TRIPLETS = [ - "arm64;x86_64-apple-darwin", - "arm64-apple-ios", - "arm64-apple-ios-sim", - "arm64-apple-tvos", - "arm64-apple-tvos-sim", - "arm64-apple-visionos", - "arm64-apple-visionos-sim", -] as const satisfies AppleTriplet[]; - -type XcodeSDKName = - | "iphoneos" - | "iphonesimulator" - | "catalyst" - | "xros" - | "xrsimulator" - | "appletvos" - | "appletvsimulator" - | "macosx"; - -const XCODE_SDK_NAMES = { - "x86_64-apple-darwin": "macosx", - "arm64-apple-darwin": "macosx", - "arm64;x86_64-apple-darwin": "macosx", - "arm64-apple-ios": "iphoneos", - "arm64-apple-ios-sim": "iphonesimulator", - "arm64-apple-tvos": "appletvos", - // "x86_64-apple-tvos": "appletvos", - "arm64-apple-tvos-sim": "appletvsimulator", - "arm64-apple-visionos": "xros", - "arm64-apple-visionos-sim": "xrsimulator", -} satisfies Record; - -type CMakeSystemName = "Darwin" | "iOS" | "tvOS" | "watchOS" | "visionOS"; - -const CMAKE_SYSTEM_NAMES = { - "x86_64-apple-darwin": "Darwin", - "arm64-apple-darwin": "Darwin", - "arm64;x86_64-apple-darwin": "Darwin", - "arm64-apple-ios": "iOS", - "arm64-apple-ios-sim": "iOS", - "arm64-apple-tvos": "tvOS", - // "x86_64-apple-tvos": "appletvos", - "arm64-apple-tvos-sim": "tvOS", - "arm64-apple-visionos": "visionOS", - "arm64-apple-visionos-sim": "visionOS", -} satisfies Record; - -type AppleArchitecture = "arm64" | "x86_64" | "arm64;x86_64"; - -export const APPLE_ARCHITECTURES = { - "x86_64-apple-darwin": "x86_64", - "arm64-apple-darwin": "arm64", - "arm64;x86_64-apple-darwin": "arm64;x86_64", - "arm64-apple-ios": "arm64", - "arm64-apple-ios-sim": "arm64", - "arm64-apple-tvos": "arm64", - // "x86_64-apple-tvos": "x86_64", - "arm64-apple-tvos-sim": "arm64", - "arm64-apple-visionos": "arm64", - "arm64-apple-visionos-sim": "arm64", -} satisfies Record; - -export function createPlistContent(values: Record) { - return [ - '', - '', - '', - "", - ...Object.entries(values).flatMap(([key, value]) => [ - `${key}`, - `${value}`, - ]), - "", - "", - ].join("\n"); -} - -type AppleConfigureOptions = { - triplet: AppleTriplet; -}; - -export function getAppleConfigureCmakeArgs({ triplet }: AppleConfigureOptions) { - assert(isAppleTriplet(triplet)); - const systemName = CMAKE_SYSTEM_NAMES[triplet]; - - return [ - // Use the XCode as generator for Apple platforms - "-G", - "Xcode", - "-D", - `CMAKE_SYSTEM_NAME=${systemName}`, - // Set the SDK path for the target platform - "-D", - `CMAKE_OSX_SYSROOT=${XCODE_SDK_NAMES[triplet]}`, - // Set the target architecture - "-D", - `CMAKE_OSX_ARCHITECTURES=${APPLE_ARCHITECTURES[triplet]}`, - ]; -} - -export function getAppleBuildArgs() { - // We expect the final application to sign these binaries - return ["CODE_SIGNING_ALLOWED=NO"]; -} - -export function isAppleSupported() { - return process.platform === "darwin"; -} diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index 86a1486b..73878fe2 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -8,39 +8,19 @@ import { spawn, SpawnFailure } from "bufout"; import { oraPromise } from "ora"; import chalk from "chalk"; -import { - DEFAULT_APPLE_TRIPLETS, - isAppleSupported, - getAppleBuildArgs, - getAppleConfigureCmakeArgs, -} from "./apple.js"; -import { - DEFAULT_ANDROID_TRIPLETS, - isAndroidSupported, - getAndroidConfigureCmakeArgs, -} from "./android.js"; import { getWeakNodeApiVariables } from "./weak-node-api.js"; - import { - SUPPORTED_TRIPLETS, - SupportedTriplet, - AndroidTriplet, - isAndroidTriplet, - isAppleTriplet, - determineAndroidLibsFilename, - createAndroidLibsDirectory, - createAppleFramework, - createXCframework, - determineXCFrameworkFilename, -} from "react-native-node-api"; + platforms, + allTargets, + findPlatformForTarget, + platformHasTarget, +} from "./platforms.js"; +import { BaseOpts, TargetContext, Platform } from "./platforms/types.js"; +import { isSupportedTriplet } from "react-native-node-api"; // We're attaching a lot of listeners when spawning in parallel EventEmitter.defaultMaxListeners = 100; -// This should match https://github.com/react-native-community/template/blob/main/template/android/build.gradle#L7 -const DEFAULT_NDK_VERSION = "27.1.12297006"; -const DEFAULT_ANDROID_SDK_VERSION = "24"; - // TODO: Add automatic ccache support const verboseOption = new Option( @@ -58,16 +38,13 @@ const configurationOption = new Option("--configuration ") .choices(["Release", "Debug"] as const) .default("Release"); -// TODO: Derive default triplets +// TODO: Derive default targets // This is especially important when driving the build from within a React Native app package. -const tripletOption = new Option( - "--triplet ", - "Triplets to build for" -).choices(SUPPORTED_TRIPLETS); - -const androidOption = new Option("--android", "Enable all Android triplets"); -const appleOption = new Option("--apple", "Enable all Apple triplets"); +const targetOption = new Option( + "--target ", + "Targets to build for" +).choices(allTargets); const buildPathOption = new Option( "--build ", @@ -84,16 +61,6 @@ const outPathOption = new Option( "Specify the output directory to store the final build artifacts" ).default(false, "./{build}/{configuration}"); -const ndkVersionOption = new Option( - "--ndk-version ", - "The NDK version to use for Android builds" -).default(DEFAULT_NDK_VERSION); - -const androidSdkVersionOption = new Option( - "--android-sdk-version ", - "The Android SDK version to use for Android builds" -).default(DEFAULT_ANDROID_SDK_VERSION); - const noAutoLinkOption = new Option( "--no-auto-link", "Don't mark the output as auto-linkable by react-native-node-api" @@ -104,233 +71,131 @@ const noWeakNodeApiLinkageOption = new Option( "Don't pass the path of the weak-node-api library from react-native-node-api" ); -const xcframeworkExtensionOption = new Option( - "--xcframework-extension", - "Don't rename the xcframework to .apple.node" -).default(false); - -export const program = new Command("cmake-rn") +let program = new Command("cmake-rn") .description("Build React Native Node API modules with CMake") + .addOption(targetOption) .addOption(verboseOption) .addOption(sourcePathOption) .addOption(buildPathOption) .addOption(outPathOption) .addOption(configurationOption) - .addOption(tripletOption) - .addOption(androidOption) - .addOption(appleOption) .addOption(cleanOption) - .addOption(ndkVersionOption) - .addOption(androidSdkVersionOption) .addOption(noAutoLinkOption) - .addOption(noWeakNodeApiLinkageOption) - .addOption(xcframeworkExtensionOption) - .action(async ({ triplet: tripletValues, ...globalContext }) => { + .addOption(noWeakNodeApiLinkageOption); + +for (const platform of platforms) { + const allOption = new Option( + `--${platform.id}`, + `Enable all ${platform.name} triplets` + ); + program = program.addOption(allOption); + program = platform.amendCommand(program); +} + +program = program.action( + async ({ target: requestedTargets, ...baseOptions }) => { try { - const buildPath = getBuildPath(globalContext); - if (globalContext.clean) { + const buildPath = getBuildPath(baseOptions); + if (baseOptions.clean) { await fs.promises.rm(buildPath, { recursive: true, force: true }); } - const triplets = new Set(tripletValues); - if (globalContext.apple) { - for (const triplet of DEFAULT_APPLE_TRIPLETS) { - triplets.add(triplet); - } - } - if (globalContext.android) { - for (const triplet of DEFAULT_ANDROID_TRIPLETS) { - triplets.add(triplet); - } - } + const targets = new Set(requestedTargets); - if (triplets.size === 0) { - if (isAndroidSupported()) { - if (process.arch === "arm64") { - triplets.add("aarch64-linux-android"); - } else if (process.arch === "x64") { - triplets.add("x86_64-linux-android"); + for (const platform of Object.values(platforms)) { + // Forcing the types a bit here, since the platform id option is dynamically added + if ((baseOptions as Record)[platform.id]) { + for (const target of platform.targets) { + targets.add(target); } } - if (isAppleSupported()) { - if (process.arch === "arm64") { - triplets.add("arm64-apple-ios-sim"); + } + + if (targets.size === 0) { + for (const platform of Object.values(platforms)) { + if (platform.isSupportedByHost()) { + for (const target of await platform.defaultTargets()) { + targets.add(target); + } } } - if (triplets.size === 0) { + if (targets.size === 0) { throw new Error( - "Found no default triplets: Install some platform specific build tools" + "Found no default targets: Install some platform specific build tools" ); } else { console.error( chalk.yellowBright("ℹ"), - "Using default triplets", - chalk.dim("(" + [...triplets].join(", ") + ")") + "Using default targets", + chalk.dim("(" + [...targets].join(", ") + ")") ); } } - if (!globalContext.out) { - globalContext.out = path.join(buildPath, globalContext.configuration); + if (!baseOptions.out) { + baseOptions.out = path.join(buildPath, baseOptions.configuration); } - const tripletContext = [...triplets].map((triplet) => { - const tripletBuildPath = getTripletBuildPath(buildPath, triplet); + const targetContexts = [...targets].map((target) => { + const platform = findPlatformForTarget(target); + const targetBuildPath = getTargetBuildPath(buildPath, target); return { - ...globalContext, - triplet, - tripletBuildPath, - tripletOutputPath: path.join(tripletBuildPath, "out"), + target, + platform, + buildPath: targetBuildPath, + outputPath: path.join(targetBuildPath, "out"), + options: baseOptions as BaseOpts & + ReturnType, }; }); // Configure every triplet project - await oraPromise(Promise.all(tripletContext.map(configureProject)), { - text: "Configuring projects", - isSilent: globalContext.verbose, - successText: "Configured projects", - failText: ({ message }) => `Failed to configure projects: ${message}`, - }); + await oraPromise( + Promise.all( + targetContexts.map(({ platform, ...context }) => + configureProject(platform, context, baseOptions) + ) + ), + { + text: "Configuring projects", + isSilent: baseOptions.verbose, + successText: "Configured projects", + failText: ({ message }) => `Failed to configure projects: ${message}`, + } + ); // Build every triplet project await oraPromise( Promise.all( - tripletContext.map(async (context) => { + targetContexts.map(async ({ platform, ...context }) => { // Delete any stale build artifacts before building // This is important, since we might rename the output files - await fs.promises.rm(context.tripletOutputPath, { + await fs.promises.rm(context.outputPath, { recursive: true, force: true, }); - await buildProject(context); + await buildProject(platform, context, baseOptions); }) ), { text: "Building projects", - isSilent: globalContext.verbose, + isSilent: baseOptions.verbose, successText: "Built projects", failText: ({ message }) => `Failed to build projects: ${message}`, } ); - // Collect triplets in vendor specific containers - const appleTriplets = tripletContext.filter(({ triplet }) => - isAppleTriplet(triplet) - ); - if (appleTriplets.length > 0) { - const libraryPaths = await Promise.all( - appleTriplets.map(async ({ tripletOutputPath }) => { - const configSpecificPath = path.join( - tripletOutputPath, - globalContext.configuration - ); - assert( - fs.existsSync(configSpecificPath), - `Expected a directory at ${configSpecificPath}` - ); - // Expect binary file(s), either .node or .dylib - const files = await fs.promises.readdir(configSpecificPath); - const result = files.map(async (file) => { - const filePath = path.join(configSpecificPath, file); - if (filePath.endsWith(".dylib")) { - return filePath; - } else if (file.endsWith(".node")) { - // Rename the file to .dylib for xcodebuild to accept it - const newFilePath = filePath.replace(/\.node$/, ".dylib"); - await fs.promises.rename(filePath, newFilePath); - return newFilePath; - } else { - throw new Error( - `Expected a .node or .dylib file, but found ${file}` - ); - } - }); - assert.equal(result.length, 1, "Expected exactly one library file"); - return await result[0]; - }) - ); - const frameworkPaths = libraryPaths.map(createAppleFramework); - const xcframeworkFilename = determineXCFrameworkFilename( - frameworkPaths, - globalContext.xcframeworkExtension ? ".xcframework" : ".apple.node" + // Perform post-build steps for each platform in sequence + for (const platform of platforms) { + // TODO: Increase type-safety around this + const relevantTargets = targetContexts.filter(({ target }) => + platformHasTarget(platform, target) ); - - // Create the xcframework - const xcframeworkOutputPath = path.resolve( - globalContext.out || globalContext.source, - xcframeworkFilename - ); - - await oraPromise( - createXCframework({ - outputPath: xcframeworkOutputPath, - frameworkPaths, - autoLink: globalContext.autoLink, - }), + await platform.postBuild( { - text: "Assembling XCFramework", - successText: `XCFramework assembled into ${chalk.dim( - path.relative(process.cwd(), xcframeworkOutputPath) - )}`, - failText: ({ message }) => - `Failed to assemble XCFramework: ${message}`, - } - ); - } - - const androidTriplets = tripletContext.filter(({ triplet }) => - isAndroidTriplet(triplet) - ); - if (androidTriplets.length > 0) { - const libraryPathByTriplet = Object.fromEntries( - await Promise.all( - androidTriplets.map(async ({ tripletOutputPath, triplet }) => { - assert( - fs.existsSync(tripletOutputPath), - `Expected a directory at ${tripletOutputPath}` - ); - // Expect binary file(s), either .node or .so - const dirents = await fs.promises.readdir(tripletOutputPath, { - withFileTypes: true, - }); - const result = dirents - .filter( - (dirent) => - dirent.isFile() && - (dirent.name.endsWith(".so") || - dirent.name.endsWith(".node")) - ) - .map((dirent) => path.join(dirent.parentPath, dirent.name)); - assert.equal( - result.length, - 1, - "Expected exactly one library file" - ); - return [triplet, result[0]] as const; - }) - ) - ) as Record; - const androidLibsFilename = determineAndroidLibsFilename( - Object.values(libraryPathByTriplet) - ); - const androidLibsOutputPath = path.resolve( - globalContext.out || globalContext.source, - androidLibsFilename - ); - - await oraPromise( - createAndroidLibsDirectory({ - outputPath: androidLibsOutputPath, - libraryPathByTriplet, - autoLink: globalContext.autoLink, - }), - { - text: "Assembling Android libs directory", - successText: `Android libs directory assembled into ${chalk.dim( - path.relative(process.cwd(), androidLibsOutputPath) - )}`, - failText: ({ message }) => - `Failed to assemble Android libs directory: ${message}`, - } + outputPath: baseOptions.out || baseOptions.source, + targets: relevantTargets, + }, + baseOptions ); } } catch (error) { @@ -339,123 +204,87 @@ export const program = new Command("cmake-rn") } throw error; } - }); - -type GlobalContext = ReturnType; -type TripletScopedContext = Omit & { - triplet: SupportedTriplet; - tripletBuildPath: string; - tripletOutputPath: string; -}; + } +); -function getBuildPath(context: GlobalContext) { +function getBuildPath({ build, source }: BaseOpts) { // TODO: Add configuration (debug vs release) - return path.resolve( - process.cwd(), - context.build || path.join(context.source, "build") - ); + return path.resolve(process.cwd(), build || path.join(source, "build")); } /** - * Namespaces the output path with the triplet + * Namespaces the output path with a target name */ -function getTripletBuildPath(buildPath: string, triplet: SupportedTriplet) { - return path.join(buildPath, triplet.replace(/;/g, "_")); +function getTargetBuildPath(buildPath: string, target: unknown) { + assert(typeof target === "string", "Expected target to be a string"); + return path.join(buildPath, target.replace(/;/g, "_")); } -function getTripletConfigureCmakeArgs( - triplet: SupportedTriplet, - { - ndkVersion, - androidSdkVersion, - }: Pick< - GlobalContext, - "ndkVersion" | "androidSdkVersion" | "weakNodeApiLinkage" - > +async function configureProject( + platform: Platform>, + context: TargetContext, + options: BaseOpts ) { - if (isAndroidTriplet(triplet)) { - return getAndroidConfigureCmakeArgs({ - triplet, - ndkVersion, - sdkVersion: androidSdkVersion, - }); - } else if (isAppleTriplet(triplet)) { - return getAppleConfigureCmakeArgs({ triplet }); - } else { - throw new Error(`Support for '${triplet}' is not implemented yet`); - } -} - -function getBuildArgs(triplet: SupportedTriplet) { - if (isAndroidTriplet(triplet)) { - return []; - } else if (isAppleTriplet(triplet)) { - return getAppleBuildArgs(); - } else { - throw new Error(`Support for '${triplet}' is not implemented yet`); - } -} + const { target, buildPath, outputPath } = context; + const { verbose, source, weakNodeApiLinkage } = options; + + const nodeApiVariables = + weakNodeApiLinkage && isSupportedTriplet(target) + ? getWeakNodeApiVariables(target) + : // TODO: Make this a part of the platform definition + {}; + + const declarations = { + ...nodeApiVariables, + CMAKE_LIBRARY_OUTPUT_DIRECTORY: outputPath, + }; -async function configureProject(context: TripletScopedContext) { - const { - verbose, - triplet, - tripletBuildPath, - source, - ndkVersion, - androidSdkVersion, - weakNodeApiLinkage, - } = context; await spawn( "cmake", [ "-S", source, "-B", - tripletBuildPath, - ...getVariablesArgs(getVariables(context)), - ...getTripletConfigureCmakeArgs(triplet, { - ndkVersion, - weakNodeApiLinkage, - androidSdkVersion, - }), + buildPath, + ...platform.configureArgs(context, options), + ...toDeclarationArguments(declarations), ], { outputMode: verbose ? "inherit" : "buffered", - outputPrefix: verbose ? chalk.dim(`[${triplet}] `) : undefined, + outputPrefix: verbose ? chalk.dim(`[${target}] `) : undefined, } ); } -async function buildProject(context: TripletScopedContext) { - const { verbose, triplet, tripletBuildPath, configuration } = context; +async function buildProject( + platform: Platform>, + context: TargetContext, + options: BaseOpts +) { + const { target, buildPath } = context; + const { verbose, configuration } = options; await spawn( "cmake", [ "--build", - tripletBuildPath, + buildPath, "--config", configuration, "--", - ...getBuildArgs(triplet), + ...platform.buildArgs(context, options), ], { outputMode: verbose ? "inherit" : "buffered", - outputPrefix: verbose ? chalk.dim(`[${triplet}] `) : undefined, + outputPrefix: verbose ? chalk.dim(`[${target}] `) : undefined, } ); } -function getVariables(context: TripletScopedContext): Record { - return { - ...(context.weakNodeApiLinkage && getWeakNodeApiVariables(context.triplet)), - CMAKE_LIBRARY_OUTPUT_DIRECTORY: context.tripletOutputPath, - }; -} - -function getVariablesArgs(variables: Record) { - return Object.entries(variables).flatMap(([key, value]) => [ +function toDeclarationArguments(declarations: Record) { + return Object.entries(declarations).flatMap(([key, value]) => [ "-D", `${key}=${value}`, ]); } + +export { program }; diff --git a/packages/cmake-rn/src/platforms.ts b/packages/cmake-rn/src/platforms.ts new file mode 100644 index 00000000..0692a3a4 --- /dev/null +++ b/packages/cmake-rn/src/platforms.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; + +import { platform as android } from "./platforms/android.js"; +import { platform as apple } from "./platforms/apple.js"; +import { Platform } from "./platforms/types.js"; + +export const platforms: Platform[] = [android, apple] as const; +export const allTargets = [...android.targets, ...apple.targets] as const; + +export function platformHasTarget

( + platform: P, + target: unknown +): target is P["targets"][number] { + return (platform.targets as unknown[]).includes(target); +} + +export function findPlatformForTarget(target: unknown) { + const platform = Object.values(platforms).find((platform) => + platformHasTarget(platform, target) + ); + assert(platform, `Unable to determine platform from target: ${target}`); + return platform; +} diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts new file mode 100644 index 00000000..7c750e2d --- /dev/null +++ b/packages/cmake-rn/src/platforms/android.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { Option } from "@commander-js/extra-typings"; +import { + createAndroidLibsDirectory, + determineAndroidLibsFilename, + AndroidTriplet as Target, +} from "react-native-node-api"; + +import type { Platform } from "./types.js"; +import { oraPromise } from "ora"; +import chalk from "chalk"; + +// This should match https://github.com/react-native-community/template/blob/main/template/android/build.gradle#L7 +const DEFAULT_NDK_VERSION = "27.1.12297006"; +const DEFAULT_ANDROID_SDK_VERSION = "24"; + +type AndroidArchitecture = "armeabi-v7a" | "arm64-v8a" | "x86" | "x86_64"; + +export const ANDROID_ARCHITECTURES = { + "armv7a-linux-androideabi": "armeabi-v7a", + "aarch64-linux-android": "arm64-v8a", + "i686-linux-android": "x86", + "x86_64-linux-android": "x86_64", +} satisfies Record; + +const ndkVersionOption = new Option( + "--ndk-version ", + "The NDK version to use for Android builds" +).default(DEFAULT_NDK_VERSION); + +const androidSdkVersionOption = new Option( + "--android-sdk-version ", + "The Android SDK version to use for Android builds" +).default(DEFAULT_ANDROID_SDK_VERSION); + +type AndroidOpts = { ndkVersion: string; androidSdkVersion: string }; + +export const platform: Platform = { + id: "android", + name: "Android", + targets: [ + "aarch64-linux-android", + "armv7a-linux-androideabi", + "i686-linux-android", + "x86_64-linux-android", + ], + defaultTargets() { + if (process.arch === "arm64") { + return ["aarch64-linux-android"]; + } else if (process.arch === "x64") { + return ["x86_64-linux-android"]; + } else { + return []; + } + }, + amendCommand(command) { + return command + .addOption(ndkVersionOption) + .addOption(androidSdkVersionOption); + }, + configureArgs({ target }, { ndkVersion, androidSdkVersion }) { + const { ANDROID_HOME } = process.env; + assert( + typeof ANDROID_HOME === "string", + "Missing env variable ANDROID_HOME" + ); + assert( + fs.existsSync(ANDROID_HOME), + `Expected the Android SDK at ${ANDROID_HOME}` + ); + const installNdkCommand = `sdkmanager --install "ndk;${ndkVersion}"`; + const ndkPath = path.resolve(ANDROID_HOME, "ndk", ndkVersion); + assert( + fs.existsSync(ndkPath), + `Missing Android NDK v${ndkVersion} (at ${ndkPath}) - run: ${installNdkCommand}` + ); + + const toolchainPath = path.join( + ndkPath, + "build/cmake/android.toolchain.cmake" + ); + const architecture = ANDROID_ARCHITECTURES[target]; + + return [ + // Use the XCode as generator for Apple platforms + "-G", + "Ninja", + "--toolchain", + toolchainPath, + "-D", + "CMAKE_SYSTEM_NAME=Android", + // "-D", + // `CPACK_SYSTEM_NAME=Android-${architecture}`, + // "-D", + // `CMAKE_INSTALL_PREFIX=${installPath}`, + // "-D", + // `CMAKE_BUILD_TYPE=${configuration}`, + "-D", + "CMAKE_MAKE_PROGRAM=ninja", + // "-D", + // "CMAKE_C_COMPILER_LAUNCHER=ccache", + // "-D", + // "CMAKE_CXX_COMPILER_LAUNCHER=ccache", + "-D", + `ANDROID_NDK=${ndkPath}`, + "-D", + `ANDROID_ABI=${architecture}`, + "-D", + "ANDROID_TOOLCHAIN=clang", + "-D", + `ANDROID_PLATFORM=${androidSdkVersion}`, + "-D", + // TODO: Make this configurable + "ANDROID_STL=c++_shared", + ]; + }, + buildArgs() { + return []; + }, + isSupportedByHost() { + const { ANDROID_HOME } = process.env; + return typeof ANDROID_HOME === "string" && fs.existsSync(ANDROID_HOME); + }, + async postBuild({ outputPath, targets }, { autoLink }) { + // TODO: Include `configuration` in the output path + const libraryPathByTriplet = Object.fromEntries( + await Promise.all( + targets.map(async ({ target, outputPath }) => { + assert( + fs.existsSync(outputPath), + `Expected a directory at ${outputPath}` + ); + // Expect binary file(s), either .node or .so + const dirents = await fs.promises.readdir(outputPath, { + withFileTypes: true, + }); + const result = dirents + .filter( + (dirent) => + dirent.isFile() && + (dirent.name.endsWith(".so") || dirent.name.endsWith(".node")) + ) + .map((dirent) => path.join(dirent.parentPath, dirent.name)); + assert.equal(result.length, 1, "Expected exactly one library file"); + return [target, result[0]] as const; + }) + ) + ) as Record; + const androidLibsFilename = determineAndroidLibsFilename( + Object.values(libraryPathByTriplet) + ); + const androidLibsOutputPath = path.resolve(outputPath, androidLibsFilename); + + await oraPromise( + createAndroidLibsDirectory({ + outputPath: androidLibsOutputPath, + libraryPathByTriplet, + autoLink, + }), + { + text: "Assembling Android libs directory", + successText: `Android libs directory assembled into ${chalk.dim( + path.relative(process.cwd(), androidLibsOutputPath) + )}`, + failText: ({ message }) => + `Failed to assemble Android libs directory: ${message}`, + } + ); + }, +}; diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts new file mode 100644 index 00000000..8343223f --- /dev/null +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; + +import { Option } from "@commander-js/extra-typings"; +import { oraPromise } from "ora"; +import { + AppleTriplet as Target, + createAppleFramework, + createXCframework, + determineXCFrameworkFilename, +} from "react-native-node-api"; + +import type { Platform } from "./types.js"; +import chalk from "chalk"; + +type XcodeSDKName = + | "iphoneos" + | "iphonesimulator" + | "catalyst" + | "xros" + | "xrsimulator" + | "appletvos" + | "appletvsimulator" + | "macosx"; + +const XCODE_SDK_NAMES = { + "x86_64-apple-darwin": "macosx", + "arm64-apple-darwin": "macosx", + "arm64;x86_64-apple-darwin": "macosx", + "arm64-apple-ios": "iphoneos", + "arm64-apple-ios-sim": "iphonesimulator", + "arm64-apple-tvos": "appletvos", + // "x86_64-apple-tvos": "appletvos", + "arm64-apple-tvos-sim": "appletvsimulator", + "arm64-apple-visionos": "xros", + "arm64-apple-visionos-sim": "xrsimulator", +} satisfies Record; + +type CMakeSystemName = "Darwin" | "iOS" | "tvOS" | "watchOS" | "visionOS"; + +const CMAKE_SYSTEM_NAMES = { + "x86_64-apple-darwin": "Darwin", + "arm64-apple-darwin": "Darwin", + "arm64;x86_64-apple-darwin": "Darwin", + "arm64-apple-ios": "iOS", + "arm64-apple-ios-sim": "iOS", + "arm64-apple-tvos": "tvOS", + // "x86_64-apple-tvos": "appletvos", + "arm64-apple-tvos-sim": "tvOS", + "arm64-apple-visionos": "visionOS", + "arm64-apple-visionos-sim": "visionOS", +} satisfies Record; + +type AppleArchitecture = "arm64" | "x86_64" | "arm64;x86_64"; + +export const APPLE_ARCHITECTURES = { + "x86_64-apple-darwin": "x86_64", + "arm64-apple-darwin": "arm64", + "arm64;x86_64-apple-darwin": "arm64;x86_64", + "arm64-apple-ios": "arm64", + "arm64-apple-ios-sim": "arm64", + "arm64-apple-tvos": "arm64", + // "x86_64-apple-tvos": "x86_64", + "arm64-apple-tvos-sim": "arm64", + "arm64-apple-visionos": "arm64", + "arm64-apple-visionos-sim": "arm64", +} satisfies Record; + +export function createPlistContent(values: Record) { + return [ + '', + '', + '', + "", + ...Object.entries(values).flatMap(([key, value]) => [ + `${key}`, + `${value}`, + ]), + "", + "", + ].join("\n"); +} + +export function getAppleBuildArgs() { + // We expect the final application to sign these binaries + return ["CODE_SIGNING_ALLOWED=NO"]; +} + +const xcframeworkExtensionOption = new Option( + "--xcframework-extension", + "Don't rename the xcframework to .apple.node" +).default(false); + +type AppleOpts = { + xcframeworkExtension: boolean; +}; + +export const platform: Platform = { + id: "apple", + name: "Apple", + targets: [ + "arm64;x86_64-apple-darwin", + "arm64-apple-ios", + "arm64-apple-ios-sim", + "arm64-apple-tvos", + "arm64-apple-tvos-sim", + "arm64-apple-visionos", + "arm64-apple-visionos-sim", + ], + defaultTargets() { + return process.arch === "arm64" ? ["arm64-apple-ios-sim"] : []; + }, + amendCommand(command) { + return command.addOption(xcframeworkExtensionOption); + }, + configureArgs({ target }) { + return [ + // Use the XCode as generator for Apple platforms + "-G", + "Xcode", + "-D", + `CMAKE_SYSTEM_NAME=${CMAKE_SYSTEM_NAMES[target]}`, + // Set the SDK path for the target platform + "-D", + `CMAKE_OSX_SYSROOT=${XCODE_SDK_NAMES[target]}`, + // Set the target architecture + "-D", + `CMAKE_OSX_ARCHITECTURES=${APPLE_ARCHITECTURES[target]}`, + ]; + }, + buildArgs() { + return []; + }, + isSupportedByHost: function (): boolean | Promise { + return process.platform === "darwin"; + }, + async postBuild( + { outputPath, targets }, + { configuration, autoLink, xcframeworkExtension } + ) { + const libraryPaths = await Promise.all( + targets.map(async ({ outputPath }) => { + const configSpecificPath = path.join(outputPath, configuration); + assert( + fs.existsSync(configSpecificPath), + `Expected a directory at ${configSpecificPath}` + ); + // Expect binary file(s), either .node or .dylib + const files = await fs.promises.readdir(configSpecificPath); + const result = files.map(async (file) => { + const filePath = path.join(configSpecificPath, file); + if (filePath.endsWith(".dylib")) { + return filePath; + } else if (file.endsWith(".node")) { + // Rename the file to .dylib for xcodebuild to accept it + const newFilePath = filePath.replace(/\.node$/, ".dylib"); + await fs.promises.rename(filePath, newFilePath); + return newFilePath; + } else { + throw new Error( + `Expected a .node or .dylib file, but found ${file}` + ); + } + }); + assert.equal(result.length, 1, "Expected exactly one library file"); + return await result[0]; + }) + ); + const frameworkPaths = libraryPaths.map(createAppleFramework); + const xcframeworkFilename = determineXCFrameworkFilename( + frameworkPaths, + xcframeworkExtension ? ".xcframework" : ".apple.node" + ); + + // Create the xcframework + const xcframeworkOutputPath = path.resolve(outputPath, xcframeworkFilename); + + await oraPromise( + createXCframework({ + outputPath: xcframeworkOutputPath, + frameworkPaths, + autoLink, + }), + { + text: "Assembling XCFramework", + successText: `XCFramework assembled into ${chalk.dim( + path.relative(process.cwd(), xcframeworkOutputPath) + )}`, + failText: ({ message }) => `Failed to assemble XCFramework: ${message}`, + } + ); + }, +}; diff --git a/packages/cmake-rn/src/platforms/types.ts b/packages/cmake-rn/src/platforms/types.ts new file mode 100644 index 00000000..bad4b416 --- /dev/null +++ b/packages/cmake-rn/src/platforms/types.ts @@ -0,0 +1,76 @@ +import * as commander from "@commander-js/extra-typings"; +import type { program } from "../cli.js"; + +type InferOptionValues = ReturnType< + Command["opts"] +>; + +type BaseCommand = typeof program; +type ExtendedCommand = commander.Command< + [], + Opts & InferOptionValues, + Record // Global opts are not supported +>; + +export type BaseOpts = Omit, "target">; + +export type TargetContext = { + target: Target; + buildPath: string; + outputPath: string; +}; + +export type Platform< + Target = unknown, + Opts extends commander.OptionValues = Record, + Command = ExtendedCommand +> = { + /** + * Used to identify the platform in the CLI. + */ + id: string; + /** + * Name of the platform, used for display purposes. + */ + name: string; + /** + * All the targets supported by this platform. + */ + targets: Readonly; + /** + * Get the limited subset of targets that should be built by default for this platform, to support a development workflow. + */ + defaultTargets(): Target[] | Promise; + /** + * Implement this to add any platform specific options to the command. + */ + amendCommand(command: BaseCommand): Command; + /** + * Check if the platform is supported by the host system, running the build. + */ + isSupportedByHost(): boolean | Promise; + /** + * Platform specific arguments passed to CMake to configure a target project. + */ + configureArgs( + context: TargetContext, + options: BaseOpts & Opts + ): string[]; + /** + * Platform specific arguments passed to CMake to build a target project. + */ + buildArgs(context: TargetContext, options: BaseOpts & Opts): string[]; + /** + * Called to combine multiple targets into a single prebuilt artefact. + */ + postBuild( + context: { + /** + * Location of the final prebuilt artefact. + */ + outputPath: string; + targets: TargetContext[]; + }, + options: BaseOpts & Opts + ): Promise; +}; diff --git a/packages/cmake-rn/src/weak-node-api.ts b/packages/cmake-rn/src/weak-node-api.ts index 2f483c9b..4db8d667 100644 --- a/packages/cmake-rn/src/weak-node-api.ts +++ b/packages/cmake-rn/src/weak-node-api.ts @@ -1,15 +1,15 @@ import fs from "node:fs"; import assert from "node:assert/strict"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { isAndroidTriplet, isAppleTriplet, SupportedTriplet, + weakNodeApiPath, } from "react-native-node-api"; -import { ANDROID_ARCHITECTURES } from "./android.js"; +import { ANDROID_ARCHITECTURES } from "./platforms/android.js"; import { getNodeAddonHeadersPath, getNodeApiHeadersPath } from "./headers.js"; export function toCmakePath(input: string) { @@ -17,12 +17,11 @@ export function toCmakePath(input: string) { } export function getWeakNodeApiPath(triplet: SupportedTriplet): string { - const basePath = fileURLToPath( - import.meta.resolve("react-native-node-api/weak-node-api") - ); - assert(fs.existsSync(basePath), "Weak Node API path does not exist"); if (isAppleTriplet(triplet)) { - const xcframeworkPath = path.join(basePath, "weak-node-api.xcframework"); + const xcframeworkPath = path.join( + weakNodeApiPath, + "weak-node-api.xcframework" + ); assert( fs.existsSync(xcframeworkPath), `Expected an XCFramework at ${xcframeworkPath}` @@ -30,7 +29,7 @@ export function getWeakNodeApiPath(triplet: SupportedTriplet): string { return xcframeworkPath; } else if (isAndroidTriplet(triplet)) { const libraryPath = path.join( - basePath, + weakNodeApiPath, "weak-node-api.android.node", ANDROID_ARCHITECTURES[triplet], "libweak-node-api.so" From dcb422f6562a234457b0d7e4a2a1f94ce048fe0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sun, 20 Jul 2025 11:40:35 +0200 Subject: [PATCH 04/11] Add unit tests to cmake-rn --- packages/cmake-rn/src/platforms.test.ts | 31 ++++++++++++++++++++++ packages/cmake-rn/tsconfig.json | 12 ++++----- packages/cmake-rn/tsconfig.node-tests.json | 14 ++++++++++ packages/cmake-rn/tsconfig.node.json | 12 +++++++++ 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 packages/cmake-rn/src/platforms.test.ts create mode 100644 packages/cmake-rn/tsconfig.node-tests.json create mode 100644 packages/cmake-rn/tsconfig.node.json diff --git a/packages/cmake-rn/src/platforms.test.ts b/packages/cmake-rn/src/platforms.test.ts new file mode 100644 index 00000000..2f8c95f7 --- /dev/null +++ b/packages/cmake-rn/src/platforms.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + platforms, + platformHasTarget, + findPlatformForTarget, +} from "./platforms.js"; +import { Platform } from "./platforms/types.js"; + +const mockPlatform = { targets: ["target1", "target2"] } as unknown as Platform; + +describe("platformHasTarget", () => { + it("returns true when platform has target", () => { + assert.equal(platformHasTarget(mockPlatform, "target1"), true); + }); + + it("returns false when platform doesn't have target", () => { + assert.equal(platformHasTarget(mockPlatform, "target3"), false); + }); +}); + +describe("findPlatformForTarget", () => { + it("returns platform when target is found", () => { + assert(platforms.length >= 2, "Expects at least two platforms"); + const [platform1, platform2] = platforms; + const platform = findPlatformForTarget(platform1.targets[0]); + assert.equal(platform, platform1); + assert.notEqual(platform, platform2); + }); +}); diff --git a/packages/cmake-rn/tsconfig.json b/packages/cmake-rn/tsconfig.json index 2f7da785..a1c18413 100644 --- a/packages/cmake-rn/tsconfig.json +++ b/packages/cmake-rn/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "@tsconfig/node22/tsconfig.json", - "compilerOptions": { - "composite": true, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.node-tests.json" } + ] } diff --git a/packages/cmake-rn/tsconfig.node-tests.json b/packages/cmake-rn/tsconfig.node-tests.json new file mode 100644 index 00000000..cabcdca7 --- /dev/null +++ b/packages/cmake-rn/tsconfig.node-tests.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": true + }, + "include": ["src/**/*.test.ts"], + "exclude": [], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/packages/cmake-rn/tsconfig.node.json b/packages/cmake-rn/tsconfig.node.json new file mode 100644 index 00000000..d6472ce0 --- /dev/null +++ b/packages/cmake-rn/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "composite": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.test.ts"] +} From 5683ea1ff079eb940d806d900107cc1e426fbdf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sun, 20 Jul 2025 11:44:02 +0200 Subject: [PATCH 05/11] Add changeset --- .changeset/shiny-hotels-wink.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/shiny-hotels-wink.md diff --git a/.changeset/shiny-hotels-wink.md b/.changeset/shiny-hotels-wink.md new file mode 100644 index 00000000..10ba1209 --- /dev/null +++ b/.changeset/shiny-hotels-wink.md @@ -0,0 +1,6 @@ +--- +"cmake-rn": patch +"react-native-node-api": patch +--- + +Refactor into a platform abstraction From d0b77f27848bb34a46717adf15ac3c55afb01540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sun, 20 Jul 2025 14:06:33 +0200 Subject: [PATCH 06/11] Update Ferric to use weakNodeApiPath --- packages/ferric/src/cargo.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/ferric/src/cargo.ts b/packages/ferric/src/cargo.ts index 26b06970..16d2f38e 100644 --- a/packages/ferric/src/cargo.ts +++ b/packages/ferric/src/cargo.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import cp from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { spawn } from "bufout"; import chalk from "chalk"; @@ -15,9 +14,7 @@ import { isAppleTarget, } from "./targets.js"; -const WEAK_NODE_API_PATH = fileURLToPath( - import.meta.resolve("react-native-node-api/weak-node-api") -); +import { weakNodeApiPath } from "react-native-node-api"; const APPLE_XCFRAMEWORK_CHILDS_PER_TARGET: Record = { "aarch64-apple-darwin": "macos-arm64_x86_64", // Universal @@ -126,18 +123,16 @@ export function getTargetAndroidPlatform(target: AndroidTargetName) { } export function getWeakNodeApiFrameworkPath(target: AppleTargetName) { - assert(fs.existsSync(WEAK_NODE_API_PATH), "Expected weak-node-api to exist"); return joinPathAndAssertExistence( - WEAK_NODE_API_PATH, + weakNodeApiPath, "weak-node-api.xcframework", APPLE_XCFRAMEWORK_CHILDS_PER_TARGET[target] ); } export function getWeakNodeApiAndroidLibraryPath(target: AndroidTargetName) { - assert(fs.existsSync(WEAK_NODE_API_PATH), "Expected weak-node-api to exist"); return joinPathAndAssertExistence( - WEAK_NODE_API_PATH, + weakNodeApiPath, "weak-node-api.android.node", ANDROID_ARCH_PR_TARGET[target] ); @@ -202,7 +197,10 @@ export function getTargetEnvironmentVariables({ toolchainBinPath, `${targetArch}-linux-${targetPlatform}-clang++${cmdMaybe}` ), - TARGET_AR: joinPathAndAssertExistence(toolchainBinPath, `llvm-ar${exeMaybe}`), + TARGET_AR: joinPathAndAssertExistence( + toolchainBinPath, + `llvm-ar${exeMaybe}` + ), TARGET_RANLIB: joinPathAndAssertExistence( toolchainBinPath, `llvm-ranlib${exeMaybe}` From 0f7be36fb60a4cf70c9a9d6d9080ea6f8fb7cff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 22 Jul 2025 20:29:44 +0200 Subject: [PATCH 07/11] Add to message when lib names mismatch --- packages/host/src/node/path-utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index 0ca2bfe9..ca4ab93e 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -425,7 +425,10 @@ export function determineLibraryBasename(libraryPaths: string[]) { path.basename(p, path.extname(p)).replace(/^lib/, "") ); const candidates = new Set(libraryNames); - assert(candidates.size === 1, "Expected all libraries to have the same name"); + assert( + candidates.size === 1, + `Expected all libraries to share name, got: ${[...candidates].join(", ")}` + ); const [name] = candidates; return name; } From d97b4d6c6373ce48e81d02d37a92543e27616270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 22 Jul 2025 20:37:31 +0200 Subject: [PATCH 08/11] Add exception to fail determineLibraryBasename early --- packages/host/src/node/path-utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index ca4ab93e..c92115cc 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -420,6 +420,10 @@ export async function findNodeApiModulePathsByDependency({ * Errors if all framework paths doesn't produce the same basename. */ export function determineLibraryBasename(libraryPaths: string[]) { + assert( + libraryPaths.length > 0, + "Expected at least one library path to determine its basename" + ); const libraryNames = libraryPaths.map((p) => // Strip the "lib" prefix and any file extension path.basename(p, path.extname(p)).replace(/^lib/, "") From 5170016af3e3c4c945ca1f60282541568ec98067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 22 Jul 2025 20:53:16 +0200 Subject: [PATCH 09/11] Use array as type argument --- packages/cmake-rn/src/cli.ts | 33 +++++++++++----------- packages/cmake-rn/src/platforms/android.ts | 2 +- packages/cmake-rn/src/platforms/apple.ts | 2 +- packages/cmake-rn/src/platforms/types.ts | 17 ++++++----- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index 73878fe2..c4292bff 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -99,7 +99,7 @@ program = program.action( if (baseOptions.clean) { await fs.promises.rm(buildPath, { recursive: true, force: true }); } - const targets = new Set(requestedTargets); + const targets = new Set(requestedTargets); for (const platform of Object.values(platforms)) { // Forcing the types a bit here, since the platform id option is dynamically added @@ -186,17 +186,18 @@ program = program.action( // Perform post-build steps for each platform in sequence for (const platform of platforms) { - // TODO: Increase type-safety around this - const relevantTargets = targetContexts.filter(({ target }) => - platformHasTarget(platform, target) - ); - await platform.postBuild( - { - outputPath: baseOptions.out || baseOptions.source, - targets: relevantTargets, - }, - baseOptions - ); + { + const relevantTargets = targetContexts.filter(({ target }) => + platformHasTarget(platform, target) + ); + await platform.postBuild( + { + outputPath: baseOptions.out || baseOptions.source, + targets: relevantTargets, + }, + baseOptions + ); + } } } catch (error) { if (error instanceof SpawnFailure) { @@ -220,8 +221,8 @@ function getTargetBuildPath(buildPath: string, target: unknown) { return path.join(buildPath, target.replace(/;/g, "_")); } -async function configureProject( - platform: Platform>, +async function configureProject( + platform: Platform>, context: TargetContext, options: BaseOpts ) { @@ -256,8 +257,8 @@ async function configureProject( ); } -async function buildProject( - platform: Platform>, +async function buildProject( + platform: Platform>, context: TargetContext, options: BaseOpts ) { diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts index 7c750e2d..3e0158a2 100644 --- a/packages/cmake-rn/src/platforms/android.ts +++ b/packages/cmake-rn/src/platforms/android.ts @@ -38,7 +38,7 @@ const androidSdkVersionOption = new Option( type AndroidOpts = { ndkVersion: string; androidSdkVersion: string }; -export const platform: Platform = { +export const platform: Platform = { id: "android", name: "Android", targets: [ diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts index 8343223f..58eea605 100644 --- a/packages/cmake-rn/src/platforms/apple.ts +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -96,7 +96,7 @@ type AppleOpts = { xcframeworkExtension: boolean; }; -export const platform: Platform = { +export const platform: Platform = { id: "apple", name: "Apple", targets: [ diff --git a/packages/cmake-rn/src/platforms/types.ts b/packages/cmake-rn/src/platforms/types.ts index bad4b416..5406cc48 100644 --- a/packages/cmake-rn/src/platforms/types.ts +++ b/packages/cmake-rn/src/platforms/types.ts @@ -14,14 +14,14 @@ type ExtendedCommand = commander.Command< export type BaseOpts = Omit, "target">; -export type TargetContext = { +export type TargetContext = { target: Target; buildPath: string; outputPath: string; }; export type Platform< - Target = unknown, + Targets extends string[] = string[], Opts extends commander.OptionValues = Record, Command = ExtendedCommand > = { @@ -36,11 +36,11 @@ export type Platform< /** * All the targets supported by this platform. */ - targets: Readonly; + targets: Readonly; /** * Get the limited subset of targets that should be built by default for this platform, to support a development workflow. */ - defaultTargets(): Target[] | Promise; + defaultTargets(): Targets[number][] | Promise; /** * Implement this to add any platform specific options to the command. */ @@ -53,13 +53,16 @@ export type Platform< * Platform specific arguments passed to CMake to configure a target project. */ configureArgs( - context: TargetContext, + context: TargetContext, options: BaseOpts & Opts ): string[]; /** * Platform specific arguments passed to CMake to build a target project. */ - buildArgs(context: TargetContext, options: BaseOpts & Opts): string[]; + buildArgs( + context: TargetContext, + options: BaseOpts & Opts + ): string[]; /** * Called to combine multiple targets into a single prebuilt artefact. */ @@ -69,7 +72,7 @@ export type Platform< * Location of the final prebuilt artefact. */ outputPath: string; - targets: TargetContext[]; + targets: TargetContext[]; }, options: BaseOpts & Opts ): Promise; From 8a00dcd6eda466d4dc05f16c8190bf825c8cbe04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 22 Jul 2025 20:56:07 +0200 Subject: [PATCH 10/11] Skip post build step if no relevant targets exists --- packages/cmake-rn/src/cli.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index c4292bff..540a42e1 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -186,18 +186,19 @@ program = program.action( // Perform post-build steps for each platform in sequence for (const platform of platforms) { - { - const relevantTargets = targetContexts.filter(({ target }) => - platformHasTarget(platform, target) - ); - await platform.postBuild( - { - outputPath: baseOptions.out || baseOptions.source, - targets: relevantTargets, - }, - baseOptions - ); + const relevantTargets = targetContexts.filter(({ target }) => + platformHasTarget(platform, target) + ); + if (relevantTargets.length == 0) { + continue; } + await platform.postBuild( + { + outputPath: baseOptions.out || baseOptions.source, + targets: relevantTargets, + }, + baseOptions + ); } } catch (error) { if (error instanceof SpawnFailure) { From 2ca36b519ec59c5cc0c5771f5385ddec90a87bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 22 Jul 2025 21:34:52 +0200 Subject: [PATCH 11/11] Incorporated feedback from CoPilot --- packages/cmake-rn/src/cli.ts | 3 +-- packages/cmake-rn/src/platforms/android.ts | 1 - packages/cmake-rn/src/platforms/apple.ts | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index 540a42e1..39ffadc3 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -143,8 +143,7 @@ program = program.action( platform, buildPath: targetBuildPath, outputPath: path.join(targetBuildPath, "out"), - options: baseOptions as BaseOpts & - ReturnType, + options: baseOptions, }; }); diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts index 3e0158a2..51e2ab76 100644 --- a/packages/cmake-rn/src/platforms/android.ts +++ b/packages/cmake-rn/src/platforms/android.ts @@ -85,7 +85,6 @@ export const platform: Platform = { const architecture = ANDROID_ARCHITECTURES[target]; return [ - // Use the XCode as generator for Apple platforms "-G", "Ninja", "--toolchain", diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts index 58eea605..ea66da98 100644 --- a/packages/cmake-rn/src/platforms/apple.ts +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -116,15 +116,12 @@ export const platform: Platform = { }, configureArgs({ target }) { return [ - // Use the XCode as generator for Apple platforms "-G", "Xcode", "-D", `CMAKE_SYSTEM_NAME=${CMAKE_SYSTEM_NAMES[target]}`, - // Set the SDK path for the target platform "-D", `CMAKE_OSX_SYSROOT=${XCODE_SDK_NAMES[target]}`, - // Set the target architecture "-D", `CMAKE_OSX_ARCHITECTURES=${APPLE_ARCHITECTURES[target]}`, ];