diff --git a/.changeset/prebuilt-hermes-command.md b/.changeset/prebuilt-hermes-command.md new file mode 100644 index 00000000..5bb4a76d --- /dev/null +++ b/.changeset/prebuilt-hermes-command.md @@ -0,0 +1,18 @@ +--- +"react-native-node-api": minor +--- + +Add a `prebuilt-hermes` command, which resolves an archive of the pinned Hermes +commit prebuilt for Apple platforms and prints its path. The archive holds the +`destroot` layout React Native's `hermes-engine.podspec` expects from a tarball +pointed at by `HERMES_ENGINE_TARBALL_PATH`, so an app that sets that variable +vendors the prebuilt frameworks rather than compiling Hermes as part of its own +build. + +It is resolved from a local cache, then from a release asset published for the +pinned commit, and only built locally if neither has it. Its name covers +everything that changes its contents — the pinned commit, the React Native +version whose `ReactCommon/jsi` it is compiled against, the build type and the +platforms — so a stale archive can never be mistaken for a matching one. + +Nothing consumes this yet: `pod install` still builds Hermes from source. diff --git a/.github/workflows/hermes-prebuilt.yml b/.github/workflows/hermes-prebuilt.yml new file mode 100644 index 00000000..cf619033 --- /dev/null +++ b/.github/workflows/hermes-prebuilt.yml @@ -0,0 +1,78 @@ +name: Hermes prebuilt + +# Builds the pinned Hermes for Apple platforms and publishes it as a release +# asset, so `pod install` downloads it instead of compiling Hermes as part of +# every app build. The asset is keyed by everything that changes its contents, +# so a bumped pin publishes alongside the previous one rather than replacing it. + +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: "url.https://github.com/.insteadOf" + GIT_CONFIG_VALUE_0: "git@github.com:" + +on: + workflow_dispatch: + push: + branches: + - main + - next + paths: + - "packages/host/src/node/cli/hermes.ts" + - "packages/host/src/node/cli/hermes-prebuilt.ts" + - ".github/workflows/hermes-prebuilt.yml" + +# Two runs publishing the same tag would race on creating the release. +concurrency: + group: ${{ github.workflow }} + +jobs: + publish: + name: Publish prebuilt Hermes + runs-on: macos-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: lts/krypton + - uses: pnpm/action-setup@v6 + with: + cache: true + - run: pnpm install + - run: pnpm run build + # Resolved from the test app so the archive is built against the React + # Native version this repository actually pins. + - name: Resolve prebuilt Hermes name + id: hermes + working-directory: apps/test-app + run: | + echo "archive=$(pnpm exec react-native-node-api prebuilt-hermes --print name)" >> "$GITHUB_OUTPUT" + echo "tag=$(pnpm exec react-native-node-api prebuilt-hermes --print tag)" >> "$GITHUB_OUTPUT" + - name: Cache prebuilt Hermes + uses: actions/cache@v6 + with: + path: ~/Library/Caches/react-native-node-api/hermes-prebuilt + key: ${{ steps.hermes.outputs.archive }} + # --no-download so a re-run rebuilds rather than round-tripping the asset + # it is about to publish. + - name: Build prebuilt Hermes + id: build + working-directory: apps/test-app + run: echo "path=$(pnpm exec react-native-node-api prebuilt-hermes --no-download)" >> "$GITHUB_OUTPUT" + # --latest=false keeps these out of the "latest release" slot, which + # belongs to the package releases changesets publishes. + - name: Publish as a release asset + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.hermes.outputs.tag }} + ARCHIVE_PATH: ${{ steps.build.outputs.path }} + run: | + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Prebuilt Hermes ($TAG)" \ + --notes "Hermes, built for Apple platforms from the commit pinned in \`packages/host/src/node/cli/hermes.ts\`. Downloaded by \`react-native-node-api prebuilt-hermes\` and injected into the app's \`pod install\` through \`HERMES_ENGINE_TARBALL_PATH\`." \ + --latest=false + fi + gh release upload "$TAG" "$ARCHIVE_PATH" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/docs/CLI.md b/docs/CLI.md index 4282f403..58c8ec35 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -9,7 +9,29 @@ npx react-native-node-api [options] Run `npx react-native-node-api help` or `npx react-native-node-api help ` to see this same information from the CLI itself. > [!NOTE] -> This document is hand-written from the [Commander](https://github.com/tj/commander.js) program definition in [`packages/host/src/node/cli/program.ts`](../packages/host/src/node/cli/program.ts) (with the `vendor-hermes` command defined in [`hermes.ts`](../packages/host/src/node/cli/hermes.ts)). It needs to be kept in sync by hand whenever a command or its options change. +> This document is hand-written from the [Commander](https://github.com/tj/commander.js) program definition in [`packages/host/src/node/cli/program.ts`](../packages/host/src/node/cli/program.ts) (with the `vendor-hermes` command defined in [`hermes.ts`](../packages/host/src/node/cli/hermes.ts) and `prebuilt-hermes` in [`hermes-prebuilt.ts`](../packages/host/src/node/cli/hermes-prebuilt.ts)). It needs to be kept in sync by hand whenever a command or its options change. + +## `prebuilt-hermes [from]` + +Resolves an archive of the pinned Hermes, prebuilt for Apple platforms, and prints its path. The archive holds the `destroot` layout React Native's `hermes-engine.podspec` expects from a tarball pointed at by `HERMES_ENGINE_TARBALL_PATH`, so an app that sets that variable vendors the prebuilt frameworks instead of compiling Hermes as part of its own build. + +The archive is looked for in this order, and cached under `~/Library/Caches/react-native-node-api/hermes-prebuilt` (overridable with `REACT_NATIVE_NODE_API_CACHE_PATH`): + +1. The cache, unless `--force` is passed. +2. The [release asset](https://github.com/callstackincubator/react-native-node-api/releases) published for the pinned commit by the `Hermes prebuilt` workflow, unless `--no-download` is passed. +3. A local build from the vendored source, unless `--no-build` is passed. This requires macOS and Xcode, and takes a while — but only once per pinned commit. + +Its name covers everything that changes its contents: the pinned Hermes commit, the React Native version whose `ReactCommon/jsi` it is compiled against, the build type and the platforms. That makes it usable as a CI cache key. + +- `[from]` — Path to a file inside the app package. Defaults to the current working directory. +- `--react-native-package ` — The React Native package to resolve Hermes for. Defaults to `react-native`. +- `--build-type ` — One of `debug` or `release`. `debug` enables Hermes' debugger. Defaults to `debug`. +- `--platform ` — Apple platform to build for, repeatable. Defaults to `iphoneos` and `iphonesimulator`. +- `--silent` — Don't print anything except the final path. Defaults to `false`. +- `--force` — Re-resolve the archive even if it is already cached. Defaults to `false`. +- `--no-download` — Don't download a published archive. +- `--no-build` — Don't build the archive locally when none is published. +- `--print ` — Print `name`, `tag` or `url` of the archive instead of resolving it. ## `vendor-hermes [from]` diff --git a/packages/host/src/node/cli/hermes-prebuilt.ts b/packages/host/src/node/cli/hermes-prebuilt.ts new file mode 100644 index 00000000..125deb16 --- /dev/null +++ b/packages/host/src/node/cli/hermes-prebuilt.ts @@ -0,0 +1,429 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + chalk, + Command, + Option, + oraPromise, + spawn, + UsageError, + wrapAction, + prettyPath, +} from "@react-native-node-api/cli-utils"; +import { readPackage } from "read-pkg"; + +import { + HERMES_GIT_SHA, + ensureHermesCheckout, + reactNativePackageOption, + resolveReactNativePath, + silentOption, +} from "./hermes"; + +const RELEASES_URL = + "https://github.com/callstackincubator/react-native-node-api/releases/download"; + +export const DEFAULT_PLATFORMS = ["iphoneos", "iphonesimulator"]; + +// Passed to Hermes' build-apple-framework.sh, which errors out rather than +// assume one. These match React Native's own podspec declarations. +const DEPLOYMENT_TARGETS = { + IOS_DEPLOYMENT_TARGET: "15.1", + MAC_DEPLOYMENT_TARGET: "10.15", + XROS_DEPLOYMENT_TARGET: "1.0", +}; + +export const BUILD_TYPES = ["debug", "release"] as const; +export type BuildType = (typeof BUILD_TYPES)[number]; + +const PRINTABLE_PROPERTIES = ["name", "tag", "url"] as const; + +/** + * Set to opt out of the prebuilt archive and have the Cocoapods integration + * build Hermes from the vendored source instead — which is what you want while + * iterating on Hermes itself, since Xcode then rebuilds it incrementally. + */ +export const FROM_SOURCE_ENV_VAR = "REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE"; + +export function getCacheDirectory() { + const { REACT_NATIVE_NODE_API_CACHE_PATH, XDG_CACHE_HOME } = process.env; + if (REACT_NATIVE_NODE_API_CACHE_PATH) { + return REACT_NATIVE_NODE_API_CACHE_PATH; + } else if (process.platform === "darwin") { + return path.join( + os.homedir(), + "Library", + "Caches", + "react-native-node-api", + ); + } else { + return path.join( + XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), + "react-native-node-api", + ); + } +} + +export function getPrebuiltDirectory() { + return path.join(getCacheDirectory(), "hermes-prebuilt"); +} + +/** + * Identifies an archive by everything that changes its contents. The React + * Native version is part of it because Hermes is compiled against that + * package's ReactCommon/jsi: a JSI mismatch between the framework and the app + * linking it is an ABI break. + */ +export function getArchiveName({ + reactNativeVersion, + buildType, + platforms, +}: { + reactNativeVersion: string; + buildType: BuildType; + platforms: string[]; +}) { + const shortSha = HERMES_GIT_SHA.slice(0, 12); + // GitHub rewrites every character outside [A-Za-z0-9._-] in a release asset + // name, so the name has to stay within that set to survive a round-trip. + const platformSuffix = [...platforms].sort().join("-"); + return `hermes-${shortSha}-rn${reactNativeVersion}-${buildType}-${platformSuffix}.tar.gz`; +} + +export function getReleaseTag() { + return `hermes-prebuilt-${HERMES_GIT_SHA.slice(0, 12)}`; +} + +export function getDownloadUrl(archiveName: string) { + return `${RELEASES_URL}/${getReleaseTag()}/${encodeURIComponent(archiveName)}`; +} + +/** + * @returns true if the archive was downloaded, false if the release doesn't + * publish one for this combination (yet). + */ +async function downloadArchive(url: string, archivePath: string) { + const response = await fetch(url); + if (response.status === 404) { + return false; + } else if (!response.ok) { + throw new Error( + `Unexpected response downloading ${url}: ${response.status} ${response.statusText}`, + ); + } + const downloadPath = `${archivePath}.download`; + await fs.promises.writeFile( + downloadPath, + Buffer.from(await response.arrayBuffer()), + ); + // Renaming last keeps a half-written download from passing as a cache hit. + await fs.promises.rename(downloadPath, archivePath); + return true; +} + +/** + * Builds the destroot layout React Native's hermes-engine.podspec expects from + * a prebuilt tarball, and archives it. + * + * The per-platform framework builds are delegated to the Hermes checkout's own + * utils/build-apple-framework.sh, which is the script that knows how to build + * that particular source tree. Everything around it — the host compiler, the + * universal XCFramework and the archive — is assembled here. + */ +async function buildArchive({ + reactNativePath, + archivePath, + buildType, + platforms, + silent, +}: { + reactNativePath: string; + archivePath: string; + buildType: BuildType; + platforms: string[]; + silent: boolean; +}) { + const hermesPath = await ensureHermesCheckout({ + reactNativePath, + force: false, + silent, + }); + const hermescPath = path.join(hermesPath, "build_host_hermesc"); + const importHostCompilersPath = path.join( + hermescPath, + "ImportHostCompilers.cmake", + ); + // Hermes is compiled against the app's React Native JSI headers, not its own + // vendored copy: the framework and the app linking it share jsi::Runtime. + const jsiPath = path.join(reactNativePath, "ReactCommon", "jsi"); + + const run = (command: string, args: string[]) => + spawn(command, args, { + cwd: hermesPath, + outputMode: "inherit", + // Keeps the build log off stdout, which callers parse for the final path. + stdout: process.stderr, + env: { + ...DEPLOYMENT_TARGETS, + ...process.env, + JSI_PATH: jsiPath, + BUILD_TYPE: buildType === "debug" ? "Debug" : "Release", + HERMES_OVERRIDE_HERMESC_PATH: importHostCompilersPath, + }, + }); + + // Configured here instead of letting build-apple-framework.sh's + // build_host_hermesc do it: that one takes no architectures, and the hermesc + // we ship has to run on both Apple Silicon and Intel Macs. + if (!fs.existsSync(importHostCompilersPath)) { + await run("cmake", [ + "-S", + ".", + "-B", + hermescPath, + `-DJSI_DIR=${jsiPath}`, + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64", + ]); + await run("cmake", [ + "--build", + hermescPath, + "--target", + "hermesc", + "-j", + os.availableParallelism().toString(), + ]); + } + + for (const platform of platforms) { + await run("./utils/build-apple-framework.sh", [platform]); + } + + const frameworksPath = path.join( + hermesPath, + "destroot", + "Library", + "Frameworks", + ); + // hermes-engine.podspec vendors macOS as a plain framework and every other + // platform out of the universal XCFramework, so macosx stays where it is. + const xcframeworkPlatforms = platforms.filter( + (platform) => platform !== "macosx", + ); + const xcframeworkPath = path.join( + frameworksPath, + "universal", + "hermesvm.xcframework", + ); + if (xcframeworkPlatforms.length > 0 && !fs.existsSync(xcframeworkPath)) { + await run("xcodebuild", [ + "-create-xcframework", + ...xcframeworkPlatforms.flatMap((platform) => [ + "-framework", + path.join(frameworksPath, platform, "hermesvm.framework"), + "-debug-symbols", + path.join(frameworksPath, platform, "hermesvm.framework.dSYM"), + ]), + "-output", + xcframeworkPath, + ]); + for (const platform of xcframeworkPlatforms) { + await fs.promises.rm(path.join(frameworksPath, platform), { + recursive: true, + force: true, + }); + } + } + + // react-native-xcode.sh falls back to destroot/bin/hermesc when + // HERMES_CLI_PATH is unset, and the podspec deliberately doesn't point that + // at the hermes-compiler npm package for local tarballs: the compiler has to + // emit bytecode this VM can read. + const binPath = path.join(hermesPath, "destroot", "bin"); + await fs.promises.mkdir(binPath, { recursive: true }); + await fs.promises.copyFile( + path.join(hermescPath, "bin", "hermesc"), + path.join(binPath, "hermesc"), + ); + + await fs.promises.mkdir(path.dirname(archivePath), { recursive: true }); + // LICENSE rides along so the archive has more than one top-level entry: + // CocoaPods flattens an archive whose sole entry is a directory, which would + // strip the destroot/ prefix that every path in hermes-engine.podspec assumes. + const partialPath = `${archivePath}.partial`; + await run("tar", ["-czf", partialPath, "destroot", "LICENSE"]); + // Renaming last keeps an interrupted archive from passing as a cache hit. + await fs.promises.rename(partialPath, archivePath); +} + +export async function resolvePrebuiltHermes({ + reactNativePath, + buildType, + platforms, + download, + build, + force, + silent, +}: { + reactNativePath: string; + buildType: BuildType; + platforms: string[]; + download: boolean; + build: boolean; + force: boolean; + silent: boolean; +}) { + const { version: reactNativeVersion } = await readPackage({ + cwd: reactNativePath, + }); + const archiveName = getArchiveName({ + reactNativeVersion, + buildType, + platforms, + }); + const archivePath = path.join(getPrebuiltDirectory(), archiveName); + + if (force) { + await fs.promises.rm(archivePath, { force: true }); + } else if (fs.existsSync(archivePath)) { + return archivePath; + } + + await fs.promises.mkdir(path.dirname(archivePath), { recursive: true }); + + if (download) { + const url = getDownloadUrl(archiveName); + const downloaded = await oraPromise(downloadArchive(url, archivePath), { + text: `Downloading prebuilt Hermes from ${chalk.dim(url)}`, + successText: (published) => + published + ? `Downloaded prebuilt Hermes into ${prettyPath(archivePath)}` + : "No prebuilt Hermes published for this React Native version", + failText: (error) => + `Failed to download prebuilt Hermes: ${error.message}`, + isSilent: silent, + }); + if (downloaded) { + return archivePath; + } + } + + if (!build) { + throw new UsageError(`Found no prebuilt Hermes archive ${archiveName}`, { + fix: { + instructions: `Drop --no-build to build it locally, or set ${chalk.bold(FROM_SOURCE_ENV_VAR)}=1 to build Hermes from source as part of the app build instead.`, + }, + }); + } + + if (process.platform !== "darwin") { + throw new UsageError( + "Building Hermes for Apple platforms requires macOS and Xcode", + ); + } + + if (!silent) { + console.error( + `Building Hermes ${HERMES_GIT_SHA.slice(0, 12)} for ${platforms.join(", ")} — this takes a while, but only once per pinned commit.`, + ); + } + await buildArchive({ + reactNativePath, + archivePath, + buildType, + platforms, + silent, + }); + return archivePath; +} + +function collectPlatform(value: string, previous: string[] | undefined) { + return [...(previous ?? []), value]; +} + +export const command = new Command("prebuilt-hermes") + .description( + "Resolve an archive of the pinned Hermes, prebuilt for Apple platforms, printing its path", + ) + .argument("[from]", "Path to a file inside the app package", process.cwd()) + .addOption(silentOption) + .option( + "--force", + "Re-resolve the archive even if it is already cached", + false, + ) + .addOption(reactNativePackageOption) + .addOption( + new Option("--build-type ", "The Hermes build type") + .choices(BUILD_TYPES) + .default("debug"), + ) + .option( + "--platform ", + `Apple platform to build for, repeatable (default: ${DEFAULT_PLATFORMS.join(", ")})`, + collectPlatform, + ) + .option("--no-download", "Don't download a published archive") + .option( + "--no-build", + "Don't build the archive locally when none is published", + ) + .addOption( + new Option( + "--print ", + "Print a property of the archive instead of resolving it", + ).choices(PRINTABLE_PROPERTIES), + ) + .action( + wrapAction( + async ( + from, + { + silent, + force, + reactNativePackage, + buildType, + platform, + download, + build, + print, + }, + ) => { + const platforms = platform ?? DEFAULT_PLATFORMS; + const reactNativePath = await resolveReactNativePath( + from, + reactNativePackage, + ); + if (print) { + const { version: reactNativeVersion } = await readPackage({ + cwd: reactNativePath, + }); + const archiveName = getArchiveName({ + reactNativeVersion, + buildType, + platforms, + }); + if (print === "name") { + console.log(archiveName); + } else if (print === "tag") { + console.log(getReleaseTag()); + } else { + console.log(getDownloadUrl(archiveName)); + } + return; + } + const archivePath = await resolvePrebuiltHermes({ + reactNativePath, + buildType, + platforms, + download, + build, + force, + silent, + }); + console.log(archivePath); + }, + ), + ); diff --git a/packages/host/src/node/cli/hermes.ts b/packages/host/src/node/cli/hermes.ts index 74f8680b..1c0b4a00 100644 --- a/packages/host/src/node/cli/hermes.ts +++ b/packages/host/src/node/cli/hermes.ts @@ -15,7 +15,7 @@ import { import { packageDirectory } from "pkg-dir"; import { readPackage } from "read-pkg"; -const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; +export const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; // Pinned commit on the `static_h` branch, which carries the first-party // Node-API implementation under `API/napi`. Bump deliberately: the JSI @@ -44,100 +44,142 @@ const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; // cpp/HermesNapiHost.hpp against `API/napi/hermes_napi.h` at the new commit: // the struct is mirrored there (not included) and any change to its member // order or signatures is an ABI break the compiler cannot catch. -const HERMES_GIT_SHA = "5a795c9f880002c862c9254a26b57199819c97f7"; +export const HERMES_GIT_SHA = "5a795c9f880002c862c9254a26b57199819c97f7"; -const platformOption = new Option( +export const reactNativePackageOption = new Option( "--react-native-package ", "The React Native package to vendor Hermes into", ).default("react-native"); +export const silentOption = new Option( + "--silent", + "Don't print anything except the final path", +).default(false); + +/** + * Locate the React Native package the app at `from` actually resolves to. + */ +export async function resolveReactNativePath( + from: string, + reactNativePackage: string, +) { + const appPackageRoot = await packageDirectory({ cwd: from }); + assert(appPackageRoot, "Failed to find package root"); + + const { dependencies = {} } = await readPackage({ cwd: appPackageRoot }); + assert( + Object.keys(dependencies).includes(reactNativePackage), + `Expected app to have a dependency on the '${reactNativePackage}' package`, + ); + + return path.dirname( + require.resolve(reactNativePackage + "/package.json", { + // Ensures we'll be patching the React Native package actually used by the app + paths: [appPackageRoot], + }), + ); +} + +export function getHermesPath(reactNativePath: string) { + return path.join(reactNativePath, "sdks", "node-api-hermes"); +} + +/** + * Clone the pinned Hermes commit into the React Native package, unless it is + * already there. + */ +export async function ensureHermesCheckout({ + reactNativePath, + force, + silent, +}: { + reactNativePath: string; + force: boolean; + silent: boolean; +}) { + const hermesPath = getHermesPath(reactNativePath); + if (force && fs.existsSync(hermesPath)) { + await oraPromise( + fs.promises.rm(hermesPath, { recursive: true, force: true }), + { + text: "Removing existing Hermes clone", + successText: "Removed existing Hermes clone", + failText: (error) => + `Failed to remove existing Hermes clone: ${error.message}`, + isSilent: silent, + }, + ); + } + if (!fs.existsSync(hermesPath)) { + try { + // GitHub allows fetching a reachable commit by SHA, so we can clone + // the pinned commit shallowly without downloading the whole history. + await oraPromise( + (async () => { + await fs.promises.mkdir(hermesPath, { recursive: true }); + const git = (args: string[]) => + spawn("git", args, { + cwd: hermesPath, + outputMode: "buffered", + }); + await git(["init", "--quiet"]); + await git(["remote", "add", "origin", HERMES_GIT_URL]); + await git(["fetch", "--depth", "1", "origin", HERMES_GIT_SHA]); + await git(["checkout", "--quiet", "FETCH_HEAD"]); + await git([ + "submodule", + "update", + "--init", + "--recursive", + "--depth", + "1", + ]); + })(), + { + text: `Cloning Hermes into ${prettyPath(hermesPath)}`, + successText: "Cloned Hermes", + failText: (err) => `Failed to clone Hermes: ${err.message}`, + isSilent: silent, + }, + ); + } catch (error) { + // A failed clone can leave a partial checkout behind, which would + // make the existence check above skip re-cloning on the next run. + await fs.promises.rm(hermesPath, { recursive: true, force: true }); + throw new UsageError("Failed to clone Hermes", { + cause: error, + fix: { + instructions: `Check the network connection and that the pinned Hermes commit ${chalk.bold(HERMES_GIT_SHA)} is still reachable on ${chalk.bold(HERMES_GIT_URL)}.`, + }, + }); + } + } + return hermesPath; +} + export const command = new Command("vendor-hermes") .argument("[from]", "Path to a file inside the app package", process.cwd()) - .option("--silent", "Don't print anything except the final path", false) + .addOption(silentOption) .option( "--force", "Don't check timestamps of input files to skip unnecessary rebuilds", false, ) - .addOption(platformOption) + .addOption(reactNativePackageOption) .action( wrapAction(async (from, { force, silent, reactNativePackage }) => { - const appPackageRoot = await packageDirectory({ cwd: from }); - assert(appPackageRoot, "Failed to find package root"); - - const { dependencies = {} } = await readPackage({ cwd: appPackageRoot }); - assert( - Object.keys(dependencies).includes(reactNativePackage), - `Expected app to have a dependency on the '${reactNativePackage}' package`, - ); - - const reactNativePath = path.dirname( - require.resolve(reactNativePackage + "/package.json", { - // Ensures we'll be patching the React Native package actually used by the app - paths: [appPackageRoot], - }), + const reactNativePath = await resolveReactNativePath( + from, + reactNativePackage, ); if (!silent) { console.log(`Vendoring Hermes at ${HERMES_GIT_SHA}`); } - - const hermesPath = path.join(reactNativePath, "sdks", "node-api-hermes"); - if (force && fs.existsSync(hermesPath)) { - await oraPromise( - fs.promises.rm(hermesPath, { recursive: true, force: true }), - { - text: "Removing existing Hermes clone", - successText: "Removed existing Hermes clone", - failText: (error) => - `Failed to remove existing Hermes clone: ${error.message}`, - isSilent: silent, - }, - ); - } - if (!fs.existsSync(hermesPath)) { - try { - // GitHub allows fetching a reachable commit by SHA, so we can clone - // the pinned commit shallowly without downloading the whole history. - await oraPromise( - (async () => { - await fs.promises.mkdir(hermesPath, { recursive: true }); - const git = (args: string[]) => - spawn("git", args, { - cwd: hermesPath, - outputMode: "buffered", - }); - await git(["init", "--quiet"]); - await git(["remote", "add", "origin", HERMES_GIT_URL]); - await git(["fetch", "--depth", "1", "origin", HERMES_GIT_SHA]); - await git(["checkout", "--quiet", "FETCH_HEAD"]); - await git([ - "submodule", - "update", - "--init", - "--recursive", - "--depth", - "1", - ]); - })(), - { - text: `Cloning Hermes into ${prettyPath(hermesPath)}`, - successText: "Cloned Hermes", - failText: (err) => `Failed to clone Hermes: ${err.message}`, - isSilent: silent, - }, - ); - } catch (error) { - // A failed clone can leave a partial checkout behind, which would - // make the existence check above skip re-cloning on the next run. - await fs.promises.rm(hermesPath, { recursive: true, force: true }); - throw new UsageError("Failed to clone Hermes", { - cause: error, - fix: { - instructions: `Check the network connection and that the pinned Hermes commit ${chalk.bold(HERMES_GIT_SHA)} is still reachable on ${chalk.bold(HERMES_GIT_URL)}.`, - }, - }); - } - } + const hermesPath = await ensureHermesCheckout({ + reactNativePath, + force, + silent, + }); console.log(hermesPath); }), ); diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index 77d8b8f4..9078d26f 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -22,14 +22,15 @@ import { } from "../path-utils"; import { command as vendorHermes } from "./hermes"; +import { command as prebuiltHermes } from "./hermes-prebuilt"; import { packageNameOption, pathSuffixOption } from "./options"; import { linkModules, pruneLinkedModules, ModuleLinker } from "./link-modules"; import { ensureXcodeBuildPhase, createAppleLinker } from "./apple"; import { linkAndroidDir } from "./android"; -export const program = new Command("react-native-node-api").addCommand( - vendorHermes, -); +export const program = new Command("react-native-node-api") + .addCommand(vendorHermes) + .addCommand(prebuiltHermes); async function createLinker(platform: PlatformName): Promise { if (platform === "android") {