Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/prebuilt-hermes-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"react-native-node-api": minor
---

Stop compiling Hermes as part of every iOS app build. The Cocoapods integration
now resolves the pinned commit with `prebuilt-hermes` and hands the archive's
path to React Native through `HERMES_ENGINE_TARBALL_PATH` — so
`hermes-engine.podspec` vendors the prebuilt frameworks instead of running its
"Build Hermesc" and "Build Hermes" script phases.

Building Hermes from source remains available and is the faster loop while
iterating on Hermes itself, since Xcode then rebuilds it incrementally: set
`REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1` before `pod install`. Setting
`REACT_NATIVE_OVERRIDE_HERMES_DIR` or `HERMES_ENGINE_TARBALL_PATH` yourself
still takes precedence, and Android is unchanged.
21 changes: 21 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,27 @@ jobs:
ccache --set-config file_clone=true
ccache --set-config depend_mode=true
ccache --set-config inode_cache=true
# Hermes dominates the iOS build (17m15s of an 18m58s "Build test app"
# step, measured in #439) and only changes when the pinned commit does, so
# it is built once into an archive that `pod install` injects through
# HERMES_ENGINE_TARBALL_PATH. The archive name covers every input that
# changes its contents — the pinned commit, the React Native version whose
# JSI it is compiled against, the build type and the platforms — which
# makes it the cache key too.
- name: Resolve prebuilt Hermes name
id: hermes
run: echo "archive=$(pnpm exec react-native-node-api prebuilt-hermes --print name)" >> "$GITHUB_OUTPUT"
working-directory: apps/test-app
- name: Cache prebuilt Hermes
uses: actions/cache@v6
with:
path: ~/Library/Caches/react-native-node-api/hermes-prebuilt
key: ${{ steps.hermes.outputs.archive }}
# Explicit rather than left to `pod install`, so a cold cache shows up as
# its own step in the job log instead of as a mysteriously slow install.
- name: Build prebuilt Hermes
run: pnpm exec react-native-node-api prebuilt-hermes
working-directory: apps/test-app
# Must precede `pod install`: react-native-test-app embeds the resources
# declared in app.json when generating the workspace, skipping missing
# ones, and the app would then expect a Metro dev server at runtime.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ patch or workaround:

## Critical Build Dependencies

- **Vendored Hermes**: Builds Hermes from a pinned commit on the `static_h` branch, which carries Hermes' first-party Node-API implementation (`API/napi`, target `hermesNapi`). The pin lives in `packages/host/src/node/cli/hermes.ts` and is fetched by the `vendor-hermes` command.
- **Vendored Hermes**: Builds Hermes from a pinned commit on the `static_h` branch, which carries Hermes' first-party Node-API implementation (`API/napi`, target `hermesNapi`). The pin lives in `packages/host/src/node/cli/hermes.ts` and is fetched by the `vendor-hermes` command. On Apple platforms it is built once into an archive by the `prebuilt-hermes` command and injected into `pod install` through `HERMES_ENGINE_TARBALL_PATH`; Android and the opt-in `REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1` path build it from that checkout instead. See [docs/CLI.md](docs/CLI.md).
- **Prebuilt Binary Spec**: All tools must output to the exact naming scheme:
- Android: `*.android.node/` with jniLibs structure + `react-native-node-api-module` marker file
- iOS: `*.apple.node` (XCFramework renamed) + marker file
Expand Down
6 changes: 5 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Run `npx react-native-node-api help` or `npx react-native-node-api help <command

## `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.
Resolves an archive of the pinned Hermes, prebuilt for Apple platforms, and prints its path. This is what the Cocoapods integration uses by default: the path is handed to React Native through the `HERMES_ENGINE_TARBALL_PATH` environment variable, and `hermes-engine.podspec` then vendors the frameworks out of the archive instead of compiling Hermes as part of every app 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`):

Expand All @@ -35,10 +35,14 @@ The host architecture is part of it because `destroot/bin/hermesc` is a native b
- `--no-build` — Don't build the archive locally when none is published.
- `--print <property>` — Print `name`, `tag` or `url` of the archive instead of resolving it.

To build Hermes from source as part of the app build instead — which is the faster loop while iterating on Hermes itself, since Xcode then rebuilds it incrementally — set `REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1` before running `pod install`. Setting `REACT_NATIVE_OVERRIDE_HERMES_DIR` or `HERMES_ENGINE_TARBALL_PATH` yourself also takes precedence.

## `vendor-hermes [from]`

Clones the pinned commit of Hermes' `static_h` branch (which carries Hermes' first-party Node-API implementation) into the `sdks/node-api-hermes` directory of the app's `react-native` package, so the native build can compile against it. Prints the path to the vendored checkout on success.

This is how Hermes is built on Android, and on Apple when the from-source path described above is selected.

- `[from]` — Path to a file inside the app package. Defaults to the current working directory.
- `--react-native-package <package-name>` — The React Native package to vendor Hermes into. Defaults to `react-native`.
- `--silent` — Don't print anything except the final path. Defaults to `false`.
Expand Down
51 changes: 39 additions & 12 deletions packages/host/scripts/patch-hermes.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,51 @@
Pod::UI.warn "!!! CONFIGURING HERMES WITH NODE-API SUPPORT !!!"

if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].nil?
def get_react_native_package
if caller.any? { |frame| frame.include?("node_modules/react-native-macos/") }
return "react-native-macos"
elsif caller.any? { |frame| frame.include?("node_modules/react-native/") }
return "react-native"
else
raise "Unable to determine React Native package from call stack."
end
def node_api_react_native_package
if caller.any? { |frame| frame.include?("node_modules/react-native-macos/") }
return "react-native-macos"
elsif caller.any? { |frame| frame.include?("node_modules/react-native/") }
return "react-native"
else
raise "Unable to determine React Native package from call stack."
end
end

def node_api_run_cli(command, react_native_package)
args = [
command,
"--react-native-package", react_native_package,
"--silent", Pod::Config.instance.installation_root.to_s
].map { |arg| "'#{arg}'" }.join(" ")
result = `npx react-native-node-api #{args}`.strip
raise "Hermes setup failed: 'react-native-node-api #{command}' exited with #{$?.exitstatus}" unless $?.success?
result
end

VENDORED_HERMES_DIR ||= `npx react-native-node-api vendor-hermes --react-native-package '#{get_react_native_package()}' --silent '#{Pod::Config.instance.installation_root}'`.strip
ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] = VENDORED_HERMES_DIR
if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].nil? && ENV['HERMES_ENGINE_TARBALL_PATH'].nil?
react_native_package = node_api_react_native_package()
# Building from source keeps Hermes inside the Xcode build, where it rebuilds
# incrementally — the faster loop while iterating on Hermes itself. Otherwise
# the pinned commit is resolved to an archive built once and reused.
#
# react-native-macos stays on the source path: the archive is only produced
# and exercised for the iOS platforms today.
if ENV['REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE'].to_s == '1' || react_native_package == "react-native-macos"
ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] = node_api_run_cli("vendor-hermes", react_native_package)
else
ENV['HERMES_ENGINE_TARBALL_PATH'] = node_api_run_cli("prebuilt-hermes", react_native_package)
end
end

if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] && !ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].empty?
if Dir.exist?(ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'])
Pod::UI.info "[Node-API] Using overridden Hermes in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}"
Pod::UI.info "[Node-API] Building Hermes from source in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}"
else
raise "Hermes setup failed: Expected override to exist in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}"
end
elsif ENV['HERMES_ENGINE_TARBALL_PATH'] && !ENV['HERMES_ENGINE_TARBALL_PATH'].empty?
if File.exist?(ENV['HERMES_ENGINE_TARBALL_PATH'])
Pod::UI.info "[Node-API] Using prebuilt Hermes from #{ENV['HERMES_ENGINE_TARBALL_PATH'].inspect}"
else
raise "Hermes setup failed: Expected prebuilt archive to exist at #{ENV['HERMES_ENGINE_TARBALL_PATH'].inspect}"
end
end
Loading