From 7887733ab48aeff5c063cc97cc8d4dbf0b742097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 10 Aug 2026 22:27:41 +0200 Subject: [PATCH 01/24] Cut pre-releases from a "next" branch Enters changeset pre mode with the "rc" tag and lets the release workflow run on this branch, so 2.0.0-rc.N can be published under the "rc" dist tag while main keeps releasing stable versions. Pre mode is repo-wide state (.changeset/pre.json lists every package), which is why it lives here rather than on main. Exit pre mode before merging this branch back into main. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pre.json | 18 ++++++++++++++++++ .github/workflows/release.yml | 1 + 2 files changed, 19 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..d58b8f48 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,18 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@react-native-node-api/test-app": "0.2.2", + "@react-native-node-api/cli-utils": "0.1.4", + "cmake-file-api": "0.1.2", + "cmake-rn": "0.6.3", + "ferric-cli": "0.3.11", + "@react-native-node-api/ferric-example": "0.1.2", + "gyp-to-cmake": "0.5.3", + "react-native-node-api": "1.0.1", + "@react-native-node-api/node-addon-examples": "0.1.1", + "@react-native-node-api/node-tests": "0.1.1", + "weak-node-api": "0.1.1" + }, + "changesets": [] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d102a01..1648b639 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ on: push: branches: - main + - next # Deliberately no workflow-level concurrency: a publish waiting for its # deployment approval would hold the group and keep every later push from From fb7a3c008fb90eac93335054c184e41a63924e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 10 Aug 2026 22:32:56 +0200 Subject: [PATCH 02/24] Run Check on the next branch too Now that next is the default branch, pushes to it should get the same coverage main gets, including the jobs gated on being on the trunk. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/check.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 71fcf106..85bb269c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,7 @@ on: push: branches: - main + - next pull_request: types: [opened, synchronize, reopened] @@ -122,7 +123,7 @@ jobs: - run: pnpm run bootstrap - run: pnpm test weak-node-api-tests: - if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'weak-node-api') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'weak-node-api') strategy: fail-fast: false matrix: @@ -159,7 +160,7 @@ jobs: ctest --test-dir build --output-on-failure working-directory: packages/weak-node-api test-ios: - if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'Apple 🍎') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'Apple 🍎') name: Test app (iOS) runs-on: macos-latest steps: @@ -400,7 +401,7 @@ jobs: name: emulator-logcat path: apps/test-app/emulator-logcat.txt test-ferric-apple-triplets: - if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'Ferric 🦀') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'Ferric 🦀') name: Test ferric Apple triplets runs-on: macos-latest steps: From c3c321eb4311ee847c6664bcb335e9de857f5206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 11 Aug 2026 06:36:39 +0200 Subject: [PATCH 03/24] Adopt Hermes' first-party Node-API (static_h) (#372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Phase 1: vendor static_h Hermes, bump to RN 0.87 nightly Begin migrating off the kraenhansen/hermes fork + JSI-patching path toward Hermes' first-party Node-API (the static_h branch). - vendor-hermes: shallow-fetch facebook/hermes at pinned static_h SHA 0ae42446d1ae669508368b0a18e60c789f76735d; drop the JSI-header copy step - patch-hermes.rb: rely on REACT_NATIVE_OVERRIDE_HERMES_DIR alone to trigger build-from-source; drop the no-op BUILD_FROM_SOURCE var and the obsolete RCT_USE_PREBUILT_RNCORE / JSI-patch guard - CxxNodeApiHostModule: stub env=nullptr (real env arrives in Phase 2 via hermes_napi_create_env) - bump react-native to 0.87.0-nightly-20260529-88857d22f (+ test-app deps, react-native-test-app 5.x); regenerate lockfile - RN 0.87 fallout: add @types/babel__core, fix test-app tsconfig extends for the tightened @react-native/typescript-config exports map, delete the podspec test asserting the removed guard Co-Authored-By: Claude Opus 4.8 (1M context) * Resolve Xcode app project resiliently in workspaces react-native-test-app 5.x generates the app's ReactTestApp.xcodeproj under the nearest node_modules, which in a workspace is the app-local node_modules (apps/test-app/node_modules/.generated), not the hoisted root. The workspace can also accumulate stale references to a project under a different node_modules. findXcodeProject took the first fileRef unconditionally, which could be the stale (non-existent) reference or the Pods project. Resolve every app project reference and pick the first whose project.pbxproj exists on disk, ignoring Pods.xcodeproj. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix test-app tooling for RN 0.87 / Metro 0.84 - Bump @rnx-kit/metro-config to ^2.2.4: 2.1.1 called metro-config's exclusionList as a bare function, but Metro 0.84 changed that module to a { default } export, breaking `react-native start`. - Gradle wrapper bumped to 9.3.1 by react-native-test-app 5.x's configureGradleWrapper during pod install (RN 0.87 alignment). Co-Authored-By: Claude Opus 4.8 (1M context) * Phase 2: create a real Node-API env via hermes_napi_create_env Replace the `env = nullptr` stub in CxxNodeApiHostModule with a real Node-API environment: cast the JSI runtime to `IHermes`, read the underlying `vm::Runtime*` via `getVMRuntimeUnsafe()`, and create the env with `hermes_napi_create_env(vm, nullptr)`. The env is owned by the runtime and cached on the module (shared across all addons). This flips the Phase 1 baseline abort (`assert(status == napi_ok)` right after `napi_create_object(env=nullptr, …)`) green: with `MOCHA_REMOTE_CONTEXT=allTests` the iOS-sim suite now reports 14 passing (node-addon-examples getting-started incl. the Rust ferric addon, buffers, async, and a js-native-api node-test). Linking note: the RN `hermesvm` framework force-loads `hermesNapi`, and the public `hermes_napi_*` entry points are exported from it as long as Hermes is built from a checkout that includes facebook/hermes #2044 ("Export public hermes_napi entry points with NAPI macros") — which the pinned SHA (0ae42446) already contains. No pod-side linker surgery or source patching is required; just ensure the vendored checkout is actually at the pinned SHA (a stale pre-#2044 checkout is what stripped the symbol during bring-up). Co-Authored-By: Claude Opus 4.8 (1M context) * Phase 2: bump Node-API to v10, drop engine/runtime split All Node-API symbols are now sourced from Hermes' hermesNapi, so the old engine (js_native_api → libhermes.so) / runtime (node_api → libnode-api-host.so) distinction and the hand-maintained IMPLEMENTED_RUNTIME_FUNCTIONS allow-list are obsolete. - weak-node-api: getNodeApiFunctions defaults to v10 and no longer computes the dead `kind`/`libraryPath` fields; CMake compiles the generated weak_node_api.cpp at NAPI_VERSION=10 (145 → 155 symbols, adding the v9/v10 node_api_* surface). - generate-injector.mts: bind every symbol (no filter) and emit `#include ` first so the injector TU also compiles at v10. - Versions.hpp: guarded bump to NAPI_VERSION 10. Regenerated (gitignored) WeakNodeApiInjector.cpp + weak-node-api/generated now expose all 155 symbols incl. TSFN and napi_make_callback. Verified: build, prettier, lint, workspace unit tests, and the weak-node-api native build + ctest all pass. iOS e2e pending (rides the cold re-vendor). Co-Authored-By: Claude Opus 4.8 (1M context) * vendor-hermes: export public hermes_napi_* entry points The clean Hermes build at the pinned SHA does NOT export hermes_napi_create_env (and the other hermes_napi_* entry points). They are declared in API/napi/hermes_napi.h with NAPI_EXTERN (visibility "default") but — unlike the sibling js_native_api.h / node_api.h headers — without any extern "C" wrapping, so they get C++ linkage. The mangled C++ symbols stay out of the framework's dynamic export table under Hermes' global -fvisibility=hidden, and a from-scratch build fails at the app link with "Undefined symbol: hermes_napi_create_env". vendor-hermes now wraps the hermes_napi.h declarations in EXTERN_C_START / EXTERN_C_END (both available via the node_api.h include), giving the entry points C linkage so they export under their unmangled C names. This mirrors the upstream fix in facebook/hermes#2106. The patch is idempotent (guarded on EXTERN_C_START) and asserts its anchors exist so a future Hermes bump fails loudly rather than silently no-op'ing. Also ignore **/build-tests/** in ESLint (CMake writes compiler_depend.ts dependency files there that aren't real TypeScript). Co-Authored-By: Claude Opus 4.8 (1M context) * vendor-hermes: apply prettier formatting Collapse the single-argument `.replace()` call in patchHermesNapiVisibility onto one line to satisfy prettier:check (fixup for the hermes_napi patch). Co-Authored-By: Claude Opus 4.8 (1M context) * Regenerate pnpm-lock.yaml for RN 0.87 dependency bumps Rebased onto main after the npm->pnpm migration (#381). The original PR's two package-lock.json maintenance commits (restore public registry URLs, restore pruned optional platform binaries) are dropped: both addressed npm-specific lockfile problems that no longer exist under pnpm. Regenerate pnpm-lock.yaml against the RN 0.87 nightly / react-native-test-app 5.x / @rnx-kit/metro-config bumps so the lockfile matches the workspace manifests. Verified with pnpm install --frozen-lockfile. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * vendor-hermes: advance pin to include upstream napi C-linkage fix Move the pinned Hermes commit forward from 0ae42446 to efcf68e2 on the static_h branch (a descendant, 18 commits ahead). The only relevant change in that range is facebook/hermes#2106 "give hermes_napi.h public API C linkage", which wraps the public hermes_napi_* entry points in extern "C". That is exactly the fix we were applying locally after cloning: without C linkage the mangled hermes_napi_create_env symbol stayed out of the framework export table under Hermes' global -fvisibility=hidden. Now that the fix is upstream at the pinned commit, drop patchHermesNapiVisibility and its header-anchor constants entirely — the vendored checkout exports the entry points as-is. No commit in the bumped range touches getVMRuntimeUnsafe or the IHermes JSI interface we depend on, so the unstable-accessor rationale for pinning still holds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * host: match hermes_napi_create_env C linkage after upstream #2106 The pinned Hermes commit now includes facebook/hermes#2106, which wraps the public hermes_napi_* entry points in extern "C". Hermes therefore exports the unmangled C symbol for hermes_napi_create_env. CxxNodeApiHostModule forward-declares that entry point (to avoid including Hermes' node_api.h) but did so with C++ linkage, so it referenced the mangled name. After the pin bump the two no longer matched and the iOS app failed to link with "Undefined symbol: hermes_napi_create_env". Wrap the forward declaration in extern "C" so the reference resolves to the exported unmangled symbol. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * android: inject ExecOperations for Gradle 9 compatibility (#386) RN 0.87 bumps the Gradle wrapper to 9.x, which removed Project.exec(). The linkNodeApiModules task used the bare `exec {}` closure in its doLast action, failing every Android build (and gradle.test.ts on all platforms) with "Could not find method exec()". Inject the ExecOperations service via an @Inject-annotated interface and call injectedExecOps.execOps.exec {} instead. Greens the ubuntu and macOS unit-test lanes. Windows surfaces a separate, pre-existing RN 0.87 / Gradle 9 issue (missing react-native/tmp projectDir) tracked separately. * android: patch RN settings.gradle.kts /tmp projectDir for Windows (#387) * android: patch RN settings.gradle.kts /tmp projectDir for Windows The Windows unit-test lane failed configuring the React Native build-from- source composite build: Configuring project ':packages:react-native' without an existing directory is not allowed. The configured projectDirectory '...\react-native\tmp' does not exist React Native's own settings.gradle.kts declares the intermediate container projects :packages and :packages:react-native with projectDir = file("/tmp"), purely to satisfy Gradle 9's rule that every project in a path have an existing folder. "/tmp" exists on the posix CI hosts but on Windows it is not an absolute path, so Gradle resolves it to a non-existent \tmp and the build fails before any task runs. This is why only windows-latest was red while ubuntu and macOS passed. Add a pnpm patch replacing file("/tmp") with file(System.getProperty("java.io.tmpdir", "/tmp")): the JVM temp dir is "/tmp" on posix and %TEMP% on Windows, both of which always exist. Remove the patch once React Native stops hardcoding "/tmp" upstream. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * android: point RN /tmp patch at the merged upstream fix The upstream fix landed on react-native main as 908872a6 (2026-07-28, facebook/react-native#57706), after the 0.87 branch cut — so 0.87-stable does not carry it. Record that in the patch comment so the removal gate is a concrete react-native version rather than "once upstream fixes it". Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude * host: apply the Kotlin plugin only when built-in Kotlin is unavailable AGP 9 ships built-in Kotlin support and enables it by default, which registers the `kotlin` extension itself. Applying `kotlin-android` on top of that fails the consumer's build with "Cannot add extension with name 'kotlin'", so any consumer who has migrated off the `builtInKotlin=false` opt-out currently cannot build against this package. Gate the plugin on the AGP major version and the consumer's opt-out, so the library works both for consumers still on AGP 8 (or opted out while they migrate) and for those already on built-in Kotlin. React Native's own ReactAndroid no longer applies the Kotlin plugin either, as of 0.87. Reuses the `com.android.Version` idiom already used by supportsNamespace(). Co-Authored-By: Claude Opus 5 (1M context) * deps: bump react-native to 0.87.0-rc.4 Moves off the 0.87.0-nightly-20260529 pin onto the 0.87 release candidate. The motivating change is AGP: the nightly still resolved AGP 8.12, while AGP 9.2.1 landed on the 0.87 line in mid-June. AGP 9 is what react-native-test-app assumes for React Native >= 0.87 (it forces Gradle 9.4.1 and then uses the built-in Kotlin `kotlin {}` extension), so the test app could not configure against the old pin. The Windows `/tmp` projectDir patch is unchanged — settings.gradle.kts is byte-identical between the two versions (same blob 2036e0f), so only the file name and the patchedDependencies key move. The fix for it is still main-only, so the patch stays until we are on 0.88+. Also switches the two React Native facing tsconfigs to nodenext module resolution. 0.87.0-rc.4 drops react-native's top-level `types` field and flips the default `types` export condition to the generated strict API, neither of which the node10 resolution inherited from @tsconfig/react-native can see — the package stopped resolving entirely (TS2688). @tsconfig/react-native is stale at every published version through 3.0.9, so there is nothing to bump there. Emit is unaffected: both projects still produce CommonJS. The strict API exports TurboModule and TurboModuleRegistry, and still references react-native's globals, so console/require stay typed. Co-Authored-By: Claude Opus 5 (1M context) * test-app: adopt built-in Kotlin on Android, opt out of the AGP 9 DSL With React Native 0.87 the test app builds against AGP 9.2.1, where built-in Kotlin is enabled by default. Nothing in the build needs the Kotlin plugin any more: ReactAndroid dropped it upstream, react-native-test-app's modules are gated on it, and react-native-node-api now only applies it when built-in Kotlin is unavailable. So unlike the React Native app template, we do not set `android.builtInKotlin=false`. The new DSL is a different matter and stays opted out: both of react-native-test-app's Gradle modules still use the old one, and that is third-party code. AGP 10 removes this opt out, so it is tracked in #389 along with the upstream code that has to migrate first. Also pins the Gradle wrapper at 9.4.1, which react-native-test-app rewrites it to at run time for React Native >= 0.87 — pinning it ourselves keeps CI from building with a dirty working tree. Co-Authored-By: Claude Opus 5 (1M context) * deps: bump react-native to a 0.88 nightly and drop the Windows patch React Native 57706 ("Fix build-from-source on Windows: use JVM temp dir instead of hardcoded /tmp", 908872a6, 2026-07-28) landed on main after the 0.87 branch cut, so it ships on the 0.88 line and not in 0.87.0-rc.4. Verified in the published artifact, not just the tree: the tarball for 0.88.0-nightly-20260809-db662caea carries the fix in settings.gradle.kts, the exact file (and path) we were patching. Our patch is now redundant. Dropping it is what makes Android build. Patching a dependency makes pnpm encode the patch hash into the virtual store directory as `..._patch_hash=`, and prefab — which the Android Gradle plugin runs over react-native's package directory — parses a positional path containing `=` as an option name and dies with "Error: no such option". That is https://github.com/google/prefab/issues/187, open since March and hitting every pnpm user with a patched dependency. With no patched dependencies there is no `=` in the store, so the bug goes untriggered. Requires react-native-test-app >= 5.4.8, which widened its peer range to `0.76 - 0.87 || >=0.88.0-0 <0.88.0` — a prerelease window covering exactly these nightlies. 5.4.5 did not accept 0.88 at all, so the floor moves up. Everything the AGP 9 work depends on is unchanged on this line: AGP 9.2.1, Kotlin 2.2.0, and react-native-test-app still resolves Gradle 9.4.1 for 0.88, matching the pinned wrapper. Co-Authored-By: Claude Opus 5 (1M context) * host: link the renamed hermesvm prefab module on Android React Native renamed the prefab module published by `hermes-engine` from `libhermes` to `hermesvm` between 0.81 and 0.83 — the Android counterpart of the `hermesvm` framework this branch already links against on Apple platforms. This CMakeLists has been on `libhermes` since #308, which was correct while the repo targeted 0.81, and stayed behind when this branch jumped to 0.87/0.88. Without it CMake fails to configure: Target "node-api-host" links to target "hermes-engine::libhermes" but the target was not found. Co-Authored-By: Claude Opus 5 (1M context) * test-app: opt out of built-in Kotlin after all fa4424b deliberately left `android.builtInKotlin` unset, on the reasoning that nothing in the build still needs the Kotlin plugin. That reasoning was wrong, and only a real Android build showed it: ComponentActivity.kt:33:9 Unresolved reference 'ComponentActivityDelegate' react-native-test-app's app module pulls in version-specific sources with `main.java.srcDirs += [...]` — src/reactactivitydelegate-0.75/java, src/reactapplication-0.76/java, src/camera/java and others. The Kotlin plugin compiles the Kotlin in those directories; AGP's built-in Kotlin only picks up the standard source directories, so every symbol defined in an added one goes unresolved (`testApp`, `reactHost`, `canUseCamera`, `ComponentBottomSheetDialogFragment`, …). Their `useBuiltInKotlin` gate avoids the plugin-conflict failure but does not make the module itself built-in-Kotlin ready, which is why their template ships this opt out. react-native-node-api itself stays built-in-Kotlin ready via the conditional in ee41927 — with this flag set it applies the Kotlin plugin, and for a consumer on built-in Kotlin it steps aside. This is only about the test harness. Co-Authored-By: Claude Opus 5 (1M context) * test-app: fail the Android run as soon as the app crashes `mocha-remote` waits indefinitely for a client to connect and has no notion of the app dying. When the test app crashed on startup, nothing ever connected: the run sat idle until the 75 minute step timeout, with the actual cause — a `FATAL EXCEPTION` one second after `am start` — only visible by downloading the logcat artifact afterwards. Add a watchdog that follows `adb logcat -b crash` alongside the app and exits non-zero when the crash buffer names the test app, printing the stack trace inline. `concurrently --kill-others-on-fail` then tears down Metro and the app run, and `mocha-remote` inherits the failing exit code, so a startup crash fails the job in seconds rather than in an hour. It deliberately only reacts to crashes — an app that hangs or never launches still falls back to the job timeout. Co-Authored-By: Claude Opus 5 (1M context) * test-app: don't let the crash watchdog hold the step's stderr open The watchdog correctly failed the run on the first crash it saw, but the job kept hanging afterwards: `@actions/exec` — how the emulator-runner action runs each line of the step's script — resolves a command only once the stdio streams it handed out are closed, and the `adb logcat` child inherited our stderr. Exiting orphaned it, so that pipe stayed open and the step waited on a dangling file descriptor long after everything else had been torn down. Give the child no stderr of its own and kill it on the way out. Verified by spawning the watchdog the way `@actions/exec` does: before, the process exited after 1.6s but its stdio never closed; now both happen together. Co-Authored-By: Claude Opus 5 (1M context) * vendor-hermes: advance the pin past Hermes' JSI_UNSTABLE default flip The Android test app crashed on startup, in `NodeApiHostPackage.`: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "_ZTIN8facebook3jsi10SerializedE" referenced by ".../libhermesvm.so" com.facebook.soloader.SoLoaderDSONotFoundError: couldn't find DSO to load: libhermesvm.so That symbol is `typeinfo for facebook::jsi::Serialized`. JSI's `Serialized` / `ISerialization` APIs sit behind `#ifdef JSI_UNSTABLE`, and React Native never defines it when building the `libjsi.so` it ships in the ReactAndroid AAR. Our pinned Hermes still defaulted `JSI_UNSTABLE` to ON, so `hermesvm` compiled those APIs in and referenced symbols that nothing in the APK defines. Apple builds are unaffected because JSI is compiled into the `hermesvm` framework itself; on Android the two are separate shared libraries, and RN's hermes-engine build imports `libjsi.so` rather than packaging the copy Hermes builds for itself. facebook/hermes 5a795c9f8 ("Fix: JSI_UNSTABLE CMake flag should be OFF by default") is the immediate child of the previous pin, so this picks up the one-line fix and nothing else. Verified by rebuilding the release APK for x86_64: `libhermesvm.so` no longer references `jsi::Serialized`, and every undefined JSI symbol it does have is defined by a library shipped in the APK. Co-Authored-By: Claude Opus 5 (1M context) * host: create one Node-API env per addon Node creates a fresh napi_env for every addon it loads (see the "Create a new napi_env for this specific module" branch of napi_module_register_by_symbol in src/node_api.cc), because the env holds addon-scoped state: instance data, last error info and the addon's Node-API version. Sharing one env across all addons breaks that isolation most visibly for instance data, where the single slot on napi_env__ means two addons built on Napi::Addon clobber each other — the second registration finalizes the first addon's object, and Addon::Unwrap then casts the wrong type. Move the env onto the addon record and create it during initialization. hermes_napi_create_env() allocates a fresh env per call and registers its teardown with the vm::Runtime, so ownership is unchanged: each env is torn down with the runtime. The call invoker registry is already keyed by env, so it needs no change beyond dropping entries when an env goes away — with an env per addon those would otherwise accumulate across reloads. Co-Authored-By: Claude Opus 5 (1M context) * Add changeset for the static_h Node-API adoption Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the vendored Hermes instead of a patched one Node-API is implemented in Hermes itself now, so nothing is patched or forked: we build from a pinned commit on the static_h branch. Also corrects HOW-IT-WORKS, which described the removed jsi::Runtime::createNodeApiEnv. Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the Node-API host struct in HOW-IT-WORKS Hermes implements both js_native_api.h and node_api.h; what it can't supply without libuv are the scheduling primitives, which the host passes in as a hermes_napi_host struct. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/eighty-moons-shave.md | 14 + AGENTS.md | 2 +- README.md | 12 +- apps/test-app/android/gradle.properties | 20 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- apps/test-app/package.json | 17 +- .../scripts/android-crash-watchdog.mts | 101 ++ apps/test-app/tsconfig.json | 2 +- apps/test-app/tsconfig.node-scripts.json | 2 +- docs/ANDROID.md | 6 +- docs/HOW-IT-WORKS.md | 16 +- eslint.config.js | 1 + package.json | 2 +- packages/host/android/CMakeLists.txt | 2 +- packages/host/android/build.gradle | 30 +- packages/host/cpp/CxxNodeApiHostModule.cpp | 38 +- packages/host/cpp/CxxNodeApiHostModule.hpp | 8 + packages/host/cpp/RuntimeNodeApiAsync.cpp | 11 + packages/host/cpp/Versions.hpp | 6 +- packages/host/package.json | 2 +- packages/host/scripts/generate-injector.mts | 29 +- packages/host/scripts/patch-hermes.rb | 33 +- packages/host/scripts/patch-xcode-project.rb | 6 +- packages/host/src/node/cli/hermes.ts | 124 +- packages/host/src/node/cli/xcode-helpers.ts | 37 +- packages/host/src/node/podspec.test.ts | 24 - packages/host/tsconfig.react-native.json | 2 + .../node-addon-examples/tsconfig.tests.json | 3 +- packages/weak-node-api/CMakeLists.txt | 2 +- .../weak-node-api/src/node-api-functions.ts | 18 +- pnpm-lock.yaml | 1198 +++++------------ 31 files changed, 708 insertions(+), 1062 deletions(-) create mode 100644 .changeset/eighty-moons-shave.md create mode 100644 apps/test-app/scripts/android-crash-watchdog.mts delete mode 100644 packages/host/src/node/podspec.test.ts diff --git a/.changeset/eighty-moons-shave.md b/.changeset/eighty-moons-shave.md new file mode 100644 index 00000000..0d68f8c8 --- /dev/null +++ b/.changeset/eighty-moons-shave.md @@ -0,0 +1,14 @@ +--- +"react-native-node-api": major +"weak-node-api": minor +--- + +Adopt Hermes' first-party Node-API (the `hermesNapi` target on the `static_h` +branch) instead of patching Hermes with our own implementation. Addons now run +against a real Node-API environment created with `hermes_napi_create_env()`, one +per addon as in Node, and Node-API is bumped from v8 to v10. + +This drops support for React Native 0.79–0.81: the vendored Hermes is built from +a pinned `static_h` commit and requires the Hermes build scripts shipped with +React Native 0.87 and later. Older React Native versions are still served by +previously published releases. diff --git a/AGENTS.md b/AGENTS.md index 5cb3f1c4..ca13226d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ patch or workaround: ## Critical Build Dependencies -- **Custom Hermes**: Currently depends on a patched Hermes with Node-API support (see [facebook/hermes#1377](https://github.com/facebook/hermes/pull/1377)) +- **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. - **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 diff --git a/README.md b/README.md index a88f3fef..308d5407 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ ## How does this work? > [!IMPORTANT] -> This library is currently dependent on a custom version of Hermes and therefore has a very limited range of supported React Native versions. -> Once the [PR adding Node-API support to Hermes](https://github.com/facebook/hermes/pull/1377) merges, we expect this restriction to be lifted. +> This library builds Hermes from a pinned commit on its `static_h` branch, which carries [Hermes' first-party Node-API implementation](https://github.com/facebook/hermes/tree/static_h/API/napi). +> React Native has not shipped that Hermes yet, so the range of supported React Native versions is very limited — see the `react-native` peer dependency of the [host package](packages/host/package.json) for the version we currently build against. +> We expect this restriction to be lifted once React Native ships a Hermes with Node-API included. > [!NOTE] > This library only works for iOS and Android and we want to eventually support React Native for Windows, macOS, visionOS and other out-of-tree platforms too. @@ -35,10 +36,9 @@ This mono-repository hosts the development of a few packages: Responsible for adding Node-API support to your React Native application: -- Declares a Podspec which downloads a special version of Hermes, with Node-API support, - - instructing React Native's Hermes Podspecs to compile from this custom source-code. - - patching React Native's JSI copy, with the updates introduced by our special version of Hermes. - - we expect this to eventually be removed, as Node-API support gets merged into Hermes upstream. +- Declares a Podspec which vendors Hermes from a pinned commit on its `static_h` branch, where Node-API is implemented, + - instructing React Native's Hermes Podspecs to compile from this checkout. + - we expect this to eventually be removed, as React Native starts shipping a Hermes with Node-API included. - Automatically discovers and adds Node-API binaries, matching the [the prebuilt binary specification](./docs/PREBUILDS.md) - This is driven by the platform specific build tools (through the Podspec on iOS and eventually Gradle on Android) - Implements a TurboModule with a `requireNodeAddon` function responsible for diff --git a/apps/test-app/android/gradle.properties b/apps/test-app/android/gradle.properties index 08ca7f53..df4582c0 100644 --- a/apps/test-app/android/gradle.properties +++ b/apps/test-app/android/gradle.properties @@ -50,4 +50,22 @@ react.buildFromSource=true #ANDROID_NDK_VERSION=26.1.10909125 # Version of Kotlin to build against. -#KOTLIN_VERSION=1.8.22 \ No newline at end of file +#KOTLIN_VERSION=1.8.22 + +# Opt out of built-in Kotlin and the new DSL, both of which ship enabled in +# AGP 9. AGP 10 removes both opt outs, so they are on borrowed time — tracked in +# https://github.com/callstackincubator/react-native-node-api/issues/389, which +# links the upstream code that has to migrate first. +# +# Both are blocked on react-native-test-app, not on us. Its Gradle modules still +# use the old DSL (`compileSdkVersion`, `lintOptions`), and its app module adds +# version-specific Kotlin sources through `main.java.srcDirs +=`. The Kotlin +# plugin compiles those; AGP's built-in Kotlin only picks up the standard source +# directories, so building without this leaves every symbol defined in an added +# directory (`testApp`, `ComponentActivityDelegate`, …) unresolved. +# +# Note that react-native-node-api itself is built-in-Kotlin ready — it applies +# the Kotlin plugin only when built-in Kotlin is unavailable — so this is purely +# about the test harness. +android.builtInKotlin=false +android.newDsl=false diff --git a/apps/test-app/android/gradle/wrapper/gradle-wrapper.properties b/apps/test-app/android/gradle/wrapper/gradle-wrapper.properties index d4081da4..c61a118f 100644 --- a/apps/test-app/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/test-app/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/apps/test-app/package.json b/apps/test-app/package.json index b4dea4aa..1383025e 100644 --- a/apps/test-app/package.json +++ b/apps/test-app/package.json @@ -6,10 +6,11 @@ "scripts": { "metro": "react-native start --no-interactive", "android": "react-native run-android --no-packager --active-arch-only", + "android:crash-watchdog": "node scripts/android-crash-watchdog.mts", "ios": "react-native run-ios --no-packager", "pod-install": "cd ios && pod install", "mocha-and-metro": "mocha-remote --watch -- react-native start", - "test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' --", + "test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' npm:android:crash-watchdog --", "test:android:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:android -- ", "test:android:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:android -- ", "test:android:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:android -- ", @@ -30,20 +31,20 @@ "@react-native-node-api/ferric-example": "workspace:*", "@react-native-node-api/node-addon-examples": "workspace:*", "@react-native-node-api/node-tests": "workspace:*", - "@react-native/babel-preset": "0.81.4", - "@react-native/metro-config": "0.81.4", - "@react-native/typescript-config": "0.81.4", - "@rnx-kit/metro-config": "^2.1.1", + "@react-native/babel-preset": "0.88.0-nightly-20260809-db662caea", + "@react-native/metro-config": "0.88.0-nightly-20260809-db662caea", + "@react-native/typescript-config": "0.88.0-nightly-20260809-db662caea", + "@rnx-kit/metro-config": "^2.2.4", "@types/mocha": "^10.0.10", "@types/react": "^19.1.0", "concurrently": "^9.1.2", "mocha": "^11.6.0", "mocha-remote-cli": "^1.13.2", "mocha-remote-react-native": "^1.13.2", - "react": "19.1.0", - "react-native": "0.81.4", + "react": "19.2.3", + "react-native": "0.88.0-nightly-20260809-db662caea", "react-native-node-api": "workspace:*", - "react-native-test-app": "^4.4.7", + "react-native-test-app": "^5.4.8", "weak-node-api": "workspace:*" } } diff --git a/apps/test-app/scripts/android-crash-watchdog.mts b/apps/test-app/scripts/android-crash-watchdog.mts new file mode 100644 index 00000000..40f2423a --- /dev/null +++ b/apps/test-app/scripts/android-crash-watchdog.mts @@ -0,0 +1,101 @@ +/** + * Fails the Android test run as soon as the app crashes. + * + * `mocha-remote` waits indefinitely for a client to connect and has no notion + * of the app dying: when the app crashes on startup, nothing ever connects and + * the run hangs until the CI job hits its timeout — 68 minutes of an emulator + * idling for a crash that happened one second after `am start`. + * + * Run alongside the app (through `concurrently --kill-others-on-fail`), this + * turns such a crash into an immediate failure with the stack trace inlined in + * the log, instead of a timeout with the cause buried in a logcat artifact. + * + * It only reacts to crashes: a hung or never-launched app still relies on the + * job timeout. + */ +import cp from "node:child_process"; +import readline from "node:readline"; + +// The application id used by react-native-test-app, which the CI workflow also +// hardcodes when uninstalling any leftover copy of the app. +const APP_ID = "com.microsoft.reacttestapp"; + +// How long to keep reading after the first line mentioning the app, to capture +// the rest of the stack trace before exiting. +const TRACE_GRACE_MS = 1000; + +/** + * Runs adb, resolving false if it couldn't run at all (not installed, no + * device, etc). The watchdog stays out of the way in that case: the build or + * the run itself will fail with a better message than anything we could add. + */ +function adb(...args: string[]): Promise { + return new Promise((resolve) => { + const child = cp.spawn("adb", args, { stdio: "ignore" }); + child.on("error", () => resolve(false)); + child.on("close", (code) => resolve(code === 0)); + }); +} + +function skip(reason: string): never { + console.warn(`[crash-watchdog] Not watching for crashes: ${reason}`); + process.exit(0); +} + +async function main() { + if (!(await adb("wait-for-device"))) { + skip("failed to wait for an adb device"); + } + + // Drop any crash from an earlier run, so we only react to this one. The app + // hasn't been installed yet at this point, so this can't discard a crash we + // care about. + await adb("logcat", "-b", "crash", "-c"); + + // Never let this child inherit our stderr: GitHub's `@actions/exec` resolves + // a step only once the stdio streams it handed out are closed, so an adb + // orphaned by our exit would hold the step open long after we failed it. + const logcat = cp.spawn("adb", ["logcat", "-b", "crash"], { + stdio: ["ignore", "pipe", "ignore"], + }); + + // ... and don't leave it running at all: killing it on the way out covers + // both failing on a crash and getting terminated once the tests pass. + process.on("exit", () => logcat.kill("SIGKILL")); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => process.exit(0)); + } + + // The line naming the app is preceded by the header of the crash it belongs + // to ("FATAL EXCEPTION: main"), so keep a few lines of lead-in around. + const LEAD_IN_LINES = 5; + const trace: string[] = []; + let crashed = false; + + logcat.on("error", () => skip("failed to spawn adb logcat")); + logcat.on("close", () => { + // Getting killed once the tests pass is the expected way for this to end. + if (!crashed) { + skip("adb logcat exited"); + } + }); + + for await (const line of readline.createInterface({ input: logcat.stdout })) { + trace.push(line); + if (crashed) { + continue; + } else if (line.includes(APP_ID)) { + crashed = true; + // Give the rest of the stack trace a moment to arrive before printing it. + setTimeout(() => { + console.error(`\n[crash-watchdog] ${APP_ID} crashed:\n`); + console.error(trace.join("\n")); + process.exit(1); + }, TRACE_GRACE_MS); + } else if (trace.length > LEAD_IN_LINES) { + trace.shift(); + } + } +} + +await main(); diff --git a/apps/test-app/tsconfig.json b/apps/test-app/tsconfig.json index 42a8c0b4..712ac620 100644 --- a/apps/test-app/tsconfig.json +++ b/apps/test-app/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@react-native/typescript-config/tsconfig.json", + "extends": "@react-native/typescript-config", "compilerOptions": { "types": ["react-native", "mocha"] }, diff --git a/apps/test-app/tsconfig.node-scripts.json b/apps/test-app/tsconfig.node-scripts.json index 66e44e08..9ce8f1da 100644 --- a/apps/test-app/tsconfig.node-scripts.json +++ b/apps/test-app/tsconfig.node-scripts.json @@ -7,5 +7,5 @@ "rootDir": "scripts", "types": ["node"] }, - "include": ["scripts/**/*.ts"] + "include": ["scripts"] } diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 31b5479b..4b285812 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -2,7 +2,7 @@ ## Building Hermes from source -Because we're using a version of Hermes patched with Node-API support, we need to build React Native from source. +Because we build Hermes from source (a pinned commit carrying its Node-API implementation), we need to build React Native from source too. Follow [the React Native documentation on how to build from source](https://reactnative.dev/contributing/how-to-build-from-source#update-your-project-to-build-from-source). @@ -23,7 +23,7 @@ In particular, you will have to edit the `android/settings.gradle` file as follo > + } > ``` -To download our custom version of Hermes, you need to run from your app package: +To fetch the pinned Hermes, you need to run from your app package: ``` npx react-native-node-api vendor-hermes @@ -39,7 +39,7 @@ export REACT_NATIVE_OVERRIDE_HERMES_DIR=$(npx react-native-node-api vendor-herme ## Cleaning your React Native build folders -If you've accidentally built your app without Hermes patched, you can clean things up by deleting the `ReactAndroid` build folder. +If you've accidentally built your app without the vendored Hermes, you can clean things up by deleting the `ReactAndroid` build folder. ``` rm -rf node_modules/react-native/ReactAndroid/build diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index cd4ae854..b78f4686 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -39,16 +39,22 @@ The native implementation of `requireNodeAddon` is responsible for loading the d In any case the native code stores the initialization function in a data-structure. -## `react-native-node-api` creates a `node_env` and initialize the Node-API module +## `react-native-node-api` creates a `napi_env` and initialize the Node-API module -The initialization function of a Node-API module expects a `node_env`, which we create by calling `createNodeApiEnv` on the `jsi::Runtime`. +The initialization function of a Node-API module expects a `napi_env`, which we create by calling `hermes_napi_create_env` with the low-level Hermes VM runtime behind the `jsi::Runtime`. As in Node.js, each addon gets its own environment. ## The library's C++ code initialize the `exports` object -An `exports` object is created for the Node-API module and both the `napi_env` and `exports` object is passed to the Node-API module's initialization function and the third party code is able to call the Node-API free functions: +An `exports` object is created for the Node-API module and both the `napi_env` and `exports` object is passed to the Node-API module's initialization function and the third party code is able to call the Node-API free functions. -- The engine-specific functions (see [js_native_api.h](https://github.com/nodejs/node/blob/main/src/js_native_api.h)) are implemented by the `jsi::Runtime` (currently only Hermes supports this). -- The runtime-specific functions (see [node_api.h](https://github.com/nodejs/node/blob/main/src/node_api.h)) are implemented by `react-native-node-api`. +Hermes implements both halves of Node-API: the engine-specific functions (see [js_native_api.h](https://github.com/nodejs/node/blob/main/src/js_native_api.h)) and the runtime-specific ones (see [node_api.h](https://github.com/nodejs/node/blob/main/src/node_api.h)). Node.js implements the latter on top of libuv, which React Native doesn't have — so Hermes leaves the host to supply the primitives they need, as a `hermes_napi_host` struct passed when the environment is created: + +- `post_work` / `cancel_work` — run a unit of work on a worker thread and report back on the JavaScript thread. This is what backs `napi_create_async_work` and friends. +- `post_task` — schedule a callback on the JavaScript thread, used by thread-safe functions to dispatch queued calls. +- `ref_loop` / `unref_loop` — keep the event loop alive while a thread-safe function is referenced, modelling libuv's "ref" semantics. +- `fatal_exception` and, for embedders that have one, a libuv loop pointer for `napi_get_uv_event_loop`. + +`react-native-node-api` provides that struct, backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a worker pool for the rest. ## `my-app` regain control and call `add` diff --git a/eslint.config.js b/eslint.config.js index df9e3915..bbe15a20 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,7 @@ export default tseslint.config( globalIgnores([ "**/dist/**", "**/build/**", + "**/build-tests/**", "apps/test-app/ios/**", "apps/macos-test-app/**", "packages/host/hermes/**", diff --git a/package.json b/package.json index 55bec8a5..5caf4ae8 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "globals": "^16.0.0", "prettier": "3.6.2", "publint": "^0.3.15", - "react-native": "0.81.4", + "react-native": "0.88.0-nightly-20260809-db662caea", "read-pkg": "^9.0.1", "tsx": "^4.20.6", "typescript": "^5.8.0", diff --git a/packages/host/android/CMakeLists.txt b/packages/host/android/CMakeLists.txt index e4c183f7..19ba1d03 100644 --- a/packages/host/android/CMakeLists.txt +++ b/packages/host/android/CMakeLists.txt @@ -28,7 +28,7 @@ target_link_libraries(node-api-host log ReactAndroid::reactnative ReactAndroid::jsi - hermes-engine::libhermes + hermes-engine::hermesvm weak-node-api # react_codegen_NodeApiHostSpec ) diff --git a/packages/host/android/build.gradle b/packages/host/android/build.gradle index 39204133..60947ffa 100644 --- a/packages/host/android/build.gradle +++ b/packages/host/android/build.gradle @@ -1,6 +1,8 @@ import java.nio.file.Paths import groovy.json.JsonSlurper +import javax.inject.Inject import org.gradle.internal.os.OperatingSystem +import org.gradle.process.ExecOperations if (!System.getenv("REACT_NATIVE_OVERRIDE_HERMES_DIR")) { throw new GradleException([ @@ -48,7 +50,21 @@ def reactNativeArchitectures() { } apply plugin: "com.android.library" -apply plugin: "kotlin-android" + +// AGP 9 ships built-in Kotlin support and enables it by default, which +// registers the `kotlin` extension itself. Applying the Kotlin plugin on top of +// that fails the consumer's build with "Cannot add extension with name +// 'kotlin'", so only apply it when built-in Kotlin isn't doing the job — either +// because the consumer is on AGP 8, or because they opted out of it while they +// migrate. See https://developer.android.com/build/migrate-to-built-in-kotlin +def useBuiltInKotlin() { + def major = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger() + return major >= 9 && project.findProperty("android.builtInKotlin") != "false" +} + +if (!useBuiltInKotlin()) { + apply plugin: "kotlin-android" +} apply plugin: "com.facebook.react" @@ -161,17 +177,25 @@ dependencies { def commandLinePrefix = OperatingSystem.current().isWindows() ? ["cmd", "/c", "node"] : [] def cliPath = file("../bin/react-native-node-api.mjs") +// Gradle 9 removed Project.exec(), so the ExecOperations service has to be +// injected and used explicitly instead of the bare `exec {}` closure. +interface InjectedExecOps { + @Inject + ExecOperations getExecOps() +} +def injectedExecOps = project.objects.newInstance(InjectedExecOps) + // Custom task to fetch jniLibs paths via CLI task linkNodeApiModules { doLast { - exec { + injectedExecOps.execOps.exec { commandLine commandLinePrefix + [cliPath, 'link', '--android', rootProject.rootDir.absolutePath] standardOutput = System.out errorOutput = System.err // Enable color output environment "FORCE_COLOR", "1" } - + android.sourceSets.main.jniLibs.srcDirs += file("../auto-linked/android").listFiles() } } diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 0b1961ec..05745892 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -2,8 +2,26 @@ #include "Logger.hpp" #include "RuntimeNodeApiAsync.hpp" +#include + using namespace facebook; +// Declared by the vendored Hermes in API/napi/hermes_napi.h. We forward declare +// it here (rather than including that header) to avoid pulling in Hermes' own +// node_api.h alongside the weak-node-api copy already included transitively. +// +// The declaration must be `extern "C"`: since facebook/hermes#2106 (included in +// the pinned Hermes commit) the public hermes_napi.h wraps these entry points +// in `extern "C"`, so Hermes exports the unmangled C symbol. Without matching C +// linkage here the reference would be to the C++-mangled name and the app fails +// to link ("Undefined symbol: hermes_napi_create_env"). Passing host as nullptr +// is enough — async work / thread-safe functions will return failure until a +// host integration is wired up (Phase 3). +extern "C" { +struct hermes_napi_host; +napi_env hermes_napi_create_env(void *hermes_runtime, hermes_napi_host *host); +} + namespace callstack::react_native_node_api { CxxNodeApiHostModule::CxxNodeApiHostModule( @@ -108,7 +126,25 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt, // TODO: Read the version from the addon // @see // https://github.com/callstackincubator/react-native-node-api/issues/4 - napi_env env = reinterpret_cast(rt.createNodeApiEnv(8)); + + // Create this addon's Node-API environment. Hermes binds an env to its + // low-level VM runtime, which we reach through the (unstable) IHermes JSI + // interface, and takes ownership: the env is torn down with the runtime, so + // there is nothing to free here. Each addon gets its own env, as in Node. + if (addon.env == nullptr) { + // Fully qualified: `using namespace facebook` makes a bare `hermes` + // ambiguous with the top-level `::hermes` (VM) namespace pulled in via + // . + auto *hermes = facebook::jsi::castInterface(&rt); + if (hermes == nullptr) { + log_debug("NapiHost: JSI runtime is not castable to IHermes; cannot " + "create a Node-API environment"); + abort(); + } + addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), nullptr); + assert(addon.env != nullptr); + } + napi_env env = addon.env; // Create the "exports" object napi_value exports; diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 4c753cfe..7be3e598 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -26,6 +26,14 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { void *moduleHandle; napi_addon_register_func init; std::string generatedName; + + // The Node-API environment for this addon, created when the addon is + // initialized. Node creates one env per addon (see + // napi_module_register_by_symbol in Node's src/node_api.cc) and the env + // carries addon-scoped state — instance data, last error info, the addon's + // Node-API version — so addons must not share one. Owned by the Hermes + // runtime, which tears it down when the runtime is destroyed. + napi_env env = nullptr; }; std::unordered_map nodeAddons_; std::shared_ptr callInvoker_; diff --git a/packages/host/cpp/RuntimeNodeApiAsync.cpp b/packages/host/cpp/RuntimeNodeApiAsync.cpp index bee380a7..647aa71b 100644 --- a/packages/host/cpp/RuntimeNodeApiAsync.cpp +++ b/packages/host/cpp/RuntimeNodeApiAsync.cpp @@ -85,10 +85,21 @@ static AsyncWorkRegistry asyncWorkRegistry; namespace callstack::react_native_node_api { +// Drop an env's entry when the env is torn down with its runtime (on a reload, +// for example). There is one env per addon, so without this the map keeps a +// stale entry per addon per runtime for the lifetime of the process. +static void NAPI_CDECL removeCallInvoker(void *env) { + callInvokers.erase(static_cast(env)); +} + void setCallInvoker( napi_env env, const std::shared_ptr &invoker) { + const bool isFirstForEnv = !callInvokers.contains(env); callInvokers[env] = invoker; + if (isFirstForEnv) { + ::napi_add_env_cleanup_hook(env, removeCallInvoker, env); + } } std::weak_ptr getCallInvoker(napi_env env) { diff --git a/packages/host/cpp/Versions.hpp b/packages/host/cpp/Versions.hpp index 2cc106ef..84a91dfe 100644 --- a/packages/host/cpp/Versions.hpp +++ b/packages/host/cpp/Versions.hpp @@ -1,3 +1,7 @@ #pragma once -#define NAPI_VERSION 8 +// Must be defined before any include so the header exposes the +// full v10 surface (js_native_api.h otherwise defaults this to 8). +#ifndef NAPI_VERSION +#define NAPI_VERSION 10 +#endif diff --git a/packages/host/package.json b/packages/host/package.json index 78018206..dfcf1d43 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -85,7 +85,7 @@ }, "peerDependencies": { "@babel/core": "^7.26.10", - "react-native": "0.79.1 || 0.79.2 || 0.79.3 || 0.79.4 || 0.79.5 || 0.79.6 || 0.79.7 || 0.80.0 || 0.80.1 || 0.80.2 || 0.81.0 || 0.81.1 || 0.81.2 || 0.81.3 || 0.81.4 || 0.81.5", + "react-native": "0.88.0-nightly-20260809-db662caea", "weak-node-api": "workspace:*" } } diff --git a/packages/host/scripts/generate-injector.mts b/packages/host/scripts/generate-injector.mts index bfd6a150..d5c6cfd3 100644 --- a/packages/host/scripts/generate-injector.mts +++ b/packages/host/scripts/generate-injector.mts @@ -6,35 +6,22 @@ import { type FunctionDecl, getNodeApiFunctions } from "weak-node-api"; export const CPP_SOURCE_PATH = path.join(import.meta.dirname, "../cpp"); -// TODO: Remove when all runtime Node API functions are implemented -const IMPLEMENTED_RUNTIME_FUNCTIONS = [ - "napi_create_buffer", - "napi_create_buffer_copy", - "napi_is_buffer", - "napi_get_buffer_info", - "napi_create_external_buffer", - "napi_create_async_work", - "napi_queue_async_work", - "napi_delete_async_work", - "napi_cancel_async_work", - "napi_fatal_error", - "napi_get_node_version", - "napi_get_version", -]; - /** * Generates source code which injects the Node API functions from the host. */ export function generateSource(functions: FunctionDecl[]) { return ` // This file is generated by react-native-node-api + // Versions.hpp must come first so exposes the full v10 surface. + #include + #include #include #include #include #include - + #if defined(__APPLE__) #define WEAK_NODE_API_LIBRARY_NAME "@rpath/weak-node-api.framework/weak-node-api" #elif defined(__ANDROID__) @@ -61,13 +48,7 @@ export function generateSource(functions: FunctionDecl[]) { log_debug("Injecting NodeApiHost"); inject_weak_node_api_host(NodeApiHost { - ${functions - .filter( - ({ kind, name }) => - kind === "engine" || IMPLEMENTED_RUNTIME_FUNCTIONS.includes(name), - ) - .flatMap(({ name }) => `.${name} = ${name},`) - .join("\n")} + ${functions.flatMap(({ name }) => `.${name} = ${name},`).join("\n")} }); } } // namespace callstack::react_native_node_api diff --git a/packages/host/scripts/patch-hermes.rb b/packages/host/scripts/patch-hermes.rb index 76252154..986f5d8f 100644 --- a/packages/host/scripts/patch-hermes.rb +++ b/packages/host/scripts/patch-hermes.rb @@ -1,33 +1,24 @@ -Pod::UI.warn "!!! PATCHING HERMES WITH NODE-API SUPPORT !!!" +Pod::UI.warn "!!! CONFIGURING HERMES WITH NODE-API SUPPORT !!!" -if ENV['RCT_USE_PREBUILT_RNCORE'] == '1' - raise "React Native Node-API cannot reliably patch JSI when React Native Core is prebuilt." -end - -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." +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 end -end -if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].nil? VENDORED_HERMES_DIR ||= `npx react-native-node-api vendor-hermes --react-native-package '#{get_react_native_package()}' --silent '#{Pod::Config.instance.installation_root}'`.strip - # Signal the patched Hermes to React Native - ENV['BUILD_FROM_SOURCE'] = 'true' ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] = VENDORED_HERMES_DIR -elsif Dir.exist?(ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR']) - # Setting an override path implies building from source - ENV['BUILD_FROM_SOURCE'] = 'true' end -if !ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].empty? +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}" else - raise "Hermes patching failed: Expected override to exist in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}" + raise "Hermes setup failed: Expected override to exist in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}" end end diff --git a/packages/host/scripts/patch-xcode-project.rb b/packages/host/scripts/patch-xcode-project.rb index 02eb5a67..ac46ad5f 100644 --- a/packages/host/scripts/patch-xcode-project.rb +++ b/packages/host/scripts/patch-xcode-project.rb @@ -5,9 +5,11 @@ NODE_BINARY = ENV["NODE_BINARY"] || `command -v node`.strip CLI_COMMAND = "'#{NODE_BINARY}' '#{File.join(__dir__, "../dist/node/cli/run.js")}'" PATCH_XCODE_PROJECT_COMMAND = "#{CLI_COMMAND} patch-xcode-project '#{Pod::Config.instance.installation_root}'" - + # Using an at_exit hook to ensure the command is executed after the pod install is complete at_exit do - system(PATCH_XCODE_PROJECT_COMMAND) or raise "Failed to patch the Xcode project" + unless system(PATCH_XCODE_PROJECT_COMMAND) + Pod::UI.warn "[Node-API] Failed to patch the Xcode project (non-fatal)" + end end end diff --git a/packages/host/src/node/cli/hermes.ts b/packages/host/src/node/cli/hermes.ts index 4b41692c..e9893d5c 100644 --- a/packages/host/src/node/cli/hermes.ts +++ b/packages/host/src/node/cli/hermes.ts @@ -15,40 +15,37 @@ import { import { packageDirectory } from "pkg-dir"; import { readPackage } from "read-pkg"; -// FIXME: make this configurable with reasonable fallback before public release -const HERMES_GIT_URL = "https://github.com/kraenhansen/hermes.git"; +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 +// accessor we rely on (`getVMRuntimeUnsafe`) is documented as unstable, so we +// vendor a known-good commit rather than tracking a moving branch. +// +// This commit includes facebook/hermes#2106 ("give hermes_napi.h public API C +// linkage"), which wraps the public `hermes_napi_*` entry points (e.g. +// `hermes_napi_create_env`) in `extern "C"`. Without it those declarations got +// C++ linkage: the mangled symbols stayed out of the framework's export table +// under Hermes' global `-fvisibility=hidden`, and consumers linking the +// framework hit "Undefined symbol: hermes_napi_create_env". We used to patch +// the header ourselves after cloning; now that the fix is upstream at this pin, +// no header patching is required. +// +// It also includes the immediately following commit, which flips Hermes' +// `JSI_UNSTABLE` CMake flag back to OFF by default. With it ON, Hermes compiles +// JSI's unstable `Serialized` / `ISerialization` APIs into `hermesvm`, but +// React Native never defines `JSI_UNSTABLE` when building the `libjsi.so` it +// ships in the ReactAndroid AAR. On Android the two are separate shared +// libraries, so `libhermesvm.so` ended up with undefined references to +// `facebook::jsi::Serialized` that nothing in the APK defined, and the app died +// on startup with "cannot locate symbol _ZTIN8facebook3jsi10SerializedE". +const HERMES_GIT_SHA = "5a795c9f880002c862c9254a26b57199819c97f7"; const platformOption = new Option( "--react-native-package ", "The React Native package to vendor Hermes into", ).default("react-native"); -type PatchJSIHeadersOptions = { - reactNativePath: string; - hermesJsiPath: string; - silent: boolean; -}; - -async function patchJsiHeaders({ - reactNativePath, - hermesJsiPath, - silent, -}: PatchJSIHeadersOptions) { - const reactNativeJsiPath = path.join(reactNativePath, "ReactCommon/jsi/jsi/"); - await oraPromise( - fs.promises.cp(hermesJsiPath, reactNativeJsiPath, { - recursive: true, - }), - { - text: `Copying JSI from patched Hermes to React Native`, - successText: "Copied JSI from patched Hermes to React Native", - failText: (err) => - `Failed to copy JSI from Hermes to React Native: ${err.message}`, - isEnabled: !silent, - }, - ); -} - 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) @@ -75,19 +72,8 @@ export const command = new Command("vendor-hermes") paths: [appPackageRoot], }), ); - const hermesVersionPath = path.join( - reactNativePath, - "sdks", - ".hermesversion", - ); - assert( - fs.existsSync(hermesVersionPath), - `Expected a file with a Hermes version at ${prettyPath(hermesVersionPath)}`, - ); - - const hermesVersion = fs.readFileSync(hermesVersionPath, "utf8").trim(); if (!silent) { - console.log(`Using Hermes version: ${hermesVersion}`); + console.log(`Vendoring Hermes at ${HERMES_GIT_SHA}`); } const hermesPath = path.join(reactNativePath, "sdks", "node-api-hermes"); @@ -104,53 +90,49 @@ export const command = new Command("vendor-hermes") ); } if (!fs.existsSync(hermesPath)) { - const patchedTag = `node-api-${hermesVersion}`; try { + // GitHub allows fetching a reachable commit by SHA, so we can clone + // the pinned commit shallowly without downloading the whole history. await oraPromise( - spawn( - "git", - [ - "clone", + (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", - "--branch", - patchedTag, - HERMES_GIT_URL, - hermesPath, - ], - { - outputMode: "buffered", - }, - ), + ]); + })(), { - text: `Cloning custom Hermes into ${prettyPath(hermesPath)}`, - successText: "Cloned custom Hermes", - failText: (err) => - `Failed to clone custom Hermes: ${err.message}`, + text: `Cloning Hermes into ${prettyPath(hermesPath)}`, + successText: "Cloned Hermes", + failText: (err) => `Failed to clone Hermes: ${err.message}`, isEnabled: !silent, }, ); } catch (error) { - throw new UsageError("Failed to clone custom Hermes", { + // 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 ensure this ${chalk.bold("react-native")} version is supported by ${chalk.bold("react-native-node-api")}.`, + 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 hermesJsiPath = path.join(hermesPath, "API/jsi/jsi"); - - assert( - fs.existsSync(hermesJsiPath), - `Hermes JSI path does not exist: ${hermesJsiPath}`, - ); - await patchJsiHeaders({ - reactNativePath, - hermesJsiPath, - silent, - }); console.log(hermesPath); }), ); diff --git a/packages/host/src/node/cli/xcode-helpers.ts b/packages/host/src/node/cli/xcode-helpers.ts index 0f17afaa..b9368358 100644 --- a/packages/host/src/node/cli/xcode-helpers.ts +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -68,18 +68,9 @@ export async function findXcodeWorkspace(fromPath: string) { throw new Error(`No Xcode workspace found in '${fromPath}'`); } -export async function findXcodeProject(fromPath: string) { - // Read the workspace contents to find the first project - const workspacePath = await findXcodeWorkspace(fromPath); - const workspace = await readXcodeWorkspace(workspacePath); - // Resolve the first project location to an absolute path - assert( - workspace.fileRefs.length > 0, - "Expected at least one project in the workspace", - ); - const [firstProject] = workspace.fileRefs; +function resolveWorkspaceFileRef(location: string, workspacePath: string) { // Extract the path from the scheme (using a regex) - const match = firstProject.location.match(/^([^:]*):(.*)$/); + const match = location.match(/^([^:]*):(.*)$/); assert(match, "Expected a project path in the workspace"); const [, scheme, projectPath] = match; assert(scheme, "Expected a scheme in the fileRef location"); @@ -93,6 +84,30 @@ export async function findXcodeProject(fromPath: string) { } } +export async function findXcodeProject(fromPath: string) { + const workspacePath = await findXcodeWorkspace(fromPath); + const workspace = await readXcodeWorkspace(workspacePath); + assert( + workspace.fileRefs.length > 0, + "Expected at least one project in the workspace", + ); + // The workspace references the Pods project alongside the app project, and in + // a monorepo it can accumulate stale references to app projects generated + // under a different node_modules. Pick the first referenced app project that + // actually exists on disk (ignoring the Pods project). + const appProjectPaths = workspace.fileRefs + .map(({ location }) => resolveWorkspaceFileRef(location, workspacePath)) + .filter((projectPath) => path.basename(projectPath) !== "Pods.xcodeproj"); + const existingProjectPath = appProjectPaths.find((projectPath) => + fs.existsSync(path.join(projectPath, "project.pbxproj")), + ); + assert( + existingProjectPath, + `Expected one of the workspace's projects to exist: ${appProjectPaths.join(", ")}`, + ); + return existingProjectPath; +} + export type ExpectedFrameworkSlice = { platform: string; platformVariant?: string; diff --git a/packages/host/src/node/podspec.test.ts b/packages/host/src/node/podspec.test.ts deleted file mode 100644 index 4da4d640..00000000 --- a/packages/host/src/node/podspec.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import cp from "node:child_process"; - -describe("Podspec", () => { - // We cannot support prebuilds of React Native Core since we're patching JSI - it( - "should error when RCT_USE_PREBUILT_RNCORE is set", - // We cannot call `pod` on non-macOS systems - { skip: process.platform !== "darwin" }, - () => { - const { status, stdout } = cp.spawnSync("pod", ["spec", "lint"], { - env: { ...process.env, RCT_USE_PREBUILT_RNCORE: "1" }, - encoding: "utf-8", - }); - - assert.notEqual(status, 0); - assert.match( - stdout, - /React Native Node-API cannot reliably patch JSI when React Native Core is prebuilt/, - ); - }, - ); -}); diff --git a/packages/host/tsconfig.react-native.json b/packages/host/tsconfig.react-native.json index c84162e8..0c0afe93 100644 --- a/packages/host/tsconfig.react-native.json +++ b/packages/host/tsconfig.react-native.json @@ -6,6 +6,8 @@ "noEmit": false, "outDir": "dist", "rootDir": "src", + "module": "nodenext", + "moduleResolution": "nodenext", "types": ["react-native"] }, "include": ["src/react-native"] diff --git a/packages/node-addon-examples/tsconfig.tests.json b/packages/node-addon-examples/tsconfig.tests.json index 629f51e3..dff90191 100644 --- a/packages/node-addon-examples/tsconfig.tests.json +++ b/packages/node-addon-examples/tsconfig.tests.json @@ -3,9 +3,10 @@ "compilerOptions": { "composite": true, "noEmit": false, - "module": "commonjs", "outDir": "dist", "rootDir": "src", + "module": "nodenext", + "moduleResolution": "nodenext", "types": ["react-native"] }, "include": ["src/*.ts"] diff --git a/packages/weak-node-api/CMakeLists.txt b/packages/weak-node-api/CMakeLists.txt index 29080360..d23d1551 100644 --- a/packages/weak-node-api/CMakeLists.txt +++ b/packages/weak-node-api/CMakeLists.txt @@ -56,7 +56,7 @@ endif() # C++20 is needed to use designated initializers target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_20) -target_compile_definitions(${PROJECT_NAME} PRIVATE NAPI_VERSION=8) +target_compile_definitions(${PROJECT_NAME} PRIVATE NAPI_VERSION=10) target_compile_options(${PROJECT_NAME} PRIVATE $<$:/W4 /WX> diff --git a/packages/weak-node-api/src/node-api-functions.ts b/packages/weak-node-api/src/node-api-functions.ts index f92bd956..0cf73fa5 100644 --- a/packages/weak-node-api/src/node-api-functions.ts +++ b/packages/weak-node-api/src/node-api-functions.ts @@ -78,24 +78,25 @@ export function getNodeApiHeaderAST(version: NodeApiVersion) { export type FunctionDecl = { name: string; - kind: "engine" | "runtime"; returnType: string; noReturn: boolean; argumentTypes: string[]; - libraryPath: string; fallbackReturnStatement: string; }; -export function getNodeApiFunctions(version: NodeApiVersion = "v8") { +export function getNodeApiFunctions(version: NodeApiVersion = "v10") { const root = getNodeApiHeaderAST(version); assert.equal(root.kind, "TranslationUnitDecl"); assert(Array.isArray(root.inner)); const foundSymbols = new Set(); + // Both interfaces are now sourced from the same host (hermesNapi provides + // every symbol), so there is no engine/runtime distinction to preserve. const symbolsPerInterface = nodeApiHeaders.symbols[version]; - const engineSymbols = new Set(symbolsPerInterface.js_native_api_symbols); - const runtimeSymbols = new Set(symbolsPerInterface.node_api_symbols); - const allSymbols = new Set([...engineSymbols, ...runtimeSymbols]); + const allSymbols = new Set([ + ...symbolsPerInterface.js_native_api_symbols, + ...symbolsPerInterface.node_api_symbols, + ]); const nodeApiFunctions: FunctionDecl[] = []; @@ -131,14 +132,9 @@ export function getNodeApiFunctions(version: NodeApiVersion = "v8") { name, returnType, noReturn: node.type.qualType.includes("__attribute__((noreturn))"), - kind: engineSymbols.has(name) ? "engine" : "runtime", argumentTypes: argumentTypes .split(",") .map((arg) => arg.trim().replace("_Bool", "bool")), - // Defer to the right library - libraryPath: engineSymbols.has(name) - ? "libhermes.so" - : "libnode-api-host.so", fallbackReturnStatement: returnType === "void" ? "abort();" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a011bada..fa6ec795 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^0.3.15 version: 0.3.21 react-native: - specifier: 0.81.4 - version: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + specifier: 0.88.0-nightly-20260809-db662caea + version: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) read-pkg: specifier: ^9.0.1 version: 9.0.1 @@ -93,17 +93,17 @@ importers: specifier: workspace:* version: link:../../packages/node-tests '@react-native/babel-preset': - specifier: 0.81.4 - version: 0.81.4(@babel/core@7.29.7) + specifier: 0.88.0-nightly-20260809-db662caea + version: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) '@react-native/metro-config': - specifier: 0.81.4 - version: 0.81.4(@babel/core@7.29.7) + specifier: 0.88.0-nightly-20260809-db662caea + version: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) '@react-native/typescript-config': - specifier: 0.81.4 - version: 0.81.4 + specifier: 0.88.0-nightly-20260809-db662caea + version: 0.88.0-nightly-20260809-db662caea '@rnx-kit/metro-config': - specifier: ^2.1.1 - version: 2.2.4(@react-native-community/cli-types@20.2.0)(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(metro@0.83.7)(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0) + specifier: ^2.2.4 + version: 2.2.4(@react-native-community/cli-types@20.2.0)(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(metro@0.87.0)(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -121,19 +121,19 @@ importers: version: 1.13.2 mocha-remote-react-native: specifier: ^1.13.2 - version: 1.13.2(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0) + version: 1.13.2(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) react: - specifier: 19.1.0 - version: 19.1.0 + specifier: 19.2.3 + version: 19.2.3 react-native: - specifier: 0.81.4 - version: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + specifier: 0.88.0-nightly-20260809-db662caea + version: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) react-native-node-api: specifier: workspace:* version: link:../../packages/host react-native-test-app: - specifier: ^4.4.7 - version: 4.4.12(@react-native-community/cli-types@20.2.0)(metro@0.83.7)(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0) + specifier: ^5.4.8 + version: 5.4.8(@react-native-community/cli-types@20.2.0)(metro@0.87.0)(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) weak-node-api: specifier: workspace:* version: link:../../packages/weak-node-api @@ -247,8 +247,8 @@ importers: specifier: ^8.0.0 version: 8.0.0 react-native: - specifier: 0.79.1 || 0.79.2 || 0.79.3 || 0.79.4 || 0.79.5 || 0.79.6 || 0.79.7 || 0.80.0 || 0.80.1 || 0.80.2 || 0.81.0 || 0.81.1 || 0.81.2 || 0.81.3 || 0.81.4 || 0.81.5 - version: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + specifier: 0.88.0-nightly-20260809-db662caea + version: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) read-pkg: specifier: ^9.0.1 version: 9.0.1 @@ -504,27 +504,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-dynamic-import@7.8.3': resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} peerDependencies: @@ -554,64 +533,22 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.29.7': resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-optional-chaining@7.8.3': resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.29.7': resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} engines: {node: '>=6.9.0'} @@ -1449,38 +1386,18 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} - engines: {node: '>=8'} - - '@jest/create-cache-key-function@29.7.0': - resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/types@29.6.3': resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2101,77 +2018,81 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - '@react-native/assets-registry@0.81.4': - resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==} - engines: {node: '>= 20.19.4'} + '@react-native/asset-utils@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-oN9CBHkciHKhI699hA+A4zkfTV+iJJ0Cb4pmGbqUTBN2E3mExCGT2+/GHT7rzcVUAqWix5vpsASfFi5o6SP2Nw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/babel-plugin-codegen@0.81.4': - resolution: {integrity: sha512-6ztXf2Tl2iWznyI/Da/N2Eqymt0Mnn69GCLnEFxFbNdk0HxHPZBNWU9shTXhsLWOL7HATSqwg/bB1+3kY1q+mA==} - engines: {node: '>= 20.19.4'} + '@react-native/babel-plugin-codegen@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-vXAc0lQdkNqY2FBF4H35lKq3e2y2KfNPX4WD3IACWc0ZnRujzEUMYQ0EXHLQVm+DyEzvWHmrYfreiZMeL1CKng==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/babel-preset@0.81.4': - resolution: {integrity: sha512-VYj0c/cTjQJn/RJ5G6P0L9wuYSbU9yGbPYDHCKstlQZQWkk+L9V8ZDbxdJBTIei9Xl3KPQ1odQ4QaeW+4v+AZg==} - engines: {node: '>= 20.19.4'} + '@react-native/babel-preset@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-FdDxdpeQtxDB8OL2b8DAJVQln0QAxmtkHN13En6s6VzBVYuUaxQ/kd9ZUzVR+HRVII7BMfcgzE6tWl6Oph+oZg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@babel/core': '*' - '@react-native/codegen@0.81.4': - resolution: {integrity: sha512-LWTGUTzFu+qOQnvkzBP52B90Ym3stZT8IFCzzUrppz8Iwglg83FCtDZAR4yLHI29VY/x/+pkcWAMCl3739XHdw==} - engines: {node: '>= 20.19.4'} + '@react-native/codegen@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-pBJM4v0Lg3gQfuFnJYeyGX3lrbq+fsIAo44ZuaOBMPNwkelZZVWy5/koPgFNHOycGKZSmvfhJ0NbFTISg9tCrA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@babel/core': '*' - '@react-native/community-cli-plugin@0.81.4': - resolution: {integrity: sha512-8mpnvfcLcnVh+t1ok6V9eozWo8Ut+TZhz8ylJ6gF9d6q9EGDQX6s8jenan5Yv/pzN4vQEKI4ib2pTf/FELw+SA==} - engines: {node: '>= 20.19.4'} + '@react-native/community-cli-plugin@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-GbcAYQatq/dRScOnj85aoUGtD3+iuuSrAmaAXwhQ4d29qAPy5kFRusSkRrTskrbuBGTqUEt8daT6Mq6ZzOsjHw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@react-native-community/cli': '*' - '@react-native/metro-config': '*' + '@react-native/metro-config': 0.88.0-nightly-20260809-db662caea peerDependenciesMeta: '@react-native-community/cli': optional: true '@react-native/metro-config': optional: true - '@react-native/debugger-frontend@0.81.4': - resolution: {integrity: sha512-SU05w1wD0nKdQFcuNC9D6De0ITnINCi8MEnx9RsTD2e4wN83ukoC7FpXaPCYyP6+VjFt5tUKDPgP1O7iaNXCqg==} - engines: {node: '>= 20.19.4'} + '@react-native/debugger-frontend@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-uh092SG1u6rT5hGZeUa315SZR/1pwSYit14FAn4vRqHMjbRPFpJoZboBvp2k6zfib3glqJGmeQtS769WvgZWFw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + '@react-native/debugger-shell@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-g7Gw7bfvUMW4BUhFb8tmTGBf3414EdlC4sAbzOcUOE5SIyqQCmU0iLcAWSSKIuP9NjFWLKKVDP9DLuTGTpBl7Q==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/dev-middleware@0.81.4': - resolution: {integrity: sha512-hu1Wu5R28FT7nHXs2wWXvQ++7W7zq5GPY83llajgPlYKznyPLAY/7bArc5rAzNB7b0kwnlaoPQKlvD/VP9LZug==} - engines: {node: '>= 20.19.4'} + '@react-native/dev-middleware@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-i+srhjb4whNoMGRXemmo73+KeBpPJVVGGor+93s89OdjCqGW3ApVQpj2TOerULOtOdFDUV4CcmFnr0Yaj7Q/vQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/gradle-plugin@0.81.4': - resolution: {integrity: sha512-T7fPcQvDDCSusZFVSg6H1oVDKb/NnVYLnsqkcHsAF2C2KGXyo3J7slH/tJAwNfj/7EOA2OgcWxfC1frgn9TQvw==} - engines: {node: '>= 20.19.4'} + '@react-native/gradle-plugin@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-+cHvVKWNVydVjwWxS1jff5YBSbIyziusABbq1E6OOdJ04JPQPto2ubQG8CI9AlW6S2hiS8iAD/qYv6KY1+2TOA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/js-polyfills@0.81.4': - resolution: {integrity: sha512-sr42FaypKXJHMVHhgSbu2f/ZJfrLzgaoQ+HdpRvKEiEh2mhFf6XzZwecyLBvWqf2pMPZa+CpPfNPiejXjKEy8w==} - engines: {node: '>= 20.19.4'} + '@react-native/js-polyfills@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-0rZKQhKppL5khczDF4oXE8HGOXmzOkvAWl+EkjtPf9Qy4Gb/XJx3a5yrbNYREBpjYAxjQrylrnU9Pb9ioIBEJg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/metro-babel-transformer@0.81.4': - resolution: {integrity: sha512-AahgamQ9kZV4B1x8I/LpTZBgbT+j9i1pQoM3KDkECPIOF1JUwNFUukEjpkq4kRSdzudLocnfASFg+eWzIgPcCA==} - engines: {node: '>= 20.19.4'} + '@react-native/metro-babel-transformer@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-TJkVCb8+ZEOJiipGQTjTI2tEnDCYxCvStH7Pd45xenlxzN2M89zZH4VRUQXtQUXN+5xJEvqzHJhIR5IECuwSXA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@babel/core': '*' - '@react-native/metro-config@0.81.4': - resolution: {integrity: sha512-aEXhRMsz6yN5X63Zk+cdKByQ0j3dsKv+ETRP9lLARdZ82fBOCMuK6IfmZMwK3A/3bI7gSvt2MFPn3QHy3WnByw==} - engines: {node: '>= 20.19.4'} + '@react-native/metro-config@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-n4U+3az3nYMUASoSU5CV4F5zQt+eiHQZiR6v5qbD5qsVlMrcGzHZo7/NkVUsozwQbWYXneYeoA+60mSuPwclpw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/normalize-colors@0.81.4': - resolution: {integrity: sha512-9nRRHO1H+tcFqjb9gAM105Urtgcanbta2tuqCVY0NATHeFPDEAB7gPyiLxCHKMi1NbhP6TH0kxgSWXKZl1cyRg==} + '@react-native/normalize-colors@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-oe3/cG9LKEIEogEw/i7d+hD9/ZVWUo4wCrCOCsIxL3ibTqNJiUdZqEmE+xbY9azmzBPurjbq/2rtQfHl4lp8Mw==} - '@react-native/typescript-config@0.81.4': - resolution: {integrity: sha512-1HSrwtfAmtbKHNK2HAMCL5ArbGhxxJjOmTViDQ4nEhLJCAllZjQJyR/Hs1GmwHJokLmgXCcg3VH/13spwQBdxw==} + '@react-native/typescript-config@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-fEWkmJR2vzZRt6X4+Z1qGm4mXQ90RYBBe/KOSRoMfbYIZPmeL/vhnmuA6jQB/9dpXzA8oJyIE4xLs73I/spJIw==} - '@react-native/virtualized-lists@0.81.4': - resolution: {integrity: sha512-hBM+rMyL6Wm1Q4f/WpqGsaCojKSNUBqAXLABNGoWm1vabZ7cSnARMxBvA/2vo3hLcoR4v7zDK8tkKm9+O0LjVA==} - engines: {node: '>= 20.19.4'} + '@react-native/virtualized-lists@0.88.0-nightly-20260809-db662caea': + resolution: {integrity: sha512-aVkgJUe00hgSAVqXTCZ8KlobzCdqnzxDx3yXrsdxuclB8P1FJcRRkpqOBhnUfqRYQwuXJtamvJijBilSvZ5LYA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: - '@types/react': ^19.1.0 + '@types/react': ^19.2.0 react: '*' - react-native: '*' + react-native: 0.88.0-nightly-20260809-db662caea peerDependenciesMeta: '@types/react': optional: true @@ -2344,12 +2265,6 @@ packages: '@sinclair/typebox@0.27.12': resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@tsconfig/node22@22.0.5': resolution: {integrity: sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==} @@ -2374,9 +2289,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2407,9 +2319,6 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2502,10 +2411,6 @@ packages: resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2576,10 +2481,6 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} @@ -2635,20 +2536,6 @@ packages: axios@1.18.1: resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -2669,23 +2556,12 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - babel-plugin-syntax-hermes-parser@0.29.1: - resolution: {integrity: sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==} + babel-plugin-syntax-hermes-parser@0.37.0: + resolution: {integrity: sha512-7I9T16lJTjHG6JhHOmM/iqO/UGGQwAcAAD4/tsk+GSjPf9bbusBsPlz1scyGehImMfH1Yopbj0aQYxhTAHdcdg==} babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2802,14 +2678,6 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} - chrome-launcher@0.15.2: - resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} - engines: {node: '>=12.13.0'} - hasBin: true - - chromium-edge-launcher@0.2.0: - resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} - ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} @@ -3001,6 +2869,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -3189,10 +3061,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3236,10 +3104,6 @@ packages: fast-xml-builder@1.3.0: resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} - fast-xml-parser@4.5.7: - resolution: {integrity: sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==} - hasBin: true - fast-xml-parser@5.10.1: resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} hasBin: true @@ -3247,6 +3111,11 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -3305,6 +3174,14 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + flow-estree@0.325.0: + resolution: {integrity: sha512-HnYTpF6Al3YZpiPwket4x33V7iXSUgyqmtOz9FvgGBRKfBzZre/A3sRpXN1Jsq1VeRson1ToHJn+EosHemaPpQ==} + engines: {node: '>=18'} + + flow-parser@0.325.0: + resolution: {integrity: sha512-3uJxDHNXuiNM5twT+tk0iIn8zdSkF67cmE/6SoWFbmxcX7BnI82qYeJSEhrfNnDx7TwhAVPnLqV7MCt+biVwZw==} + engines: {node: '>=0.4.0'} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -3342,9 +3219,6 @@ packages: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3382,10 +3256,6 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3407,10 +3277,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - global-modules@1.0.0: resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} engines: {node: '>=0.10.0'} @@ -3463,17 +3329,20 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hermes-estree@0.29.1: - resolution: {integrity: sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==} + hermes-compiler@260318099.0.1: + resolution: {integrity: sha512-jDXx48/z7ULUr2+sf+A3+3RQRiwcvH4Y+mhlxMdUhNQGOsnQY8eeDgy+8yhKrDHbLvRfW2JwEnDFfmyf96F5SQ==} + + hermes-estree@0.36.1: + resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} - hermes-estree@0.35.0: - resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + hermes-estree@0.37.0: + resolution: {integrity: sha512-Frp4+A518C4zZgB2+Qo1srlM3y6nbrFbNNzBF3g7tEuQXHXTb2h39V25JQLNHotxclkD+AZTN/JCCpiTFlhIGg==} - hermes-parser@0.29.1: - resolution: {integrity: sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==} + hermes-parser@0.36.1: + resolution: {integrity: sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==} - hermes-parser@0.35.0: - resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + hermes-parser@0.37.0: + resolution: {integrity: sha512-8PuWcyaF6VHQHL2dbU+QEIkbtHYQrTX/6+ep4VcBLJ3f/ehevowr5Zj8LQfYXQQGFG2O+df7hIMtt4PkkhfK3Q==} homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} @@ -3538,10 +3407,6 @@ packages: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3657,41 +3522,13 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3779,9 +3616,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lighthouse-logger@1.4.2: - resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3840,9 +3674,6 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - marky@1.3.0: - resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3864,62 +3695,62 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - metro-babel-transformer@0.83.7: - resolution: {integrity: sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==} - engines: {node: '>=20.19.4'} + metro-babel-transformer@0.87.0: + resolution: {integrity: sha512-IEn1K1FyY4J1sA5y6zqDjf2OkfmpTEqhZOeP6MJX8HepSW0cuHGw1m8bYOdv2adkG3XUE9dtM0csUs0gP/Xa5w==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-cache-key@0.83.7: - resolution: {integrity: sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==} - engines: {node: '>=20.19.4'} + metro-cache-key@0.87.0: + resolution: {integrity: sha512-Q+MPt6jl0zQogr4Q02WaJK6HY+GtE5A0nzj8kIV1Owgrx6OMNvm6scPTr1SM/R4LpCE8EH/Y5qfbXQ84GHTr0Q==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-cache@0.83.7: - resolution: {integrity: sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==} - engines: {node: '>=20.19.4'} + metro-cache@0.87.0: + resolution: {integrity: sha512-146vS1BMSKcp99jddOhFBfHwzUEWN35NrsnSJDF2sQQ0ZT5OsBcOjd574PM233TWEZISRJ5DOK+vokD+1ubx+w==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-config@0.83.7: - resolution: {integrity: sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==} - engines: {node: '>=20.19.4'} + metro-config@0.87.0: + resolution: {integrity: sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-core@0.83.7: - resolution: {integrity: sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==} - engines: {node: '>=20.19.4'} + metro-core@0.87.0: + resolution: {integrity: sha512-yW57+pCOHRC/CJZ99GA2PTd+30dORwDAjUPRCokj91IWW5In9Jwtt2FB5wACrGO8P0GHTyVHdTwDNyZsNndUbA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-file-map@0.83.7: - resolution: {integrity: sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==} - engines: {node: '>=20.19.4'} + metro-file-map@0.87.0: + resolution: {integrity: sha512-Dc57t8jsINwA90bbVlqaeDlxf1rVGgj5SmOEnOMbaHNUM/HCYYTJxPV8SRdOBwh7qTz/biO9vaQvBTjRBgbbsg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-minify-terser@0.83.7: - resolution: {integrity: sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==} - engines: {node: '>=20.19.4'} + metro-minify-terser@0.87.0: + resolution: {integrity: sha512-tPa0O983PDutFu3LXbArRH5NduogcKrvW6fs9VHhksTKUA1iqDyoD1ZSj/Me52xJ6T9/9pOwIVyj9ulKc/zMkg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-resolver@0.83.7: - resolution: {integrity: sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==} - engines: {node: '>=20.19.4'} + metro-resolver@0.87.0: + resolution: {integrity: sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-runtime@0.83.7: - resolution: {integrity: sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==} - engines: {node: '>=20.19.4'} + metro-runtime@0.87.0: + resolution: {integrity: sha512-XsXZkgEwI0ZMYSBfvOMAbenzwa60XlObXJ27g6/Khgrz9ESbiBbAsd7hR62G2jRBYOhGAzG61Gk22dpRJj/mdw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-source-map@0.83.7: - resolution: {integrity: sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==} - engines: {node: '>=20.19.4'} + metro-source-map@0.87.0: + resolution: {integrity: sha512-31BrYqu1c2co93rF1LN9Pw+7g+BrfDyxJkNQWrYm+pfA/+eVYVumF7tFMHbXLePLmHfhvSgVvbK6su1Oyiw1ng==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-symbolicate@0.83.7: - resolution: {integrity: sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==} - engines: {node: '>=20.19.4'} + metro-symbolicate@0.87.0: + resolution: {integrity: sha512-uOpTxAXu74N+RSujUZ78L6gjI6bDdnz6XuW+AIUNuubZDEQPIpaX0StzIb0GMeQWP6zfHOHwFrWPP5Iquu1GXw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true - metro-transform-plugins@0.83.7: - resolution: {integrity: sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==} - engines: {node: '>=20.19.4'} + metro-transform-plugins@0.87.0: + resolution: {integrity: sha512-i8keUe9+BaSwMuQM26DGheElCpTtflAKIrSwJAm8ZsgDb50RAUQus+e6zt2suaJXJ1OxZa7vqgtqoZxdniM6Fw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro-transform-worker@0.83.7: - resolution: {integrity: sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==} - engines: {node: '>=20.19.4'} + metro-transform-worker@0.87.0: + resolution: {integrity: sha512-YftLzNJxCTYxEN5k4AzR8KYwiENTEuz30L+4QeoMrtDd+U8mDThZg/ArR3JVRd8LaikwPOjVAS5SP3xPJN0AaA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - metro@0.83.7: - resolution: {integrity: sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==} - engines: {node: '>=20.19.4'} + metro@0.87.0: + resolution: {integrity: sha512-fRqFhSzQhLNQSCvJFeuRzBRXAOOKXf1O8d2cvmMtG6yFR0jCllQ7vBsXoLP18yuqtf+N1XwWXTPF11eWy9q6dQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true micromatch@4.0.8: @@ -4092,10 +3923,6 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4108,9 +3935,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} - ob1@0.83.7: - resolution: {integrity: sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==} - engines: {node: '>=20.19.4'} + ob1@0.87.0: + resolution: {integrity: sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} @@ -4140,9 +3967,6 @@ packages: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -4155,9 +3979,9 @@ packages: resolution: {integrity: sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==} engines: {node: '>=8'} - open@7.4.2: - resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} - engines: {node: '>=8'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -4245,10 +4069,6 @@ packages: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4275,6 +4095,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -4358,17 +4182,17 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-native-test-app@4.4.12: - resolution: {integrity: sha512-bCUpjF5A5NaCO0d7eYuqS4enRs+T5ZxOTkA80JfxNODSNynNPGXK85JAtfrNpPoAT79tai0oczFx5yrBOqePmw==} - engines: {node: '>=16.17'} + react-native-test-app@5.4.8: + resolution: {integrity: sha512-J8gdIUmHDZX5k2Cnx8odARmZrvGCgnOY2kpKtlWJfmSnWyJN4nAqCe2/PUk/7NAlrlkvSWGI4x+03k3cx2mxww==} + engines: {node: '>=20.19.4'} hasBin: true peerDependencies: - '@callstack/react-native-visionos': 0.73 - 0.79 + '@callstack/react-native-visionos': 0.76 - 0.79 '@expo/config-plugins': '>=5.0' - react: 18.1 - 19.1 - react-native: 0.70 - 0.82 || >=0.83.0-0 <0.83.0 - react-native-macos: ^0.0.0-0 || 0.71 - 0.79 - react-native-windows: ^0.0.0-0 || 0.70 - 0.79 + react: 18.2 - 19.2 + react-native: 0.76 - 0.87 || >=0.88.0-0 <0.88.0 + react-native-macos: ^0.0.0-0 || 0.76 - 0.81 + react-native-windows: ^0.0.0-0 || 0.76 - 0.83 peerDependenciesMeta: '@callstack/react-native-visionos': optional: true @@ -4379,13 +4203,13 @@ packages: react-native-windows: optional: true - react-native@0.81.4: - resolution: {integrity: sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==} - engines: {node: '>= 20.19.4'} + react-native@0.88.0-nightly-20260809-db662caea: + resolution: {integrity: sha512-3norxpYjYN9UeUI9ajhW880BejAKIasC7d01RkxFCWRrOnIVJeeSFKekwy81EJFTkM8FUCmJud5ys5hviXM/ZQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true peerDependencies: - '@types/react': ^19.1.0 - react: ^19.1.0 + '@types/react': ^19.1.1 + react: ^19.2.3 peerDependenciesMeta: '@types/react': optional: true @@ -4394,8 +4218,8 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} - react@19.1.0: - resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} read-pkg@9.0.1: @@ -4482,11 +4306,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - rolldown@1.0.0-beta.29: resolution: {integrity: sha512-EsoOi8moHN6CAYyTZipxDDVTJn0j2nBCWor4wRU45RQ8ER2qREDykXLr3Ulz6hBh6oBKCFTQIjo21i0FXNo/IA==} hasBin: true @@ -4511,8 +4330,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -4697,9 +4516,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@1.1.2: - resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} @@ -4725,10 +4541,6 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -4784,10 +4596,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} @@ -4876,10 +4684,6 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -4942,13 +4746,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - ws@6.2.6: resolution: {integrity: sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==} peerDependencies: @@ -5293,26 +5090,6 @@ snapshots: dependencies: '@babel/core': 7.29.7 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5338,61 +5115,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6337,62 +6074,14 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/ttlcache@1.4.1': {} + '@isaacs/cliui@9.0.0': {} - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.15.0 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.6': {} - - '@jest/create-cache-key-function@29.7.0': - dependencies: - '@jest/types': 29.6.3 - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.20.1 - jest-mock: 29.7.0 - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.20.1 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 + '@isaacs/ttlcache@1.4.1': {} '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.12 - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.29.7 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - '@jest/types@29.6.3': dependencies: '@jest/schemas': 29.6.3 @@ -6975,17 +6664,17 @@ snapshots: - typescript - utf-8-validate - '@react-native/assets-registry@0.81.4': {} + '@react-native/asset-utils@0.88.0-nightly-20260809-db662caea': {} - '@react-native/babel-plugin-codegen@0.81.4(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)': dependencies: '@babel/traverse': 7.29.7 - '@react-native/codegen': 0.81.4(@babel/core@7.29.7) + '@react-native/codegen': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-preset@0.81.4(@babel/core@7.29.7)': + '@react-native/babel-preset@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) @@ -6993,27 +6682,19 @@ snapshots: '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) @@ -7022,101 +6703,105 @@ snapshots: '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@babel/template': 7.29.7 - '@react-native/babel-plugin-codegen': 0.81.4(@babel/core@7.29.7) - babel-plugin-syntax-hermes-parser: 0.29.1 + '@react-native/babel-plugin-codegen': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) + babel-plugin-syntax-hermes-parser: 0.37.0 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + flow-parser: 0.325.0 react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/codegen@0.81.4(@babel/core@7.29.7)': + '@react-native/codegen@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 - glob: 7.2.3 - hermes-parser: 0.29.1 + flow-parser: 0.325.0 + hermes-parser: 0.37.0 invariant: 2.2.4 nullthrows: 1.1.1 + tinyglobby: 0.2.17 yargs: 17.7.3 - '@react-native/community-cli-plugin@0.81.4(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))': + '@react-native/community-cli-plugin@0.88.0-nightly-20260809-db662caea(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))': dependencies: - '@react-native/dev-middleware': 0.81.4 + '@react-native/asset-utils': 0.88.0-nightly-20260809-db662caea + '@react-native/dev-middleware': 0.88.0-nightly-20260809-db662caea + commander: 12.1.0 debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 - metro: 0.83.7 - metro-config: 0.83.7 - metro-core: 0.83.7 + metro: 0.87.0 semver: 7.8.5 optionalDependencies: '@react-native-community/cli': 20.2.0(typescript@5.9.3) - '@react-native/metro-config': 0.81.4(@babel/core@7.29.7) + '@react-native/metro-config': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@react-native/debugger-frontend@0.81.4': {} + '@react-native/debugger-frontend@0.88.0-nightly-20260809-db662caea': {} + + '@react-native/debugger-shell@0.88.0-nightly-20260809-db662caea': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color - '@react-native/dev-middleware@0.81.4': + '@react-native/dev-middleware@0.88.0-nightly-20260809-db662caea': dependencies: '@isaacs/ttlcache': 1.4.1 - '@react-native/debugger-frontend': 0.81.4 - chrome-launcher: 0.15.2 - chromium-edge-launcher: 0.2.0 + '@react-native/debugger-frontend': 0.88.0-nightly-20260809-db662caea + '@react-native/debugger-shell': 0.88.0-nightly-20260809-db662caea connect: 3.7.0 debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 nullthrows: 1.1.1 - open: 7.4.2 + open: 8.4.2 serve-static: 1.16.3 - ws: 6.2.6 + ws: 7.5.13 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@react-native/gradle-plugin@0.81.4': {} + '@react-native/gradle-plugin@0.88.0-nightly-20260809-db662caea': {} - '@react-native/js-polyfills@0.81.4': {} + '@react-native/js-polyfills@0.88.0-nightly-20260809-db662caea': {} - '@react-native/metro-babel-transformer@0.81.4(@babel/core@7.29.7)': + '@react-native/metro-babel-transformer@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@react-native/babel-preset': 0.81.4(@babel/core@7.29.7) - hermes-parser: 0.29.1 + '@react-native/babel-preset': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) + flow-parser: 0.325.0 + hermes-parser: 0.37.0 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.81.4(@babel/core@7.29.7)': + '@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)': dependencies: - '@react-native/js-polyfills': 0.81.4 - '@react-native/metro-babel-transformer': 0.81.4(@babel/core@7.29.7) - metro-config: 0.83.7 - metro-runtime: 0.83.7 + '@react-native/js-polyfills': 0.88.0-nightly-20260809-db662caea + '@react-native/metro-babel-transformer': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) + metro-config: 0.87.0 + metro-runtime: 0.87.0 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate - '@react-native/normalize-colors@0.81.4': {} + '@react-native/normalize-colors@0.88.0-nightly-20260809-db662caea': {} - '@react-native/typescript-config@0.81.4': {} + '@react-native/typescript-config@0.88.0-nightly-20260809-db662caea': {} - '@react-native/virtualized-lists@0.81.4(@types/react@19.2.17)(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0)': + '@react-native/virtualized-lists@0.88.0-nightly-20260809-db662caea(@types/react@19.2.17)(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + react: 19.2.3 + react-native: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) optionalDependencies: '@types/react': 19.2.17 @@ -7125,37 +6810,37 @@ snapshots: '@actions/core': 2.0.3 stack-utils: 2.0.6 - '@rnx-kit/metro-config@2.2.4(@react-native-community/cli-types@20.2.0)(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(metro@0.83.7)(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0)': + '@rnx-kit/metro-config@2.2.4(@react-native-community/cli-types@20.2.0)(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(metro@0.87.0)(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': dependencies: - '@rnx-kit/tools-node': 3.0.5(metro@0.83.7) - '@rnx-kit/tools-react-native': 2.3.8(@react-native-community/cli-types@20.2.0)(metro@0.83.7) + '@rnx-kit/tools-node': 3.0.5(metro@0.87.0) + '@rnx-kit/tools-react-native': 2.3.8(@react-native-community/cli-types@20.2.0)(metro@0.87.0) '@rnx-kit/tools-workspaces': 0.2.3 - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + react: 19.2.3 + react-native: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) optionalDependencies: - '@react-native/metro-config': 0.81.4(@babel/core@7.29.7) + '@react-native/metro-config': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) transitivePeerDependencies: - '@react-native-community/cli-types' - memfs - metro - '@rnx-kit/react-native-host@0.5.21(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))': + '@rnx-kit/react-native-host@0.5.21(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))': dependencies: - react-native: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + react-native: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) '@rnx-kit/tools-filesystem@0.2.0': {} - '@rnx-kit/tools-node@3.0.5(metro@0.83.7)': + '@rnx-kit/tools-node@3.0.5(metro@0.87.0)': dependencies: - '@rnx-kit/types-node': 1.0.0(metro@0.83.7) + '@rnx-kit/types-node': 1.0.0(metro@0.87.0) transitivePeerDependencies: - metro - '@rnx-kit/tools-react-native@2.3.8(@react-native-community/cli-types@20.2.0)(metro@0.83.7)': + '@rnx-kit/tools-react-native@2.3.8(@react-native-community/cli-types@20.2.0)(metro@0.87.0)': dependencies: '@rnx-kit/tools-filesystem': 0.2.0 - '@rnx-kit/tools-node': 3.0.5(metro@0.83.7) - '@rnx-kit/types-bundle-config': 1.0.0(metro@0.83.7) + '@rnx-kit/tools-node': 3.0.5(metro@0.87.0) + '@rnx-kit/types-bundle-config': 1.0.0(metro@0.87.0) optionalDependencies: '@react-native-community/cli-types': 20.2.0 transitivePeerDependencies: @@ -7170,26 +6855,26 @@ snapshots: read-yaml-file: 2.1.0 strip-json-comments: 3.1.1 - '@rnx-kit/types-bundle-config@1.0.0(metro@0.83.7)': + '@rnx-kit/types-bundle-config@1.0.0(metro@0.87.0)': dependencies: '@rnx-kit/types-metro-serializer-esbuild': 1.0.2 '@rnx-kit/types-plugin-cyclic-dependencies': 1.0.0 '@rnx-kit/types-plugin-duplicates-checker': 1.0.0 '@rnx-kit/types-plugin-typescript': 1.0.0 optionalDependencies: - metro: 0.83.7 + metro: 0.87.0 - '@rnx-kit/types-kit-config@1.0.0(metro@0.83.7)': + '@rnx-kit/types-kit-config@1.0.0(metro@0.87.0)': dependencies: - '@rnx-kit/types-bundle-config': 1.0.0(metro@0.83.7) + '@rnx-kit/types-bundle-config': 1.0.0(metro@0.87.0) transitivePeerDependencies: - metro '@rnx-kit/types-metro-serializer-esbuild@1.0.2': {} - '@rnx-kit/types-node@1.0.0(metro@0.83.7)': + '@rnx-kit/types-node@1.0.0(metro@0.87.0)': dependencies: - '@rnx-kit/types-kit-config': 1.0.0(metro@0.83.7) + '@rnx-kit/types-kit-config': 1.0.0(metro@0.87.0) transitivePeerDependencies: - metro @@ -7258,14 +6943,6 @@ snapshots: '@sinclair/typebox@0.27.12': {} - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - '@tsconfig/node22@22.0.5': {} '@tsconfig/react-native@3.0.6': {} @@ -7298,10 +6975,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 22.20.1 - '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -7330,8 +7003,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/stack-utils@2.0.3': {} - '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -7467,10 +7138,6 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -7537,11 +7204,6 @@ snapshots: ansis@4.3.1: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - anynum@1.0.1: {} appdirsjs@1.2.7: {} @@ -7595,36 +7257,6 @@ snapshots: - debug - supports-color - babel-jest@29.7.0(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.7) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-istanbul@6.1.1: - dependencies: - '@babel/helper-plugin-utils': 7.29.7 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.6 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.29.7 @@ -7657,9 +7289,9 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-syntax-hermes-parser@0.29.1: + babel-plugin-syntax-hermes-parser@0.37.0: dependencies: - hermes-parser: 0.29.1 + hermes-parser: 0.37.0 babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): dependencies: @@ -7667,31 +7299,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) - - babel-preset-jest@29.6.3(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) - balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -7808,26 +7415,6 @@ snapshots: chownr@2.0.0: {} - chrome-launcher@0.15.2: - dependencies: - '@types/node': 22.20.1 - escape-string-regexp: 4.0.0 - is-wsl: 2.2.0 - lighthouse-logger: 1.4.2 - transitivePeerDependencies: - - supports-color - - chromium-edge-launcher@0.2.0: - dependencies: - '@types/node': 22.20.1 - escape-string-regexp: 4.0.0 - is-wsl: 2.2.0 - lighthouse-logger: 1.4.2 - mkdirp: 1.0.4 - rimraf: 3.0.2 - transitivePeerDependencies: - - supports-color - ci-info@2.0.0: {} ci-info@3.9.0: {} @@ -8020,6 +7607,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -8239,8 +7828,6 @@ snapshots: etag@1.8.1: {} - event-target-shim@5.0.1: {} - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -8292,10 +7879,6 @@ snapshots: path-expression-matcher: 1.6.2 xml-naming: 0.3.0 - fast-xml-parser@4.5.7: - dependencies: - strnum: 1.1.2 - fast-xml-parser@5.10.1: dependencies: '@nodable/entities': 3.0.0 @@ -8309,6 +7892,8 @@ snapshots: dependencies: reusify: 1.1.0 + fb-dotslash@0.5.8: {} + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -8373,6 +7958,12 @@ snapshots: flow-enums-runtime@0.0.6: {} + flow-estree@0.325.0: {} + + flow-parser@0.325.0: + dependencies: + flow-estree: 0.325.0 + follow-redirects@1.16.0(debug@4.4.3): optionalDependencies: debug: 4.4.3(supports-color@8.1.1) @@ -8412,8 +8003,6 @@ snapshots: dependencies: minipass: 3.3.6 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -8453,8 +8042,6 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -8479,15 +8066,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - global-modules@1.0.0: dependencies: global-prefix: 1.0.2 @@ -8532,17 +8110,19 @@ snapshots: he@1.2.0: {} - hermes-estree@0.29.1: {} + hermes-compiler@260318099.0.1: {} + + hermes-estree@0.36.1: {} - hermes-estree@0.35.0: {} + hermes-estree@0.37.0: {} - hermes-parser@0.29.1: + hermes-parser@0.36.1: dependencies: - hermes-estree: 0.29.1 + hermes-estree: 0.36.1 - hermes-parser@0.35.0: + hermes-parser@0.37.0: dependencies: - hermes-estree: 0.35.0 + hermes-estree: 0.37.0 homedir-polyfill@1.0.3: dependencies: @@ -8603,11 +8183,6 @@ snapshots: index-to-position@1.2.0: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ini@1.3.8: {} @@ -8695,71 +8270,14 @@ snapshots: isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - '@istanbuljs/schema': 0.1.6 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.20.1 - jest-mock: 29.7.0 - jest-util: 29.7.0 - jest-get-type@29.6.3: {} - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 22.20.1 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.29.7 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.20.1 - jest-util: 29.7.0 - - jest-regex-util@29.6.3: {} - jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -8854,13 +8372,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lighthouse-logger@1.4.2: - dependencies: - debug: 2.6.9 - marky: 1.3.0 - transitivePeerDependencies: - - supports-color - lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -8919,8 +8430,6 @@ snapshots: dependencies: tmpl: 1.0.5 - marky@1.3.0: {} - math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -8935,51 +8444,50 @@ snapshots: merge2@1.4.1: {} - metro-babel-transformer@0.83.7: + metro-babel-transformer@0.87.0: dependencies: '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 - hermes-parser: 0.35.0 - metro-cache-key: 0.83.7 + hermes-parser: 0.36.1 + metro-cache-key: 0.87.0 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-cache-key@0.83.7: + metro-cache-key@0.87.0: dependencies: flow-enums-runtime: 0.0.6 - metro-cache@0.83.7: + metro-cache@0.87.0: dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 https-proxy-agent: 7.0.6 - metro-core: 0.83.7 + metro-core: 0.87.0 transitivePeerDependencies: - supports-color - metro-config@0.83.7: + metro-config@0.87.0: dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.83.7 - metro-cache: 0.83.7 - metro-core: 0.83.7 - metro-runtime: 0.83.7 - yaml: 2.9.0 + metro: 0.87.0 + metro-cache: 0.87.0 + metro-core: 0.87.0 + metro-runtime: 0.87.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro-core@0.83.7: + metro-core@0.87.0: dependencies: flow-enums-runtime: 0.0.6 lodash.throttle: 4.1.1 - metro-resolver: 0.83.7 + metro-resolver: 0.87.0 - metro-file-map@0.83.7: + metro-file-map@0.87.0: dependencies: debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 @@ -8993,46 +8501,46 @@ snapshots: transitivePeerDependencies: - supports-color - metro-minify-terser@0.83.7: + metro-minify-terser@0.87.0: dependencies: flow-enums-runtime: 0.0.6 terser: 5.49.0 - metro-resolver@0.83.7: + metro-resolver@0.87.0: dependencies: flow-enums-runtime: 0.0.6 - metro-runtime@0.83.7: + metro-runtime@0.87.0: dependencies: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-source-map@0.83.7: + metro-source-map@0.87.0: dependencies: '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-symbolicate: 0.83.7 + metro-symbolicate: 0.87.0 nullthrows: 1.1.1 - ob1: 0.83.7 + ob1: 0.87.0 source-map: 0.5.7 vlq: 1.0.1 transitivePeerDependencies: - supports-color - metro-symbolicate@0.83.7: + metro-symbolicate@0.87.0: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.83.7 + metro-source-map: 0.87.0 nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 transitivePeerDependencies: - supports-color - metro-transform-plugins@0.83.7: + metro-transform-plugins@0.87.0: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 @@ -9043,27 +8551,27 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-worker@0.83.7: + metro-transform-worker@0.87.0: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 flow-enums-runtime: 0.0.6 - metro: 0.83.7 - metro-babel-transformer: 0.83.7 - metro-cache: 0.83.7 - metro-cache-key: 0.83.7 - metro-minify-terser: 0.83.7 - metro-source-map: 0.83.7 - metro-transform-plugins: 0.83.7 + metro: 0.87.0 + metro-babel-transformer: 0.87.0 + metro-cache: 0.87.0 + metro-cache-key: 0.87.0 + metro-minify-terser: 0.87.0 + metro-source-map: 0.87.0 + metro-transform-plugins: 0.87.0 nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro@0.83.7: + metro@0.87.0: dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -9079,24 +8587,24 @@ snapshots: error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 - hermes-parser: 0.35.0 + hermes-parser: 0.36.1 image-size: 1.2.1 invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.83.7 - metro-cache: 0.83.7 - metro-cache-key: 0.83.7 - metro-config: 0.83.7 - metro-core: 0.83.7 - metro-file-map: 0.83.7 - metro-resolver: 0.83.7 - metro-runtime: 0.83.7 - metro-source-map: 0.83.7 - metro-symbolicate: 0.83.7 - metro-transform-plugins: 0.83.7 - metro-transform-worker: 0.83.7 + metro-babel-transformer: 0.87.0 + metro-cache: 0.87.0 + metro-cache-key: 0.87.0 + metro-config: 0.87.0 + metro-core: 0.87.0 + metro-file-map: 0.87.0 + metro-resolver: 0.87.0 + metro-runtime: 0.87.0 + metro-source-map: 0.87.0 + metro-symbolicate: 0.87.0 + metro-transform-plugins: 0.87.0 + metro-transform-worker: 0.87.0 mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -9195,11 +8703,11 @@ snapshots: transitivePeerDependencies: - supports-color - mocha-remote-react-native@1.13.2(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0): + mocha-remote-react-native@1.13.2(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): dependencies: mocha-remote-client: 1.13.2 - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + react: 19.2.3 + react-native: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9292,8 +8800,6 @@ snapshots: semver: 7.8.5 validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} - npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -9307,7 +8813,7 @@ snapshots: nullthrows@1.1.1: {} - ob1@0.83.7: + ob1@0.87.0: dependencies: flow-enums-runtime: 0.0.6 @@ -9339,10 +8845,6 @@ snapshots: on-headers@1.1.0: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -9355,8 +8857,9 @@ snapshots: dependencies: is-wsl: 1.1.0 - open@7.4.2: + open@8.4.2: dependencies: + define-lazy-prop: 2.0.0 is-docker: 2.2.1 is-wsl: 2.2.0 @@ -9474,8 +8977,6 @@ snapshots: path-expression-matcher@1.6.2: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} @@ -9493,6 +8994,8 @@ snapshots: picomatch@4.0.5: {} + pify@4.0.1: {} + pirates@4.0.7: {} pkg-dir@8.0.0: @@ -9582,59 +9085,55 @@ snapshots: react-is@18.3.1: {} - react-native-test-app@4.4.12(@react-native-community/cli-types@20.2.0)(metro@0.83.7)(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0): + react-native-test-app@5.4.8(@react-native-community/cli-types@20.2.0)(metro@0.87.0)(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): dependencies: - '@rnx-kit/react-native-host': 0.5.21(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0)) - '@rnx-kit/tools-react-native': 2.3.8(@react-native-community/cli-types@20.2.0)(metro@0.83.7) + '@isaacs/cliui': 9.0.0 + '@rnx-kit/react-native-host': 0.5.21(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) + '@rnx-kit/tools-react-native': 2.3.8(@react-native-community/cli-types@20.2.0)(metro@0.87.0) ajv: 8.20.0 - cliui: 8.0.1 - fast-xml-parser: 4.5.7 + fast-xml-builder: 1.3.0 + fast-xml-parser: 5.10.1 prompts: 2.4.2 - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0) + react: 19.2.3 + react-native: 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) semver: 7.8.5 - uuid: 11.1.1 transitivePeerDependencies: - '@react-native-community/cli-types' - memfs - metro - react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0): - dependencies: - '@jest/create-cache-key-function': 29.7.0 - '@react-native/assets-registry': 0.81.4 - '@react-native/codegen': 0.81.4(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.81.4(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7)) - '@react-native/gradle-plugin': 0.81.4 - '@react-native/js-polyfills': 0.81.4 - '@react-native/normalize-colors': 0.81.4 - '@react-native/virtualized-lists': 0.81.4(@types/react@19.2.17)(react-native@0.81.4(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.81.4(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.1.0))(react@19.1.0) - abort-controller: 3.0.0 + react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3): + dependencies: + '@react-native/asset-utils': 0.88.0-nightly-20260809-db662caea + '@react-native/codegen': 0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.88.0-nightly-20260809-db662caea(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)) + '@react-native/gradle-plugin': 0.88.0-nightly-20260809-db662caea + '@react-native/normalize-colors': 0.88.0-nightly-20260809-db662caea + '@react-native/virtualized-lists': 0.88.0-nightly-20260809-db662caea(@types/react@19.2.17)(react-native@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@5.9.3))(@react-native/metro-config@0.88.0-nightly-20260809-db662caea(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) anser: 1.4.10 ansi-regex: 5.0.1 - babel-jest: 29.7.0(@babel/core@7.29.7) - babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-syntax-hermes-parser: 0.37.0 base64-js: 1.5.1 - commander: 12.1.0 flow-enums-runtime: 0.0.6 - glob: 7.2.3 + flow-parser: 0.325.0 + hermes-compiler: 260318099.0.1 invariant: 2.2.4 - jest-environment-node: 29.7.0 memoize-one: 5.2.1 - metro-runtime: 0.83.7 - metro-source-map: 0.83.7 + metro-runtime: 0.87.0 + metro-source-map: 0.87.0 nullthrows: 1.1.1 pretty-format: 29.7.0 promise: 8.3.0 - react: 19.1.0 + react: 19.2.3 react-devtools-core: 6.1.5 react-refresh: 0.14.2 regenerator-runtime: 0.13.11 - scheduler: 0.26.0 + scheduler: 0.27.0 semver: 7.8.5 stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 6.2.6 + ws: 7.5.13 yargs: 17.7.3 optionalDependencies: '@types/react': 19.2.17 @@ -9648,7 +9147,7 @@ snapshots: react-refresh@0.14.2: {} - react@19.1.0: {} + react@19.2.3: {} read-pkg@9.0.1: dependencies: @@ -9734,10 +9233,6 @@ snapshots: reusify@1.1.0: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - rolldown@1.0.0-beta.29(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2): dependencies: '@oxc-project/runtime': 0.77.3 @@ -9785,7 +9280,7 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.26.0: {} + scheduler@0.27.0: {} semver-compare@1.0.0: {} @@ -9976,8 +9471,6 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@1.1.2: {} - strnum@2.4.1: dependencies: anynum: 1.0.1 @@ -10008,12 +9501,6 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 7.2.3 - minimatch: 3.1.5 - throat@5.0.0: {} tinyexec@1.2.4: {} @@ -10055,8 +9542,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - type-fest@0.7.1: {} type-fest@4.41.0: {} @@ -10129,8 +9614,6 @@ snapshots: utils-merge@1.0.1: {} - uuid@11.1.1: {} - uuid@8.3.2: {} validate-npm-package-license@3.0.4: @@ -10198,13 +9681,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 - wrappy@1.0.2: {} - - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - ws@6.2.6: dependencies: async-limiter: 1.0.1 From cf5ed4edf41ec7c3aeef72ee1cb8eae2b80666c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 12 Aug 2026 10:15:02 +0200 Subject: [PATCH 04/24] Implement hermes_napi_host for async work and thread-safe functions (#398) * Implement hermes_napi_host and pass it to hermes_napi_create_env Provide the Phase 3 host integration for Hermes' first-party Node-API: - New HermesNapiHost.{hpp,cpp}: a mirror of the hermes_napi_host struct (pinned to HERMES_GIT_SHA) and a HostContext per React Native runtime, backed by a process-global 4-thread worker pool (post_work / cancel_work) and the runtime's CallInvoker behind a type-erased JS dispatcher (post_task and work completions). fatal_exception stringifies the error, logs and aborts; uv_loop and ref_loop/unref_loop stay null by design. Contexts are retained for the process lifetime because the env reads the struct during Runtime teardown after env cleanup hooks have run. - CxxNodeApiHostModule passes the host at env creation - before the addon's init runs, fixing init-time async work - and drops setCallInvoker. - Delete the RuntimeNodeApiAsync overrides: async work falls through to Hermes' implementation, so execute now runs on a worker thread instead of the JS thread, and thread-safe functions work for the first time. - tests/async: execute/complete thread-identity assertions, a gated blocking execute (deadlock-proof that execute is off the JS thread) and a deterministic cancel-of-running-work case. - tests/threadsafe-function: port of Node's test_threadsafe_function (pthread shim for uv threads, upstream assertions restored) plus JS-thread and never-inline supplements; re-enable the async_work_thread_safe_function example (its SIGABRT was the null host). - packages/host/tests: Catch2 suite exercising the worker pool, cancellation atomicity, post_task ordering/reentrancy and the teardown drop path on plain Linux, with a host-cpp-tests CI job mirroring weak-node-api-tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF * Trigger CI for the label-gated device lanes The Check workflow only reacts to opened/synchronize/reopened, so the Apple and Android labels added to the PR need a synchronize event to be seen by the job conditions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF * Trigger CI with the weak-node-api and host labels applied Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF * Address review: truthful teardown outcomes, duplicate-queue drop - JsDispatcher now reports acceptance and WorkItem holds its HostContext strongly: the weak_ptr could never expire (contexts are retained for the process lifetime), so the pool's drop branches were dead code and napi_cancel_async_work could claim success for a completion the dispatcher was about to drop. cancel_work now returns the dispatcher's verdict, and workerMain/postTask log drops where they actually happen. - WorkerPool::enqueue drops a double-queued (loopData, workData) instead of enqueueing it: a second entry meant two completions for one napi_async_work and a use-after-free once the addon deletes the work inside the first. Covered by a new Catch2 test; the saturation helper now uses distinct jobs per worker so it does not trip the detection. - Delete HostContext copy/move: host_.data points at this. - Justify the CallInvoker-liveness assumption at the dispatcher site (RuntimeSchedulerCallInvoker holds a weak RuntimeScheduler owned together with the runtime, so accepted work cannot outlive it) and correct the WorkItem comment: the (loopData, workData) pair separates runtimes/reloads, not envs. - Rework the Catch2 teardown test to model an expired CallInvoker (the state production reaches) instead of dropping the last context ref (which it never does), and cover the rejected post_task path. - Scope the 30s mocha timeout to the threadsafe-function suite so a genuine deadlock elsewhere still fails fast; add a TODO on fatal_exception about routing through RN error handling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF --------- Co-authored-by: Claude --- .changeset/hermes-napi-host-integration.md | 5 + .github/workflows/check.yml | 36 ++ apps/test-app/App.tsx | 8 +- docs/HOW-IT-WORKS.md | 2 +- eslint.config.js | 3 + packages/host/.gitignore | 3 + packages/host/android/CMakeLists.txt | 4 +- packages/host/cpp/CxxNodeApiHostModule.cpp | 55 ++- packages/host/cpp/CxxNodeApiHostModule.hpp | 4 + packages/host/cpp/HermesNapiHost.cpp | 251 +++++++++++ packages/host/cpp/HermesNapiHost.hpp | 125 +++++ packages/host/cpp/RuntimeNodeApiAsync.cpp | 200 -------- packages/host/cpp/RuntimeNodeApiAsync.hpp | 24 - packages/host/package.json | 3 + packages/host/scripts/generate-injector.mts | 1 - packages/host/src/node/cli/hermes.ts | 5 + packages/host/tests/CMakeLists.txt | 42 ++ packages/host/tests/test_hermes_napi_host.cpp | 426 ++++++++++++++++++ packages/node-addon-examples/src/index.ts | 7 +- .../node-addon-examples/tests/async/addon.c | 143 ++++++ .../node-addon-examples/tests/async/addon.js | 76 +++- .../tests/threadsafe-function/CMakeLists.txt | 26 ++ .../tests/threadsafe-function/addon.c | 407 +++++++++++++++++ .../tests/threadsafe-function/addon.js | 287 ++++++++++++ .../tests/threadsafe-function/binding.gyp | 8 + .../tests/threadsafe-function/package.json | 14 + 26 files changed, 1913 insertions(+), 252 deletions(-) create mode 100644 .changeset/hermes-napi-host-integration.md create mode 100644 packages/host/cpp/HermesNapiHost.cpp create mode 100644 packages/host/cpp/HermesNapiHost.hpp delete mode 100644 packages/host/cpp/RuntimeNodeApiAsync.cpp delete mode 100644 packages/host/cpp/RuntimeNodeApiAsync.hpp create mode 100644 packages/host/tests/CMakeLists.txt create mode 100644 packages/host/tests/test_hermes_napi_host.cpp create mode 100644 packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt create mode 100644 packages/node-addon-examples/tests/threadsafe-function/addon.c create mode 100644 packages/node-addon-examples/tests/threadsafe-function/addon.js create mode 100644 packages/node-addon-examples/tests/threadsafe-function/binding.gyp create mode 100644 packages/node-addon-examples/tests/threadsafe-function/package.json diff --git a/.changeset/hermes-napi-host-integration.md b/.changeset/hermes-napi-host-integration.md new file mode 100644 index 00000000..5a3f632c --- /dev/null +++ b/.changeset/hermes-napi-host-integration.md @@ -0,0 +1,5 @@ +--- +"react-native-node-api": minor +--- + +Provide a `hermes_napi_host` implementation to the Hermes Node-API environments. This enables thread-safe functions (`napi_create_threadsafe_function` and friends) and moves `napi_async_work` execution onto a worker pool — previously the `execute` callback ran on the JavaScript thread, blocking it for the duration of the work. The host is also in place before an addon's module init runs, so async work and thread-safe functions can now be created during initialization. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 85bb269c..582d7650 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -159,6 +159,42 @@ jobs: cmake --build build ctest --test-dir build --output-on-failure working-directory: packages/weak-node-api + host-cpp-tests: + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'host') + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-latest + - windows-latest + - macos-latest + runs-on: ${{ matrix.runner }} + name: Host C++ tests (${{ matrix.runner }}) + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/krypton + cache: pnpm + - name: Setup cpp tools + uses: aminya/setup-cpp@v1 + with: + clang-format: true + - name: ccache + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ${{ github.job }}-${{ runner.os }} + - run: pnpm install + - run: pnpm run build + - name: Prepare weak-node-api + run: pnpm --filter weak-node-api run prebuild:prepare + - name: Build and run react-native-node-api host C++ tests + run: | + cmake -S tests -B tests/build + cmake --build tests/build + ctest --test-dir tests/build --output-on-failure + working-directory: packages/host test-ios: if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'Apple 🍎') name: Test app (iOS) diff --git a/apps/test-app/App.tsx b/apps/test-app/App.tsx index 397409bb..3322b136 100644 --- a/apps/test-app/App.tsx +++ b/apps/test-app/App.tsx @@ -38,7 +38,13 @@ function loadTests({ )) { describe(suiteName, () => { for (const [exampleName, requireExample] of Object.entries(examples)) { - it(exampleName, async () => { + it(exampleName, async function () { + if (exampleName === "threadsafe-function") { + // The ported Node.js suite marshals thousands of values across + // threads; every other example keeps the default timeout so a + // genuine deadlock still fails fast. + this.timeout(30_000); + } const test = requireExample(); if (test instanceof Function) { const result = test(); diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index b78f4686..3a5167b3 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -54,7 +54,7 @@ Hermes implements both halves of Node-API: the engine-specific functions (see [j - `ref_loop` / `unref_loop` — keep the event loop alive while a thread-safe function is referenced, modelling libuv's "ref" semantics. - `fatal_exception` and, for embedders that have one, a libuv loop pointer for `napi_get_uv_event_loop`. -`react-native-node-api` provides that struct, backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a worker pool for the rest. +`react-native-node-api` provides that struct (see `packages/host/cpp/HermesNapiHost.cpp`), backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a process-global worker pool (four threads, like libuv's default) for the rest. `ref_loop` / `unref_loop` and the libuv loop pointer are deliberately left null: React Native's JavaScript thread has no ref-counted event-loop lifetime to model, so thread-safe function ref/unref are tracked but inert, and `napi_get_uv_event_loop` returns `napi_generic_failure` as upstream documents for hosts without libuv. ## `my-app` regain control and call `add` diff --git a/eslint.config.js b/eslint.config.js index bbe15a20..7e4d196d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,6 +62,9 @@ export default tseslint.config( }, globals: { ...globals.commonjs, + // Timers provided by React Native's runtime, where these files run. + setTimeout: "readonly", + setImmediate: "readonly", }, }, rules: { diff --git a/packages/host/.gitignore b/packages/host/.gitignore index 5ba3e2fe..42920839 100644 --- a/packages/host/.gitignore +++ b/packages/host/.gitignore @@ -18,3 +18,6 @@ android/build/ # Generated via `npm run generate-weak-node-api-injector` /cpp/WeakNodeApiInjector.cpp + +# C++ test build artifacts (see `npm run test:configure`) +/tests/build/ diff --git a/packages/host/android/CMakeLists.txt b/packages/host/android/CMakeLists.txt index 19ba1d03..2a45e96d 100644 --- a/packages/host/android/CMakeLists.txt +++ b/packages/host/android/CMakeLists.txt @@ -14,8 +14,8 @@ add_library(node-api-host SHARED ../cpp/WeakNodeApiInjector.cpp ../cpp/RuntimeNodeApi.cpp ../cpp/RuntimeNodeApi.hpp - ../cpp/RuntimeNodeApiAsync.cpp - ../cpp/RuntimeNodeApiAsync.hpp + ../cpp/HermesNapiHost.cpp + ../cpp/HermesNapiHost.hpp ) target_include_directories(node-api-host PRIVATE diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 05745892..6720d272 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -1,27 +1,10 @@ #include "CxxNodeApiHostModule.hpp" #include "Logger.hpp" -#include "RuntimeNodeApiAsync.hpp" #include using namespace facebook; -// Declared by the vendored Hermes in API/napi/hermes_napi.h. We forward declare -// it here (rather than including that header) to avoid pulling in Hermes' own -// node_api.h alongside the weak-node-api copy already included transitively. -// -// The declaration must be `extern "C"`: since facebook/hermes#2106 (included in -// the pinned Hermes commit) the public hermes_napi.h wraps these entry points -// in `extern "C"`, so Hermes exports the unmangled C symbol. Without matching C -// linkage here the reference would be to the C++-mangled name and the app fails -// to link ("Undefined symbol: hermes_napi_create_env"). Passing host as nullptr -// is enough — async work / thread-safe functions will return failure until a -// host integration is wired up (Phase 3). -extern "C" { -struct hermes_napi_host; -napi_env hermes_napi_create_env(void *hermes_runtime, hermes_napi_host *host); -} - namespace callstack::react_native_node_api { CxxNodeApiHostModule::CxxNodeApiHostModule( @@ -31,6 +14,40 @@ CxxNodeApiHostModule::CxxNodeApiHostModule( MethodMetadata{1, &CxxNodeApiHostModule::requireNodeAddon}; callInvoker_ = std::move(jsInvoker); + + // The JS-thread dispatcher behind the hermes_napi_host integration: + // CallInvoker::invokeAsync is callable from any thread, never runs the + // function inline and delivers in order on the JS thread. + // + // Teardown is the load-bearing case. What the host integration needs is + // that a function handed to this dispatcher either runs on the JS thread + // while the runtime is alive, or is dropped — never invoked against a + // destroyed runtime. In bridgeless React Native the CallInvoker received + // here is a RuntimeSchedulerCallInvoker holding a std::weak_ptr to the + // RuntimeScheduler; the ReactInstance owns scheduler and runtime together + // and invokeAsync no-ops once the scheduler is gone, so work cannot outlive + // the runtime it targets. The weak capture below covers the remaining + // window where this module (and its CallInvoker reference) is released + // during instance teardown. + // + // Dropping is safe precisely because a drop implies that teardown: every + // env this host serves is owned by that same runtime and destroyed with it, + // so the completion or tsfn dispatch being dropped has no live observer. + // The one caller that could still see the difference — + // napi_cancel_async_work — receives the verdict through this dispatcher's + // return value (see HostContext::cancelWork). + hostContext_ = HostContext::create( + [weakInvoker = std::weak_ptr(callInvoker_)](std::function &&fn) { + auto invoker = weakInvoker.lock(); + if (!invoker) { + log_warning( + "NapiHost: dropping a task posted after runtime teardown"); + return false; + } + invoker->invokeAsync(std::move(fn)); + return true; + }); + HostContext::retainForProcessLifetime(hostContext_); } jsi::Value @@ -141,7 +158,8 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt, "create a Node-API environment"); abort(); } - addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), nullptr); + addon.env = + hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), hostContext_->host()); assert(addon.env != nullptr); } napi_env env = addon.env; @@ -163,7 +181,6 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt, napi_set_named_property(env, global, addon.generatedName.data(), exports); assert(status == napi_ok); - callstack::react_native_node_api::setCallInvoker(env, callInvoker_); return true; } diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 7be3e598..e71df553 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -5,6 +5,7 @@ #include #include "AddonLoaders.hpp" +#include "HermesNapiHost.hpp" namespace callstack::react_native_node_api { @@ -37,6 +38,9 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { }; std::unordered_map nodeAddons_; std::shared_ptr callInvoker_; + // The hermes_napi_host integration passed to every env this module creates. + // Also retained process-wide, as the envs outlive this module on teardown. + std::shared_ptr hostContext_; using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround // for my lazyness (work on iOS and Android) diff --git a/packages/host/cpp/HermesNapiHost.cpp b/packages/host/cpp/HermesNapiHost.cpp new file mode 100644 index 00000000..681e16d2 --- /dev/null +++ b/packages/host/cpp/HermesNapiHost.cpp @@ -0,0 +1,251 @@ +#include "HermesNapiHost.hpp" +#include "Logger.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace callstack::react_native_node_api { +namespace { + +struct WorkItem { + // Identifies the HostContext that posted the item; matched together with + // workData on cancellation. All envs of one runtime share one context, so + // the pair only disambiguates across runtimes (i.e. reloads), where a freed + // napi_async_work address could be reused by a new runtime's env. + void *loopData = nullptr; + // Held strongly: contexts are retained for the process lifetime anyway, and + // whether the item's runtime can still receive its completion is reported + // by the context's dispatcher, not by this pointer's liveness. + std::shared_ptr context; + void *workData = nullptr; + void (*execute)(void *work_data) = nullptr; + void (*complete)(void *work_data, napi_status status) = nullptr; +}; + +class WorkerPool { +public: + static WorkerPool &instance() { + // Deliberately leaked, with detached threads, like libuv's process-global + // thread pool: the pool must be able to outlive any single React Native + // runtime and there is no shutdown point at which joining would be safe. + static WorkerPool *pool = new WorkerPool(); + return *pool; + } + + void enqueue(WorkItem &&item) { + { + std::lock_guard lock(mutex_); + for (const WorkItem &queued : queue_) { + if (queued.loopData == item.loopData && + queued.workData == item.workData) { + // Queueing the same napi_async_work twice is undefined behavior in + // Node (libuv asserts). Drop the duplicate instead of crashing: + // enqueueing it would produce two completions for one work item, + // and the second is a use-after-free once the addon has called + // napi_delete_async_work from inside the first. + log_warning("NapiHost: dropping napi_async_work %p, queued while " + "already queued", + item.workData); + return; + } + } + queue_.push_back(std::move(item)); + } + cv_.notify_one(); + } + + bool tryRemove(void *loopData, void *workData, WorkItem &result) { + std::lock_guard lock(mutex_); + for (auto it = queue_.begin(); it != queue_.end(); ++it) { + if (it->loopData == loopData && it->workData == workData) { + result = std::move(*it); + queue_.erase(it); + return true; + } + } + return false; + } + +private: + // libuv's default thread pool size. Keep this below 5: the cancellation + // tests make cancel-while-queued deterministic by saturating the pool with + // 5 blocking jobs before queueing the item they cancel. + static constexpr size_t kThreadCount = 4; + + WorkerPool() { + for (size_t i = 0; i < kThreadCount; i++) { + std::thread([this] { workerMain(); }).detach(); + } + } + + void workerMain() { + for (;;) { + WorkItem item; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return !queue_.empty(); }); + item = std::move(queue_.front()); + queue_.pop_front(); + } + // An item is either popped here (execute runs, complete gets napi_ok) + // or removed by tryRemove (complete gets napi_cancelled) — never both, + // as both happen under the queue mutex. + item.execute(item.workData); + bool accepted = item.context->dispatchToJs( + [workData = item.workData, complete = item.complete] { + // No pool state refers to workData at this point, so the + // complete callback is free to napi_delete_async_work it. + complete(workData, napi_ok); + }); + if (!accepted) { + log_warning("NapiHost: dropping an async work completion posted after " + "runtime teardown"); + } + } + } + + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; +}; + +std::optional stringValue(napi_env env, napi_value value) { + size_t length = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) { + return std::nullopt; + } + std::string result(length, '\0'); + if (napi_get_value_string_utf8(env, value, result.data(), length + 1, + nullptr) != napi_ok) { + return std::nullopt; + } + return result; +} + +std::string describeError(napi_env env, napi_value err) { + // Prefer the error's stack (which includes its message), fall back to + // coercing the value to a string. Every call is status-checked: this runs + // right before an abort and must not assume anything about the value. + napi_value stack = nullptr; + napi_valuetype type = napi_undefined; + if (napi_get_named_property(env, err, "stack", &stack) == napi_ok && + napi_typeof(env, stack, &type) == napi_ok && type == napi_string) { + if (auto text = stringValue(env, stack)) { + return *text; + } + } + napi_value coerced = nullptr; + if (napi_coerce_to_string(env, err, &coerced) == napi_ok) { + if (auto text = stringValue(env, coerced)) { + return *text; + } + } + return "(unable to stringify the error value)"; +} + +} // namespace + +HostContext::HostContext(JsDispatcher dispatchToJs) + : dispatchToJs_(std::move(dispatchToJs)), + host_{ + .post_work = &HostContext::postWork, + // Hermes null-checks only the host pointer itself before invoking + // post_work and cancel_work, so neither may individually be null. + .cancel_work = &HostContext::cancelWork, + .post_task = &HostContext::postTask, + .data = this, + // React Native has no libuv loop: napi_get_uv_event_loop() returns + // napi_generic_failure, as upstream documents for non-Node hosts. + .uv_loop = nullptr, + .fatal_exception = &HostContext::fatalException, + // The JS thread outlives every producer thread, so there is no loop + // lifetime to model: tsfn ref/unref are tracked by Hermes but inert. + .ref_loop = nullptr, + .unref_loop = nullptr, + } {} + +std::shared_ptr HostContext::create(JsDispatcher dispatchToJs) { + return std::shared_ptr(new HostContext(std::move(dispatchToJs))); +} + +void HostContext::retainForProcessLifetime( + std::shared_ptr context) { + // Leaked for the same reason as the WorkerPool: no safe destruction point. + static std::mutex *mutex = new std::mutex(); + static auto *retained = new std::vector>(); + std::lock_guard lock(*mutex); + retained->push_back(std::move(context)); +} + +void HostContext::postWork(void *loop_data, void *work_data, + void (*execute)(void *work_data), + void (*complete)(void *work_data, + napi_status status)) noexcept { + auto *self = static_cast(loop_data); + WorkerPool::instance().enqueue(WorkItem{ + .loopData = loop_data, + .context = self->shared_from_this(), + .workData = work_data, + .execute = execute, + .complete = complete, + }); +} + +bool HostContext::cancelWork(void *loop_data, void *work_data) noexcept { + WorkItem item; + if (!WorkerPool::instance().tryRemove(loop_data, work_data, item)) { + // Already picked up by a worker (or never queued): cancellation failed + // and Hermes surfaces napi_generic_failure, like Node. + return false; + } + // Deliver the cancelled completion asynchronously, matching Node, where a + // cancelled complete callback still runs on a later loop tick. Success is + // only reported while the dispatcher accepts the delivery: once the runtime + // is torn down the complete callback can never run, and claiming success + // would leave the addon waiting for a complete(napi_cancelled) that never + // arrives. + return item.context->dispatchToJs( + [workData = item.workData, complete = item.complete] { + complete(workData, napi_cancelled); + }); +} + +void HostContext::postTask(void *loop_data, void *task_data, + void (*callback)(void *task_data)) noexcept { + auto *self = static_cast(loop_data); + // Thread-safe functions call this from arbitrary producer threads, and + // Hermes' tsfnDispatch re-posts itself from inside the callback. The + // dispatcher never runs the callback inline (JS would run off-thread) and + // never drops it while the runtime is alive — a dropped dispatch would + // permanently wedge the tsfn, as its dispatch_pending flag stays set. A + // rejected dispatch therefore implies the runtime (and with it the tsfn's + // env) is gone, making the wedged flag unobservable. + if (!self->dispatchToJs_([task_data, callback] { callback(task_data); })) { + log_warning("NapiHost: dropping a thread-safe function dispatch posted " + "after runtime teardown"); + } +} + +void HostContext::fatalException(void *, napi_env env, + napi_value err) noexcept { + // Called on the JS thread by napi_fatal_exception(). Node routes this to + // process.emit('uncaughtException'); with no process object we log the + // error and abort — the same observable outcome as Hermes' null-host + // default, but surfaced through the host logger. `err` is only valid for + // the duration of this call, so it is stringified before returning. + // TODO: Route through React Native's error handling (ErrorUtils / LogBox), + // with abort() as the fallback, to get closer to Node's observable and + // handleable 'uncaughtException' — note node-addon-api calls + // napi_fatal_exception whenever an exception escapes a thread-safe + // function callback, so today a single throwing tsfn callback is fatal. + log_error("napi_fatal_exception: %s", describeError(env, err).c_str()); + abort(); +} + +} // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/HermesNapiHost.hpp b/packages/host/cpp/HermesNapiHost.hpp new file mode 100644 index 00000000..3d8f314a --- /dev/null +++ b/packages/host/cpp/HermesNapiHost.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include + +#include +#include + +// Mirror of the host-integration interface declared by the vendored Hermes in +// API/napi/hermes_napi.h. We mirror it here (rather than including that +// header) to avoid pulling in Hermes' own node_api.h alongside the +// weak-node-api copy already included transitively. +// +// IMPORTANT: member order and types must match API/napi/hermes_napi.h at the +// commit pinned as HERMES_GIT_SHA in src/node/cli/hermes.ts — re-diff this +// struct against that header whenever the pin is bumped. +// +// The declarations must be `extern "C"`: since facebook/hermes#2106 (included +// in the pinned Hermes commit) the public hermes_napi.h wraps its entry points +// in `extern "C"`, so Hermes exports the unmangled C symbol. Without matching +// C linkage here the reference would be to the C++-mangled name and the app +// fails to link ("Undefined symbol: hermes_napi_create_env"). +extern "C" { +struct uv_loop_s; + +struct hermes_napi_host { + /// Schedule `execute` to run on a worker thread. When execute completes, + /// schedule `complete` to run on the main (JS) thread with napi_ok, or with + /// napi_cancelled if the work was cancelled before it started. + void (*post_work)(void *loop_data, void *work_data, + void (*execute)(void *work_data), + void (*complete)(void *work_data, napi_status status)); + + /// Attempt to cancel a previously posted work item. Returns true if the + /// work was still queued (its `complete` will run with napi_cancelled), + /// false if it already started or completed. + bool (*cancel_work)(void *loop_data, void *work_data); + + /// Schedule `callback` to run on the main (JS) thread. Used by thread-safe + /// functions to dispatch queued calls; may be invoked from any thread. + void (*post_task)(void *loop_data, void *task_data, + void (*callback)(void *task_data)); + + /// Opaque pointer passed as `loop_data` to the callbacks above. + void *data; + + /// If non-null, napi_get_uv_event_loop() returns this pointer. + struct uv_loop_s *uv_loop; + + /// If non-null, called by napi_fatal_exception() instead of aborting. + void (*fatal_exception)(void *data, napi_env env, napi_value err); + + /// Optional libuv-style loop refs used by thread-safe functions; may both + /// be null, in which case tsfn ref/unref are tracked but inert. + void (*ref_loop)(void *loop_data); + void (*unref_loop)(void *loop_data); +}; + +napi_env hermes_napi_create_env(void *hermes_runtime, hermes_napi_host *host); +} + +namespace callstack::react_native_node_api { + +/// Provides the `hermes_napi_host` integration for the Hermes Node-API +/// environments created by the host: a worker pool backing +/// napi_queue_async_work / napi_cancel_async_work and a JS-thread dispatcher +/// backing thread-safe functions. +/// +/// One instance exists per React Native runtime. The JS-thread hop is +/// type-erased as `JsDispatcher` (backed by CallInvoker::invokeAsync in the +/// app) so this class has no React Native dependencies and its threading +/// machinery can be exercised by plain C++ tests. +class HostContext : public std::enable_shared_from_this { +public: + /// Dispatches a function onto the JS thread, returning whether it was + /// accepted for delivery. Implementations must be safe to call from + /// arbitrary threads, must never run the function inline and must deliver + /// accepted functions one at a time, in order, on the single JS thread. + /// Returning false (and dropping the function) is only acceptable once the + /// JS runtime is gone — callers use the verdict to report outcomes + /// truthfully, e.g. cancel_work only claims success while the cancelled + /// completion can actually be delivered. + using JsDispatcher = std::function &&)>; + + static std::shared_ptr create(JsDispatcher dispatchToJs); + + /// Keep `context` alive for the remaining lifetime of the process. The + /// Hermes env reads the host struct during Runtime teardown *after* running + /// env cleanup hooks (napi_env__::shutdown() runs cleanup hooks first, then + /// hermes_napi_cleanup_tsfns, which reaches host_->unref_loop through + /// releaseTsfnLoopRef — verified at the pinned Hermes commit), so no + /// cleanup hook can tell us when the last env is truly done with the + /// struct. Retaining the context forever guarantees the documented + /// contract that the struct outlives every env it was passed to, at the + /// cost of a small allocation per React Native runtime (i.e. per reload). + static void retainForProcessLifetime(std::shared_ptr context); + + /// The struct to pass to hermes_napi_create_env. Owned by this context. + hermes_napi_host *host() { return &host_; } + + bool dispatchToJs(std::function &&fn) { + return dispatchToJs_(std::move(fn)); + } + + // host_.data points at this object and the static callbacks cast it back, + // so a copied or moved instance would service callbacks meant for another. + HostContext(const HostContext &) = delete; + HostContext &operator=(const HostContext &) = delete; + +private: + explicit HostContext(JsDispatcher dispatchToJs); + + static void postWork(void *loop_data, void *work_data, + void (*execute)(void *work_data), + void (*complete)(void *work_data, + napi_status status)) noexcept; + static bool cancelWork(void *loop_data, void *work_data) noexcept; + static void postTask(void *loop_data, void *task_data, + void (*callback)(void *task_data)) noexcept; + static void fatalException(void *data, napi_env env, napi_value err) noexcept; + + JsDispatcher dispatchToJs_; + hermes_napi_host host_; +}; + +} // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/RuntimeNodeApiAsync.cpp b/packages/host/cpp/RuntimeNodeApiAsync.cpp deleted file mode 100644 index 647aa71b..00000000 --- a/packages/host/cpp/RuntimeNodeApiAsync.cpp +++ /dev/null @@ -1,200 +0,0 @@ -#include "RuntimeNodeApiAsync.hpp" -#include "Logger.hpp" -#include - -struct AsyncJob { - using IdType = uint64_t; - enum State { Created, Queued, Completed, Cancelled, Deleted }; - - IdType id{}; - State state{}; - napi_env env; - napi_value async_resource; - napi_value async_resource_name; - napi_async_execute_callback execute; - napi_async_complete_callback complete; - void *data{nullptr}; - - static AsyncJob *fromWork(napi_async_work work) { - return reinterpret_cast(work); - } - static napi_async_work toWork(AsyncJob *job) { - return reinterpret_cast(job); - } -}; - -class AsyncWorkRegistry { -public: - using IdType = AsyncJob::IdType; - - std::shared_ptr create(napi_env env, napi_value async_resource, - napi_value async_resource_name, - napi_async_execute_callback execute, - napi_async_complete_callback complete, - void *data) { - const auto job = std::shared_ptr(new AsyncJob{ - .id = next_id(), - .state = AsyncJob::State::Created, - .env = env, - .async_resource = async_resource, - .async_resource_name = async_resource_name, - .execute = execute, - .complete = complete, - .data = data, - }); - - jobs_[job->id] = job; - return job; - } - - std::shared_ptr get(napi_async_work work) const { - const auto job = AsyncJob::fromWork(work); - if (!job) { - return {}; - } - if (const auto it = jobs_.find(job->id); it != jobs_.end()) { - return it->second; - } - return {}; - } - - bool release(IdType id) { - if (const auto it = jobs_.find(id); it != jobs_.end()) { - it->second->state = AsyncJob::State::Deleted; - jobs_.erase(it); - return true; - } - return false; - } - -private: - IdType next_id() { - if (current_id_ == std::numeric_limits::max()) [[unlikely]] { - current_id_ = 0; - } - return ++current_id_; - } - - IdType current_id_{0}; - std::unordered_map> jobs_; -}; - -static std::unordered_map> - callInvokers; -static AsyncWorkRegistry asyncWorkRegistry; - -namespace callstack::react_native_node_api { - -// Drop an env's entry when the env is torn down with its runtime (on a reload, -// for example). There is one env per addon, so without this the map keeps a -// stale entry per addon per runtime for the lifetime of the process. -static void NAPI_CDECL removeCallInvoker(void *env) { - callInvokers.erase(static_cast(env)); -} - -void setCallInvoker( - napi_env env, - const std::shared_ptr &invoker) { - const bool isFirstForEnv = !callInvokers.contains(env); - callInvokers[env] = invoker; - if (isFirstForEnv) { - ::napi_add_env_cleanup_hook(env, removeCallInvoker, env); - } -} - -std::weak_ptr getCallInvoker(napi_env env) { - return callInvokers.contains(env) - ? callInvokers[env] - : std::weak_ptr{}; -} - -napi_status napi_create_async_work(napi_env env, napi_value async_resource, - napi_value async_resource_name, - napi_async_execute_callback execute, - napi_async_complete_callback complete, - void *data, napi_async_work *result) { - const auto job = asyncWorkRegistry.create( - env, async_resource, async_resource_name, execute, complete, data); - if (!job) { - log_debug("Error: Failed to create async work job"); - return napi_generic_failure; - } - - *result = AsyncJob::toWork(job.get()); - return napi_ok; -} - -napi_status napi_queue_async_work(node_api_basic_env env, - napi_async_work work) { - const auto job = asyncWorkRegistry.get(work); - if (!job) { - log_debug("Error: Received null job in napi_queue_async_work"); - return napi_invalid_arg; - } - - const auto invoker = getCallInvoker(env).lock(); - if (!invoker) { - log_debug("Error: No CallInvoker available for async work"); - return napi_invalid_arg; - } - - invoker->invokeAsync([env, weakJob = std::weak_ptr{job}]() { - const auto job = weakJob.lock(); - if (!job) { - log_debug("Error: Async job has been deleted before execution"); - return; - } - if (job->state == AsyncJob::State::Queued) { - job->execute(job->env, job->data); - } - - job->complete(env, - job->state == AsyncJob::State::Cancelled ? napi_cancelled - : napi_ok, - job->data); - job->state = AsyncJob::State::Completed; - }); - - job->state = AsyncJob::State::Queued; - return napi_ok; -} - -napi_status napi_delete_async_work(node_api_basic_env env, - napi_async_work work) { - const auto job = asyncWorkRegistry.get(work); - if (!job) { - log_debug("Error: Received non-existent job in napi_delete_async_work"); - return napi_invalid_arg; - } - - if (!asyncWorkRegistry.release(job->id)) { - log_debug("Error: Failed to release async work job"); - return napi_generic_failure; - } - - return napi_ok; -} - -napi_status napi_cancel_async_work(node_api_basic_env env, - napi_async_work work) { - const auto job = asyncWorkRegistry.get(work); - if (!job) { - log_debug("Error: Received null job in napi_cancel_async_work"); - return napi_invalid_arg; - } - switch (job->state) { - case AsyncJob::State::Completed: - log_debug("Error: Cannot cancel async work that is already completed"); - return napi_generic_failure; - case AsyncJob::State::Deleted: - log_debug("Warning: Async work job is already deleted"); - return napi_generic_failure; - case AsyncJob::State::Cancelled: - log_debug("Warning: Async work job is already cancelled"); - return napi_ok; - } - - job->state = AsyncJob::State::Cancelled; - return napi_ok; -} -} // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/RuntimeNodeApiAsync.hpp b/packages/host/cpp/RuntimeNodeApiAsync.hpp deleted file mode 100644 index be20128c..00000000 --- a/packages/host/cpp/RuntimeNodeApiAsync.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "node_api.h" -#include -#include - -namespace callstack::react_native_node_api { -void setCallInvoker( - napi_env env, const std::shared_ptr &invoker); - -napi_status napi_create_async_work(napi_env env, napi_value async_resource, - napi_value async_resource_name, - napi_async_execute_callback execute, - napi_async_complete_callback complete, - void *data, napi_async_work *result); - -napi_status napi_queue_async_work(node_api_basic_env env, napi_async_work work); - -napi_status napi_delete_async_work(node_api_basic_env env, - napi_async_work work); - -napi_status napi_cancel_async_work(node_api_basic_env env, - napi_async_work work); -} // namespace callstack::react_native_node_api diff --git a/packages/host/package.json b/packages/host/package.json index dfcf1d43..0209c52c 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -46,6 +46,9 @@ "injector:generate": "node scripts/generate-injector.mts", "test": "tsx --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout src/node/**/*.test.ts src/node/*.test.ts", "test:gradle": "ENABLE_GRADLE_TESTS=true node --run test", + "test:configure": "cmake -S tests -B tests/build", + "test:build": "cmake --build tests/build", + "test:run": "ctest --test-dir tests/build --output-on-failure", "bootstrap": "node --run injector:generate", "prerelease": "node --run injector:generate" }, diff --git a/packages/host/scripts/generate-injector.mts b/packages/host/scripts/generate-injector.mts index d5c6cfd3..c58bb2f5 100644 --- a/packages/host/scripts/generate-injector.mts +++ b/packages/host/scripts/generate-injector.mts @@ -20,7 +20,6 @@ export function generateSource(functions: FunctionDecl[]) { #include #include - #include #if defined(__APPLE__) #define WEAK_NODE_API_LIBRARY_NAME "@rpath/weak-node-api.framework/weak-node-api" diff --git a/packages/host/src/node/cli/hermes.ts b/packages/host/src/node/cli/hermes.ts index e9893d5c..b1f74a3c 100644 --- a/packages/host/src/node/cli/hermes.ts +++ b/packages/host/src/node/cli/hermes.ts @@ -39,6 +39,11 @@ const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; // libraries, so `libhermesvm.so` ended up with undefined references to // `facebook::jsi::Serialized` that nothing in the APK defined, and the app died // on startup with "cannot locate symbol _ZTIN8facebook3jsi10SerializedE". +// +// When bumping this pin, re-diff the `hermes_napi_host` mirror in +// 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"; const platformOption = new Option( diff --git a/packages/host/tests/CMakeLists.txt b/packages/host/tests/CMakeLists.txt new file mode 100644 index 00000000..43723bbb --- /dev/null +++ b/packages/host/tests/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.19) +project(react-native-node-api-host-tests) + +find_package(Threads REQUIRED) + +# Build weak-node-api from source for the host platform: it provides the +# node_api.h headers HermesNapiHost.hpp needs and the napi_* symbols the +# fatal-exception path references at link time. Requires the generated sources +# from `pnpm --filter weak-node-api run prebuild:prepare`. +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../weak-node-api weak-node-api) + +Include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.11.0 +) + +FetchContent_MakeAvailable(Catch2) + +add_executable(node-api-host-tests + test_hermes_napi_host.cpp + ../cpp/HermesNapiHost.cpp + ../cpp/Logger.cpp +) +target_include_directories(node-api-host-tests PRIVATE ../cpp) +target_link_libraries(node-api-host-tests + PRIVATE + weak-node-api + Catch2::Catch2WithMain + Threads::Threads +) + +target_compile_features(node-api-host-tests PRIVATE cxx_std_20) +target_compile_definitions(node-api-host-tests PRIVATE NAPI_VERSION=10) + +# As per https://github.com/catchorg/Catch2/blob/devel/docs/cmake-integration.md#catchcmake-and-catchaddtestscmake +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +include(CTest) +include(Catch) +catch_discover_tests(node-api-host-tests) diff --git a/packages/host/tests/test_hermes_napi_host.cpp b/packages/host/tests/test_hermes_napi_host.cpp new file mode 100644 index 00000000..cbe52368 --- /dev/null +++ b/packages/host/tests/test_hermes_napi_host.cpp @@ -0,0 +1,426 @@ +// Exercises the hermes_napi_host implementation (HermesNapiHost.cpp) from the +// Hermes side of the contract: the tests stand in for the calls Hermes' NAPI +// makes through the struct (napi_queue_async_work -> post_work, +// napi_cancel_async_work -> cancel_work, tsfn dispatch -> post_task), with a +// manually drained queue standing in for the CallInvoker-backed JS thread. +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace callstack::react_native_node_api; +using namespace std::chrono_literals; + +namespace { + +// Stands in for the JS thread: functions are queued by the dispatcher (from +// any thread) and only run when the test drains the queue. Flipping +// setAccepting(false) models the CallInvoker expiring on runtime teardown: +// the dispatcher rejects the function and drops it. +struct FakeJsQueue { + HostContext::JsDispatcher dispatcher() { + return [this](std::function &&fn) -> bool { + if (!accepting_.load()) { + return false; + } + { + std::lock_guard lock(mutex_); + queue_.push_back(std::move(fn)); + } + cv_.notify_all(); + return true; + }; + } + + void setAccepting(bool accepting) { accepting_.store(accepting); } + + // Runs queued functions one at a time until the queue is empty, including + // functions queued reentrantly while draining. Returns how many ran. + size_t drain() { + size_t count = 0; + for (;;) { + std::function fn; + { + std::lock_guard lock(mutex_); + if (queue_.empty()) { + return count; + } + fn = std::move(queue_.front()); + queue_.pop_front(); + } + fn(); + count++; + } + } + + bool waitForItems(size_t count, std::chrono::milliseconds timeout = 5s) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, + [&] { return queue_.size() >= count; }); + } + + size_t size() { + std::lock_guard lock(mutex_); + return queue_.size(); + } + +private: + std::atomic accepting_{true}; + std::mutex mutex_; + std::condition_variable cv_; + std::deque> queue_; +}; + +// A work payload whose execute blocks until the gate opens, for holding +// worker threads busy or keeping an item observably "running". +struct GatedWork { + std::mutex mutex; + std::condition_variable cv; + bool open = false; + std::atomic started{0}; + std::atomic completions{0}; + std::atomic executions{0}; + napi_status lastStatus = napi_ok; + + static void execute(void *data) { + auto *self = static_cast(data); + self->executions++; + self->started++; + std::unique_lock lock(self->mutex); + self->cv.wait(lock, [self] { return self->open; }); + } + + static void complete(void *data, napi_status status) { + auto *self = static_cast(data); + self->lastStatus = status; + self->completions++; + } + + void openGate() { + { + std::lock_guard lock(mutex); + open = true; + } + cv.notify_all(); + } + + void waitForStarted(int count) { + while (started.load() < count) { + std::this_thread::sleep_for(1ms); + } + } +}; + +// Matches WorkerPool::kThreadCount in HermesNapiHost.cpp; saturating all +// workers keeps a subsequently posted item deterministically queued. +constexpr int kWorkerCount = 4; + +// Fills every pool worker with its own gate-blocked job, so a subsequently +// posted item deterministically stays queued. Jobs are heap-allocated and +// deliberately leaked: their completions may never run (e.g. when delivery is +// rejected) and workers may still touch them when a test ends. +std::vector saturatePool(hermes_napi_host *host) { + std::vector jobs; + for (int i = 0; i < kWorkerCount; i++) { + auto *job = new GatedWork(); + host->post_work(host->data, job, GatedWork::execute, GatedWork::complete); + jobs.push_back(job); + } + for (auto *job : jobs) { + job->waitForStarted(1); + } + return jobs; +} + +} // namespace + +TEST_CASE("post_work runs execute off the posting thread and delivers " + "complete(napi_ok) through the dispatcher") { + FakeJsQueue js; + auto context = HostContext::create(js.dispatcher()); + hermes_napi_host *host = context->host(); + + struct Work { + std::thread::id executeThread{}; + std::atomic executed{false}; + std::atomic completions{0}; + napi_status status = napi_cancelled; + } work; + + host->post_work( + host->data, &work, + [](void *data) { + auto *w = static_cast(data); + w->executeThread = std::this_thread::get_id(); + w->executed = true; + }, + [](void *data, napi_status status) { + auto *w = static_cast(data); + w->status = status; + w->completions++; + }); + + // The completion is posted to the JS queue once execute finished on a + // worker thread — and must not have run inline. + REQUIRE(js.waitForItems(1)); + REQUIRE(work.executed.load()); + REQUIRE(work.executeThread != std::this_thread::get_id()); + REQUIRE(work.completions.load() == 0); + REQUIRE(js.drain() == 1); + REQUIRE(work.completions.load() == 1); + REQUIRE(work.status == napi_ok); +} + +TEST_CASE("cancel_work cancels queued items and rejects started items") { + FakeJsQueue js; + auto context = HostContext::create(js.dispatcher()); + hermes_napi_host *host = context->host(); + + SECTION("a queued item is cancelled: execute skipped, complete gets " + "napi_cancelled, a second cancel fails") { + auto busy = saturatePool(host); + + // Every worker is blocked on a gate, so this item stays queued. + auto *target = new GatedWork(); + host->post_work(host->data, target, GatedWork::execute, + GatedWork::complete); + REQUIRE(host->cancel_work(host->data, target)); + // Cancelling the same item again fails: it is no longer queued. + REQUIRE(!host->cancel_work(host->data, target)); + + REQUIRE(js.waitForItems(1)); + REQUIRE(js.drain() == 1); + REQUIRE(target->completions.load() == 1); + REQUIRE(target->lastStatus == napi_cancelled); + REQUIRE(target->executions.load() == 0); + + for (auto *job : busy) { + job->openGate(); + } + REQUIRE(js.waitForItems(kWorkerCount)); + REQUIRE(js.drain() == kWorkerCount); + for (auto *job : busy) { + REQUIRE(job->completions.load() == 1); + } + } + + SECTION("an item that started executing cannot be cancelled") { + auto *target = new GatedWork(); + host->post_work(host->data, target, GatedWork::execute, + GatedWork::complete); + target->waitForStarted(1); + REQUIRE(!host->cancel_work(host->data, target)); + target->openGate(); + REQUIRE(js.waitForItems(1)); + REQUIRE(js.drain() == 1); + REQUIRE(target->completions.load() == 1); + REQUIRE(target->lastStatus == napi_ok); + REQUIRE(target->executions.load() == 1); + delete target; + } +} + +TEST_CASE("queueing the same work item twice drops the duplicate") { + FakeJsQueue js; + auto context = HostContext::create(js.dispatcher()); + hermes_napi_host *host = context->host(); + + auto busy = saturatePool(host); + + auto *target = new GatedWork(); + host->post_work(host->data, target, GatedWork::execute, GatedWork::complete); + // Double-queueing is undefined behavior in Node (libuv aborts); the pool + // drops the duplicate so the one-completion invariant holds. + host->post_work(host->data, target, GatedWork::execute, GatedWork::complete); + + // Exactly one queue entry exists: the first cancel claims it, the second + // finds nothing, and precisely one napi_cancelled completion arrives. + REQUIRE(host->cancel_work(host->data, target)); + REQUIRE(!host->cancel_work(host->data, target)); + REQUIRE(js.waitForItems(1)); + REQUIRE(js.drain() == 1); + REQUIRE(target->completions.load() == 1); + REQUIRE(target->lastStatus == napi_cancelled); + REQUIRE(target->executions.load() == 0); + + for (auto *job : busy) { + job->openGate(); + } + REQUIRE(js.waitForItems(kWorkerCount)); + REQUIRE(js.drain() == kWorkerCount); +} + +TEST_CASE("cancel_work racing worker pickup yields exactly one outcome") { + FakeJsQueue js; + auto context = HostContext::create(js.dispatcher()); + hermes_napi_host *host = context->host(); + + struct Work { + std::atomic executions{0}; + std::atomic completions{0}; + std::atomic status{napi_generic_failure}; + }; + + for (int i = 0; i < 200; i++) { + Work work; + host->post_work( + host->data, &work, + [](void *data) { static_cast(data)->executions++; }, + [](void *data, napi_status status) { + auto *w = static_cast(data); + w->status = status; + w->completions++; + }); + bool cancelled = host->cancel_work(host->data, &work); + + // Exactly one completion arrives either way... + REQUIRE(js.waitForItems(1)); + REQUIRE(js.drain() == 1); + REQUIRE(work.completions.load() == 1); + // ...and it matches whether execute ran: cancelled XOR executed. + if (cancelled) { + REQUIRE(work.executions.load() == 0); + REQUIRE(work.status.load() == napi_cancelled); + } else { + REQUIRE(work.executions.load() == 1); + REQUIRE(work.status.load() == napi_ok); + } + } +} + +TEST_CASE("post_task delivers exactly once, in order and never inline") { + FakeJsQueue js; + auto context = HostContext::create(js.dispatcher()); + hermes_napi_host *host = context->host(); + + SECTION("a task posted from the current thread does not run inline") { + std::atomic runs{0}; + auto callback = [](void *data) { + static_cast *>(data)->fetch_add(1); + }; + host->post_task(host->data, &runs, callback); + REQUIRE(runs.load() == 0); + REQUIRE(js.drain() == 1); + REQUIRE(runs.load() == 1); + } + + SECTION("tasks are delivered in posting order") { + std::vector order; + struct Task { + std::vector *order; + int value; + }; + std::vector tasks; + for (int i = 0; i < 10; i++) { + tasks.push_back(Task{&order, i}); + } + for (auto &task : tasks) { + host->post_task(host->data, &task, [](void *data) { + auto *t = static_cast(data); + t->order->push_back(t->value); + }); + } + REQUIRE(js.drain() == 10); + REQUIRE(order == std::vector{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}); + } + + SECTION("tasks posted concurrently from many threads are all delivered") { + constexpr int kThreads = 8; + constexpr int kPostsPerThread = 100; + std::atomic runs{0}; + std::vector producers; + for (int i = 0; i < kThreads; i++) { + producers.emplace_back([&] { + for (int j = 0; j < kPostsPerThread; j++) { + host->post_task(host->data, &runs, [](void *data) { + static_cast *>(data)->fetch_add(1); + }); + } + }); + } + for (auto &producer : producers) { + producer.join(); + } + REQUIRE(js.drain() == kThreads * kPostsPerThread); + REQUIRE(runs.load() == kThreads * kPostsPerThread); + } + + SECTION("a task can repost itself from inside its own callback, as Hermes' " + "tsfn dispatch does") { + struct Repost { + hermes_napi_host *host; + std::atomic runs{0}; + + static void callback(void *data) { + auto *self = static_cast(data); + if (self->runs.fetch_add(1) + 1 < 5) { + self->host->post_task(self->host->data, self, &Repost::callback); + } + } + } repost{host, {}}; + host->post_task(host->data, &repost, &Repost::callback); + // The drain loop keeps going until reposted tasks stop arriving. + REQUIRE(js.drain() == 5); + REQUIRE(repost.runs.load() == 5); + } +} + +TEST_CASE("a dispatcher that stops accepting (runtime teardown) fails " + "cancellations and drops completions") { + // Models the state production actually reaches: the HostContext is retained + // for the process lifetime, but its dispatcher's CallInvoker expires with + // the runtime, so dispatchToJs starts returning false. + FakeJsQueue js; + auto context = HostContext::create(js.dispatcher()); + hermes_napi_host *host = context->host(); + + SECTION("cancel_work reports failure when the cancelled completion can no " + "longer be delivered") { + auto busy = saturatePool(host); + + auto *target = new GatedWork(); + host->post_work(host->data, target, GatedWork::execute, + GatedWork::complete); + js.setAccepting(false); + // The item is removed from the queue, but the cancelled completion cannot + // be delivered — so the cancellation must not claim success. + REQUIRE(!host->cancel_work(host->data, target)); + for (auto *job : busy) { + job->openGate(); + } + std::this_thread::sleep_for(100ms); + REQUIRE(js.size() == 0); + REQUIRE(target->executions.load() == 0); + REQUIRE(target->completions.load() == 0); + } + + SECTION("a completion for executed work is dropped, not crashed") { + auto *work = new GatedWork(); + host->post_work(host->data, work, GatedWork::execute, GatedWork::complete); + work->waitForStarted(1); + js.setAccepting(false); + work->openGate(); + std::this_thread::sleep_for(100ms); + REQUIRE(js.size() == 0); + REQUIRE(work->completions.load() == 0); + } + + SECTION("a rejected post_task is dropped, not crashed") { + js.setAccepting(false); + std::atomic runs{0}; + host->post_task(host->data, &runs, [](void *data) { + static_cast *>(data)->fetch_add(1); + }); + REQUIRE(js.size() == 0); + REQUIRE(js.drain() == 0); + REQUIRE(runs.load() == 0); + } +} diff --git a/packages/node-addon-examples/src/index.ts b/packages/node-addon-examples/src/index.ts index 869d4825..68bac88a 100644 --- a/packages/node-addon-examples/src/index.ts +++ b/packages/node-addon-examples/src/index.ts @@ -77,13 +77,16 @@ export const suites: Record< }, ["hello world"]), }, "5-async-work": { - // TODO: This crashes (SIGABRT) - // "async_work_thread_safe_function": () => require("../examples/5-async-work/async_work_thread_safe_function/napi/index.js"), + async_work_thread_safe_function: () => { + require("../examples/5-async-work/async_work_thread_safe_function/napi/index.js"); + }, }, tests: { buffers: () => { require("../tests/buffers/addon.js"); }, async: () => require("../tests/async/addon.js") as () => Promise, + "threadsafe-function": () => + require("../tests/threadsafe-function/addon.js") as () => Promise, }, }; diff --git a/packages/node-addon-examples/tests/async/addon.c b/packages/node-addon-examples/tests/async/addon.c index 9444aacf..b385c50f 100644 --- a/packages/node-addon-examples/tests/async/addon.c +++ b/packages/node-addon-examples/tests/async/addon.c @@ -1,5 +1,7 @@ #include #include +#include +#include #include #include #include @@ -242,11 +244,152 @@ static napi_value DoRepeatedWork(napi_env env, napi_callback_info info) { return NULL; } +// The thread the addon was initialized on, i.e. the JS thread. +static pthread_t js_thread; + +typedef struct { + pthread_t execute_thread; + napi_ref callback; + napi_async_work work; +} thread_check_carrier; + +static thread_check_carrier thread_check; + +static void ThreadCheckExecute(napi_env env, void* data) { + thread_check_carrier* c = (thread_check_carrier*)data; + c->execute_thread = pthread_self(); +} + +static void ThreadCheckComplete(napi_env env, napi_status status, void* data) { + thread_check_carrier* c = (thread_check_carrier*)data; + napi_value argv[2]; + NODE_API_CALL_RETURN_VOID(env, + napi_get_boolean( + env, !pthread_equal(c->execute_thread, js_thread), &argv[0])); + NODE_API_CALL_RETURN_VOID(env, + napi_get_boolean(env, pthread_equal(pthread_self(), js_thread), &argv[1])); + napi_value callback; + NODE_API_CALL_RETURN_VOID( + env, napi_get_reference_value(env, c->callback, &callback)); + napi_value global; + NODE_API_CALL_RETURN_VOID(env, napi_get_global(env, &global)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, global, callback, 2, argv, NULL)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, c->callback)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_async_work(env, c->work)); +} + +// Queues work whose execute records its thread; the callback receives +// (executeRanOffJsThread, completeRanOnJsThread) booleans. +static napi_value TestExecuteThread(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value cb, resource_name; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, &cb, NULL, NULL)); + NODE_API_CALL(env, napi_create_reference(env, cb, 1, &thread_check.callback)); + NODE_API_CALL(env, + napi_create_string_utf8( + env, "TestExecuteThread", NAPI_AUTO_LENGTH, &resource_name)); + NODE_API_CALL(env, + napi_create_async_work(env, + NULL, + resource_name, + ThreadCheckExecute, + ThreadCheckComplete, + &thread_check, + &thread_check.work)); + NODE_API_CALL(env, napi_queue_async_work(env, thread_check.work)); + return NULL; +} + +static atomic_bool gate_open; +static atomic_bool gate_started; + +typedef struct { + napi_ref callback; + napi_async_work work; +} gated_carrier; + +static gated_carrier gated; + +static void GatedExecute(napi_env env, void* data) { + atomic_store(&gate_started, true); + while (!atomic_load(&gate_open)) { + sleep_ms(1); + } +} + +static void GatedComplete(napi_env env, napi_status status, void* data) { + gated_carrier* c = (gated_carrier*)data; + napi_value argv[1]; + NODE_API_CALL_RETURN_VOID( + env, napi_create_uint32(env, (uint32_t)status, &argv[0])); + napi_value callback; + NODE_API_CALL_RETURN_VOID( + env, napi_get_reference_value(env, c->callback, &callback)); + napi_value global; + NODE_API_CALL_RETURN_VOID(env, napi_get_global(env, &global)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, global, callback, 1, argv, NULL)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, c->callback)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_async_work(env, c->work)); +} + +// Queues work whose execute blocks until ReleaseGate() is called from JS. If +// execute ran on the JS thread (as the pre-hermes_napi_host implementation +// did), the JS thread could never call ReleaseGate and the test would hang. +static napi_value TestBlockingExecute(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value cb, resource_name; + atomic_store(&gate_open, false); + atomic_store(&gate_started, false); + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, &cb, NULL, NULL)); + NODE_API_CALL(env, napi_create_reference(env, cb, 1, &gated.callback)); + NODE_API_CALL(env, + napi_create_string_utf8( + env, "TestBlockingExecute", NAPI_AUTO_LENGTH, &resource_name)); + NODE_API_CALL(env, + napi_create_async_work(env, + NULL, + resource_name, + GatedExecute, + GatedComplete, + &gated, + &gated.work)); + NODE_API_CALL(env, napi_queue_async_work(env, gated.work)); + return NULL; +} + +static napi_value HasStarted(napi_env env, napi_callback_info info) { + napi_value result; + NODE_API_CALL(env, napi_get_boolean(env, atomic_load(&gate_started), &result)); + return result; +} + +static napi_value ReleaseGate(napi_env env, napi_callback_info info) { + atomic_store(&gate_open, true); + return NULL; +} + +// Attempts to cancel the gated work and returns napi_cancel_async_work's +// status as a number, so JS can assert cancelling running work fails. +static napi_value CancelGated(napi_env env, napi_callback_info info) { + napi_status status = napi_cancel_async_work(env, gated.work); + napi_value result; + NODE_API_CALL(env, napi_create_uint32(env, (uint32_t)status, &result)); + return result; +} + static napi_value Init(napi_env env, napi_value exports) { + js_thread = pthread_self(); napi_property_descriptor properties[] = { DECLARE_NODE_API_PROPERTY("Test", Test), DECLARE_NODE_API_PROPERTY("TestCancel", TestCancel), DECLARE_NODE_API_PROPERTY("DoRepeatedWork", DoRepeatedWork), + DECLARE_NODE_API_PROPERTY("TestExecuteThread", TestExecuteThread), + DECLARE_NODE_API_PROPERTY("TestBlockingExecute", TestBlockingExecute), + DECLARE_NODE_API_PROPERTY("HasStarted", HasStarted), + DECLARE_NODE_API_PROPERTY("ReleaseGate", ReleaseGate), + DECLARE_NODE_API_PROPERTY("CancelGated", CancelGated), }; NODE_API_CALL(env, diff --git a/packages/node-addon-examples/tests/async/addon.js b/packages/node-addon-examples/tests/async/addon.js index a4a4bc6e..92821bfc 100644 --- a/packages/node-addon-examples/tests/async/addon.js +++ b/packages/node-addon-examples/tests/async/addon.js @@ -41,6 +41,78 @@ const doRepeatedWork = (count = 0) => test_async.DoRepeatedWork(workDone); }); -module.exports = () => { - return Promise.all([test(), testCancel(), doRepeatedWork()]); +const testExecuteThread = () => + new Promise((resolve, reject) => { + test_async.TestExecuteThread((executeOffJsThread, completeOnJsThread) => { + try { + assert.strictEqual( + executeOffJsThread, + true, + "expected execute to run off the JS thread", + ); + assert.strictEqual( + completeOnJsThread, + true, + "expected complete to run on the JS thread", + ); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForExecuteStart = async () => { + while (!test_async.HasStarted()) { + await delay(1); + } +}; + +const testBlockingExecute = async () => { + let completed = false; + const completion = new Promise((resolve, reject) => { + test_async.TestBlockingExecute((status) => { + completed = true; + try { + assert.strictEqual(status, 0 /* napi_ok */); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + await waitForExecuteStart(); + assert.strictEqual(completed, false); + test_async.ReleaseGate(); + await completion; +}; + +const testCancelRunning = async () => { + const completion = new Promise((resolve, reject) => { + test_async.TestBlockingExecute((status) => { + try { + assert.strictEqual(status, 0 /* napi_ok */); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + await waitForExecuteStart(); + // The work is executing, so cancellation must fail (unlike TestCancel, + // which cancels work that is still queued). + const status = test_async.CancelGated(); + assert.strictEqual(status, 9 /* napi_generic_failure */); + test_async.ReleaseGate(); + await completion; +}; + +module.exports = async () => { + await Promise.all([test(), testCancel(), doRepeatedWork()]); + // The gated tests share state in the addon, so they run sequentially. + await testExecuteThread(); + await testBlockingExecute(); + await testCancelRunning(); }; diff --git a/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt b/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt new file mode 100644 index 00000000..1be47aff --- /dev/null +++ b/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.15...3.31) +project(threadsafe-function-test) + +find_package(weak-node-api REQUIRED CONFIG) + +add_library(addon SHARED addon.c) + +option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) + +if(APPLE AND BUILD_APPLE_FRAMEWORK) + set_target_properties(addon PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER threadsafe-function-test.addon + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + XCODE_ATTRIBUTE_SKIP_INSTALL NO + ) +else() + set_target_properties(addon PROPERTIES + PREFIX "" + SUFFIX .node + ) +endif() + +target_link_libraries(addon PRIVATE weak-node-api) +target_compile_features(addon PRIVATE cxx_std_17) diff --git a/packages/node-addon-examples/tests/threadsafe-function/addon.c b/packages/node-addon-examples/tests/threadsafe-function/addon.c new file mode 100644 index 00000000..ea694aa2 --- /dev/null +++ b/packages/node-addon-examples/tests/threadsafe-function/addon.c @@ -0,0 +1,407 @@ +// Ported from Node.js' test/node-api/test_threadsafe_function/binding.c. +// Upstream uses libuv's threading library; React Native has no libuv, so a +// small pthread-based shim stands in for the uv_* calls. The test logic and +// its assertions are kept as close to upstream as practical, with supplements +// marked as such: the call-into-JS callbacks assert they run on the JS thread, +// and Ref is exported alongside Unref (upstream exercises unref through a +// child-process teardown test, which does not port to React Native). +#include +#include +#include +#include +#include +#include +#include "../RuntimeNodeApiTestsCommon.h" + +// Upstream uses ARRAY_LENGTH 10000 and pauses every 1000 items; scaled down +// to keep the on-device runtime within the test timeout while preserving the +// ratios that matter: ARRAY_LENGTH / 2 must exceed Hermes' 1000-item tsfn +// dispatch budget (kMaxDispatchCount in API/napi/hermes_napi_tsfn.cpp) so the +// final run exercises the budget-exhausted re-post path, and the abort runs +// still get multiple pause windows. +#define ARRAY_LENGTH 2500 +#define MAX_QUEUE_SIZE 2 +#define PAUSE_EVERY 250 + +// pthread-based stand-ins for the libuv threading APIs used upstream. +typedef pthread_t uv_thread_t; +typedef void (*uv_thread_cb)(void* arg); + +typedef struct { + uv_thread_cb entry; + void* arg; +} uv_thread_shim; + +static void* uv_thread_shim_main(void* arg) { + uv_thread_shim shim = *(uv_thread_shim*)arg; + free(arg); + shim.entry(shim.arg); + return NULL; +} + +static int uv_thread_create(uv_thread_t* tid, uv_thread_cb entry, void* arg) { + uv_thread_shim* shim = malloc(sizeof(uv_thread_shim)); + if (shim == NULL) return -1; + shim->entry = entry; + shim->arg = arg; + int result = pthread_create(tid, NULL, uv_thread_shim_main, shim); + if (result != 0) free(shim); + return result; +} + +static int uv_thread_join(uv_thread_t* tid) { return pthread_join(*tid, NULL); } + +static uint64_t uv_hrtime(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000u + (uint64_t)ts.tv_nsec; +} + +// Supplement: the thread the addon was initialized on, i.e. the JS thread. +static pthread_t js_thread; + +static void assert_on_js_thread(const char* who) { + if (!pthread_equal(pthread_self(), js_thread)) { + napi_fatal_error(who, NAPI_AUTO_LENGTH, + "expected to be called on the JS thread", NAPI_AUTO_LENGTH); + } +} + +static uv_thread_t uv_threads[2]; +static napi_threadsafe_function ts_fn; + +typedef struct { + napi_threadsafe_function_call_mode block_on_full; + napi_threadsafe_function_release_mode abort; + bool start_secondary; + napi_ref js_finalize_cb; + uint32_t max_queue_size; +} ts_fn_hint; + +static ts_fn_hint ts_info; + +// Thread data to transmit to JS +static int ints[ARRAY_LENGTH]; + +static void secondary_thread(void* data) { + napi_threadsafe_function ts_fn = data; + + if (napi_release_threadsafe_function(ts_fn, napi_tsfn_release) != napi_ok) { + napi_fatal_error("secondary_thread", NAPI_AUTO_LENGTH, + "napi_release_threadsafe_function failed", NAPI_AUTO_LENGTH); + } +} + +// Source thread producing the data +static void data_source_thread(void* data) { + napi_threadsafe_function ts_fn = data; + int index; + void* hint; + ts_fn_hint* ts_fn_info; + napi_status status; + bool queue_was_full = false; + bool queue_was_closing = false; + + if (napi_get_threadsafe_function_context(ts_fn, &hint) != napi_ok) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "napi_get_threadsafe_function_context failed", NAPI_AUTO_LENGTH); + } + + ts_fn_info = (ts_fn_hint*)hint; + + if (ts_fn_info != &ts_info) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "thread-safe function hint is not as expected", NAPI_AUTO_LENGTH); + } + + if (ts_fn_info->start_secondary) { + if (napi_acquire_threadsafe_function(ts_fn) != napi_ok) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "napi_acquire_threadsafe_function failed", NAPI_AUTO_LENGTH); + } + + if (uv_thread_create(&uv_threads[1], secondary_thread, ts_fn) != 0) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "failed to start secondary thread", NAPI_AUTO_LENGTH); + } + } + + for (index = ARRAY_LENGTH - 1; index > -1 && !queue_was_closing; index--) { + status = napi_call_threadsafe_function(ts_fn, &ints[index], + ts_fn_info->block_on_full); + if (ts_fn_info->max_queue_size == 0 && (index % PAUSE_EVERY == 0)) { + // Let's make this thread really busy for 200 ms to give the main thread + // a chance to abort. + uint64_t start = uv_hrtime(); + for (; uv_hrtime() - start < 200000000;); + } + switch (status) { + case napi_queue_full: + queue_was_full = true; + index++; + // fall through + + case napi_ok: + continue; + + case napi_closing: + queue_was_closing = true; + break; + + default: + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "napi_call_threadsafe_function failed", NAPI_AUTO_LENGTH); + } + } + + // Assert that the enqueuing of a value was refused at least once, if this is + // a non-blocking test run. + if (!ts_fn_info->block_on_full && !queue_was_full) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "queue was never full", NAPI_AUTO_LENGTH); + } + + // Assert that the queue was marked as closing at least once, if this is an + // aborting test run. + if (ts_fn_info->abort == napi_tsfn_abort && !queue_was_closing) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "queue was never closing", NAPI_AUTO_LENGTH); + } + + if (!queue_was_closing && + napi_release_threadsafe_function(ts_fn, napi_tsfn_release) != napi_ok) { + napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH, + "napi_release_threadsafe_function failed", NAPI_AUTO_LENGTH); + } +} + +// Getting the data into JS +static void call_js(napi_env env, napi_value cb, void* hint, void* data) { + if (!(env == NULL || cb == NULL)) { + assert_on_js_thread("call_js"); + napi_value argv, undefined; + NODE_API_CALL_RETURN_VOID(env, napi_create_int32(env, *(int*)data, &argv)); + NODE_API_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, undefined, cb, 1, &argv, NULL)); + } +} + +static napi_ref alt_ref; +// Getting the data into JS with the alternative reference +static void call_ref(napi_env env, napi_value _, void* hint, void* data) { + if (!(env == NULL || alt_ref == NULL)) { + assert_on_js_thread("call_ref"); + napi_value fn, argv, undefined; + NODE_API_CALL_RETURN_VOID(env, napi_get_reference_value(env, alt_ref, &fn)); + NODE_API_CALL_RETURN_VOID(env, napi_create_int32(env, *(int*)data, &argv)); + NODE_API_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, undefined, fn, 1, &argv, NULL)); + } +} + +// Cleanup +static napi_value StopThread(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + napi_valuetype value_type; + NODE_API_CALL(env, napi_typeof(env, argv[0], &value_type)); + NODE_API_ASSERT(env, value_type == napi_function, + "StopThread argument is a function"); + NODE_API_ASSERT(env, (ts_fn != NULL), "Existing threadsafe function"); + NODE_API_CALL(env, + napi_create_reference(env, argv[0], 1, &(ts_info.js_finalize_cb))); + bool abort; + NODE_API_CALL(env, napi_get_value_bool(env, argv[1], &abort)); + NODE_API_CALL(env, + napi_release_threadsafe_function( + ts_fn, abort ? napi_tsfn_abort : napi_tsfn_release)); + ts_fn = NULL; + return NULL; +} + +// Join the thread and inform JS that we're done. +static void join_the_threads(napi_env env, void* data, void* hint) { + assert_on_js_thread("join_the_threads"); + uv_thread_t* the_threads = data; + ts_fn_hint* the_hint = hint; + napi_value js_cb, undefined; + + uv_thread_join(&the_threads[0]); + if (the_hint->start_secondary) { + uv_thread_join(&the_threads[1]); + } + + NODE_API_CALL_RETURN_VOID( + env, napi_get_reference_value(env, the_hint->js_finalize_cb, &js_cb)); + NODE_API_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, undefined, js_cb, 0, NULL, NULL)); + NODE_API_CALL_RETURN_VOID( + env, napi_delete_reference(env, the_hint->js_finalize_cb)); + if (alt_ref != NULL) { + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, alt_ref)); + alt_ref = NULL; + } +} + +static napi_value StartThreadInternal(napi_env env, napi_callback_info info, + napi_threadsafe_function_call_js cb, bool block_on_full, + bool alt_ref_js_cb) { + size_t argc = 4; + napi_value argv[4]; + + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + if (alt_ref_js_cb) { + NODE_API_CALL(env, napi_create_reference(env, argv[0], 1, &alt_ref)); + argv[0] = NULL; + } + + ts_info.block_on_full = + (block_on_full ? napi_tsfn_blocking : napi_tsfn_nonblocking); + + NODE_API_ASSERT(env, (ts_fn == NULL), "Existing thread-safe function"); + napi_value async_name; + NODE_API_CALL(env, + napi_create_string_utf8(env, "Node-API Thread-safe Function Test", + NAPI_AUTO_LENGTH, &async_name)); + NODE_API_CALL(env, + napi_get_value_uint32(env, argv[3], &ts_info.max_queue_size)); + NODE_API_CALL(env, + napi_create_threadsafe_function(env, + argv[0], + NULL, + async_name, + ts_info.max_queue_size, + 2, + uv_threads, + join_the_threads, + &ts_info, + cb, + &ts_fn)); + bool abort; + NODE_API_CALL(env, napi_get_value_bool(env, argv[1], &abort)); + ts_info.abort = abort ? napi_tsfn_abort : napi_tsfn_release; + NODE_API_CALL(env, + napi_get_value_bool(env, argv[2], &(ts_info.start_secondary))); + + NODE_API_ASSERT(env, + (uv_thread_create(&uv_threads[0], data_source_thread, ts_fn) == 0), + "Thread creation"); + + return NULL; +} + +static napi_value Ref(napi_env env, napi_callback_info info) { + NODE_API_ASSERT(env, ts_fn != NULL, "No existing thread-safe function"); + NODE_API_CALL(env, napi_ref_threadsafe_function(env, ts_fn)); + return NULL; +} + +static napi_value Unref(napi_env env, napi_callback_info info) { + NODE_API_ASSERT(env, ts_fn != NULL, "No existing thread-safe function"); + NODE_API_CALL(env, napi_unref_threadsafe_function(env, ts_fn)); + return NULL; +} + +static napi_value Release(napi_env env, napi_callback_info info) { + NODE_API_ASSERT(env, ts_fn != NULL, "No existing thread-safe function"); + NODE_API_CALL( + env, napi_release_threadsafe_function(ts_fn, napi_tsfn_release)); + return NULL; +} + +// Startup +static napi_value StartThread(napi_env env, napi_callback_info info) { + return StartThreadInternal(env, info, call_js, + /** block_on_full */ true, /** alt_ref_js_cb */ false); +} + +static napi_value StartThreadNonblocking(napi_env env, + napi_callback_info info) { + return StartThreadInternal(env, info, call_js, + /** block_on_full */ false, /** alt_ref_js_cb */ false); +} + +static napi_value StartThreadNoNative(napi_env env, napi_callback_info info) { + return StartThreadInternal(env, info, NULL, + /** block_on_full */ true, /** alt_ref_js_cb */ false); +} + +static napi_value StartThreadNoJsFunc(napi_env env, napi_callback_info info) { + return StartThreadInternal(env, info, call_ref, + /** block_on_full */ true, /** alt_ref_js_cb */ true); +} + +// Testing calling into JavaScript +static void ThreadSafeFunctionFinalize(napi_env env, void* finalize_data, + void* finalize_hint) { + assert_on_js_thread("ThreadSafeFunctionFinalize"); + napi_ref js_func_ref = (napi_ref)finalize_data; + napi_value js_func; + napi_value recv; + NODE_API_CALL_RETURN_VOID( + env, napi_get_reference_value(env, js_func_ref, &js_func)); + NODE_API_CALL_RETURN_VOID(env, napi_get_global(env, &recv)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, recv, js_func, 0, NULL, NULL)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, js_func_ref)); +} + +// Testing calling into JavaScript +static napi_value CallIntoModule(napi_env env, napi_callback_info info) { + size_t argc = 4; + napi_value argv[4]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + + napi_ref finalize_func; + NODE_API_CALL(env, napi_create_reference(env, argv[3], 1, &finalize_func)); + + napi_threadsafe_function tsfn; + NODE_API_CALL(env, + napi_create_threadsafe_function(env, argv[0], argv[1], argv[2], 0, 1, + finalize_func, ThreadSafeFunctionFinalize, NULL, NULL, &tsfn)); + NODE_API_CALL( + env, napi_call_threadsafe_function(tsfn, NULL, napi_tsfn_blocking)); + NODE_API_CALL(env, napi_release_threadsafe_function(tsfn, napi_tsfn_release)); + return NULL; +} + +// Module init +static napi_value Init(napi_env env, napi_value exports) { + js_thread = pthread_self(); + size_t index; + for (index = 0; index < ARRAY_LENGTH; index++) { + ints[index] = index; + } + napi_value js_array_length, js_max_queue_size; + napi_create_uint32(env, ARRAY_LENGTH, &js_array_length); + napi_create_uint32(env, MAX_QUEUE_SIZE, &js_max_queue_size); + + napi_property_descriptor properties[] = { + {"ARRAY_LENGTH", NULL, NULL, NULL, NULL, js_array_length, napi_enumerable, + NULL}, + {"MAX_QUEUE_SIZE", NULL, NULL, NULL, NULL, js_max_queue_size, + napi_enumerable, NULL}, + DECLARE_NODE_API_PROPERTY("StartThread", StartThread), + DECLARE_NODE_API_PROPERTY("StartThreadNoNative", StartThreadNoNative), + DECLARE_NODE_API_PROPERTY("StartThreadNonblocking", + StartThreadNonblocking), + DECLARE_NODE_API_PROPERTY("StartThreadNoJsFunc", StartThreadNoJsFunc), + DECLARE_NODE_API_PROPERTY("StopThread", StopThread), + DECLARE_NODE_API_PROPERTY("Ref", Ref), + DECLARE_NODE_API_PROPERTY("Unref", Unref), + DECLARE_NODE_API_PROPERTY("Release", Release), + DECLARE_NODE_API_PROPERTY("CallIntoModule", CallIntoModule), + }; + + NODE_API_CALL(env, + napi_define_properties( + env, exports, sizeof(properties) / sizeof(properties[0]), + properties)); + + return exports; +} +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/packages/node-addon-examples/tests/threadsafe-function/addon.js b/packages/node-addon-examples/tests/threadsafe-function/addon.js new file mode 100644 index 00000000..a1b8e385 --- /dev/null +++ b/packages/node-addon-examples/tests/threadsafe-function/addon.js @@ -0,0 +1,287 @@ +// Ported from Node.js' test/node-api/test_threadsafe_function/test.js. The +// upstream child-process teardown tests (testUnref) do not port to React +// Native; ref/unref are instead exercised in-process by testRefUnref, and +// testCallIntoModule supplements the suite by asserting that delivery is +// never synchronous, even when calling from the JS thread itself. +const assert = require("assert"); +const binding = require("bindings")("addon.node"); +const expectedArray = (function (arrayLength) { + const result = []; + for (let index = 0; index < arrayLength; index++) { + result.push(arrayLength - 1 - index); + } + return result; +})(binding.ARRAY_LENGTH); + +function testWithJSMarshaller({ + threadStarter, + quitAfter, + abort, + maxQueueSize, + launchSecondary, +}) { + return new Promise((resolve) => { + const array = []; + binding[threadStarter]( + function testCallback(value) { + array.push(value); + if (array.length === quitAfter) { + setImmediate(() => { + binding.StopThread(() => { + resolve(array); + }, !!abort); + }); + } + }, + !!abort, + !!launchSecondary, + maxQueueSize, + ); + if (threadStarter === "StartThreadNonblocking") { + // Let's make this thread really busy for a short while to ensure that + // the queue fills and the thread receives a napi_queue_full. + const start = Date.now(); + while (Date.now() - start < 200); + } + }); +} + +function testWithoutJSMarshaller() { + return new Promise((resolve) => { + let callCount = 0; + binding.StartThreadNoNative( + function testCallback() { + callCount++; + + // The default call-into-JS implementation passes no arguments. + assert.strictEqual(arguments.length, 0); + if (callCount === binding.ARRAY_LENGTH) { + setImmediate(() => { + binding.StopThread(() => { + resolve(); + }, false); + }); + } + }, + false /* abort */, + false /* launchSecondary */, + binding.MAX_QUEUE_SIZE, + ); + }); +} + +// With no libuv loop to act on in React Native (ref_loop/unref_loop are left +// null in the hermes_napi_host), napi_ref/unref_threadsafe_function must +// still succeed and leave delivery unaffected. +function testRefUnref() { + return new Promise((resolve) => { + const array = []; + let refCycled = false; + binding.StartThread( + function testCallback(value) { + array.push(value); + if (!refCycled) { + refCycled = true; + binding.Unref(); + binding.Ref(); + binding.Unref(); + } + if (array.length === binding.ARRAY_LENGTH) { + setImmediate(() => { + binding.StopThread(() => { + resolve(array); + }, false); + }); + } + }, + false /* abort */, + false /* launchSecondary */, + binding.MAX_QUEUE_SIZE, + ); + }).then((result) => assert.deepStrictEqual(result, expectedArray)); +} + +// Create a threadsafe function and call it from the JS thread itself: the +// delivery and the finalize callback must both still happen asynchronously. +function testCallIntoModule() { + return new Promise((resolve, reject) => { + let delivered = false; + let finalized = false; + binding.CallIntoModule( + () => { + delivered = true; + }, + {}, + "test_tsfn_resource", + () => { + finalized = true; + try { + assert.strictEqual(delivered, true); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + assert.strictEqual(delivered, false); + assert.strictEqual(finalized, false); + }); +} + +module.exports = () => + testWithoutJSMarshaller() + // Start the thread in blocking mode, and assert that all values are + // passed. Quit after it's done. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + maxQueueSize: binding.MAX_QUEUE_SIZE, + quitAfter: binding.ARRAY_LENGTH, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in blocking mode, and assert that all values are + // passed. Quit after it's done. + // Doesn't pass the callback js function to napi_create_threadsafe_function. + // Instead, use an alternative reference to get js function called. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThreadNoJsFunc", + maxQueueSize: binding.MAX_QUEUE_SIZE, + quitAfter: binding.ARRAY_LENGTH, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in blocking mode with an infinite queue, and assert + // that all values are passed. Quit after it's done. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + maxQueueSize: 0, + quitAfter: binding.ARRAY_LENGTH, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in non-blocking mode, and assert that all values are + // passed. Quit after it's done. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThreadNonblocking", + maxQueueSize: binding.MAX_QUEUE_SIZE, + quitAfter: binding.ARRAY_LENGTH, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in blocking mode, and assert that all values are + // passed. Quit early, but let the thread finish. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + maxQueueSize: binding.MAX_QUEUE_SIZE, + quitAfter: 1, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in blocking mode with an infinite queue, and assert + // that all values are passed. Quit early, but let the thread finish. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + maxQueueSize: 0, + quitAfter: 1, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in non-blocking mode, and assert that all values are + // passed. Quit early, but let the thread finish. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThreadNonblocking", + maxQueueSize: binding.MAX_QUEUE_SIZE, + quitAfter: 1, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in blocking mode, and assert that all values are + // passed. Quit early, but let the thread finish. Launch a secondary thread + // to test the reference counter incrementing functionality. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + quitAfter: 1, + maxQueueSize: binding.MAX_QUEUE_SIZE, + launchSecondary: true, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in non-blocking mode, and assert that all values are + // passed. Quit early, but let the thread finish. Launch a secondary thread + // to test the reference counter incrementing functionality. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThreadNonblocking", + quitAfter: 1, + maxQueueSize: binding.MAX_QUEUE_SIZE, + launchSecondary: true, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + // Start the thread in blocking mode, and assert that it could not finish. + // Quit early by aborting. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + quitAfter: 1, + maxQueueSize: binding.MAX_QUEUE_SIZE, + abort: true, + }), + ) + .then((result) => assert.strictEqual(result.indexOf(0), -1)) + + // Start the thread in blocking mode with an infinite queue, and assert + // that it could not finish. Quit early by aborting. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThread", + quitAfter: 1, + maxQueueSize: 0, + abort: true, + }), + ) + .then((result) => assert.strictEqual(result.indexOf(0), -1)) + + // Start the thread in non-blocking mode, and assert that it could not + // finish. Quit early and aborting. + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThreadNonblocking", + quitAfter: 1, + maxQueueSize: binding.MAX_QUEUE_SIZE, + abort: true, + }), + ) + .then((result) => assert.strictEqual(result.indexOf(0), -1)) + + // Make sure that the threadsafe function isn't stalled when the queue + // outgrows what a single dispatch may drain (kMaxDispatchCount in + // Hermes' API/napi/hermes_napi_tsfn.cpp). + .then(() => + testWithJSMarshaller({ + threadStarter: "StartThreadNonblocking", + maxQueueSize: binding.ARRAY_LENGTH >>> 1, + quitAfter: binding.ARRAY_LENGTH, + }), + ) + .then((result) => assert.deepStrictEqual(result, expectedArray)) + + .then(() => testRefUnref()) + .then(() => testCallIntoModule()); diff --git a/packages/node-addon-examples/tests/threadsafe-function/binding.gyp b/packages/node-addon-examples/tests/threadsafe-function/binding.gyp new file mode 100644 index 00000000..80f9fa87 --- /dev/null +++ b/packages/node-addon-examples/tests/threadsafe-function/binding.gyp @@ -0,0 +1,8 @@ +{ + "targets": [ + { + "target_name": "addon", + "sources": [ "addon.c" ] + } + ] +} diff --git a/packages/node-addon-examples/tests/threadsafe-function/package.json b/packages/node-addon-examples/tests/threadsafe-function/package.json new file mode 100644 index 00000000..c2fdf057 --- /dev/null +++ b/packages/node-addon-examples/tests/threadsafe-function/package.json @@ -0,0 +1,14 @@ +{ + "name": "threadsafe-function-test", + "version": "0.0.0", + "description": "Tests of runtime threadsafe functions", + "main": "addon.js", + "private": true, + "dependencies": { + "bindings": "~1.5.0" + }, + "scripts": { + "test": "node addon.js" + }, + "gypfile": true +} From 2b3cb61166899f8a7492511bb25ae27ca9a5bb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 12 Aug 2026 13:18:43 +0200 Subject: [PATCH 05/24] ci: bring host-cpp-tests in line with the rest of the workflow (#408) * ci: bring host-cpp-tests in line with the rest of the workflow host-cpp-tests was added on next (#398), so the Node.js 20 deprecation sweep on main (#405, #407) never reached it: it is the one job in the workflow still on actions/checkout@v4, pnpm/action-setup@v4 and actions/setup-node@v6, and so the only remaining source of the runner's "targets Node.js 20" warning. Merges main to pick up #407 and gives the job the same arrangement as its siblings: Node.js set up before pnpm, pnpm/action-setup v6 owning the store cache, and ccache-action pinned to an exact patch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W14eEfXdK5DYzv43MazryE * ci: re-run checks for the newly applied labels check.yml only runs on opened/synchronize/reopened, so the host-gated job this change is about does not start from labelling alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W14eEfXdK5DYzv43MazryE --------- Co-authored-by: Claude --- .github/workflows/check.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 582d7650..df5ee3cd 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -171,18 +171,19 @@ jobs: runs-on: ${{ matrix.runner }} name: Host C++ tests (${{ matrix.runner }}) steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: lts/krypton - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + cache: true - name: Setup cpp tools uses: aminya/setup-cpp@v1 with: clang-format: true - name: ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ${{ github.job }}-${{ runner.os }} - run: pnpm install From 3cc30a93800703a2f0f0a4444cb21b5160b59529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 12 Aug 2026 15:30:37 +0200 Subject: [PATCH 06/24] docs: clarify the Android Hermes vendoring steps (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android setup has two separate requirements — building React Native from source and pointing it at the vendored Hermes — and the doc ran them together without saying why either is needed. - Split them into their own sections and say up front that this is the manual equivalent of what `pod install` does on Apple platforms. - Note that apps based on react-native-test-app get the dependency substitutions from `react.buildFromSource=true` instead of editing settings.gradle themselves, as apps/test-app does. - Spell out that REACT_NATIVE_OVERRIDE_HERMES_DIR is read from the environment (Gradle cannot set it for its own build), so it has to be exported for every shell — or for the environment Android Studio is launched from — and what goes wrong without it. Also drop the last two references to a "patched" Hermes from the host README, left over from before we adopted Hermes' first-party Node-API. Claude-Session: https://claude.ai/code/session_01HX4imsygeawVtsmoP1sj3F Co-authored-by: Claude --- docs/ANDROID.md | 38 ++++++++++++++++++++++++++++++++++++-- packages/host/README.md | 4 ++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 4b285812..6fddd15a 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -1,8 +1,13 @@ # Android support -## Building Hermes from source +Android needs two things that iOS gets automatically during `pod install`: +React Native has to be built from source, and the build has to be pointed at the +Hermes we vendor. Both are described below. -Because we build Hermes from source (a pinned commit carrying its Node-API implementation), we need to build React Native from source too. +## Building React Native from source + +Because we build Hermes from source (a pinned commit carrying its Node-API +implementation), we need to build React Native from source too. Follow [the React Native documentation on how to build from source](https://reactnative.dev/contributing/how-to-build-from-source#update-your-project-to-build-from-source). @@ -23,6 +28,14 @@ In particular, you will have to edit the `android/settings.gradle` file as follo > + } > ``` +If your app is based on [`react-native-test-app`](https://github.com/microsoft/react-native-test-app), +you don't need to edit `settings.gradle` yourself: it applies the same +substitutions when `react.buildFromSource=true` is set in +`android/gradle.properties`. That is how the test app in this repository builds — +see [`apps/test-app/android/gradle.properties`](../apps/test-app/android/gradle.properties). + +## Vendoring Hermes + To fetch the pinned Hermes, you need to run from your app package: ``` @@ -37,6 +50,27 @@ This can be combined into a single line: export REACT_NATIVE_OVERRIDE_HERMES_DIR=$(npx react-native-node-api vendor-hermes --silent) ``` +React Native reads this as an environment variable, and Gradle cannot set one +for its own build, so it has to be exported in whatever ends up invoking Gradle: + +- the terminal you run `./gradlew` or `npx react-native run-android` from, for + every new shell, +- or the environment Android Studio is launched from — starting it from a shell + that has the variable set is the simplest way to get it there. + +Re-running the command is cheap: it re-uses the existing clone and just prints +its path. If the variable is missing, the build fails early with a message +repeating the command to run. + +Without the override, React Native downloads and builds its own Hermes, which +does not carry the Node-API implementation this package links against — the +build then fails to find `hermes_napi_create_env`. + +> [!NOTE] +> On Apple platforms this is automated: the podspec vendors Hermes during +> `pod install` when the variable isn't already set, so there is no manual step +> there. + ## Cleaning your React Native build folders If you've accidentally built your app without the vendored Hermes, you can clean things up by deleting the `ReactAndroid` build folder. diff --git a/packages/host/README.md b/packages/host/README.md index db57dfdc..cda6517e 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -47,7 +47,7 @@ The plugin rewrites the `require("./addon.node")` (and `require("bindings")("add ### 3. Build your app - **iOS:** run `pod install` as usual — addons found in your dependencies are linked as part of it. Re-run it whenever you add or remove a dependency shipping an addon. -- **Android:** requires a few extra steps, since React Native has to be built from source against the patched Hermes. See [the Android documentation](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/ANDROID.md). +- **Android:** requires a few extra steps, since React Native has to be built from source against the vendored Hermes. See [the Android documentation](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/ANDROID.md). ## Usage @@ -79,6 +79,6 @@ This prints every Node-API module it finds in your dependencies and the name it ## Documentation - [Auto-linking](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/AUTO-LINKING.md) — how prebuilt binaries are discovered, copied and renamed. -- [Android support](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/ANDROID.md) — building React Native from source with the patched Hermes. +- [Android support](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/ANDROID.md) — building React Native from source with the vendored Hermes. - [Usage](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/USAGE.md) — for library authors wanting to ship a Node-API module. - [How it works](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/HOW-IT-WORKS.md) — the path from `import` to native code. From 8cc8e5927ed80f3f55ec3c2238650b2459d8cfb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 12 Aug 2026 15:31:18 +0200 Subject: [PATCH 07/24] fix(host): make vendor-hermes --silent actually silent (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spinners were passed `isEnabled: !silent`. A disabled ora spinner still writes `- ` on start and the success/fail symbol on completion (to stderr) — it only skips the animation. `isSilent` is the option that suppresses output entirely. Callers capture stdout only (`$(... --silent)` in CI and the Gradle error message, backticks in patch-hermes.rb), so the stray output was noise rather than a broken path, but `--silent` now does what it says. Claude-Session: https://claude.ai/code/session_01HX4imsygeawVtsmoP1sj3F Co-authored-by: Claude --- .changeset/silent-vendor-hermes.md | 9 +++++++++ packages/host/src/node/cli/hermes.ts | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .changeset/silent-vendor-hermes.md diff --git a/.changeset/silent-vendor-hermes.md b/.changeset/silent-vendor-hermes.md new file mode 100644 index 00000000..272847f4 --- /dev/null +++ b/.changeset/silent-vendor-hermes.md @@ -0,0 +1,9 @@ +--- +"react-native-node-api": patch +--- + +Make `vendor-hermes --silent` actually silent. The spinners were passed +`isEnabled: false`, which stops the animation but still writes the spinner text +and its final symbol to stderr. They now use `isSilent`, which suppresses the +output entirely, leaving the vendored Hermes path on stdout as the command's +only output. diff --git a/packages/host/src/node/cli/hermes.ts b/packages/host/src/node/cli/hermes.ts index b1f74a3c..74f8680b 100644 --- a/packages/host/src/node/cli/hermes.ts +++ b/packages/host/src/node/cli/hermes.ts @@ -90,7 +90,7 @@ export const command = new Command("vendor-hermes") successText: "Removed existing Hermes clone", failText: (error) => `Failed to remove existing Hermes clone: ${error.message}`, - isEnabled: !silent, + isSilent: silent, }, ); } @@ -123,7 +123,7 @@ export const command = new Command("vendor-hermes") text: `Cloning Hermes into ${prettyPath(hermesPath)}`, successText: "Cloned Hermes", failText: (err) => `Failed to clone Hermes: ${err.message}`, - isEnabled: !silent, + isSilent: silent, }, ); } catch (error) { From 75311997fa385c0455502c8ed3b059fe52c1b954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 07:04:07 +0200 Subject: [PATCH 08/24] ci: verify the ferric Apple binaries depend on weak-node-api (#409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: verify the ferric Apple binaries depend on weak-node-api Extends the "Test ferric Apple triplets" job so it doesn't only assert which architectures were produced, but also that each produced binary actually links the weak-node-api framework, catching regressions where a triplet builds but drops the dependency. The expected number of `@rpath/weak-node-api.framework/weak-node-api` lines is derived from the otool output itself — `otool -L` prints one header per file, or one per architecture for fat files — rather than hard-coded, so it doesn't rot when a triplet is added or dropped. Also renames lipo-info.txt to lipo-output.txt for symmetry with the new otool-output.txt, and uploads both as artifacts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SrPdjhQ6aG949mVDDaiT2U * ci: accept the versioned weak-node-api install name on macOS The first CI run of this check reported 8 dependencies across 10 binary slices. The two misses were the macOS slices: macOS frameworks use the versioned bundle layout, so weak-node-api's install name there is @rpath/weak-node-api.framework/Versions/0.1.1/weak-node-api where iOS, tvOS and visionOS get the flat @rpath/weak-node-api.framework/weak-node-api Both are a genuine dependency on the framework, so match the optional "Versions//" component rather than only the flat form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SrPdjhQ6aG949mVDDaiT2U --------- Co-authored-by: Claude --- .github/workflows/check.yml | 38 +++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index df5ee3cd..1d441008 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -474,17 +474,24 @@ jobs: - run: pnpm exec ferric --apple working-directory: packages/ferric-example - name: Inspect the structure of the prebuilt binary - run: lipo -info ferric_example.apple.node/*/libferric_example.framework/libferric_example > lipo-info.txt + run: | + lipo -info ferric_example.apple.node/*/libferric_example.framework/libferric_example > lipo-output.txt + otool -L ferric_example.apple.node/*/libferric_example.framework/libferric_example > otool-output.txt working-directory: packages/ferric-example - - name: Upload lipo info + - name: Upload lipo output + uses: actions/upload-artifact@v7 + with: + name: lipo-output + path: packages/ferric-example/lipo-output.txt + - name: Upload otool output uses: actions/upload-artifact@v7 with: - name: lipo-info - path: packages/ferric-example/lipo-info.txt + name: otool-output + path: packages/ferric-example/otool-output.txt - name: Verify Apple triplet builds run: | # Create expected fixture content - cat > expected-lipo-info.txt << 'EOF' + cat > expected-lipo-output.txt << 'EOF' Architectures in the fat file: ferric_example.apple.node/ios-arm64_x86_64-simulator/libferric_example.framework/libferric_example are: x86_64 arm64 Architectures in the fat file: ferric_example.apple.node/macos-arm64_x86_64/libferric_example.framework/libferric_example are: x86_64 arm64 Architectures in the fat file: ferric_example.apple.node/tvos-arm64_x86_64-simulator/libferric_example.framework/libferric_example are: x86_64 arm64 @@ -494,5 +501,24 @@ jobs: Non-fat file: ferric_example.apple.node/xros-arm64/libferric_example.framework/libferric_example is architecture: arm64 EOF # Compare with expected fixture (will fail if files differ) - diff expected-lipo-info.txt lipo-info.txt + diff expected-lipo-output.txt lipo-output.txt + # Verify every binary depends on the weak-node-api framework. + # otool -L prints one header line per file, or one per architecture + # when the file is fat, so the number of headers is exactly the number + # of weak-node-api dependencies we expect. Deriving it beats + # hard-coding a count, which silently rots whenever a triplet is added + # or dropped. + # macOS frameworks use the versioned bundle layout, so their install + # name is .../weak-node-api.framework/Versions//weak-node-api + # where the embedded platforms get the flat + # .../weak-node-api.framework/weak-node-api — hence the optional + # "Versions//" in the pattern. + SLICE_COUNT=$(grep -c "^ferric_example\.apple\.node/.*:$" otool-output.txt || true) + WEAK_NODE_API_COUNT=$(grep -cE "@rpath/weak-node-api\.framework/(Versions/[^/]+/)?weak-node-api" otool-output.txt || true) + echo "Found $WEAK_NODE_API_COUNT weak-node-api dependencies across $SLICE_COUNT binaries" + if [ "$SLICE_COUNT" -eq 0 ] || [ "$WEAK_NODE_API_COUNT" -ne "$SLICE_COUNT" ]; then + echo "Expected $SLICE_COUNT dependencies on the weak-node-api framework (one per binary), found $WEAK_NODE_API_COUNT" + cat otool-output.txt + exit 1 + fi working-directory: packages/ferric-example From 43c2b8ff99bfdb9a4bbf3f534d469bf0d3b12923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 10:12:00 +0200 Subject: [PATCH 09/24] docs: fill in HOW-IT-WORKS.md and CLI.md placeholders (#429) - HOW-IT-WORKS.md: replace the three TODO comments near the top with a real, runnable example of calculator-lib's native C addon (mirrors docs/USAGE.md) plus the JS that requires and calls it, and clone instructions for readers who want to follow along with the source referenced later in the document. - CLI.md: hand-write documentation for all five react-native-node-api CLI commands (vendor-hermes, link, list, info, patch-xcode-project), their options and the shared library-naming strategies, sourced from packages/host/src/node/cli/program.ts, hermes.ts and options.ts, with a note to keep it in sync with those definitions. Fixes #425 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm Co-authored-by: Claude --- docs/CLI.md | 66 +++++++++++++++++++++++++++++++++++++++++++- docs/HOW-IT-WORKS.md | 64 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 9156480f..4282f403 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,3 +1,67 @@ # The `react-native-node-api` command-line interface (CLI) - +The `react-native-node-api` package installs a `react-native-node-api` binary, which app and library authors use to vendor Hermes, link Node-API modules into an app and inspect how the library-naming scheme resolves for a given module. + +```bash +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. + +## `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. + +- `[from]` — Path to a file inside the app package. Defaults to the current working directory. +- `--react-native-package ` — The React Native package to vendor Hermes into. Defaults to `react-native`. +- `--silent` — Don't print anything except the final path. Defaults to `false`. +- `--force` — Don't check timestamps of input files to skip unnecessary rebuilds; removes and re-clones an existing checkout. Defaults to `false`. + +## `link [path]` + +Auto-links the Node-API modules found among the app's dependencies for one or more platforms, copying (and, on Apple, signing) them into place. + +- `[path]` — Some path inside the app package. Defaults to the current working directory. +- `--android` — Link Android modules. +- `--apple` — Link Apple modules. +- `--prune` — Delete previously vendored modules that are no longer auto-linked. Defaults to `true`. +- `--package-name ` — Controls how a dependency's package name is transformed into a library name. One of `strip`, `keep` or `omit` (see [Library naming](#library-naming) below). Defaults to `strip`, or the `NODE_API_PACKAGE_NAME` environment variable if set. +- `--path-suffix ` — Controls how the path of the addon inside a package is transformed into a library name. One of `strip`, `keep` or `omit` (see [Library naming](#library-naming) below). Defaults to `strip`, or the `NODE_API_PATH_SUFFIX` environment variable if set. + +At least one of `--android` / `--apple` must be passed, or the command exits with an error listing the supported platforms. + +## `list [from-path]` + +Lists the Node-API modules found among the dependencies of the package at (or above) a path, without linking them. + +- `[from-path]` — Some path inside the app package. Defaults to the current working directory. +- `--json` — Output the result as JSON instead of a human-readable summary. Defaults to `false`. +- `--package-name ` — Same as for `link` (see [Library naming](#library-naming)). +- `--path-suffix ` — Same as for `link` (see [Library naming](#library-naming)). + +## `info ` + +Utility to print the resolved module path, package name and computed library name for a single Node-API module, given its path. Useful for debugging naming collisions. + +- `` — Path to a Node-API module (e.g. an `*.android.node` directory or `*.apple.node` framework). +- `--package-name ` — Same as for `link` (see [Library naming](#library-naming)). +- `--path-suffix ` — Same as for `link` (see [Library naming](#library-naming)). + +## `patch-xcode-project [path]` + +Patches the app's Xcode project to add a build phase which copies, renames and signs the Node-API frameworks (equivalent to running `link --apple` as part of the Xcode build). Only supported on macOS. + +- `[path]` — Some path inside the app package. Defaults to the current working directory. + +## Library naming + +`--package-name` and `--path-suffix` both control how the [cross-platform library name](./PREBUILDS.md) (`package-name--path-component--addon-name`) is derived, and accept the same three strategies. Given a package `@my-org/my-pkg` with an addon at `build/Release/my-addon.node`: + +| Strategy | `--package-name` effect | `--path-suffix` effect | +| -------- | ----------------------------------------- | --------------------------------------------------- | +| `strip` | Scope is dropped: `my-pkg--my-addon` | Path is reduced to its basename: `my-pkg--my-addon` | +| `keep` | Scope is kept: `my-org--my-pkg--my-addon` | Full path is kept: `my-pkg--build-Release-my-addon` | +| `omit` | Package name is dropped: `my-addon` | Path is dropped: `my-pkg` | diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 3a5167b3..edb0e0ac 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -2,9 +2,67 @@ This document will outline what happens throughout the various parts of the system, when the app calls the `add` method on the library introduced in the ["usage" document](./USAGE.md). - - - +If you want to follow along with the source code referenced throughout this document (such as `packages/host/cpp/HermesNapiHost.cpp`), clone this repo: + +```bash +git clone https://github.com/callstackincubator/react-native-node-api.git +``` + +`calculator-lib`'s native code is a small Node-API addon written in C (see the ["usage" document](./USAGE.md#implement-native-code) for the full walkthrough of writing and building it): + +```cpp +// addon.c + +#include +#include + +static napi_value Add(napi_env env, napi_callback_info info) { + napi_status status; + + size_t argc = 2; + napi_value args[2]; + status = napi_get_cb_info(env, info, &argc, args, NULL, NULL); + assert(status == napi_ok); + + double value0, value1; + status = napi_get_value_double(env, args[0], &value0); + assert(status == napi_ok); + status = napi_get_value_double(env, args[1], &value1); + assert(status == napi_ok); + + napi_value sum; + status = napi_create_double(env, value0 + value1, &sum); + assert(status == napi_ok); + + return sum; +} + +#define DECLARE_NAPI_METHOD(name, func) \ + { name, 0, func, 0, 0, 0, napi_default, 0 } + +NAPI_MODULE_INIT(/* napi_env env, napi_value exports */) { + napi_status status; + + napi_property_descriptor addDescriptor = DECLARE_NAPI_METHOD("add", Add); + status = napi_define_properties(env, exports, 1, &addDescriptor); + assert(status == napi_ok); + + return exports; +} +``` + +`calculator-lib`'s JavaScript entrypoint requires the prebuilt binary produced from that C code: + +```javascript +module.exports = require("./prebuild.node"); +``` + +And `my-app` imports and calls it: + +```javascript +import { add } from "calculator-lib"; +console.log("1 + 2 =", add(1, 2)); +``` ## `my-app` makes an `import` From c73d30cc3aef1952538d6c228748ee5b0559cd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 10:25:31 +0200 Subject: [PATCH 10/24] ci: run linting without building native code (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: run linting without building native code (#414) The lint job set up a full native toolchain (JDK 17, Android SDK + NDK, x86_64-linux-android Rust target) and ran two native bootstraps purely to get generated TypeScript types for type-checking. Resolve both TODOs: - Add `ferric build --dts-only`, which generates a crate's `.d.ts` and JS entrypoint via a plain host `cargo build` (napi-rs typedef codegen), without cross-compiling any Android/Apple binaries. The library basename is derived from `cargo metadata`'s cdylib target instead of from built artifact paths, so no platform build is needed to compute it. Wire this up as `ferric-example`'s new `build:types` script. - Use `weak-node-api`'s existing `prebuild:prepare` script (header copy + C++/TS declaration codegen) instead of `bootstrap` (which also runs the native CMake build). It already required nothing beyond clang-format. With both native builds no longer needed for typing, the lint job drops the JDK 17, Android SDK, and `rustup target add` steps entirely. Verified locally (Node 24, cargo present, no Android/Apple SDK): fresh `pnpm install && pnpm run build`, then `pnpm --filter weak-node-api run prebuild:prepare`, `pnpm --filter @react-native-node-api/ferric-example run build:types`, `pnpm run lint`, `pnpm run prettier:check`, `pnpm run depcheck` and `pnpm run publint` all pass end-to-end with no native toolchain present, reproducing the new lint job's steps. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm * ci: commit ferric-example's declarations as a fixture instead of building them kraenhansen suspected generateTypeScriptDeclarations doesn't actually skip a native build. Confirmed: napi-rs's `napi build` has no typegen-only mode — it always runs a real `cargo build`, and --dts-only leaves a fully populated ~123MB target/ directory (including a compiled libferric_example.so) behind. "Skipping the native build entirely" was wrong; only Android/Apple cross-compilation was actually skipped, and the lint job stayed coupled to the host Rust toolchain's health exactly as #414 wanted to avoid. Switch to the issue's other suggested option: commit ferric_example.d.ts and ferric_example.js as a checked-in fixture (no longer gitignored), and drop the ferric-example build:types step from the lint job entirely — it no longer needs to regenerate anything. --dts-only stays, now documented accurately, as the way to regenerate the fixture by hand after changing packages/ferric-example/src/lib.rs. To catch drift, the two CI jobs that already do a real `ferric build` (Android and Apple triplets) now `git diff --exit-code` the two committed files right after building. Both are label-gated rather than running on every PR, so this doesn't fully close the gap — flagged in the PR thread. Also excludes the two fixture files from Prettier: they're left in napi-rs's own output formatting so regenerating them reproduces the committed bytes exactly, and the new drift check doesn't false-positive on formatting alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm * ci: drop the explanatory comment from ferric-example/.gitignore Per review feedback on #435. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm --------- Co-authored-by: Claude --- .changeset/wet-carrots-relax.md | 5 ++ .github/workflows/check.yml | 32 ++++++------- .prettierignore | 7 +++ packages/ferric-example/.gitignore | 4 -- packages/ferric-example/ferric_example.d.ts | 11 +++++ packages/ferric-example/ferric_example.js | 13 +++++ packages/ferric-example/package.json | 3 +- packages/ferric/src/build.ts | 53 ++++++++++++++++++++- packages/ferric/src/cargo.ts | 30 ++++++++++++ 9 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 .changeset/wet-carrots-relax.md create mode 100644 packages/ferric-example/ferric_example.d.ts create mode 100644 packages/ferric-example/ferric_example.js diff --git a/.changeset/wet-carrots-relax.md b/.changeset/wet-carrots-relax.md new file mode 100644 index 00000000..6050c6cb --- /dev/null +++ b/.changeset/wet-carrots-relax.md @@ -0,0 +1,5 @@ +--- +"ferric-cli": patch +--- + +Add `--dts-only` flag to `ferric build`, generating just the TypeScript declaration file and JS entrypoint without cross-compiling any Android/Apple binaries. It still runs a real host `cargo build` (napi-rs has no lighter typegen-only mode), so it's meant for regenerating a checked-in declarations fixture rather than for environments without a Rust toolchain. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1d441008..632bb32b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -59,24 +59,18 @@ jobs: uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ${{ github.job }}-${{ runner.os }} - # Set up JDK and Android SDK only because we need weak-node-api, to build ferric-example and to run the linting - # TODO: Remove this once we have a way to run linting without building the native code - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: "17" - distribution: "temurin" - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - with: - packages: tools platform-tools ndk;${{ env.NDK_VERSION }} - - run: rustup target add x86_64-linux-android - run: pnpm install - run: pnpm run build - # Bootstrap weak-node-api and ferric-example to get types - # TODO: Solve this by adding an option to ferric to build only types or by committing the types into the repo as a fixture for an "init" command - - run: pnpm --filter weak-node-api run bootstrap - - run: pnpm --filter @react-native-node-api/ferric-example run bootstrap + # Generate the TypeScript/C++ declarations that other packages' type-checking + # depends on, without building any native binaries: weak-node-api's + # "prebuild:prepare" only copies headers and runs codegen (needs clang-format, + # set up above, but no JDK/Android SDK/NDK). ferric-example's declarations are + # committed as a fixture instead (see packages/ferric-example/.gitignore) — + # napi-rs's dts generation has no way to run without a real `cargo build` + # (confirmed: it leaves a populated target/ directory), so unlike + # weak-node-api's codegen it can't be reproduced here without reintroducing a + # native build into the fastest-feedback job. See #414. + - run: pnpm --filter weak-node-api run prebuild:prepare - run: pnpm run lint env: DEBUG: eslint:eslint @@ -406,6 +400,9 @@ jobs: - name: Build ferric-example for all architectures run: pnpm run build --android working-directory: packages/ferric-example + - name: Verify committed ferric-example TypeScript declarations are up to date + run: git diff --exit-code -- ferric_example.d.ts ferric_example.js + working-directory: packages/ferric-example - name: Run tests (Android) timeout-minutes: 75 uses: reactivecircus/android-emulator-runner@v2 @@ -473,6 +470,9 @@ jobs: # Build Ferric example for all Apple architectures - run: pnpm exec ferric --apple working-directory: packages/ferric-example + - name: Verify committed ferric-example TypeScript declarations are up to date + run: git diff --exit-code -- ferric_example.d.ts ferric_example.js + working-directory: packages/ferric-example - name: Inspect the structure of the prebuilt binary run: | lipo -info ferric_example.apple.node/*/libferric_example.framework/libferric_example > lipo-output.txt diff --git a/.prettierignore b/.prettierignore index 23bef2b3..c8aaf613 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,10 @@ packages/node-addon-examples/examples packages/node-tests/node packages/node-tests/tests packages/node-tests/*.generated.js + +# Committed napi-rs codegen fixture (see packages/ferric-example/.gitignore) — left +# in napi-rs's own output formatting so `pnpm run build:types` reproduces it exactly +# and the CI drift check (see .github/workflows/check.yml) doesn't false-positive on +# formatting alone. +packages/ferric-example/ferric_example.d.ts +packages/ferric-example/ferric_example.js diff --git a/packages/ferric-example/.gitignore b/packages/ferric-example/.gitignore index f1d36c32..a225cf72 100644 --- a/packages/ferric-example/.gitignore +++ b/packages/ferric-example/.gitignore @@ -3,7 +3,3 @@ /*.xcframework/ /*.apple.node/ /*.android.node/ - -# Generated files -/ferric_example.d.ts -/ferric_example.js diff --git a/packages/ferric-example/ferric_example.d.ts b/packages/ferric-example/ferric_example.d.ts new file mode 100644 index 00000000..3b38df96 --- /dev/null +++ b/packages/ferric-example/ferric_example.d.ts @@ -0,0 +1,11 @@ +/** + * This file was generated by + * ╭─────────────────────────╮ + * │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│ + * │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│ + * │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│ + * ╰─────────────────────────╯ + * Powered by napi.rs + */ +/* eslint-disable */ +export declare function sum(a: number, b: number): number diff --git a/packages/ferric-example/ferric_example.js b/packages/ferric-example/ferric_example.js new file mode 100644 index 00000000..69fff2e3 --- /dev/null +++ b/packages/ferric-example/ferric_example.js @@ -0,0 +1,13 @@ +/* eslint-disable */ + +/** + * This file was generated by + * ╭─────────────────────────╮ + * │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│ + * │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│ + * │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│ + * ╰─────────────────────────╯ + * Powered by napi.rs + */ + +module.exports = require('./ferric_example.node'); diff --git a/packages/ferric-example/package.json b/packages/ferric-example/package.json index 28735907..3e9fd232 100644 --- a/packages/ferric-example/package.json +++ b/packages/ferric-example/package.json @@ -19,7 +19,8 @@ ], "scripts": { "build": "ferric build", - "bootstrap": "node --run build" + "bootstrap": "node --run build", + "build:types": "ferric build --dts-only" }, "dependencies": { "react-native-node-api": "workspace:*" diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts index 6cda0582..5a72ec03 100644 --- a/packages/ferric/src/build.ts +++ b/packages/ferric/src/build.ts @@ -25,7 +25,7 @@ import { determineLibraryBasename, } from "react-native-node-api"; -import { ensureCargo, build } from "./cargo.js"; +import { ensureCargo, build, determineCargoLibraryName } from "./cargo.js"; import { ALL_TARGETS, ANDROID_TARGETS, @@ -104,6 +104,10 @@ const xcframeworkExtensionOption = new Option( "--xcframework-extension", "Don't rename the xcframework to .apple.node", ).default(false); +const dtsOnlyOption = new Option( + "--dts-only", + "Only generate the TypeScript declarations and entrypoint, skipping Android/Apple cross-compilation. Still runs a real `cargo build` for the host target (napi-rs has no lighter typegen-only mode), so this is not a no-op — it's meant for regenerating a checked-in declarations fixture, not for toolchain-free environments.", +).default(false); const outputPathOption = new Option( "--output ", @@ -153,6 +157,7 @@ export const buildCommand = new Command("build") .addOption(appleBundleIdentifierOption) .addOption(concurrencyOption) .addOption(verboseOption) + .addOption(dtsOnlyOption) .action( wrapAction( async ({ @@ -167,7 +172,53 @@ export const buildCommand = new Command("build") appleBundleIdentifier, concurrency, verbose, + dtsOnly, }) => { + if (dtsOnly) { + assertFixable( + targetArg.length === 0 && !apple && !android && !clean, + "The --dts-only flag cannot be combined with --target, --apple, --android or --clean", + { + instructions: + "Drop --dts-only to build native binaries, or remove the other flags to only generate TypeScript declarations", + }, + ); + ensureCargo(); + const libraryName = determineCargoLibraryName(process.cwd()); + const declarationsFilename = `${libraryName}.d.ts`; + const declarationsPath = path.join(outputPath, declarationsFilename); + await oraPromise( + generateTypeScriptDeclarations({ + outputFilename: declarationsFilename, + createPath: process.cwd(), + outputPath, + }), + { + text: "Generating TypeScript declarations", + successText: `Generated TypeScript declarations ${prettyPath( + declarationsPath, + )}`, + failText: (error) => + `Failed to generate TypeScript declarations: ${error.message}`, + }, + ); + const entrypointPath = path.join(outputPath, `${libraryName}.js`); + await oraPromise( + generateEntrypoint({ + libraryName, + outputPath: entrypointPath, + }), + { + text: `Generating entrypoint`, + successText: `Generated entrypoint into ${prettyPath( + entrypointPath, + )}`, + failText: (error) => + `Failed to generate entrypoint: ${error.message}`, + }, + ); + return; + } if (clean) { await oraPromise( () => spawn("cargo", ["clean"], { outputMode: "buffered" }), diff --git a/packages/ferric/src/cargo.ts b/packages/ferric/src/cargo.ts index fc4fb2ac..31c8eaa3 100644 --- a/packages/ferric/src/cargo.ts +++ b/packages/ferric/src/cargo.ts @@ -93,6 +93,36 @@ export function ensureCargo() { } } +type CargoMetadata = { + packages: { targets: { name: string; kind: string[] }[] }[]; +}; + +/** + * Determine the name of the crate's "cdylib" target, without building anything, + * by asking cargo for its metadata. This matches the basename a full build would + * produce (e.g. "ferric_example" for a crate named "ferric-example"), since cargo + * normalizes the crate name (dashes to underscores) for the compiled artifact. + */ +export function determineCargoLibraryName(cwd: string): string { + const output = cp.execFileSync( + "cargo", + ["metadata", "--no-deps", "--format-version", "1"], + { cwd, encoding: "utf-8" }, + ); + const { packages } = JSON.parse(output) as CargoMetadata; + const cdylibNames = packages + .flatMap((pkg) => pkg.targets) + .filter((target) => target.kind.includes("cdylib")) + .map((target) => target.name); + const candidates = new Set(cdylibNames); + assert( + candidates.size === 1, + `Expected exactly one cdylib target, got: ${[...candidates].join(", ")}`, + ); + const [name] = candidates; + return name; +} + type BuildOptions = { configuration: "debug" | "release"; verbose: boolean; From 0c1d597512c282158f77a3cc3974bf9b143aab13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 12:50:51 +0200 Subject: [PATCH 11/24] fix(cmake-rn): let ANDROID_STL be overridden via --define (#433) `ANDROID_STL` was hardcoded to `c++_shared` when configuring Android builds, with no escape hatch for an addon that needs `c++_static` or must match a prebuilt third-party dependency's STL (#418). The generic `-D`/`--define` cache-variable pass-through (added for #332, which #227 also asks for) already lets a consumer set arbitrary CMake cache variables, including `ANDROID_STL` - but it didn't actually work: our hardcoded Android defaults were appended to the CMake command line *after* the user-provided `-D` arguments, and CMake resolves a variable set multiple times via `-D` to its last occurrence, so the hardcoded value always won. Fix the ordering so the user's `--define` is applied last. `ANDROID_STL` still defaults to `c++_shared`, matching what React Native itself uses. Extract the CMake definitions building into an exported `buildCommonDefinitions` and add unit tests covering the default and the override precedence. Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm Co-authored-by: Claude --- .changeset/gentle-pandas-learn.md | 15 ++++ .../cmake-rn/src/platforms/android.test.ts | 60 +++++++++++++++ packages/cmake-rn/src/platforms/android.ts | 77 ++++++++++++++----- 3 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 .changeset/gentle-pandas-learn.md create mode 100644 packages/cmake-rn/src/platforms/android.test.ts diff --git a/.changeset/gentle-pandas-learn.md b/.changeset/gentle-pandas-learn.md new file mode 100644 index 00000000..f291a8cd --- /dev/null +++ b/.changeset/gentle-pandas-learn.md @@ -0,0 +1,15 @@ +--- +"cmake-rn": minor +--- + +Let a consumer override the Android `ANDROID_STL` CMake cache variable via +the existing `-D`/`--define` option (e.g. `--define ANDROID_STL=c++_static`). +It still defaults to `c++_shared`, matching what React Native itself uses, +but an addon that must match a prebuilt third-party dependency's STL, or one +that's genuinely self-contained, can now ask for a different value. + +This also fixes an ordering bug where a `--define` targeting any of the +Android platform's own default CMake variables (including `ANDROID_STL`) was +silently discarded: our hardcoded defaults were appended to the CMake +command line _after_ the user-provided `-D` arguments, and CMake resolves a +cache variable set multiple times via `-D` to its last occurrence. diff --git a/packages/cmake-rn/src/platforms/android.test.ts b/packages/cmake-rn/src/platforms/android.test.ts new file mode 100644 index 00000000..88a4011e --- /dev/null +++ b/packages/cmake-rn/src/platforms/android.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { toDefineArguments } from "../helpers.js"; +import { buildCommonDefinitions } from "./android.js"; + +function baseArgs( + overrides: Partial[0]> = {}, +) { + return { + configuration: "Release" as const, + ndkPath: "/opt/ndk", + androidSdkVersion: "24", + ccachePath: null, + define: [], + ...overrides, + }; +} + +describe("buildCommonDefinitions", () => { + it("defaults ANDROID_STL to c++_shared", () => { + const args = toDefineArguments(buildCommonDefinitions(baseArgs())); + const index = args.indexOf("-D"); + assert(index >= 0); + assert(args.includes("ANDROID_STL=c++_shared")); + }); + + it("lets a consumer override ANDROID_STL via --define", () => { + // CMake resolves a cache variable passed multiple times via `-D` to its + // *last* occurrence on the command line, so what matters is which + // ANDROID_STL entry comes last - not merely that c++_static is present. + const args = toDefineArguments( + buildCommonDefinitions( + baseArgs({ define: [{ ANDROID_STL: "c++_static" }] }), + ), + ); + const stlEntries = args.filter((arg) => arg.startsWith("ANDROID_STL=")); + assert.deepEqual(stlEntries, [ + "ANDROID_STL=c++_shared", + "ANDROID_STL=c++_static", + ]); + }); + + it("applies the user's --define after (so it wins over) every default", () => { + const definitions = buildCommonDefinitions( + baseArgs({ define: [{ ANDROID_STL: "c++_static" }] }), + ); + // The user-provided define must be the last entry, since CMake resolves + // a -D variable passed multiple times to its last occurrence. + assert.deepEqual(definitions.at(-1), { ANDROID_STL: "c++_static" }); + }); + + it("includes ccache launcher variables when a ccache path is given", () => { + const args = toDefineArguments( + buildCommonDefinitions(baseArgs({ ccachePath: "/usr/bin/ccache" })), + ); + assert(args.includes("CMAKE_C_COMPILER_LAUNCHER=/usr/bin/ccache")); + assert(args.includes("CMAKE_CXX_COMPILER_LAUNCHER=/usr/bin/ccache")); + }); +}); diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts index da5f19c2..5c8b16ae 100644 --- a/packages/cmake-rn/src/platforms/android.ts +++ b/packages/cmake-rn/src/platforms/android.ts @@ -91,6 +91,56 @@ function getNdkLlvmBinPath(ndkPath: string) { return path.join(prebuiltPath, platforms[0], "bin"); } +const DEFAULT_ANDROID_STL = "c++_shared"; + +/** + * Builds the list of CMake cache variable definitions common to every + * triplet's configure step. + * + * `define` (populated from the repeatable `-D`/`--define` CLI option) is + * spread last, so a consumer's explicit `-D ANDROID_STL=c++_static` (or any + * other variable set here by default) takes precedence over our own + * defaults: CMake resolves a cache variable passed multiple times via `-D` + * to its last occurrence on the command line. + */ +export function buildCommonDefinitions({ + configuration, + ndkPath, + androidSdkVersion, + ccachePath, + define, +}: { + configuration: BaseOpts["configuration"]; + ndkPath: string; + androidSdkVersion: string; + ccachePath: BaseOpts["ccachePath"]; + define: BaseOpts["define"]; +}) { + return [ + { + CMAKE_BUILD_TYPE: configuration, + CMAKE_SYSTEM_NAME: "Android", + // "CMAKE_INSTALL_PREFIX": installPath, + CMAKE_MAKE_PROGRAM: "ninja", + ANDROID_NDK: ndkPath, + ANDROID_TOOLCHAIN: "clang", + ANDROID_PLATFORM: androidSdkVersion, + // Defaults to c++_shared, matching what React Native itself uses. + // Override with -D/--define ANDROID_STL=c++_static (or another value + // accepted by the NDK's CMake toolchain) when an addon must match a + // prebuilt third-party dependency's STL. + ANDROID_STL: DEFAULT_ANDROID_STL, + }, + ccachePath + ? { + CMAKE_C_COMPILER_LAUNCHER: ccachePath, + CMAKE_CXX_COMPILER_LAUNCHER: ccachePath, + } + : {}, + ...define, + ]; +} + export const platform: Platform = { id: "android", name: "Android", @@ -140,26 +190,13 @@ export const platform: Platform = { const ndkPath = getNdkPath(ndkVersion); const toolchainPath = getNdkToolchainPath(ndkPath); - const commonDefinitions = [ - ...define, - { - CMAKE_BUILD_TYPE: configuration, - CMAKE_SYSTEM_NAME: "Android", - // "CMAKE_INSTALL_PREFIX": installPath, - CMAKE_MAKE_PROGRAM: "ninja", - ANDROID_NDK: ndkPath, - ANDROID_TOOLCHAIN: "clang", - ANDROID_PLATFORM: androidSdkVersion, - // TODO: Make this configurable - ANDROID_STL: "c++_shared", - }, - ccachePath - ? { - CMAKE_C_COMPILER_LAUNCHER: ccachePath, - CMAKE_CXX_COMPILER_LAUNCHER: ccachePath, - } - : {}, - ]; + const commonDefinitions = buildCommonDefinitions({ + configuration, + ndkPath, + androidSdkVersion, + ccachePath, + define, + }); await Promise.all( triplets.map(async ({ triplet, spawn }) => { From 715a24ef6225286a2a90abd9f832e29779e53da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 13:16:56 +0200 Subject: [PATCH 12/24] Route napi_fatal_exception through ErrorUtils.reportFatalError (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostContext::fatalException previously stringified the error and called abort() unconditionally, matching Hermes' null-host default but giving node-addon-api's tsfn error path (which calls napi_fatal_exception whenever an exception escapes a thread-safe-function callback) no chance of being observed or handled — a single throwing tsfn callback hard-killed the app with no LogBox and no JS-side handler getting a say. napi_fatal_exception (unlike the noreturn napi_fatal_error) is a plain napi_status-returning function, and the pinned Hermes commit's hermes_napi_error.cpp explicitly supports the host hook returning normally, so routing can be done synchronously against the passed env with plain Node-API calls, keeping HermesNapiHost.cpp free of React Native/JSI includes: - Attempt global.ErrorUtils.reportFatalError(err) via napi_get_global + napi_get_named_property (x2, type-checked at each step) + napi_call_function. - On success, return normally (napi_ok reaches the addon), matching Node's process.emit('uncaughtException') returning to the caller when a handler is installed. - On any failure (ErrorUtils/reportFatalError absent or not the right type, or the call itself throwing) fall back to the previous stringify + log_error + abort() path, clearing any pending exception first so the fallback's own Node-API calls aren't defeated by a stale exception. - Guard reentrancy with a HostContext member flag: if the ErrorUtils handler itself triggers another napi_fatal_exception, the nested call skips straight to the fallback instead of recursing. Adds a "minor" changeset for react-native-node-api: this is an observable behavior change for addons/apps that relied on the previous immediate abort. Not compiled or exercised on-device in this environment (no Android/iOS toolchain here) — see the PR description for what remains to be verified. Closes #402 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm Co-authored-by: Claude --- ...oute-fatal-exception-through-errorutils.md | 5 + packages/host/cpp/HermesNapiHost.cpp | 100 +++++++++++++++--- packages/host/cpp/HermesNapiHost.hpp | 6 ++ 3 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 .changeset/route-fatal-exception-through-errorutils.md diff --git a/.changeset/route-fatal-exception-through-errorutils.md b/.changeset/route-fatal-exception-through-errorutils.md new file mode 100644 index 00000000..ab78f30f --- /dev/null +++ b/.changeset/route-fatal-exception-through-errorutils.md @@ -0,0 +1,5 @@ +--- +"react-native-node-api": minor +--- + +Route `napi_fatal_exception` through React Native's `ErrorUtils.reportFatalError` instead of unconditionally logging and calling `abort()`. This is what node-addon-api calls whenever an exception escapes a thread-safe-function callback, so a single throwing tsfn callback no longer hard-kills the app: in dev the error and its stack now surface in LogBox, in release RN's default handler rethrows into the native crash path, and apps can observe or handle it via `ErrorUtils.setGlobalHandler` — the moral equivalent of Node's `'uncaughtException'`. The previous stringify-and-abort behavior remains as a fallback for when `ErrorUtils`/`reportFatalError` isn't available (non-RN embedders, very early startup) or the handler itself throws. diff --git a/packages/host/cpp/HermesNapiHost.cpp b/packages/host/cpp/HermesNapiHost.cpp index 681e16d2..1ebd17d2 100644 --- a/packages/host/cpp/HermesNapiHost.cpp +++ b/packages/host/cpp/HermesNapiHost.cpp @@ -149,6 +149,63 @@ std::string describeError(napi_env env, napi_value err) { return "(unable to stringify the error value)"; } +// Stringifies `err`, logs it and aborts — the pre-#402 behavior, kept as the +// fallback for whenever routing through ErrorUtils isn't possible. +[[noreturn]] void abortWithFatalException(napi_env env, napi_value err) { + log_error("napi_fatal_exception: %s", describeError(env, err).c_str()); + abort(); +} + +// Attempts to route `err` through React Native's +// `global.ErrorUtils.reportFatalError`, the moral equivalent of Node's +// process.emit('uncaughtException'): in dev this surfaces the real error and +// stack in LogBox, in release it feeds RN's default rethrow-to-native-crash +// handler, and apps can observe/handle it via ErrorUtils.setGlobalHandler. +// Returns whether the error was routed successfully. On any failure — +// ErrorUtils or reportFatalError absent/not the right type, or the call +// itself throwing — clears any pending exception before returning false, so +// the caller's fallback (which does its own Node-API calls) starts clean. +bool tryReportFatalError(napi_env env, napi_value err) { + napi_value global = nullptr; + if (napi_get_global(env, &global) != napi_ok) { + return false; + } + + napi_valuetype type = napi_undefined; + napi_value error_utils = nullptr; + if (napi_get_named_property(env, global, "ErrorUtils", &error_utils) != + napi_ok || + napi_typeof(env, error_utils, &type) != napi_ok || + type != napi_object) { + return false; + } + + napi_value report_fatal_error = nullptr; + if (napi_get_named_property(env, error_utils, "reportFatalError", + &report_fatal_error) != napi_ok || + napi_typeof(env, report_fatal_error, &type) != napi_ok || + type != napi_function) { + return false; + } + + napi_value argv[] = {err}; + napi_status call_status = napi_call_function( + env, error_utils, report_fatal_error, 1, argv, nullptr); + if (call_status != napi_ok) { + // Most likely napi_pending_exception (the handler itself threw). Clear + // it so the fallback path — which makes further Node-API calls — isn't + // itself defeated by a stale pending exception. + bool is_pending = false; + if (napi_is_exception_pending(env, &is_pending) == napi_ok && is_pending) { + napi_value discarded = nullptr; + napi_get_and_clear_last_exception(env, &discarded); + } + return false; + } + + return true; +} + } // namespace HostContext::HostContext(JsDispatcher dispatchToJs) @@ -232,20 +289,37 @@ void HostContext::postTask(void *loop_data, void *task_data, } } -void HostContext::fatalException(void *, napi_env env, +void HostContext::fatalException(void *data, napi_env env, napi_value err) noexcept { - // Called on the JS thread by napi_fatal_exception(). Node routes this to - // process.emit('uncaughtException'); with no process object we log the - // error and abort — the same observable outcome as Hermes' null-host - // default, but surfaced through the host logger. `err` is only valid for - // the duration of this call, so it is stringified before returning. - // TODO: Route through React Native's error handling (ErrorUtils / LogBox), - // with abort() as the fallback, to get closer to Node's observable and - // handleable 'uncaughtException' — note node-addon-api calls - // napi_fatal_exception whenever an exception escapes a thread-safe - // function callback, so today a single throwing tsfn callback is fatal. - log_error("napi_fatal_exception: %s", describeError(env, err).c_str()); - abort(); + // Called on the JS thread by napi_fatal_exception() — Hermes returns + // napi_ok to the caller once this returns, matching Node, where emitting + // 'uncaughtException' returns to the caller and only aborts the process if + // no handler is installed (unlike napi_fatal_error, this hook has no + // noreturn contract). `err` is only valid for the duration of this call. + // + // Routed through ErrorUtils.reportFatalError, RN's own + // uncaughtException-equivalent: in dev the default handler shows LogBox + // with the real error and stack, in release it rethrows into the native + // crash path, and apps can observe/handle it via + // ErrorUtils.setGlobalHandler. node-addon-api calls napi_fatal_exception + // whenever an exception escapes a thread-safe function callback, so this + // is what stands between a throwing tsfn callback and a silent, unhandled + // abort. + auto *self = static_cast(data); + if (self->inFatalException_) { + // Reentrant call: the ErrorUtils handler (or something it triggered) + // itself hit a fatal exception. Recursing back into reportFatalError + // could loop forever, so go straight to the fallback. + abortWithFatalException(env, err); + } + self->inFatalException_ = true; + bool routed = tryReportFatalError(env, err); + self->inFatalException_ = false; + if (!routed) { + // ErrorUtils/reportFatalError absent (non-RN embedder, or called before + // React Native has installed it) or the handler itself threw. + abortWithFatalException(env, err); + } } } // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/HermesNapiHost.hpp b/packages/host/cpp/HermesNapiHost.hpp index 3d8f314a..3e8b1ab3 100644 --- a/packages/host/cpp/HermesNapiHost.hpp +++ b/packages/host/cpp/HermesNapiHost.hpp @@ -120,6 +120,12 @@ class HostContext : public std::enable_shared_from_this { JsDispatcher dispatchToJs_; hermes_napi_host host_; + // Reentrancy guard for fatalException(): true for the duration of routing + // an error through ErrorUtils.reportFatalError. fatalException always runs + // synchronously on the JS thread (see its doc comment), so a plain member + // — no atomics or thread_local — is sufficient to detect a handler that + // itself triggers napi_fatal_exception before the outer call returns. + bool inFatalException_ = false; }; } // namespace callstack::react_native_node_api From c22f39c3dd90a8fd2246a9ccac8e88d67d0267e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 13:24:22 +0200 Subject: [PATCH 13/24] Drop the host's shadowing implementations of runtime Node-API functions (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Drop the host's shadowing implementations of runtime Node-API functions Hermes' first-party Node-API (adopted in #372, integrated via hermes_napi_host in #398) already implements the buffer functions, napi_get_version and napi_get_node_version. RuntimeNodeApi.{cpp,hpp} still defined all of these, and since the generated injector (scripts/generate-injector.mts) resolves each NodeApiHost field by unqualified name inside `namespace callstack::react_native_node_api`, the host's shims won and Hermes' implementations were never reached. Remove napi_create_buffer, napi_create_buffer_copy, napi_create_external_buffer, napi_get_buffer_info, napi_is_buffer, napi_get_version and napi_get_node_version from RuntimeNodeApi.{cpp,hpp}, letting unqualified lookup fall through to Hermes' own symbols. This also fixes two bugs the shims carried: - napi_get_buffer_info wrote its typed-array-kind output into a mutable global (`ArrayType`) that every subsequent napi_create_buffer / napi_create_external_buffer call read back, so calling it on e.g. a Float64Array corrupted every later buffer creation (and raced across runtimes). - napi_create_buffer_copy accepted `result_data` but never wrote it. napi_is_buffer / napi_get_buffer_info also become stricter, matching Node: true/napi_ok only for Uint8Array, napi_invalid_arg otherwise, instead of accepting any ArrayBuffer/TypedArray. Keep napi_fatal_error's host-side implementation: Hermes routes it to stderr, which is not logcat on Android, while the host's version reaches logcat via the "NodeApiHost" logger tag. Documented why this one intentionally keeps shadowing Hermes so a future sweep doesn't remove it as dead weight. RuntimeNodeApi.{cpp,hpp} keep their own translation unit rather than folding into Logger-adjacent code: the file now holds exactly the one shim the host deliberately keeps, and renaming would touch the injector, CMakeLists and podspec globbing for no functional benefit. Adds a changeset (patch) for the observable behavior change: addons now see Hermes' real napi_get_node_version instead of napi_generic_failure, and the stricter buffer type-checking. Closes #67. Verified: pnpm install && pnpm run build, pnpm --filter react-native-node-api run test (pre-existing failures only, confirmed present on unmodified origin/next too - they stem from running as root, not this change), eslint and prettier on touched files. Native C++ compilation was not verified - no Android/iOS toolchain is available in this environment. Closes #428 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm * ci: fix host-cpp-tests label check to match the actual "Host 🏡" label The job checked for a label literally named "host", but the repository's real label (used on issues, e.g. #428/#420/#412) is "Host 🏡" — a label named plain "host" existed too, seemingly a leftover/duplicate, and has since been deleted. The condition never actually matched the label anyone would apply in practice, so this job only ever ran on pushes to main/next, never on a labeled PR. Found while attaching labels to this PR: the CI still showed green with host-cpp-tests silently not running, exactly the kind of gap .claude/CLAUDE.md (#436) exists to prevent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm --------- Co-authored-by: Claude --- .changeset/drop-shadowing-napi-shims.md | 26 +++++ .github/workflows/check.yml | 2 +- packages/host/cpp/RuntimeNodeApi.cpp | 138 +----------------------- packages/host/cpp/RuntimeNodeApi.hpp | 40 +++---- 4 files changed, 50 insertions(+), 156 deletions(-) create mode 100644 .changeset/drop-shadowing-napi-shims.md diff --git a/.changeset/drop-shadowing-napi-shims.md b/.changeset/drop-shadowing-napi-shims.md new file mode 100644 index 00000000..9c78c2d3 --- /dev/null +++ b/.changeset/drop-shadowing-napi-shims.md @@ -0,0 +1,26 @@ +--- +"react-native-node-api": patch +--- + +Drop the host's shadowing implementations of Node-API functions that Hermes' +first-party Node-API already provides, so addons observe Hermes' behavior +instead of the host's older shims: + +- `napi_get_node_version` now reports Hermes' own version (release name + `"hermes"`) instead of unconditionally failing with `napi_generic_failure`. +- `napi_is_buffer` now returns `true` only for `Uint8Array`, matching Node, + instead of any `ArrayBuffer`/`TypedArray`. +- `napi_get_buffer_info` now returns `napi_invalid_arg` for non-`Uint8Array` + values, matching Node, instead of `napi_ok` with zeroed output. +- `napi_create_buffer_copy` now writes a non-`NULL` `result_data` argument, as + documented, instead of silently ignoring it. +- `napi_create_buffer`, `napi_create_external_buffer` and `napi_get_version` + are unchanged in observable behavior, now served by Hermes directly. + +This also fixes a bug where calling `napi_get_buffer_info` on a non-`Uint8Array` +typed array (e.g. a `Float64Array`) left a process-global flag corrupted, so +that every subsequent `napi_create_buffer`/`napi_create_external_buffer` call +produced the wrong typed array view. + +`napi_fatal_error` keeps its host-side implementation, so fatal Node-API +errors keep reaching logcat on Android instead of only stderr. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 632bb32b..53cdb585 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -154,7 +154,7 @@ jobs: ctest --test-dir build --output-on-failure working-directory: packages/weak-node-api host-cpp-tests: - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'host') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'Host 🏡') strategy: fail-fast: false matrix: diff --git a/packages/host/cpp/RuntimeNodeApi.cpp b/packages/host/cpp/RuntimeNodeApi.cpp index c4e1c44c..880d904c 100644 --- a/packages/host/cpp/RuntimeNodeApi.cpp +++ b/packages/host/cpp/RuntimeNodeApi.cpp @@ -1,125 +1,12 @@ #include "RuntimeNodeApi.hpp" #include "Logger.hpp" -#include "Versions.hpp" -#include -auto ArrayType = napi_uint8_array; +#include namespace callstack::react_native_node_api { -napi_status napi_create_buffer(napi_env env, size_t length, void **data, - napi_value *result) { - napi_value buffer; - if (const auto status = napi_create_arraybuffer(env, length, data, &buffer); - status != napi_ok) { - return status; - } - - // Warning: The returned data structure does not fully align with the - // characteristics of a Buffer. - // @see - // https://github.com/callstackincubator/react-native-node-api/issues/171 - return napi_create_typedarray(env, ArrayType, length, buffer, 0, result); -} - -napi_status napi_create_buffer_copy(napi_env env, size_t length, - const void *data, void **result_data, - napi_value *result) { - if (!length || !data || !result) { - return napi_invalid_arg; - } - - void *buffer = nullptr; - if (const auto status = callstack::react_native_node_api::napi_create_buffer( - env, length, &buffer, result); - status != napi_ok) { - return status; - } - - std::memcpy(buffer, data, length); - return napi_ok; -} - -napi_status napi_is_buffer(napi_env env, napi_value value, bool *result) { - if (!result) { - return napi_invalid_arg; - } - - if (!value) { - *result = false; - return napi_ok; - } - - napi_valuetype type{}; - if (const auto status = napi_typeof(env, value, &type); status != napi_ok) { - return status; - } - - if (type != napi_object && type != napi_external) { - *result = false; - return napi_ok; - } - - auto isArrayBuffer{false}; - if (const auto status = napi_is_arraybuffer(env, value, &isArrayBuffer); - status != napi_ok) { - return status; - } - auto isTypedArray{false}; - if (const auto status = napi_is_typedarray(env, value, &isTypedArray); - status != napi_ok) { - return status; - } - - *result = isArrayBuffer || isTypedArray; - return napi_ok; -} - -napi_status napi_get_buffer_info(napi_env env, napi_value value, void **data, - size_t *length) { - if (!data || !length) { - return napi_invalid_arg; - } - *data = nullptr; - *length = 0; - if (!value) { - return napi_ok; - } - - auto isArrayBuffer{false}; - if (const auto status = napi_is_arraybuffer(env, value, &isArrayBuffer); - status == napi_ok && isArrayBuffer) { - return napi_get_arraybuffer_info(env, value, data, length); - } - - auto isTypedArray{false}; - if (const auto status = napi_is_typedarray(env, value, &isTypedArray); - status == napi_ok && isTypedArray) { - return napi_get_typedarray_info(env, value, &ArrayType, length, data, - nullptr, nullptr); - } - - return napi_ok; -} - -napi_status -napi_create_external_buffer(napi_env env, size_t length, void *data, - node_api_basic_finalize basic_finalize_cb, - void *finalize_hint, napi_value *result) { - napi_value buffer; - if (const auto status = napi_create_external_arraybuffer( - env, data, length, basic_finalize_cb, finalize_hint, &buffer); - status != napi_ok) { - return status; - } - - // Warning: The returned data structure does not fully align with the - // characteristics of a Buffer. - // @see - // https://github.com/callstackincubator/react-native-node-api/issues/171 - return napi_create_typedarray(env, ArrayType, length, buffer, 0, result); -} - +// See the comment on the declaration in RuntimeNodeApi.hpp for why this +// deliberately shadows Hermes' own napi_fatal_error. void napi_fatal_error(const char *location, size_t location_len, const char *message, size_t message_len) { if (location && location_len) { @@ -132,23 +19,4 @@ void napi_fatal_error(const char *location, size_t location_len, abort(); } -napi_status napi_get_node_version(node_api_basic_env env, - const napi_node_version **result) { - if (!result) { - return napi_invalid_arg; - } - - *result = nullptr; - return napi_generic_failure; -} - -napi_status napi_get_version(node_api_basic_env env, uint32_t *result) { - if (!result) { - return napi_invalid_arg; - } - - *result = NAPI_VERSION; - return napi_ok; -} - } // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/RuntimeNodeApi.hpp b/packages/host/cpp/RuntimeNodeApi.hpp index 1a5e62ea..0440027c 100644 --- a/packages/host/cpp/RuntimeNodeApi.hpp +++ b/packages/host/cpp/RuntimeNodeApi.hpp @@ -3,30 +3,30 @@ #include "node_api.h" namespace callstack::react_native_node_api { -napi_status napi_create_buffer(napi_env env, size_t length, void **data, - napi_value *result); - -napi_status napi_create_buffer_copy(napi_env env, size_t length, - const void *data, void **result_data, - napi_value *result); - -napi_status napi_is_buffer(napi_env env, napi_value value, bool *result); - -napi_status napi_get_buffer_info(napi_env env, napi_value value, void **data, - size_t *length); - -napi_status -napi_create_external_buffer(napi_env env, size_t length, void *data, - node_api_basic_finalize basic_finalize_cb, - void *finalize_hint, napi_value *result); +// Hermes' first-party Node-API implementation (API/napi/hermes_napi.cpp) +// already defines napi_fatal_error, routing it through hermes_fatal() -> +// llvh::report_fatal_error(), which writes to stderr. On Android stderr is +// not logcat, so that message would be lost exactly when it matters most: +// right before the process aborts. +// +// This declaration is deliberately kept so it shadows Hermes' symbol: the +// generated injector (scripts/generate-injector.mts) resolves each +// NodeApiHost field by unqualified name inside +// `namespace callstack::react_native_node_api`, and this header is included +// there, so unqualified lookup finds this declaration before it would reach +// Hermes' exported symbol. That lets the host's implementation win, which +// logs via the host logger to logcat with the "NodeApiHost" tag (see +// Logger.cpp) before aborting. +// +// Every other Node-API function the host used to shim here (buffers, +// napi_get_version, napi_get_node_version) was removed in favor of letting +// the same lookup fall through to Hermes' own implementation - see +// https://github.com/callstackincubator/react-native-node-api/issues/428. +// Do not remove this one the same way without replacing the logcat routing. void __attribute__((noreturn)) napi_fatal_error(const char *location, size_t location_len, const char *message, size_t message_len); -napi_status napi_get_node_version(node_api_basic_env env, - const napi_node_version **result); - -napi_status napi_get_version(node_api_basic_env env, uint32_t *result); } // namespace callstack::react_native_node_api From d9ab417439ca27de1fae741452be06ccf5a8d1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 13:35:26 +0200 Subject: [PATCH 14/24] cmake-rn: make CODE_SIGNING_ALLOWED configurable for Apple builds (#430) Add a --code-signing-allowed flag to the Apple platform of cmake-rn. CODE_SIGNING_ALLOWED=NO remains the default (needed for the free-standing dynamic libraries we produce), but a consumer who needs signed binaries in the XCFramework - enterprise distribution, or a target whose downstream tooling verifies signatures - can now opt in. Addresses the Apple half of #418. Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm Co-authored-by: Claude --- .changeset/silly-cobras-invite.md | 5 +++++ packages/cmake-rn/src/platforms/apple.ts | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 .changeset/silly-cobras-invite.md diff --git a/.changeset/silly-cobras-invite.md b/.changeset/silly-cobras-invite.md new file mode 100644 index 00000000..6c79c0e3 --- /dev/null +++ b/.changeset/silly-cobras-invite.md @@ -0,0 +1,5 @@ +--- +"cmake-rn": minor +--- + +Add a `--code-signing-allowed` flag to `cmake-rn`. `CODE_SIGNING_ALLOWED=NO` remains the default (needed for the free-standing dynamic libraries we produce), but a consumer who needs signed binaries in the XCFramework can now pass `--code-signing-allowed` to opt in. diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts index 36c595b9..b2c8b640 100644 --- a/packages/cmake-rn/src/platforms/apple.ts +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -161,9 +161,15 @@ const appleBundleIdentifierOption = new Option( "Unique CFBundleIdentifier used for Apple framework artifacts", ).default(undefined, "com.callstackincubator.node-api.{libraryName}"); +const codeSigningAllowedOption = new Option( + "--code-signing-allowed", + "Allow code signing when building free dynamic libraries (passed as CODE_SIGNING_ALLOWED to xcodebuild)", +).default(false); + type AppleOpts = { xcframeworkExtension: boolean; appleBundleIdentifier?: string; + codeSigningAllowed: boolean; }; function getBuildPath(baseBuildPath: string, triplet: Triplet) { @@ -259,7 +265,8 @@ export const platform: Platform = { amendCommand(command) { return command .addOption(xcframeworkExtensionOption) - .addOption(appleBundleIdentifierOption); + .addOption(appleBundleIdentifierOption) + .addOption(codeSigningAllowedOption); }, assertValidTriplets(triplets) { for (const suffix of SIMULATOR_TRIPLET_SUFFIXES) { @@ -366,7 +373,7 @@ export const platform: Platform = { }, async build( { spawn, triplet }, - { build, target, configuration, appleBundleIdentifier }, + { build, target, configuration, appleBundleIdentifier, codeSigningAllowed }, ) { // We expect the final application to sign these binaries if (target.length > 1) { @@ -440,9 +447,10 @@ export const platform: Platform = { ...(target.length > 0 ? ["--target", ...target] : []), "--", - // Skip code-signing (needed when building free dynamic libraries) - // TODO: Make this configurable - "CODE_SIGNING_ALLOWED=NO", + // Skip code-signing by default (needed when building free dynamic + // libraries), but let a consumer opt into signed binaries via + // --code-signing-allowed. + `CODE_SIGNING_ALLOWED=${codeSigningAllowed ? "YES" : "NO"}`, ]); // Create a framework const { artifacts } = sharedLibrary; From 0b3df68bf6079c87f264d79fa5fc85198543ba3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 13:46:28 +0200 Subject: [PATCH 15/24] fix(host): compile out log_debug in release (NDEBUG) builds (#431) * fix(host): compile out log_debug in release (NDEBUG) builds `log_debug`'s per-addon diagnostic chatter (library found/loaded, symbol resolution, ...) was firing unconditionally on every addon load, including in shipped release builds - unwanted logcat/os_log output plus string-formatting cost on a startup path. log_debug is now an inline no-op declared in Logger.hpp when NDEBUG is defined (set by CMake's Release/MinSizeRel/RelWithDebInfo configurations, and by Xcode's Release configuration by default), mirroring React Native's own dev/release logging split. Logger.cpp's real definition is compiled only outside of NDEBUG. log_warning/log_error are untouched and keep firing in every build type, including RelWithDebInfo. This is compile-time only, not the "compile-time default with runtime override" the issue floats as the ideal: there's no existing runtime config plumbing (env var, JS-settable flag, ...) in this codebase to hook an override into, so adding one would mean inventing new plumbing rather than reusing something established. Shipping the safer compile-time-only guard now per the issue's own fallback. Closes #420 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm * docs: keep inline comments brief, trim the Logger ones Adds a `.claude/CLAUDE.md` section: default to no inline comment, and keep the ones that survive to a line or two. Rationale, rejected alternatives and change narration go in the PR description, which is where a `git blame` leads anyway and which does not go stale as the surrounding code moves. Applies it to the log_debug/NDEBUG comments this PR added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NmbKZsRagnasxGVXtnCLoF --------- Co-authored-by: Claude --- .changeset/release-debug-logging.md | 10 ++++++++++ .claude/CLAUDE.md | 27 +++++++++++++++++++++++++++ packages/host/cpp/Logger.cpp | 4 +++- packages/host/cpp/Logger.hpp | 9 +++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .changeset/release-debug-logging.md diff --git a/.changeset/release-debug-logging.md b/.changeset/release-debug-logging.md new file mode 100644 index 00000000..218065a6 --- /dev/null +++ b/.changeset/release-debug-logging.md @@ -0,0 +1,10 @@ +--- +"react-native-node-api": patch +--- + +Stop emitting `log_debug`'s per-addon diagnostic chatter (library +found/loaded, symbol resolution, ...) in release builds. It is now compiled +out in `NDEBUG` builds (CMake's `Release`/`MinSizeRel`/`RelWithDebInfo` +configurations, and Xcode's default `Release` configuration), mirroring React +Native's own dev/release logging split. `log_warning` and `log_error` are +unaffected and keep firing in every build type. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 173babe3..31fda3c4 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,6 +4,33 @@ Guidance specific to Claude Code sessions that open pull requests against this repository (including automated/scheduled sessions). See the root `CLAUDE.md` and `AGENTS.md` for everything else. +## Keep inline comments very brief — put the color in the PR description + +Default to **no comment at all**. Write one only when it carries knowledge a +reader cannot get from the code itself plus a `git blame` pointing at the PR +that introduced it. When you do write one, keep it to a line or two. + +Rationale, rejected alternatives, benchmark numbers, "we tried X and it +didn't work", links to upstream issues, and anything that reads as a history +lesson belong in the **PR description** (and, where user-facing, the +changeset) — not in the source. Those places are where a reader who has +already found the line via `git blame` will end up anyway, and they don't +have to be maintained as the code around them changes. + +Concretely, do not write comments that: + +- restate what the next line already says; +- explain why an alternative implementation was _not_ chosen; +- narrate the change (`// now compiled out in release builds`) — that is a + commit message, and it goes stale the moment the code moves; +- document a well-known toolchain fact (e.g. what `NDEBUG` means) that a + reader can look up. + +Comments that _do_ earn their place: a non-obvious constraint the compiler or +platform imposes, a workaround with the exact condition that makes it +removable (see the upstream-fix guidance in `AGENTS.md`), or a subtle +invariant a future edit could silently break. + ## Attach CI labels when you open a PR `.github/workflows/check.yml`'s `pull_request` trigger only fires on diff --git a/packages/host/cpp/Logger.cpp b/packages/host/cpp/Logger.cpp index b863fcdf..ce9d7c0c 100644 --- a/packages/host/cpp/Logger.cpp +++ b/packages/host/cpp/Logger.cpp @@ -63,13 +63,15 @@ void log_message_internal(LogLevel level, const char *format, va_list args) { namespace callstack::react_native_node_api { +#ifndef NDEBUG void log_debug(const char *format, ...) { - // TODO: Disable logging in release builds va_list args; va_start(args, format); log_message_internal(LogLevel::Debug, format, args); va_end(args); } +#endif + void log_warning(const char *format, ...) { va_list args; va_start(args, format); diff --git a/packages/host/cpp/Logger.hpp b/packages/host/cpp/Logger.hpp index c064e7da..7bec0047 100644 --- a/packages/host/cpp/Logger.hpp +++ b/packages/host/cpp/Logger.hpp @@ -3,7 +3,16 @@ #include namespace callstack::react_native_node_api { + +// Inline (rather than a no-op in Logger.cpp) to let the optimizer drop the +// argument evaluation at every call site. +#ifdef NDEBUG +inline void log_debug(const char *, ...) {} +#else void log_debug(const char *format, ...); +#endif + void log_warning(const char *format, ...); void log_error(const char *format, ...); + } // namespace callstack::react_native_node_api From 48fa7fc6856b8c5b32bd5a2587fc6573a5d59f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 14:29:22 +0200 Subject: [PATCH 16/24] Upgrade bufout to v1.0.0 and remove EventEmitter listener limits (#438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: upgrade bufout to v1.0.0 and drop defaultMaxListeners bumps bufout v1.0.0 keeps the number of listeners on the process and on the output streams constant regardless of how many children are spawned concurrently: a single shared exit/SIGINT listener is attached only while children are running, and every child pipes into one shared pass-through per destination stream. That removes the reason the CLIs raised EventEmitter.defaultMaxListeners to 100, so those assignments (and the now-unused node:events / node:stream imports) are gone and Node's default limit applies again, restoring the leak warning it exists to give. Verified with 80 concurrent children in both "inherit" and "buffered" mode, plus the SpawnFailure flush path, at the default limit of 10: no MaxListenersExceededWarning, and process listener counts return to zero. The public API is unchanged from 0.3.x — the major bump reflects the 1.0.0 milestone, not a breaking change to spawn/SpawnFailure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BAjP89a9VA9EtQsVBxGcto * ci: trigger label-gated jobs The Check workflow only runs on opened/synchronize/reopened, so the Apple/Android/Ferric jobs gated on labels never evaluated the labels added after the pull request was opened. This empty commit fires a synchronize event so they run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BAjP89a9VA9EtQsVBxGcto --------- Co-authored-by: Claude --- .changeset/bufout-one-point-oh.md | 16 ++++++++++++++++ packages/cli-utils/package.json | 2 +- packages/cmake-rn/src/cli.ts | 4 ---- packages/ferric/src/run.ts | 5 ----- packages/host/src/node/cli/program.ts | 4 ---- pnpm-lock.yaml | 10 +++++----- 6 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 .changeset/bufout-one-point-oh.md diff --git a/.changeset/bufout-one-point-oh.md b/.changeset/bufout-one-point-oh.md new file mode 100644 index 00000000..83ec2f32 --- /dev/null +++ b/.changeset/bufout-one-point-oh.md @@ -0,0 +1,16 @@ +--- +"@react-native-node-api/cli-utils": patch +"react-native-node-api": patch +"cmake-rn": patch +"ferric-cli": patch +--- + +Upgrade `bufout` to v1.0.0, which keeps the number of listeners on the process +and the output streams constant regardless of how many children are spawned +concurrently: a single shared `exit`/`SIGINT` listener is attached only while +children are running, and every child pipes into one shared pass-through per +destination stream. + +That removes the reason for the CLIs to raise `EventEmitter.defaultMaxListeners` +to 100, so those assignments are gone and Node's default limit again applies — +restoring the leak warning it exists to give. diff --git a/packages/cli-utils/package.json b/packages/cli-utils/package.json index 25cbaeef..f6fb1122 100644 --- a/packages/cli-utils/package.json +++ b/packages/cli-utils/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", - "bufout": "^0.3.2", + "bufout": "^1.0.0", "chalk": "^5.4.1", "commander": "^14.0.1", "ora": "^8.2.0", diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index b94e2fa2..a2b07e74 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; -import { EventEmitter } from "node:events"; import { chalk, @@ -22,9 +21,6 @@ import { import { Platform } from "./platforms/types.js"; import { getCcachePath } from "./ccache.js"; -// We're attaching a lot of listeners when spawning in parallel -EventEmitter.defaultMaxListeners = 100; - const verboseOption = new Option( "--verbose", "Print more output during the build", diff --git a/packages/ferric/src/run.ts b/packages/ferric/src/run.ts index 01311284..7b14eb8b 100644 --- a/packages/ferric/src/run.ts +++ b/packages/ferric/src/run.ts @@ -1,8 +1,3 @@ -import EventEmitter from "node:events"; - import { program } from "./program.js"; -// We're attaching a lot of listeners when spawning in parallel -EventEmitter.defaultMaxListeners = 100; - program.parseAsync(process.argv).catch(console.error); diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index e3c63904..77d8b8f4 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import path from "node:path"; -import { EventEmitter } from "node:stream"; import { Command, @@ -28,9 +27,6 @@ import { linkModules, pruneLinkedModules, ModuleLinker } from "./link-modules"; import { ensureXcodeBuildPhase, createAppleLinker } from "./apple"; import { linkAndroidDir } from "./android"; -// We're attaching a lot of listeners when spawning in parallel -EventEmitter.defaultMaxListeners = 100; - export const program = new Command("react-native-node-api").addCommand( vendorHermes, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa6ec795..6a99748a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,8 +144,8 @@ importers: specifier: ^14.0.0 version: 14.0.0(commander@14.0.3) bufout: - specifier: ^0.3.2 - version: 0.3.4 + specifier: ^1.0.0 + version: 1.0.0 chalk: specifier: ^5.4.1 version: 5.6.2 @@ -2618,8 +2618,8 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - bufout@0.3.4: - resolution: {integrity: sha512-m8iGxYUvWLdQ9CQ9Sjnmr8hJHlpXfRQn2CV3eI5b107MWQqAe/K/pqsCGmczkSy3r7E1HW5u5z86z2aBYbwwxQ==} + bufout@1.0.0: + resolution: {integrity: sha512-ZCFKJOWLZqZKitcDUTIsJucC9EhOiYl/wb7Fg1TfzlFz1FhjrykZAkn5Gb7JyIsW5ViZjDXL8tr2bXReM/u/XQ==} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -7367,7 +7367,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bufout@0.3.4: {} + bufout@1.0.0: {} bytes@3.1.2: {} From 1ab6a118c1917bacd2a8dde8932fb9ba52c47eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 16:05:51 +0200 Subject: [PATCH 17/24] Multi addon projects (#413) * Add --namespaced-targets to gyp-to-cmake * Use namespaced targets in node-addon-examples * Support building multiple addons * Limit spawn concurrency * Emit prebuilds per target, next to their sources A project declaring multiple addons wrote every prebuild into a single output directory, named after the CMake target. Both the location and the name are now derived per target from the CMake File API: - The output directory defaults to {targetSourceDir}/build/{configuration}, where the new {targetSourceDir} placeholder expands to the target's own source directory. A single-addon project reports "." and so resolves to the same path as before. - The prebuild is named after the artifact on disk (the target's OUTPUT_NAME) rather than the target name, so a target renamed to avoid a clash within the project still produces the name the JS require expects. Together this keeps a prebuild where the Babel plugin and auto-linking resolve it from, and reduces --namespaced-targets to an internal concern. Also fixes, in the same area: - gyp-to-cmake emitted OUTPUT_NAME regardless of --namespaced-targets, due to an always-truthy condition, and never emitted it for Apple framework targets, which CMake names after it. - The Apple build ran a full "cmake --build" once per shared library, concurrently against one build tree, and called "xcodebuild -list" (a synchronous spawn) once per library per triplet. - xcodebuild invocations now run in sequence per build directory, as concurrent invocations against a single Xcode project are not reliable. - postBuild looked for ".framework" while createAppleFramework names it after the artifact, so the two diverged under namespacing. - --concurrency accepted any value, and did not implement the documented fallback to 1 under --verbose. Max listeners is now derived from it. - verify-prebuilds globbed a directory the prebuilds had moved out of, so it passed by finding nothing. It now covers tests/ too and requires a non-zero count. - The root example project globbed recursively, which both missed examples copied in after configure and would add a nested project twice. It is now generated from the same script pipeline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KfKQDvEkxNtkSE4aaF9yG8 --------- Co-authored-by: Claude --- .changeset/chilly-trains-nail.md | 23 ++ .changeset/real-emus-jam.md | 10 + packages/cmake-rn/src/cli.ts | 98 +++-- packages/cmake-rn/src/helpers.ts | 21 ++ packages/cmake-rn/src/output-path.test.ts | 89 +++++ packages/cmake-rn/src/output-path.ts | 35 ++ packages/cmake-rn/src/platforms/android.ts | 99 +++--- packages/cmake-rn/src/platforms/apple.ts | 336 ++++++++++-------- packages/cmake-rn/src/platforms/types.ts | 12 +- packages/gyp-to-cmake/src/cli.ts | 7 + packages/gyp-to-cmake/src/transformer.test.ts | 106 ++++++ packages/gyp-to-cmake/src/transformer.ts | 34 +- packages/node-addon-examples/.gitignore | 1 + packages/node-addon-examples/package.json | 7 +- .../scripts/build-examples.mts | 14 - .../scripts/cmake-projects.mts | 41 +-- .../scripts/generate-root-project.mts | 32 ++ .../scripts/verify-prebuilds.mts | 37 +- .../tests/async/CMakeLists.txt | 12 +- .../tests/buffers/CMakeLists.txt | 12 +- .../tests/threadsafe-function/CMakeLists.txt | 12 +- 21 files changed, 729 insertions(+), 309 deletions(-) create mode 100644 .changeset/chilly-trains-nail.md create mode 100644 .changeset/real-emus-jam.md create mode 100644 packages/cmake-rn/src/output-path.test.ts create mode 100644 packages/cmake-rn/src/output-path.ts delete mode 100644 packages/node-addon-examples/scripts/build-examples.mts create mode 100644 packages/node-addon-examples/scripts/generate-root-project.mts diff --git a/.changeset/chilly-trains-nail.md b/.changeset/chilly-trains-nail.md new file mode 100644 index 00000000..e9576eb7 --- /dev/null +++ b/.changeset/chilly-trains-nail.md @@ -0,0 +1,23 @@ +--- +"cmake-rn": minor +--- + +Add support for building projects declaring multiple shared object libraries into Node-API addons. + +Each addon is emitted next to the sources it was built from, so that a project +declaring many addons produces the same layout as building each of them on its +own. Both the location and the name of an artifact are derived from the target +that produced it: + +- `--out` supports a new `{targetSourceDir}` placeholder, expanding to the source + directory of the target being emitted, and now defaults to + `{targetSourceDir}/build/{configuration}`. This resolves to the same path as + before, unless `--build` is pointed outside of the source directory. +- The artifact is named after the target's `OUTPUT_NAME` rather than the CMake + target name. These are the same unless `OUTPUT_NAME` is set explicitly, which is + how a project can give its targets the unique names CMake requires without + affecting the name of the addon. + +Also adds `--concurrency`, limiting how many build tasks run at once. It defaults +to the available parallelism, or to 1 when `--verbose` is enabled, since +interleaved output from concurrent builds is hard to read. diff --git a/.changeset/real-emus-jam.md b/.changeset/real-emus-jam.md new file mode 100644 index 00000000..13a4194c --- /dev/null +++ b/.changeset/real-emus-jam.md @@ -0,0 +1,10 @@ +--- +"gyp-to-cmake": minor +--- + +Add --namespaced-targets to allow a root project to add many sub-projects. + +CMake requires target names to be unique across a project tree, so sub-projects +that each declare an `addon` target cannot be added to a single root project. This +prefixes the target name with the project name, while setting `OUTPUT_NAME` so the +artifact keeps the name a `require` resolves against. diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index a2b07e74..be687979 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; +import os from "node:os"; import { chalk, @@ -10,6 +11,8 @@ import { oraPromise, assertFixable, wrapAction, + pLimit, + InvalidArgumentError, } from "@react-native-node-api/cli-utils"; import { @@ -20,6 +23,7 @@ import { } from "./platforms.js"; import { Platform } from "./platforms/types.js"; import { getCcachePath } from "./ccache.js"; +import { createOutputPathResolver, expandTemplate } from "./output-path.js"; const verboseOption = new Option( "--verbose", @@ -71,8 +75,8 @@ const cleanOption = new Option( const outPathOption = new Option( "--out ", - "Specify the output directory to store the final build artifacts", -).default("{build}/{configuration}"); + "Specify the output directory to store the final build artifacts. Supports the {targetSourceDir} placeholder, which expands to the source directory of the target being emitted", +).default("{targetSourceDir}/build/{configuration}"); const defineOption = new Option( "-D,--define ", @@ -125,6 +129,22 @@ const ccachePathOption = new Option( "Specify the path to the ccache executable", ).default(getCcachePath()); +const concurrencyOption = new Option( + "--concurrency ", + "Limit the number of concurrent tasks", +) + .argParser((value) => { + const result = Number(value); + if (!Number.isSafeInteger(result) || result < 1) { + throw new InvalidArgumentError("Expected a positive integer."); + } + return result; + }) + .default( + undefined, + `${os.availableParallelism()} or 1 when --verbose is enabled`, + ); + let program = new Command("cmake-rn") .description("Build React Native Node API modules with CMake") .addOption(tripletOption) @@ -140,7 +160,8 @@ let program = new Command("cmake-rn") .addOption(noAutoLinkOption) .addOption(noWeakNodeApiLinkageOption) .addOption(cmakeJsOption) - .addOption(ccachePathOption); + .addOption(ccachePathOption) + .addOption(concurrencyOption); for (const platform of platforms) { const allOption = new Option( @@ -151,25 +172,15 @@ for (const platform of platforms) { program = platform.amendCommand(program); } -function expandTemplate( - input: string, - values: Record, -): string { - return input.replaceAll(/{([^}]+)}/g, (_, key: string) => - typeof values[key] === "string" ? values[key] : "", - ); -} - program = program.action( wrapAction(async ({ triplet: requestedTriplets, ...baseOptions }) => { baseOptions.build = path.resolve( process.cwd(), expandTemplate(baseOptions.build, baseOptions), ); - baseOptions.out = path.resolve( - process.cwd(), - expandTemplate(baseOptions.out, baseOptions), - ); + // Note: {targetSourceDir} is deliberately left unexpanded here, as it is + // only known per target, once the CMake File API has been read. + baseOptions.out = expandTemplate(baseOptions.out, baseOptions); const { verbose, clean, @@ -228,6 +239,13 @@ program = program.action( } } + // Interleaved output from concurrent builds is unreadable, so verbose + // builds default to running one task at a time. + const concurrency = + baseOptions.concurrency ?? (verbose ? 1 : os.availableParallelism()); + const limit = pLimit(concurrency); + const resolveOutputPath = createOutputPathResolver(out, source); + const tripletContexts = [...triplets].map((triplet) => { const platform = findPlatformForTriplet(triplet); @@ -240,17 +258,21 @@ program = program.action( triplet, platform, async spawn(command: string, args: string[], cwd?: string) { - const outputPrefix = verbose ? chalk.dim(`[${triplet}] `) : undefined; - if (verbose) { - console.log( - `${outputPrefix}» ${command} ${args.map((arg) => chalk.dim(`${arg}`)).join(" ")}`, - cwd ? `(in ${chalk.dim(cwd)})` : "", - ); - } - await spawn(command, args, { - outputMode: verbose ? "inherit" : "buffered", - outputPrefix, - cwd, + await limit(async () => { + const outputPrefix = verbose + ? chalk.dim(`[${triplet}] `) + : undefined; + if (verbose) { + console.log( + `${outputPrefix}» ${command} ${args.map((arg) => chalk.dim(`${arg}`)).join(" ")}`, + cwd ? `(in ${chalk.dim(cwd)})` : "", + ); + } + await spawn(command, args, { + outputMode: verbose ? "inherit" : "buffered", + outputPrefix, + cwd, + }); }); }, }; @@ -276,13 +298,15 @@ program = program.action( relevantTriplets, baseOptions, (command, args, cwd) => - spawn(command, args, { - outputMode: verbose ? "inherit" : "buffered", - outputPrefix: verbose - ? chalk.dim(`[${platform.name}] `) - : undefined, - cwd, - }), + limit(() => + spawn(command, args, { + outputMode: verbose ? "inherit" : "buffered", + outputPrefix: verbose + ? chalk.dim(`[${platform.name}] `) + : undefined, + cwd, + }), + ), ); } }), @@ -325,7 +349,11 @@ program = program.action( if (relevantTriplets.length == 0) { continue; } - await platform.postBuild(out, relevantTriplets, baseOptions); + await platform.postBuild( + resolveOutputPath, + relevantTriplets, + baseOptions, + ); } }), ); diff --git a/packages/cmake-rn/src/helpers.ts b/packages/cmake-rn/src/helpers.ts index 83db44ad..ecbc921f 100644 --- a/packages/cmake-rn/src/helpers.ts +++ b/packages/cmake-rn/src/helpers.ts @@ -1,3 +1,24 @@ +import path from "node:path"; + +/** + * The name of the emitted prebuild is derived from the artifact on disk (i.e. + * the target's OUTPUT_NAME) rather than the CMake target name. + * + * A project declaring multiple addons has to give its targets unique names, + * which for generated projects means namespacing them (see gyp-to-cmake's + * --namespaced-targets). The artifact keeps the name the JS `require` expects, + * so deriving from it keeps the prebuild's name independent of how the target + * had to be named to avoid a clash. + */ +export function getArtifactName(artifactPath: string) { + const basename = path.basename(artifactPath, path.extname(artifactPath)); + // Unless a target clears PREFIX (as the generated addon projects do), CMake + // prefixes a shared library with "lib". The prebuild is named after the + // library rather than the file, mirroring how createAndroidLibsDirectory adds + // the prefix back when copying the library into the libs directory. + return basename.startsWith("lib") ? basename.slice("lib".length) : basename; +} + export function toDefineArguments( declarations: Array>, ) { diff --git a/packages/cmake-rn/src/output-path.test.ts b/packages/cmake-rn/src/output-path.test.ts new file mode 100644 index 00000000..f657eee1 --- /dev/null +++ b/packages/cmake-rn/src/output-path.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { createOutputPathResolver, expandTemplate } from "./output-path.js"; +import { getArtifactName } from "./helpers.js"; + +describe("expandTemplate", () => { + it("expands known placeholders", () => { + assert.equal( + expandTemplate("{build}/{configuration}", { + build: "/tmp/build", + configuration: "Release", + }), + "/tmp/build/Release", + ); + }); + + it("leaves unknown placeholders untouched, to allow a later pass", () => { + assert.equal( + expandTemplate("{targetSourceDir}/build/{configuration}", { + configuration: "Release", + }), + "{targetSourceDir}/build/Release", + ); + }); +}); + +describe("createOutputPathResolver", () => { + const source = path.resolve("/projects/my-app"); + + it("resolves a top-level target next to the source directory", () => { + const resolve = createOutputPathResolver( + "{targetSourceDir}/build/Release", + source, + ); + // A single-addon project reports "." as the target's source directory, + // which has to keep emitting where it always has. + assert.equal(resolve("."), path.join(source, "build/Release")); + }); + + it("resolves each target of a multi-addon project next to its own sources", () => { + const resolve = createOutputPathResolver( + "{targetSourceDir}/build/Release", + source, + ); + assert.equal( + resolve("examples/hello"), + path.join(source, "examples/hello/build/Release"), + ); + assert.equal( + resolve("examples/goodbye"), + path.join(source, "examples/goodbye/build/Release"), + ); + }); + + it("handles a target source directory outside the top-level source", () => { + const resolve = createOutputPathResolver( + "{targetSourceDir}/build/Release", + source, + ); + const outside = path.resolve("/elsewhere/vendored"); + assert.equal(resolve(outside), path.join(outside, "build/Release")); + }); + + it("supports a template without the placeholder", () => { + const resolve = createOutputPathResolver("/tmp/out", source); + assert.equal(resolve("examples/hello"), path.resolve("/tmp/out")); + }); +}); + +describe("getArtifactName", () => { + it("derives the name from the artifact rather than the target", () => { + // gyp-to-cmake --namespaced-targets builds "addon.node" from a target named + // "-addon", and the prebuild has to keep the artifact's name. + assert.equal(getArtifactName("examples/hello/addon.node"), "addon"); + }); + + it("handles framework artifacts", () => { + assert.equal(getArtifactName("out/addon.framework/addon"), "addon"); + }); + + it("strips the prefix CMake adds to shared libraries", () => { + // weak-node-api does not clear PREFIX, so it builds a "libweak-node-api.so" + // and has to keep emitting a "weak-node-api.android.node" — the path + // packages/host/android/build.gradle points its jniLibs at. + assert.equal(getArtifactName("libweak-node-api.so"), "weak-node-api"); + }); +}); diff --git a/packages/cmake-rn/src/output-path.ts b/packages/cmake-rn/src/output-path.ts new file mode 100644 index 00000000..33a5845a --- /dev/null +++ b/packages/cmake-rn/src/output-path.ts @@ -0,0 +1,35 @@ +import path from "node:path"; + +/** + * Expand `{placeholder}` occurrences in a template. + * + * Placeholders without a value are left untouched, so a template can be expanded + * in multiple passes as more values become known. + */ +export function expandTemplate( + input: string, + values: Record, +): string { + return input.replaceAll(/{([^}]+)}/g, (match, key: string) => + typeof values[key] === "string" ? values[key] : match, + ); +} + +/** + * The final artifacts are emitted per target, relative to the source directory + * of the target itself. This keeps a target's prebuild next to the sources it + * was built from, even when a single project declares many addons, which is what + * the Babel plugin and auto-linking rely on to resolve a `require`. + */ +export function createOutputPathResolver(outTemplate: string, source: string) { + return function resolveOutputPath(targetSourceDir: string) { + return path.resolve( + process.cwd(), + expandTemplate(outTemplate, { + // `paths.source` is relative to the top-level source directory, unless + // the target lives outside of it, in which case it is already absolute. + targetSourceDir: path.resolve(source, targetSourceDir), + }), + ); + }; +} diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts index 5c8b16ae..8364e1ce 100644 --- a/packages/cmake-rn/src/platforms/android.ts +++ b/packages/cmake-rn/src/platforms/android.ts @@ -14,7 +14,7 @@ import { import * as cmakeFileApi from "cmake-file-api"; import type { BaseOpts, Platform } from "./types.js"; -import { toDefineArguments } from "../helpers.js"; +import { getArtifactName, toDefineArguments } from "../helpers.js"; import { getCmakeJSVariables, getWeakNodeApiVariables, @@ -201,7 +201,6 @@ export const platform: Platform = { await Promise.all( triplets.map(async ({ triplet, spawn }) => { const buildPath = getBuildPath(build, triplet, configuration); - const outputPath = path.join(buildPath, "out"); // We want to use the CMake File API to query information later await cmakeFileApi.createSharedStatelessQuery( buildPath, @@ -226,7 +225,6 @@ export const platform: Platform = { ...commonDefinitions, { // "CPACK_SYSTEM_NAME": `Android-${architecture}`, - CMAKE_LIBRARY_OUTPUT_DIRECTORY: outputPath, ANDROID_ABI: ANDROID_ARCHITECTURES[triplet], }, ]), @@ -247,13 +245,20 @@ export const platform: Platform = { return typeof ANDROID_HOME === "string" && fs.existsSync(ANDROID_HOME); }, async postBuild( - outputPath, + resolveOutputPath, triplets, { autoLink, configuration, target, build, strip, ndkVersion }, ) { + // Keyed by CMake target name, which CMake guarantees to be unique within a + // project. The artifact name is not: every addon of a multi-addon project + // may well build an "addon.node". const prebuilds: Record< string, - { triplet: Triplet; libraryPath: string }[] + { + artifactName: string; + targetSourceDir: string; + libraries: { triplet: Triplet; libraryPath: string }[]; + } > = {}; for (const { triplet, spawn } of triplets) { @@ -269,47 +274,51 @@ export const platform: Platform = { type === "SHARED_LIBRARY" && (target.length === 0 || target.includes(name)), ); - assert.equal( - sharedLibraries.length, - 1, - "Expected exactly one shared library", - ); - const [sharedLibrary] = sharedLibraries; - const { artifacts } = sharedLibrary; - assert( - artifacts && artifacts.length === 1, - "Expected exactly one artifact", - ); - const [artifact] = artifacts; - // Add prebuild entry, creating a new entry if needed - if (!(sharedLibrary.name in prebuilds)) { - prebuilds[sharedLibrary.name] = []; - } - const libraryPath = path.join(buildPath, artifact.path); - assert( - fs.existsSync(libraryPath), - `Expected built library at ${libraryPath}`, - ); + await Promise.all( + sharedLibraries.map(async (sharedLibrary) => { + const { artifacts } = sharedLibrary; + assert( + artifacts && artifacts.length === 1, + "Expected exactly one artifact", + ); + const [artifact] = artifacts; + // Add prebuild entry, creating a new entry if needed + if (!(sharedLibrary.name in prebuilds)) { + prebuilds[sharedLibrary.name] = { + artifactName: getArtifactName(artifact.path), + targetSourceDir: sharedLibrary.paths.source, + libraries: [], + }; + } + const libraryPath = path.join(buildPath, artifact.path); + assert( + fs.existsSync(libraryPath), + `Expected built library at ${libraryPath}`, + ); - if (strip) { - const llvmBinPath = getNdkLlvmBinPath(getNdkPath(ndkVersion)); - const stripToolPath = path.join(llvmBinPath, `llvm-strip`); - assert( - fs.existsSync(stripToolPath), - `Expected llvm-strip to exist at ${stripToolPath}`, - ); - await spawn(stripToolPath, [libraryPath]); - } - prebuilds[sharedLibrary.name].push({ - triplet, - libraryPath, - }); + if (strip) { + const llvmBinPath = getNdkLlvmBinPath(getNdkPath(ndkVersion)); + const stripToolPath = path.join(llvmBinPath, `llvm-strip`); + assert( + fs.existsSync(stripToolPath), + `Expected llvm-strip to exist at ${stripToolPath}`, + ); + await spawn(stripToolPath, [libraryPath]); + } + prebuilds[sharedLibrary.name].libraries.push({ + triplet, + libraryPath, + }); + }), + ); } - for (const [libraryName, libraries] of Object.entries(prebuilds)) { + for (const { artifactName, targetSourceDir, libraries } of Object.values( + prebuilds, + )) { const prebuildOutputPath = path.resolve( - outputPath, - `${libraryName}.android.node`, + resolveOutputPath(targetSourceDir), + `${artifactName}.android.node`, ); await oraPromise( createAndroidLibsDirectory({ @@ -318,10 +327,10 @@ export const platform: Platform = { autoLink, }), { - text: `Assembling Android libs directory (${libraryName})`, - successText: `Android libs directory (${libraryName}) assembled into ${prettyPath(prebuildOutputPath)}`, + text: `Assembling Android libs directory (${artifactName})`, + successText: `Android libs directory (${artifactName}) assembled into ${prettyPath(prebuildOutputPath)}`, failText: ({ message }) => - `Failed to assemble Android libs directory (${libraryName}): ${message}`, + `Failed to assemble Android libs directory (${artifactName}): ${message}`, }, ); } diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts index b2c8b640..2811bcbb 100644 --- a/packages/cmake-rn/src/platforms/apple.ts +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; import cp from "node:child_process"; +import { promisify } from "node:util"; import { assertFixable, @@ -18,7 +19,7 @@ import { import type { Platform } from "./types.js"; import * as cmakeFileApi from "cmake-file-api"; -import { toDefineArguments } from "../helpers.js"; +import { getArtifactName, toDefineArguments } from "../helpers.js"; import { getCmakeJSVariables, getWeakNodeApiVariables, @@ -35,17 +36,16 @@ const XcodeListOutput = z.object({ }), }); -function listXcodeProject(cwd: string): z.infer { - const result = cp.spawnSync("xcodebuild", ["-list", "-json"], { +const execFile = promisify(cp.execFile); + +async function listXcodeProject( + cwd: string, +): Promise> { + const { stdout } = await execFile("xcodebuild", ["-list", "-json"], { encoding: "utf-8", cwd, }); - assert.equal( - result.status, - 0, - `Failed to run xcodebuild -list: ${result.stderr}`, - ); - const parsed = JSON.parse(result.stdout) as unknown; + const parsed = JSON.parse(stdout) as unknown; return XcodeListOutput.parse(parsed); } @@ -176,7 +176,7 @@ function getBuildPath(baseBuildPath: string, triplet: Triplet) { return path.join(baseBuildPath, triplet.replace(/;/g, "_")); } -async function readCmakeSharedLibraryTarget( +async function readCmakeSharedLibraryTargets( buildPath: string, configuration: string, target: string[], @@ -186,18 +186,11 @@ async function readCmakeSharedLibraryTarget( configuration, "2.0", ); - const sharedLibraries = targets.filter( + return targets.filter( ({ type, name }) => type === "SHARED_LIBRARY" && (target.length === 0 || target.includes(name)), ); - assert.equal( - sharedLibraries.length, - 1, - "Expected exactly one shared library", - ); - const [sharedLibrary] = sharedLibraries; - return sharedLibrary; } const SIMULATOR_TRIPLET_SUFFIXES = [ @@ -363,8 +356,22 @@ export const platform: Platform = { // where an unexpanded variable would emitted in the artifact paths. // This is okay, since we're generating per triplet build directories anyway. // https://gitlab.kitware.com/cmake/cmake/-/issues/24161 - CMAKE_LIBRARY_OUTPUT_DIRECTORY: path.join(buildPath, "out"), - CMAKE_ARCHIVE_OUTPUT_DIRECTORY: path.join(buildPath, "out"), + // + // The directory is per target: a project declaring multiple addons + // gives every target the same OUTPUT_NAME (see gyp-to-cmake's + // --namespaced-targets), so a shared directory would have them + // overwrite each other's framework and every prebuild would end up + // assembled from whichever target happened to build last. + CMAKE_LIBRARY_OUTPUT_DIRECTORY: path.join( + buildPath, + "out", + "$", + ), + CMAKE_ARCHIVE_OUTPUT_DIRECTORY: path.join( + buildPath, + "out", + "$", + ), }, ]), ]); @@ -375,70 +382,58 @@ export const platform: Platform = { { spawn, triplet }, { build, target, configuration, appleBundleIdentifier, codeSigningAllowed }, ) { - // We expect the final application to sign these binaries - if (target.length > 1) { - throw new Error("Building for multiple targets is not supported yet"); - } - const buildPath = getBuildPath(build, triplet); - const sharedLibrary = await readCmakeSharedLibraryTarget( + const sharedLibraries = await readCmakeSharedLibraryTargets( buildPath, configuration, target, ); - const isFramework = sharedLibrary.nameOnDisk?.includes(".framework/"); + const frameworkTargets = sharedLibraries.filter(({ nameOnDisk }) => + nameOnDisk?.includes(".framework/"), + ); + const libraryTargets = sharedLibraries.filter( + ({ nameOnDisk }) => !nameOnDisk?.includes(".framework/"), + ); - if (isFramework) { - const { project } = listXcodeProject(buildPath); + if (frameworkTargets.length > 0) { + const { project } = await listXcodeProject(buildPath); const schemes = project.schemes.filter( (scheme) => scheme !== "ALL_BUILD" && scheme !== "ZERO_CHECK", ); - assert( - schemes.length === 1, - `Expected exactly one buildable scheme, got ${schemes.join(", ")}`, - ); - - const [scheme] = schemes; - - if (target.length === 1) { - assert.equal( - scheme, - target[0], - "Expected the only scheme to match the requested target", + // Note: These run in sequence on purpose. Concurrent invocations of + // xcodebuild against the same Xcode project (and its derived data) are + // not reliable, and every target of a triplet shares a single project. + for (const { name } of frameworkTargets) { + assert( + schemes.includes(name), + `Expected to find a scheme for ${name}, got ${schemes.join(", ")}`, ); + + for (const action of ["archive", "install"] as const) { + await spawn( + "xcodebuild", + [ + action, + "-scheme", + name, + "-configuration", + configuration, + "-destination", + DESTINATION_BY_TRIPLET[triplet], + ], + buildPath, + ); + } } + } - await spawn( - "xcodebuild", - [ - "archive", - "-scheme", - scheme, - "-configuration", - configuration, - "-destination", - DESTINATION_BY_TRIPLET[triplet], - ], - buildPath, - ); - await spawn( - "xcodebuild", - [ - "install", - "-scheme", - scheme, - "-configuration", - configuration, - "-destination", - DESTINATION_BY_TRIPLET[triplet], - ], - buildPath, - ); - } else { + if (libraryTargets.length > 0) { + // A single invocation builds every requested target, so this is hoisted + // out of the per-target loop below. await spawn("cmake", [ "--build", buildPath, @@ -452,115 +447,146 @@ export const platform: Platform = { // --code-signing-allowed. `CODE_SIGNING_ALLOWED=${codeSigningAllowed ? "YES" : "NO"}`, ]); - // Create a framework - const { artifacts } = sharedLibrary; - assert( - artifacts && artifacts.length === 1, - "Expected exactly one artifact", + + // We expect the final application to sign these binaries + await Promise.all( + libraryTargets.map(async ({ artifacts }) => { + assert( + artifacts && artifacts.length === 1, + "Expected exactly one artifact", + ); + const [artifact] = artifacts; + await createAppleFramework({ + libraryPath: path.join(buildPath, artifact.path), + kind: triplet.endsWith("-darwin") ? "versioned" : "flat", + bundleIdentifier: appleBundleIdentifier, + }); + }), ); - const [artifact] = artifacts; - await createAppleFramework({ - libraryPath: path.join(buildPath, artifact.path), - kind: triplet.endsWith("-darwin") ? "versioned" : "flat", - bundleIdentifier: appleBundleIdentifier, - }); } }, isSupportedByHost: function (): boolean | Promise { return process.platform === "darwin"; }, async postBuild( - outputPath, + resolveOutputPath, triplets, { configuration, autoLink, xcframeworkExtension, target, build, strip }, ) { - const libraryNames = new Set(); - const frameworkPaths: string[] = []; + // Keyed by CMake target name, which CMake guarantees to be unique within a + // project. The artifact name is not: every addon of a multi-addon project + // may well build an "addon.node". + const prebuilds: Record< + string, + { + artifactName: string; + targetSourceDir: string; + frameworkPaths: string[]; + } + > = {}; + // TODO: Run this in parallel for (const { spawn, triplet } of triplets) { const buildPath = getBuildPath(build, triplet); assert(fs.existsSync(buildPath), `Expected a directory at ${buildPath}`); - const sharedLibrary = await readCmakeSharedLibraryTarget( + const sharedLibraries = await readCmakeSharedLibraryTargets( buildPath, configuration, target, ); - const { artifacts } = sharedLibrary; - assert( - artifacts && artifacts.length === 1, - "Expected exactly one artifact", - ); - const [artifact] = artifacts; - - const artifactPath = path.join(buildPath, artifact.path); - if (strip) { - // -r: All relocation entries. - // -S: All symbol table entries. - // -T: All text relocation entries. - // -x: All local symbols. - await spawn("strip", ["-rSTx", artifactPath]); - } - - libraryNames.add(sharedLibrary.name); - // Locate the path of the framework, if a free dynamic library was built - if (artifact.path.includes(".framework/")) { - frameworkPaths.push(path.dirname(artifactPath)); - } else { - const libraryName = path.basename( - artifact.path, - path.extname(artifact.path), - ); - const frameworkPath = path.join( - buildPath, - path.dirname(artifact.path), - `${libraryName}.framework`, - ); - assert( - fs.existsSync(frameworkPath), - `Expected to find a framework at: ${frameworkPath}`, - ); - frameworkPaths.push(frameworkPath); - } + await Promise.all( + sharedLibraries.map(async (sharedLibrary) => { + const { name, paths, artifacts } = sharedLibrary; + assert( + artifacts && artifacts.length === 1, + "Expected exactly one artifact", + ); + const [artifact] = artifacts; + const artifactName = getArtifactName(artifact.path); + + const artifactPath = path.join(buildPath, artifact.path); + + if (strip) { + // -r: All relocation entries. + // -S: All symbol table entries. + // -T: All text relocation entries. + // -x: All local symbols. + await spawn("strip", ["-rSTx", artifactPath]); + } + + // Locate the path of the framework, if a free dynamic library was built + let frameworkPath: string; + if (artifact.path.includes(".framework/")) { + frameworkPath = path.dirname(artifactPath); + } else { + // createAppleFramework names the framework after the artifact file, + // keeping any "lib" prefix, so this is derived the same way rather + // than from the (prefix-stripped) name of the prebuild. + frameworkPath = path.join( + buildPath, + path.dirname(artifact.path), + `${path.basename( + artifact.path, + path.extname(artifact.path), + )}.framework`, + ); + assert( + fs.existsSync(frameworkPath), + `Expected to find a framework at: ${frameworkPath}`, + ); + } + + if (name in prebuilds) { + prebuilds[name].frameworkPaths.push(frameworkPath); + } else { + prebuilds[name] = { + artifactName, + targetSourceDir: paths.source, + frameworkPaths: [frameworkPath], + }; + } + }), + ); } - // Make sure none of the frameworks are symlinks - // We do this before creating an xcframework to avoid symlink paths being invalidated - // as the xcframework might be moved to a different location - await Promise.all( - frameworkPaths.map(async (frameworkPath) => { - const stat = await fs.promises.lstat(frameworkPath); - if (stat.isSymbolicLink()) { - await dereferenceDirectory(frameworkPath); - } - }), - ); - - const extension = xcframeworkExtension ? ".xcframework" : ".apple.node"; + for (const { + artifactName, + targetSourceDir, + frameworkPaths, + } of Object.values(prebuilds)) { + // Make sure none of the frameworks are symlinks + // We do this before creating an xcframework to avoid symlink paths being invalidated + // as the xcframework might be moved to a different location + await Promise.all( + frameworkPaths.map(async (frameworkPath) => { + const stat = await fs.promises.lstat(frameworkPath); + if (stat.isSymbolicLink()) { + await dereferenceDirectory(frameworkPath); + } + }), + ); - assert( - libraryNames.size === 1, - "Expected all libraries to have the same name", - ); - const [libraryName] = libraryNames; + const extension = xcframeworkExtension ? ".xcframework" : ".apple.node"; - // Create the xcframework - const xcframeworkOutputPath = path.resolve( - outputPath, - `${libraryName}${extension}`, - ); + // Create the xcframework + const xcframeworkOutputPath = path.resolve( + resolveOutputPath(targetSourceDir), + `${artifactName}${extension}`, + ); - await oraPromise( - createXCframework({ - outputPath: xcframeworkOutputPath, - frameworkPaths, - autoLink, - }), - { - text: `Assembling XCFramework (${libraryName})`, - successText: `XCFramework (${libraryName}) assembled into ${prettyPath(xcframeworkOutputPath)}`, - failText: ({ message }) => - `Failed to assemble XCFramework (${libraryName}): ${message}`, - }, - ); + await oraPromise( + createXCframework({ + outputPath: xcframeworkOutputPath, + frameworkPaths, + autoLink, + }), + { + text: `Assembling XCFramework (${artifactName})`, + successText: `XCFramework (${artifactName}) assembled into ${prettyPath(xcframeworkOutputPath)}`, + failText: ({ message }) => + `Failed to assemble XCFramework (${artifactName}): ${message}`, + }, + ); + } }, }; diff --git a/packages/cmake-rn/src/platforms/types.ts b/packages/cmake-rn/src/platforms/types.ts index d6cd3963..98e68f2a 100644 --- a/packages/cmake-rn/src/platforms/types.ts +++ b/packages/cmake-rn/src/platforms/types.ts @@ -29,6 +29,14 @@ export type Spawn = ( cwd?: string, ) => Promise; +/** + * Resolve the directory a target's final artifact should be emitted into. + * @param targetSourceDir The target's source directory, as reported by the CMake + * File API: relative to the top-level source directory, or absolute if the + * target lives outside of it. + */ +export type ResolveOutputPath = (targetSourceDir: string) => string; + export type Platform< Triplets extends string[] = string[], Opts extends cli.OptionValues = Record, @@ -86,9 +94,9 @@ export type Platform< */ postBuild( /** - * Location of the final prebuilt artefact. + * Resolve the location of the final prebuilt artefact, per target. */ - outputPath: string, + resolveOutputPath: ResolveOutputPath, triplets: TripletContext[], options: BaseOpts & Opts, ): Promise; diff --git a/packages/gyp-to-cmake/src/cli.ts b/packages/gyp-to-cmake/src/cli.ts index 45cefbaa..13ff8480 100644 --- a/packages/gyp-to-cmake/src/cli.ts +++ b/packages/gyp-to-cmake/src/cli.ts @@ -90,6 +90,11 @@ export const program = new Command("gyp-to-cmake") "Disable emitting target properties to produce Apple frameworks", ) .option("--cpp ", "C++ standard version", "17") + .option( + "--namespaced-targets", + "Use namespaced targets, to allow multiple targets with the same name to be referenced from a single parent project", + false, + ) .addOption(projectNameOption) .argument( "[path]", @@ -107,6 +112,7 @@ export const program = new Command("gyp-to-cmake") weakNodeApi, appleFramework, projectName, + namespacedTargets, }, ) => { const options: Omit = { @@ -117,6 +123,7 @@ export const program = new Command("gyp-to-cmake") defineNapiVersion, weakNodeApi, appleFramework, + namespacedTargets, }; const stat = fs.statSync(targetPath); if (stat.isFile()) { diff --git a/packages/gyp-to-cmake/src/transformer.test.ts b/packages/gyp-to-cmake/src/transformer.test.ts index 06d62f8f..a43a794a 100644 --- a/packages/gyp-to-cmake/src/transformer.test.ts +++ b/packages/gyp-to-cmake/src/transformer.test.ts @@ -125,4 +125,110 @@ describe("bindingGypToCmakeLists", () => { ); }); }); + + describe("namespaced targets", () => { + const gyp = { + targets: [{ target_name: "addon", sources: ["addon.cc"] }], + }; + + it("should not namespace or set OUTPUT_NAME by default", () => { + const output = bindingGypToCmakeLists({ + projectName: "some-project", + gyp, + }); + + assert( + output.includes("add_library(addon SHARED addon.cc"), + `Expected an un-namespaced target:\n${output}`, + ); + assert( + !output.includes("OUTPUT_NAME"), + `Expected no OUTPUT_NAME when not namespacing:\n${output}`, + ); + }); + + it("should prefix the target name with the project name", () => { + const output = bindingGypToCmakeLists({ + projectName: "some-project", + gyp, + namespacedTargets: true, + }); + + assert( + output.includes("add_library(some-project-addon SHARED addon.cc"), + `Expected a namespaced target:\n${output}`, + ); + assert( + !output.includes("add_library(addon "), + `Expected no un-namespaced target:\n${output}`, + ); + }); + + it("should reference the namespaced target in target-specific commands", () => { + const output = bindingGypToCmakeLists({ + projectName: "some-project", + gyp: { + targets: [ + { + target_name: "addon", + sources: ["addon.cc"], + include_dirs: ["include"], + defines: ["FOO"], + }, + ], + }, + namespacedTargets: true, + weakNodeApi: true, + compileFeatures: ["cxx_std_17"], + }); + + for (const command of [ + "target_link_libraries(some-project-addon PRIVATE weak-node-api)", + "target_include_directories(some-project-addon PRIVATE include)", + "target_compile_definitions(some-project-addon PRIVATE FOO)", + "target_compile_features(some-project-addon PRIVATE cxx_std_17)", + ]) { + assert( + output.includes(command), + `Expected output to include "${command}":\n${output}`, + ); + } + }); + + it("should keep the artifact name un-namespaced in both Apple branches", () => { + const output = bindingGypToCmakeLists({ + projectName: "some-project", + gyp, + namespacedTargets: true, + }); + + // CMake names the framework bundle after OUTPUT_NAME, so both the + // framework and the plain shared library branch need it. Otherwise the + // prebuild ends up named after the namespaced target. + assert.equal( + output.match(/OUTPUT_NAME addon$/gm)?.length, + 2, + `Expected OUTPUT_NAME in both branches:\n${output}`, + ); + }); + + it("should set OUTPUT_NAME when Apple framework support is disabled", () => { + const output = bindingGypToCmakeLists({ + projectName: "some-project", + gyp, + namespacedTargets: true, + appleFramework: false, + }); + + assert( + output.includes("set_target_properties(some-project-addon PROPERTIES"), + `Expected properties on the namespaced target:\n${output}`, + ); + assert.equal( + output.match(/OUTPUT_NAME addon$/gm)?.length, + 1, + `Expected a single OUTPUT_NAME:\n${output}`, + ); + }); + }); }); diff --git a/packages/gyp-to-cmake/src/transformer.ts b/packages/gyp-to-cmake/src/transformer.ts index 4901df4f..de4c0773 100644 --- a/packages/gyp-to-cmake/src/transformer.ts +++ b/packages/gyp-to-cmake/src/transformer.ts @@ -16,6 +16,7 @@ export type GypToCmakeListsOptions = { defineNapiVersion?: boolean; weakNodeApi?: boolean; appleFramework?: boolean; + namespacedTargets?: boolean; }; function isCmdExpansion(value: string) { @@ -50,6 +51,7 @@ export function bindingGypToCmakeLists({ weakNodeApi = false, appleFramework = true, compileFeatures = [], + namespacedTargets = false, }: GypToCmakeListsOptions): string { function mapExpansion(value: string): string[] { if (!isCmdExpansion(value)) { @@ -123,12 +125,23 @@ export function bindingGypToCmakeLists({ escapedIncludes.push("${CMAKE_JS_INC}"); } + const actualTargetName = namespacedTargets + ? `${projectName}-${targetName}` + : targetName; + + // Namespacing only disambiguates the CMake target name: the artifact on disk + // keeps the name the JS `require` expects, which is what cmake-rn derives the + // final prebuild name from. + const outputNameProperties: Record = namespacedTargets + ? { OUTPUT_NAME: targetName } + : {}; + function setTargetPropertiesLines( properties: Record, indent = "", ): string[] { return [ - `${indent}set_target_properties(${targetName} PROPERTIES`, + `${indent}set_target_properties(${actualTargetName} PROPERTIES`, ...Object.entries(properties).map( ([key, value]) => `${indent} ${key} ${value ? value : '""'}`, ), @@ -136,7 +149,9 @@ export function bindingGypToCmakeLists({ ]; } - lines.push(`add_library(${targetName} SHARED ${escapedSources.join(" ")})`); + lines.push( + `add_library(${actualTargetName} SHARED ${escapedSources.join(" ")})`, + ); if (appleFramework) { lines.push( @@ -153,6 +168,9 @@ export function bindingGypToCmakeLists({ MACOSX_FRAMEWORK_SHORT_VERSION_STRING: "1.0", MACOSX_FRAMEWORK_BUNDLE_VERSION: "1.0", XCODE_ATTRIBUTE_SKIP_INSTALL: "NO", + // CMake names the framework bundle after OUTPUT_NAME, so this has to + // be set here too for the artifact to keep its non-namespaced name. + ...outputNameProperties, }, " ", ), @@ -161,6 +179,7 @@ export function bindingGypToCmakeLists({ { PREFIX: "", SUFFIX: ".node", + ...outputNameProperties, }, " ", ), @@ -172,19 +191,20 @@ export function bindingGypToCmakeLists({ ...setTargetPropertiesLines({ PREFIX: "", SUFFIX: ".node", + ...outputNameProperties, }), ); } if (libraries.length > 0) { lines.push( - `target_link_libraries(${targetName} PRIVATE ${libraries.join(" ")})`, + `target_link_libraries(${actualTargetName} PRIVATE ${libraries.join(" ")})`, ); } if (escapedIncludes.length > 0) { lines.push( - `target_include_directories(${targetName} PRIVATE ${escapedIncludes.join( + `target_include_directories(${actualTargetName} PRIVATE ${escapedIncludes.join( " ", )})`, ); @@ -192,17 +212,17 @@ export function bindingGypToCmakeLists({ if (escapedDefines.length > 0) { lines.push( - `target_compile_definitions(${targetName} PRIVATE ${escapedDefines.join(" ")})`, + `target_compile_definitions(${actualTargetName} PRIVATE ${escapedDefines.join(" ")})`, ); } if (compileFeatures.length > 0) { lines.push( - `target_compile_features(${targetName} PRIVATE ${compileFeatures.join(" ")})`, + `target_compile_features(${actualTargetName} PRIVATE ${compileFeatures.join(" ")})`, ); } - // `set_target_properties(${targetName} PROPERTIES CXX_STANDARD 11 CXX_STANDARD_REQUIRED YES CXX_EXTENSIONS NO)`, + // `set_target_properties(${actualTargetName} PROPERTIES CXX_STANDARD 11 CXX_STANDARD_REQUIRED YES CXX_EXTENSIONS NO)`, } if (!weakNodeApi) { diff --git a/packages/node-addon-examples/.gitignore b/packages/node-addon-examples/.gitignore index 7470cb91..e7b17a9e 100644 --- a/packages/node-addon-examples/.gitignore +++ b/packages/node-addon-examples/.gitignore @@ -1,2 +1,3 @@ examples/ build/ +/CMakeLists.txt diff --git a/packages/node-addon-examples/package.json b/packages/node-addon-examples/package.json index 0d2d129b..acfd70ec 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -21,9 +21,10 @@ }, "scripts": { "copy-examples": "tsx scripts/copy-examples.mts", - "gyp-to-cmake": "gyp-to-cmake --weak-node-api .", - "build": "tsx scripts/build-examples.mts", - "copy-and-build": "node --run copy-examples && node --run gyp-to-cmake && node --run build", + "gyp-to-cmake": "gyp-to-cmake --namespaced-targets --weak-node-api .", + "generate-root-project": "tsx scripts/generate-root-project.mts", + "build": "cmake-rn --configuration RelWithDebInfo", + "copy-and-build": "node --run copy-examples && node --run gyp-to-cmake && node --run generate-root-project && node --run build", "verify": "tsx scripts/verify-prebuilds.mts", "test": "node --run copy-and-build && node --run verify", "bootstrap": "node --run copy-and-build" diff --git a/packages/node-addon-examples/scripts/build-examples.mts b/packages/node-addon-examples/scripts/build-examples.mts deleted file mode 100644 index bc447e71..00000000 --- a/packages/node-addon-examples/scripts/build-examples.mts +++ /dev/null @@ -1,14 +0,0 @@ -import { execSync } from "node:child_process"; - -import { findCMakeProjects } from "./cmake-projects.mjs"; - -const projectDirectories = findCMakeProjects(); - -for (const projectDirectory of projectDirectories) { - console.log(`Running "cmake-rn" in ${projectDirectory}`); - execSync("cmake-rn --configuration RelWithDebInfo", { - cwd: projectDirectory, - stdio: "inherit", - }); - console.log(); -} diff --git a/packages/node-addon-examples/scripts/cmake-projects.mts b/packages/node-addon-examples/scripts/cmake-projects.mts index 56aab0f0..22dcd5e7 100644 --- a/packages/node-addon-examples/scripts/cmake-projects.mts +++ b/packages/node-addon-examples/scripts/cmake-projects.mts @@ -1,26 +1,27 @@ -import { readdirSync, statSync } from "node:fs"; +import fs from "node:fs"; import path from "node:path"; -export const EXAMPLES_DIR = path.resolve(import.meta.dirname, "../examples"); -export const TESTS_DIR = path.resolve(import.meta.dirname, "../tests"); +export const PACKAGE_DIR = path.resolve(import.meta.dirname, ".."); +export const EXAMPLES_DIR = path.resolve(PACKAGE_DIR, "examples"); +export const TESTS_DIR = path.resolve(PACKAGE_DIR, "tests"); export const DIRS = [EXAMPLES_DIR, TESTS_DIR]; -export function findCMakeProjectsRecursively(dir: string): string[] { - let results: string[] = []; - const files = readdirSync(dir); - - for (const file of files) { - const fullPath = path.join(dir, file); - if (statSync(fullPath).isDirectory()) { - results = results.concat(findCMakeProjectsRecursively(fullPath)); - } else if (file === "CMakeLists.txt") { - results.push(dir); - } +/** + * Find the shallowest directories declaring a CMake project. + * + * Recursion stops at the first CMakeLists.txt found on a path: an example + * bringing its own nested CMake project has to be added to the root project + * once, since CMake requires target names to be unique across the project tree. + */ +export function findRootCMakeProjects(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; } - - return results; -} - -export function findCMakeProjects(): string[] { - return DIRS.flatMap(findCMakeProjectsRecursively); + if (fs.existsSync(path.join(dir, "CMakeLists.txt"))) { + return [dir]; + } + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => findRootCMakeProjects(path.join(dir, entry.name))); } diff --git a/packages/node-addon-examples/scripts/generate-root-project.mts b/packages/node-addon-examples/scripts/generate-root-project.mts new file mode 100644 index 00000000..8d2b4df9 --- /dev/null +++ b/packages/node-addon-examples/scripts/generate-root-project.mts @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { DIRS, PACKAGE_DIR, findRootCMakeProjects } from "./cmake-projects.mjs"; + +// A single root project lets cmake-rn build every example in one invocation. +// It is generated rather than globbed, because a glob is evaluated once at +// configure time and would miss examples copied in afterwards. +const projectDirectories = DIRS.flatMap(findRootCMakeProjects) + .map((directory) => + path.relative(PACKAGE_DIR, directory).split(path.sep).join(path.posix.sep), + ) + .sort(); + +const outputPath = path.join(PACKAGE_DIR, "CMakeLists.txt"); + +fs.writeFileSync( + outputPath, + [ + "# Generated by scripts/generate-root-project.mts - do not edit.", + "cmake_minimum_required(VERSION 3.15...3.31)", + "project(node-addon-examples)", + "", + ...projectDirectories.map((directory) => `add_subdirectory(${directory})`), + "", + ].join("\n"), + "utf-8", +); + +console.log( + `Generated ${path.relative(process.cwd(), outputPath)} with ${projectDirectories.length} sub-projects`, +); diff --git a/packages/node-addon-examples/scripts/verify-prebuilds.mts b/packages/node-addon-examples/scripts/verify-prebuilds.mts index cdbd312b..94b4e1bb 100644 --- a/packages/node-addon-examples/scripts/verify-prebuilds.mts +++ b/packages/node-addon-examples/scripts/verify-prebuilds.mts @@ -2,7 +2,7 @@ import fs from "node:fs"; import assert from "node:assert/strict"; import path from "node:path"; -import { EXAMPLES_DIR } from "./cmake-projects.mjs"; +import { DIRS } from "./cmake-projects.mjs"; const EXPECTED_ANDROID_ARCHS = ["armeabi-v7a", "arm64-v8a", "x86_64", "x86"]; @@ -82,17 +82,28 @@ async function verifyApplePrebuild(dirent: fs.Dirent) { } } -for await (const dirent of fs.promises.glob("**/*.*.node", { - cwd: EXAMPLES_DIR, - withFileTypes: true, -})) { - if (dirent.name.endsWith(".android.node")) { - await verifyAndroidPrebuild(dirent); - } else if (dirent.name.endsWith(".apple.node")) { - await verifyApplePrebuild(dirent); - } else { - throw new Error( - `Unexpected prebuild file: ${dirent.name} in ${dirent.parentPath}`, - ); +let verified = 0; + +for (const cwd of DIRS) { + for await (const dirent of fs.promises.glob("**/*.*.node", { + cwd, + withFileTypes: true, + })) { + if (dirent.name.endsWith(".android.node")) { + await verifyAndroidPrebuild(dirent); + } else if (dirent.name.endsWith(".apple.node")) { + await verifyApplePrebuild(dirent); + } else { + throw new Error( + `Unexpected prebuild file: ${dirent.name} in ${dirent.parentPath}`, + ); + } + verified++; } } + +// Without this, the script passes by simply not finding any prebuilds, which is +// exactly what happens if they stop being emitted next to the sources they were +// built from. +assert(verified > 0, `Found no prebuilds in ${DIRS.join(", ")}`); +console.log(`Verified ${verified} prebuilds`); diff --git a/packages/node-addon-examples/tests/async/CMakeLists.txt b/packages/node-addon-examples/tests/async/CMakeLists.txt index 67e5448b..ca9532d7 100644 --- a/packages/node-addon-examples/tests/async/CMakeLists.txt +++ b/packages/node-addon-examples/tests/async/CMakeLists.txt @@ -3,24 +3,26 @@ project(async-test) find_package(weak-node-api REQUIRED CONFIG) -add_library(addon SHARED addon.c) +add_library(async-test-addon SHARED addon.c) option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) if(APPLE AND BUILD_APPLE_FRAMEWORK) - set_target_properties(addon PROPERTIES + set_target_properties(async-test-addon PROPERTIES FRAMEWORK TRUE MACOSX_FRAMEWORK_IDENTIFIER async-test.addon MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 XCODE_ATTRIBUTE_SKIP_INSTALL NO + OUTPUT_NAME addon ) else() - set_target_properties(addon PROPERTIES + set_target_properties(async-test-addon PROPERTIES PREFIX "" SUFFIX .node + OUTPUT_NAME addon ) endif() -target_link_libraries(addon PRIVATE weak-node-api) -target_compile_features(addon PRIVATE cxx_std_17) \ No newline at end of file +target_link_libraries(async-test-addon PRIVATE weak-node-api) +target_compile_features(async-test-addon PRIVATE cxx_std_17) \ No newline at end of file diff --git a/packages/node-addon-examples/tests/buffers/CMakeLists.txt b/packages/node-addon-examples/tests/buffers/CMakeLists.txt index da615db2..d9314224 100644 --- a/packages/node-addon-examples/tests/buffers/CMakeLists.txt +++ b/packages/node-addon-examples/tests/buffers/CMakeLists.txt @@ -3,24 +3,26 @@ project(buffers-test) find_package(weak-node-api REQUIRED CONFIG) -add_library(addon SHARED addon.c) +add_library(buffers-test-addon SHARED addon.c) option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) if(APPLE AND BUILD_APPLE_FRAMEWORK) - set_target_properties(addon PROPERTIES + set_target_properties(buffers-test-addon PROPERTIES FRAMEWORK TRUE MACOSX_FRAMEWORK_IDENTIFIER buffers-test.addon MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 XCODE_ATTRIBUTE_SKIP_INSTALL NO + OUTPUT_NAME addon ) else() - set_target_properties(addon PROPERTIES + set_target_properties(buffers-test-addon PROPERTIES PREFIX "" SUFFIX .node + OUTPUT_NAME addon ) endif() -target_link_libraries(addon PRIVATE weak-node-api) -target_compile_features(addon PRIVATE cxx_std_17) \ No newline at end of file +target_link_libraries(buffers-test-addon PRIVATE weak-node-api) +target_compile_features(buffers-test-addon PRIVATE cxx_std_17) \ No newline at end of file diff --git a/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt b/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt index 1be47aff..40ffb7f8 100644 --- a/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt +++ b/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt @@ -3,24 +3,26 @@ project(threadsafe-function-test) find_package(weak-node-api REQUIRED CONFIG) -add_library(addon SHARED addon.c) +add_library(threadsafe-function-test-addon SHARED addon.c) option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) if(APPLE AND BUILD_APPLE_FRAMEWORK) - set_target_properties(addon PROPERTIES + set_target_properties(threadsafe-function-test-addon PROPERTIES FRAMEWORK TRUE MACOSX_FRAMEWORK_IDENTIFIER threadsafe-function-test.addon MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 XCODE_ATTRIBUTE_SKIP_INSTALL NO + OUTPUT_NAME addon ) else() - set_target_properties(addon PROPERTIES + set_target_properties(threadsafe-function-test-addon PROPERTIES PREFIX "" SUFFIX .node + OUTPUT_NAME addon ) endif() -target_link_libraries(addon PRIVATE weak-node-api) -target_compile_features(addon PRIVATE cxx_std_17) +target_link_libraries(threadsafe-function-test-addon PRIVATE weak-node-api) +target_compile_features(threadsafe-function-test-addon PRIVATE cxx_std_17) \ No newline at end of file From 166b3bfab1c3a98c036d7f5d08ba999046d5ce78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 17:00:26 +0200 Subject: [PATCH 18/24] Add a prebuilt-hermes command and a workflow that publishes its archive (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a prebuilt-hermes command and a workflow that publishes its archive Building Hermes for Apple platforms is the expensive part of an iOS build, and it only changes when the pinned commit does. Build it once into an archive in the destroot layout hermes-engine.podspec expects from HERMES_ENGINE_TARBALL_PATH, and publish it as a release asset keyed by the pinned commit. Nothing consumes the archive yet — pod install still builds Hermes from source. This lands the command and the publishing workflow first, so the workflow is dispatchable and an asset exists before anything depends on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH * Trigger CI with the labels attached The workflow's pull_request trigger doesn't fire on `labeled`, so the label-gated jobs need a synchronize event to be evaluated against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH --------- Co-authored-by: Claude Opus 5 --- .changeset/prebuilt-hermes-command.md | 18 + .github/workflows/hermes-prebuilt.yml | 78 ++++ docs/CLI.md | 24 +- packages/host/src/node/cli/hermes-prebuilt.ts | 429 ++++++++++++++++++ packages/host/src/node/cli/hermes.ts | 196 ++++---- packages/host/src/node/cli/program.ts | 7 +- 6 files changed, 671 insertions(+), 81 deletions(-) create mode 100644 .changeset/prebuilt-hermes-command.md create mode 100644 .github/workflows/hermes-prebuilt.yml create mode 100644 packages/host/src/node/cli/hermes-prebuilt.ts 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") { From 263a3bcb517856aa8a3292b1e23fa08b02406f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 18:00:51 +0200 Subject: [PATCH 19/24] Configure the host Hermes compiler for one architecture (#442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Configure the host Hermes compiler for one architecture Passing CMAKE_OSX_ARCHITECTURES=arm64;x86_64 to build a universal hermesc makes llvh's feature try-compiles fail — standard headers report as missing and the configure dies on CheckAtomic. Configure it the way Hermes and React Native do, for the host architecture only. hermesc is then native to the Mac that built the archive, so the archive name carries the host architecture: a Mac of the other architecture finds no published archive and builds its own, instead of downloading a hermesc it cannot execute. Also assign each command substitution before echoing it into GITHUB_OUTPUT. Inside `echo "x=$(cmd)"` the step's exit status is echo's, so the failing build above passed its step with an empty path and only surfaced one step later, as `gh release upload ""`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH * Trigger CI with the labels attached The workflow's pull_request trigger doesn't fire on `labeled`, so the label-gated jobs need a synchronize event to be evaluated against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH --------- Co-authored-by: Claude Opus 5 --- .changeset/prebuilt-hermes-host-compiler.md | 15 +++++++++++++++ .github/workflows/hermes-prebuilt.yml | 13 ++++++++++--- docs/CLI.md | 4 +++- packages/host/src/node/cli/hermes-prebuilt.ts | 16 ++++++++++------ 4 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 .changeset/prebuilt-hermes-host-compiler.md diff --git a/.changeset/prebuilt-hermes-host-compiler.md b/.changeset/prebuilt-hermes-host-compiler.md new file mode 100644 index 00000000..c8dd3a99 --- /dev/null +++ b/.changeset/prebuilt-hermes-host-compiler.md @@ -0,0 +1,15 @@ +--- +"react-native-node-api": patch +--- + +Fix `prebuilt-hermes` failing to configure the host Hermes compiler. It passed +`CMAKE_OSX_ARCHITECTURES=arm64;x86_64` to build a universal `hermesc`, but a +multi-arch host configure makes llvh's feature try-compiles fail — standard +headers report as missing and the configure dies with "Host compiler appears to +require libatomic, but cannot find it". The host compiler is now configured the +way Hermes and React Native configure it, for the host architecture only. + +`hermesc` is consequently native to the Mac that built the archive, so the +archive name now carries the host architecture. An Intel Mac finds no published +archive for its architecture and builds its own, rather than downloading one +whose `hermesc` it cannot execute. diff --git a/.github/workflows/hermes-prebuilt.yml b/.github/workflows/hermes-prebuilt.yml index cf619033..9c9a1a79 100644 --- a/.github/workflows/hermes-prebuilt.yml +++ b/.github/workflows/hermes-prebuilt.yml @@ -43,12 +43,17 @@ jobs: - run: pnpm run build # Resolved from the test app so the archive is built against the React # Native version this repository actually pins. + # Each command substitution is assigned before it is echoed: inside + # `echo "x=$(cmd)"` the step's exit status is echo's, so a failing cmd + # passes the step with an empty value. - 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" + archive=$(pnpm exec react-native-node-api prebuilt-hermes --print name) + tag=$(pnpm exec react-native-node-api prebuilt-hermes --print tag) + echo "archive=$archive" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Cache prebuilt Hermes uses: actions/cache@v6 with: @@ -59,7 +64,9 @@ jobs: - 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" + run: | + path=$(pnpm exec react-native-node-api prebuilt-hermes --no-download) + echo "path=$path" >> "$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 diff --git a/docs/CLI.md b/docs/CLI.md index 58c8ec35..a8c62549 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -21,7 +21,9 @@ The archive is looked for in this order, and cached under `~/Library/Caches/reac 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. +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, the platforms and the host architecture. That makes it usable as a CI cache key. + +The host architecture is part of it because `destroot/bin/hermesc` is a native binary for whichever Mac built the archive. Archives are published from Apple Silicon runners, so an Intel Mac finds none to download and builds its own instead of getting a `hermesc` it cannot execute. - `[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`. diff --git a/packages/host/src/node/cli/hermes-prebuilt.ts b/packages/host/src/node/cli/hermes-prebuilt.ts index 125deb16..3470cd09 100644 --- a/packages/host/src/node/cli/hermes-prebuilt.ts +++ b/packages/host/src/node/cli/hermes-prebuilt.ts @@ -74,7 +74,10 @@ export function getPrebuiltDirectory() { * 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. + * linking it is an ABI break. The host architecture is part of it because the + * hermesc in destroot/bin is a native binary for whichever Mac built it, so a + * host of the other architecture has to build its own rather than download one + * it cannot execute. */ export function getArchiveName({ reactNativeVersion, @@ -89,7 +92,7 @@ export function getArchiveName({ // 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`; + return `hermes-${shortSha}-rn${reactNativeVersion}-${buildType}-${platformSuffix}-${process.arch}.tar.gz`; } export function getReleaseTag() { @@ -174,9 +177,11 @@ async function buildArchive({ }, }); - // 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. + // Configured here rather than by build-apple-framework.sh's + // build_host_hermesc only so the build type is explicit — the pinned Hermes + // hard-errors without one. Do not add CMAKE_OSX_ARCHITECTURES: a multi-arch + // host configure makes llvh's try-compiles fail, down to "Host compiler + // appears to require libatomic, but cannot find it". if (!fs.existsSync(importHostCompilersPath)) { await run("cmake", [ "-S", @@ -185,7 +190,6 @@ async function buildArchive({ hermescPath, `-DJSI_DIR=${jsiPath}`, "-DCMAKE_BUILD_TYPE=Release", - "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64", ]); await run("cmake", [ "--build", From 8f91084be32be838c20786fb5450785a57222ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 19:07:45 +0200 Subject: [PATCH 20/24] Stop the host Hermes compiler build from targeting visionOS (#443) * Dump the CMake configure log when the Hermes build fails CMake reports a failed feature check as a bare "not found", with the compiler's actual complaint only in its configure log. The host hermesc configure is failing on the macOS runner with checks that succeed everywhere else, so surface that log rather than guess at it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH * Stop the host Hermes compiler build from targeting visionOS Every command got all three deployment targets build-apple-framework.sh can ask for, including the host compiler build. XROS_DEPLOYMENT_TARGET is also a clang driver variable, so clang targeted visionOS against the macOS sysroot: clang: warning: using sysroot for 'MacOSX' but targeting 'XR' error: 'pthread_mutexattr_init' is unavailable: not available on visionOS Every API marked unavailable on visionOS then failed to compile, which is why unistd.h (which reaches _fd_def.h through sys/select.h) came back "not found" while sys/stat.h did not, and why the configure died on CheckAtomic. Each platform build now gets only the deployment target it needs, and the host compiler build gets none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH --------- Co-authored-by: Claude Opus 5 --- .changeset/prebuilt-hermes-visionos-env.md | 13 ++++++ .github/workflows/hermes-prebuilt.yml | 31 ++++++++++++++ packages/host/src/node/cli/hermes-prebuilt.ts | 40 ++++++++++++++----- 3 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 .changeset/prebuilt-hermes-visionos-env.md diff --git a/.changeset/prebuilt-hermes-visionos-env.md b/.changeset/prebuilt-hermes-visionos-env.md new file mode 100644 index 00000000..0b6876e4 --- /dev/null +++ b/.changeset/prebuilt-hermes-visionos-env.md @@ -0,0 +1,13 @@ +--- +"react-native-node-api": patch +--- + +Fix `prebuilt-hermes` failing to configure the host Hermes compiler with "Host +compiler appears to require libatomic, but cannot find it". It exported all +three deployment targets Hermes' `build-apple-framework.sh` can ask for to every +command it ran, including the host compiler build. `XROS_DEPLOYMENT_TARGET` is +also a clang driver variable, so clang targeted visionOS against the macOS +sysroot, and every API marked unavailable there — `pthread_mutexattr_init`, the +`fd_set` helpers reached through `unistd.h` — failed to compile. Each platform +build now gets only the deployment target it needs, and the host compiler build +gets none. diff --git a/.github/workflows/hermes-prebuilt.yml b/.github/workflows/hermes-prebuilt.yml index 9c9a1a79..f5fd7c27 100644 --- a/.github/workflows/hermes-prebuilt.yml +++ b/.github/workflows/hermes-prebuilt.yml @@ -67,6 +67,37 @@ jobs: run: | path=$(pnpm exec react-native-node-api prebuilt-hermes --no-download) echo "path=$path" >> "$GITHUB_OUTPUT" + # CMake reports a failed feature check as a bare "not found", with the + # compiler's actual complaint only in its configure log. Surface that log + # here so a failure is diagnosable without another round trip. + - name: Dump CMake configure log + if: failure() + run: | + log=$(find "$PWD" -name CMakeConfigureLog.yaml -path '*build_host_hermesc*' | head -1) + if [ -z "$log" ]; then + echo "No CMakeConfigureLog.yaml found" + find "$PWD" -name 'CMakeError.log' -path '*build_host_hermesc*' -exec cat {} \; + exit 0 + fi + echo "::group::Environment seen by CMake" + env | sort + xcode-select --print-path + xcrun --show-sdk-path || true + echo "::endgroup::" + echo "::group::unistd.h check" + grep -n -B 5 -A 45 'unistd\.h' "$log" | head -150 + echo "::endgroup::" + echo "::group::atomics check" + grep -n -B 5 -A 60 'HAVE_CXX_ATOMICS_WITHOUT_LIB' "$log" | head -180 + echo "::endgroup::" + cp "$log" "$RUNNER_TEMP/CMakeConfigureLog.yaml" + - name: Upload CMake configure log + if: failure() + uses: actions/upload-artifact@v7 + with: + name: hermesc-cmake-configure-log + path: ${{ runner.temp }}/CMakeConfigureLog.yaml + if-no-files-found: ignore # --latest=false keeps these out of the "latest release" slot, which # belongs to the package releases changesets publishes. - name: Publish as a release asset diff --git a/packages/host/src/node/cli/hermes-prebuilt.ts b/packages/host/src/node/cli/hermes-prebuilt.ts index 3470cd09..574e24cf 100644 --- a/packages/host/src/node/cli/hermes-prebuilt.ts +++ b/packages/host/src/node/cli/hermes-prebuilt.ts @@ -27,13 +27,25 @@ const RELEASES_URL = 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", -}; +/** + * The deployment target build-apple-framework.sh reads for a platform — it + * errors out rather than assume one. These match React Native's own podspec + * declarations. + * + * Only the variable that platform needs is passed. XROS_DEPLOYMENT_TARGET is + * also a clang driver variable, so leaking it into a build that isn't for + * visionOS makes clang target visionOS against whatever sysroot it was given — + * and every API marked unavailable there then fails to compile. + */ +function getDeploymentTarget(platform: string): Record { + if (platform === "macosx") { + return { MAC_DEPLOYMENT_TARGET: "10.15" }; + } else if (platform === "xros" || platform === "xrsimulator") { + return { XROS_DEPLOYMENT_TARGET: "1.0" }; + } else { + return { IOS_DEPLOYMENT_TARGET: "15.1" }; + } +} export const BUILD_TYPES = ["debug", "release"] as const; export type BuildType = (typeof BUILD_TYPES)[number]; @@ -162,15 +174,19 @@ async function buildArchive({ // 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[]) => + const run = ( + command: string, + args: string[], + extraEnv: Record = {}, + ) => 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, + ...extraEnv, JSI_PATH: jsiPath, BUILD_TYPE: buildType === "debug" ? "Debug" : "Release", HERMES_OVERRIDE_HERMESC_PATH: importHostCompilersPath, @@ -202,7 +218,11 @@ async function buildArchive({ } for (const platform of platforms) { - await run("./utils/build-apple-framework.sh", [platform]); + await run( + "./utils/build-apple-framework.sh", + [platform], + getDeploymentTarget(platform), + ); } const frameworksPath = path.join( From 2ba52274d2aee4911900008ec5afc082c025ce5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 19:37:23 +0200 Subject: [PATCH 21/24] Skip the Hermes build when the archive is already published (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paths filter fires on any edit to hermes.ts, hermes-prebuilt.ts or this workflow, not just a bumped pin — and `--no-download` meant the run then rebuilt for half an hour and re-uploaded 118 MB identical to what was already on the release. Merging #443 did exactly that. The Actions cache does not cover this: it is scoped to the branch that wrote it, so a build on a feature branch leaves nothing behind for `next`, and it evicts after 7 days idle or under the repository's 10 GB cap, which several multi-gigabyte ccache entries already compete for. The archive name covers every input that changes its contents, so an asset already published under that name is what the run would rebuild. Look it up and skip the build and the upload, with a `force` dispatch input for deliberate rebuilds. Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH Co-authored-by: Claude Opus 5 --- .github/workflows/hermes-prebuilt.yml | 36 +++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hermes-prebuilt.yml b/.github/workflows/hermes-prebuilt.yml index f5fd7c27..128b27d7 100644 --- a/.github/workflows/hermes-prebuilt.yml +++ b/.github/workflows/hermes-prebuilt.yml @@ -12,6 +12,11 @@ env: on: workflow_dispatch: + inputs: + force: + description: Rebuild and re-upload even when the archive is already published + type: boolean + default: false push: branches: - main @@ -54,7 +59,32 @@ jobs: tag=$(pnpm exec react-native-node-api prebuilt-hermes --print tag) echo "archive=$archive" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" + # The paths filter above fires on any edit to these files, not just a + # bumped pin — and the archive name covers every input that changes its + # contents, so an asset already published under that name is exactly what + # this run would spend half an hour rebuilding. The Actions cache is not + # enough on its own: it is scoped to the branch that wrote it, and evicts + # after 7 days or under the repository's 10 GB cap. + - name: Look for an already-published archive + id: published + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.hermes.outputs.tag }} + ARCHIVE: ${{ steps.hermes.outputs.archive }} + FORCE: ${{ inputs.force }} + run: | + if [ "$FORCE" = "true" ]; then + echo "::notice::Forced: rebuilding $ARCHIVE regardless of what is published" + echo "exists=false" >> "$GITHUB_OUTPUT" + elif gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' 2>/dev/null | grep -qxF "$ARCHIVE"; then + echo "::notice::$ARCHIVE is already published under $TAG — nothing to build" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi - name: Cache prebuilt Hermes + if: steps.published.outputs.exists != 'true' uses: actions/cache@v6 with: path: ~/Library/Caches/react-native-node-api/hermes-prebuilt @@ -63,6 +93,7 @@ jobs: # it is about to publish. - name: Build prebuilt Hermes id: build + if: steps.published.outputs.exists != 'true' working-directory: apps/test-app run: | path=$(pnpm exec react-native-node-api prebuilt-hermes --no-download) @@ -71,7 +102,7 @@ jobs: # compiler's actual complaint only in its configure log. Surface that log # here so a failure is diagnosable without another round trip. - name: Dump CMake configure log - if: failure() + if: failure() && steps.published.outputs.exists != 'true' run: | log=$(find "$PWD" -name CMakeConfigureLog.yaml -path '*build_host_hermesc*' | head -1) if [ -z "$log" ]; then @@ -92,7 +123,7 @@ jobs: echo "::endgroup::" cp "$log" "$RUNNER_TEMP/CMakeConfigureLog.yaml" - name: Upload CMake configure log - if: failure() + if: failure() && steps.published.outputs.exists != 'true' uses: actions/upload-artifact@v7 with: name: hermesc-cmake-configure-log @@ -101,6 +132,7 @@ jobs: # --latest=false keeps these out of the "latest release" slot, which # belongs to the package releases changesets publishes. - name: Publish as a release asset + if: steps.published.outputs.exists != 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.hermes.outputs.tag }} From 56ae5f8cc1076ec22a97292693dc9b1c42ad6520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 19:44:52 +0200 Subject: [PATCH 22/24] Use the prebuilt Hermes archive for iOS builds (#441) Hermes is the iOS build: 17m15s of an 18m58s "Build test app" step. Point pod install at the archive built by `prebuilt-hermes` through HERMES_ENGINE_TARBALL_PATH, so hermes-engine.podspec vendors the prebuilt frameworks and its two Hermes script phases don't run at all. Building from source stays available behind REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1, which is the faster loop while iterating on Hermes itself. react-native-macos stays on that path for now, see #392. Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH Co-authored-by: Claude Opus 5 --- .changeset/prebuilt-hermes-archive.md | 15 ++++++++ .github/workflows/check.yml | 21 +++++++++++ AGENTS.md | 2 +- docs/CLI.md | 6 +++- packages/host/scripts/patch-hermes.rb | 51 ++++++++++++++++++++------- 5 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 .changeset/prebuilt-hermes-archive.md diff --git a/.changeset/prebuilt-hermes-archive.md b/.changeset/prebuilt-hermes-archive.md new file mode 100644 index 00000000..3de83cf9 --- /dev/null +++ b/.changeset/prebuilt-hermes-archive.md @@ -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. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 53cdb585..77c0254e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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. diff --git a/AGENTS.md b/AGENTS.md index ca13226d..2cc40564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/CLI.md b/docs/CLI.md index a8c62549..fab4b1a5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -13,7 +13,7 @@ Run `npx react-native-node-api help` or `npx react-native-node-api help ` — 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 ` — The React Native package to vendor Hermes into. Defaults to `react-native`. - `--silent` — Don't print anything except the final path. Defaults to `false`. diff --git a/packages/host/scripts/patch-hermes.rb b/packages/host/scripts/patch-hermes.rb index 986f5d8f..1e5093cc 100644 --- a/packages/host/scripts/patch-hermes.rb +++ b/packages/host/scripts/patch-hermes.rb @@ -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 From f41deb04cafa776258cd70696b11ae409ca2877d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 20:59:17 +0200 Subject: [PATCH 23/24] Load addons through Hermes' `hermes_napi_load_module` (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Load addons through Hermes' hermes_napi_load_module The vendored Hermes ships a first-party addon loader that does what CxxNodeApiHostModule did by hand — dlopen, resolve the init function, create the exports object and call it — plus the deprecated napi_module_register fallback the host never implemented. Hand the platform specific path to it instead, and drop AddonLoaders.hpp along with the host's own loading and initialization code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX * Trigger the label-gated CI jobs The check workflow only re-evaluates its label conditions on opened, synchronize and reopened events, so the labels added after opening this PR need a push to take effect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX --------- Co-authored-by: Claude --- .changeset/hermes-loads-addons.md | 21 +++ docs/HOW-IT-WORKS.md | 10 +- packages/host/cpp/AddonLoaders.hpp | 109 ------------ packages/host/cpp/CxxNodeApiHostModule.cpp | 185 +++++++++++---------- packages/host/cpp/CxxNodeApiHostModule.hpp | 16 +- packages/host/cpp/HermesNapiHost.hpp | 10 ++ 6 files changed, 143 insertions(+), 208 deletions(-) create mode 100644 .changeset/hermes-loads-addons.md delete mode 100644 packages/host/cpp/AddonLoaders.hpp diff --git a/.changeset/hermes-loads-addons.md b/.changeset/hermes-loads-addons.md new file mode 100644 index 00000000..3b4cc78f --- /dev/null +++ b/.changeset/hermes-loads-addons.md @@ -0,0 +1,21 @@ +--- +"react-native-node-api": patch +--- + +Load addons through Hermes' `hermes_napi_load_module` instead of the host's own +`dlopen` + `dlsym` implementation: + +- Addons that register themselves by calling the deprecated + `napi_module_register` are now supported. Previously only addons exporting a + `napi_register_module_v1` symbol could be loaded, and the rest resolved to + `undefined`. +- A failing `requireNodeAddon` now throws an error naming the addon, the path + that was tried and the underlying reason (e.g. the `dlopen` error), instead + of silently resolving to `undefined`. +- `node_api_get_module_file_name` now reports the path the addon was loaded + from, instead of an empty string. + +This also opens a Node-API handle scope around loading and initializing an +addon. Without one, the `exports` object handed to the addon's initialization +function was not reachable by the garbage collector, so a collection triggered +during initialization could free it while the addon was still populating it. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index edb0e0ac..5ebd0951 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -91,15 +91,15 @@ module.exports = require("react-native-node-api").requireNodeAddon( > In the time of writing, this code only supports iOS as passes the path to the library with its .framework. > We plan on generalizing this soon 🤞 -## Transformed code calls into `react-native-node-api`, loading the platform specific dynamic library +## `react-native-node-api` creates a `napi_env` for the addon -The native implementation of `requireNodeAddon` is responsible for loading the dynamic library and allow the Node-API module to register its initialization function, either by exporting a `napi_register_module_v1` function or by calling the (deprecated) `napi_module_register` function. +The native implementation of `requireNodeAddon` turns the library name into a platform specific path (`@rpath/.framework/` on Apple platforms, `lib.so` on Android) and creates a `napi_env` for the addon by calling `hermes_napi_create_env` with the low-level Hermes VM runtime behind the `jsi::Runtime`. As in Node.js, each addon gets its own environment. -In any case the native code stores the initialization function in a data-structure. +## Hermes loads the platform specific dynamic library and initializes the addon -## `react-native-node-api` creates a `napi_env` and initialize the Node-API module +The host hands the path and the environment to `hermes_napi_load_module`, which opens the dynamic library and finds the addon's initialization function, either by looking up an exported `napi_register_module_v1` symbol or by falling back to the `napi_module` passed to a (deprecated) `napi_module_register` call made while the library was loading. It then calls that function with the environment and a fresh `exports` object. -The initialization function of a Node-API module expects a `napi_env`, which we create by calling `hermes_napi_create_env` with the low-level Hermes VM runtime behind the `jsi::Runtime`. As in Node.js, each addon gets its own environment. +If the library cannot be opened, or exports no initialization function, `requireNodeAddon` throws. ## The library's C++ code initialize the `exports` object diff --git a/packages/host/cpp/AddonLoaders.hpp b/packages/host/cpp/AddonLoaders.hpp deleted file mode 100644 index 2836bdfa..00000000 --- a/packages/host/cpp/AddonLoaders.hpp +++ /dev/null @@ -1,109 +0,0 @@ -#pragma once -#include "Logger.hpp" - -#include - -#if defined(__APPLE__) || defined(__ANDROID__) -#include -#include - -using callstack::react_native_node_api::log_debug; - -struct PosixLoader { - using Module = void *; - using Symbol = void *; - - static Module loadLibrary(const char *filePath) { - assert(NULL != filePath); - - Module result = dlopen(filePath, RTLD_NOW | RTLD_LOCAL); - if (NULL == result) { - log_debug("NapiHost: Failed to load library '%s': %s", filePath, - dlerror()); - } - return result; - } - - static Symbol getSymbol(Module library, const char *name) { - assert(NULL != library); - assert(NULL != name); - Symbol result = dlsym(library, name); - // if (NULL == result) { - // NSLog(@"NapiHost: Cannot find '%s' symbol!", name); - // } - return result; - } - - static void unloadLibrary(Module library) { - if (NULL != library) { - dlclose(library); - } - } -}; -#endif - -#if defined(_WIN32) -struct Win32Loader { - using Module = HMODULE; - using Symbol = void *; - - static Module loadLibrary(const char *filePath) { - assert(NULL != filePath); - Module result = LoadLibrary(filePath); - if (NULL == result) { - // TODO: Handle the error case... call GetLastError() that gives us error - // code as DWORD - } - return result; - } - - static Symbol getSymbol(Module library, const char *name) { - assert(NULL != library); - assert(NULL != name); - Symbol result = GetProcAddress(library, name); - if (NULL == result) { - // TODO: Handle the error case... call GetLastError() that gives us error - // code as DWORD - } - return result; - } - - static void unloadLibrary(Module library) { - if (NULL != library) { - FreeLibrary(library); - } - } -}; - -struct WinRTLoader { - using Module = HMODULE; - using Symbol = void *; - - static Module loadLibrary(const char *filePath) { - assert(NULL != filePath); - Module result = LoadPackagedLibrary(filePath); - if (NULL == result) { - // TODO: Handle the error case... call GetLastError() that gives us error - // code as DWORD - } - return result; - } - - static Symbol getSymbol(Module library, const char *name) { - assert(NULL != library); - assert(NULL != name); - Symbol result = GetProcAddress(library, name); - if (NULL == result) { - // TODO: Handle the error case... call GetLastError() that gives us error - // code as DWORD - } - return result; - } - - static void unloadLibrary(Module library) { - if (NULL != library) { - FreeLibrary(library); - } - } -}; -#endif \ No newline at end of file diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 6720d272..e9e04f51 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -3,10 +3,45 @@ #include +#include +#include +#include + using namespace facebook; namespace callstack::react_native_node_api { +namespace { + +/// Renders the exception Hermes left pending on `env` as a message, clearing +/// it so the env is usable again. Returns an empty string if the exception +/// cannot be read, in which case the caller reports the status alone. +std::string takePendingExceptionMessage(napi_env env) { + napi_value error = nullptr; + if (napi_get_and_clear_last_exception(env, &error) != napi_ok || + error == nullptr) { + return {}; + } + napi_value asString = nullptr; + if (napi_coerce_to_string(env, error, &asString) != napi_ok) { + return {}; + } + size_t length = 0; + if (napi_get_value_string_utf8(env, asString, nullptr, 0, &length) != + napi_ok) { + return {}; + } + std::string message(length, '\0'); + if (napi_get_value_string_utf8(env, asString, message.data(), length + 1, + &length) != napi_ok) { + return {}; + } + message.resize(length); + return message; +} + +} // namespace + CxxNodeApiHostModule::CxxNodeApiHostModule( std::shared_ptr jsInvoker) : TurboModule(CxxNodeApiHostModule::kModuleName, jsInvoker) { @@ -58,8 +93,8 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, if (1 == count && args[0].isString()) { return thisModule.requireNodeAddon(rt, args[0].asString(rt)); } - // TODO: Throw a meaningful error - return jsi::Value::undefined(); + throw jsi::JSError(rt, "Expected requireNodeAddon to be called with a single " + "library name string"); } jsi::Value @@ -72,116 +107,94 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, // Check if this module has been loaded already, if not then load it... if (inserted) { - if (!loadNodeAddon(addon, libraryNameStr)) { - return jsi::Value::undefined(); + try { + loadNodeAddon(rt, addon, libraryNameStr); + } catch (...) { + // Leave no half-initialized entry behind, so a later require of the same + // addon retries the load instead of reading a missing global. + nodeAddons_.erase(it); + throw; } } - // Initialize the addon if it has not already been initialized - if (!rt.global().hasProperty(rt, addon.generatedName.data())) { - initializeNodeModule(rt, addon); - } - // Look the exports up (using JSI) and return it... - return rt.global().getProperty(rt, addon.generatedName.data()); + return rt.global().getProperty(rt, addon.generatedName.c_str()); } -bool CxxNodeApiHostModule::loadNodeAddon(NodeAddon &addon, - const std::string &libraryName) const { +void CxxNodeApiHostModule::loadNodeAddon(jsi::Runtime &rt, NodeAddon &addon, + const std::string &libraryName) { #if defined(__APPLE__) - std::string libraryPath = + const std::string libraryPath = "@rpath/" + libraryName + ".framework/" + libraryName; #elif defined(__ANDROID__) - std::string libraryPath = "lib" + libraryName + ".so"; + const std::string libraryPath = "lib" + libraryName + ".so"; #else - abort() +#error "Loading Node-API addons is unsupported on this platform" #endif log_debug("[%s] Loading addon by '%s'", libraryName.c_str(), libraryPath.c_str()); - typename LoaderPolicy::Symbol initFn = NULL; - typename LoaderPolicy::Module library = - LoaderPolicy::loadLibrary(libraryPath.c_str()); - if (NULL != library) { - log_debug("[%s] Loaded addon", libraryName.c_str()); - addon.moduleHandle = library; - - // Generate a name allowing us to reference the exports object from JSI - // later Instead of using random numbers to avoid name clashes, we just use - // the pointer address of the loaded module - addon.generatedName.resize(32, '\0'); - snprintf(addon.generatedName.data(), addon.generatedName.size(), - "RN$NodeAddon_%p", addon.moduleHandle); - - initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1"); - if (NULL != initFn) { - log_debug("[%s] Found napi_register_module_v1 (%p)", libraryName.c_str(), - initFn); - addon.init = (napi_addon_register_func)initFn; - } else { - log_debug("[%s] Failed to find napi_register_module_v1. Expecting the " - "addon to call napi_module_register to register itself.", - libraryName.c_str()); - } - // TODO: Read "node_api_module_get_api_version_v1" to support the addon - // declaring its Node-API version - // @see - // https://github.com/callstackincubator/react-native-node-api/issues/4 - } else { - log_debug("[%s] Failed to load library", libraryName.c_str()); - } - return NULL != initFn; -} - -bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt, - NodeAddon &addon) { - // We should check if the module has already been initialized - assert(NULL != addon.moduleHandle); - assert(NULL != addon.init); - napi_status status = napi_ok; - // TODO: Read the version from the addon - // @see - // https://github.com/callstackincubator/react-native-node-api/issues/4 - // Create this addon's Node-API environment. Hermes binds an env to its // low-level VM runtime, which we reach through the (unstable) IHermes JSI // interface, and takes ownership: the env is torn down with the runtime, so // there is nothing to free here. Each addon gets its own env, as in Node. - if (addon.env == nullptr) { - // Fully qualified: `using namespace facebook` makes a bare `hermes` - // ambiguous with the top-level `::hermes` (VM) namespace pulled in via - // . - auto *hermes = facebook::jsi::castInterface(&rt); - if (hermes == nullptr) { - log_debug("NapiHost: JSI runtime is not castable to IHermes; cannot " - "create a Node-API environment"); - abort(); - } - addon.env = - hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), hostContext_->host()); - assert(addon.env != nullptr); + // + // Fully qualified: `using namespace facebook` makes a bare `hermes` + // ambiguous with the top-level `::hermes` (VM) namespace pulled in via + // . + auto *hermes = facebook::jsi::castInterface(&rt); + if (hermes == nullptr) { + log_debug("NapiHost: JSI runtime is not castable to IHermes; cannot " + "create a Node-API environment"); + abort(); } + addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), + hostContext_->host()); + assert(addon.env != nullptr); napi_env env = addon.env; - // Create the "exports" object - napi_value exports; - status = napi_create_object(env, &exports); + // A name to reference the exports object by from JSI. Instead of using + // random numbers to avoid name clashes, we use the address of the env, which + // is unique per addon per runtime. + char generatedName[32]; + snprintf(generatedName, sizeof(generatedName), "RN$NodeAddon_%p", + static_cast(env)); + addon.generatedName = generatedName; + + // Every napi_value below is created in this scope, and only reachable from + // the JavaScript global (or dropped) once it closes. + napi_handle_scope scope = nullptr; + napi_status status = napi_open_handle_scope(env, &scope); assert(status == napi_ok); - // Call the addon init function to populate the "exports" object - // Allowing it to replace the value entirely by its return value - exports = addon.init(env, exports); - - napi_value global; - napi_get_global(env, &global); - assert(status == napi_ok); + napi_value exports = nullptr; + status = hermes_napi_load_module(env, libraryPath.c_str(), &exports); + if (status == napi_ok) { + napi_value global = nullptr; + status = napi_get_global(env, &global); + assert(status == napi_ok); + status = napi_set_named_property(env, global, addon.generatedName.c_str(), + exports); + assert(status == napi_ok); + } - status = - napi_set_named_property(env, global, addon.generatedName.data(), exports); - assert(status == napi_ok); + const bool failed = status != napi_ok; + std::string message; + if (failed) { + message = takePendingExceptionMessage(env); + if (message.empty()) { + message = "Node-API status " + std::to_string(status); + } + } + const napi_status closeStatus = napi_close_handle_scope(env, scope); + assert(closeStatus == napi_ok); + (void)closeStatus; - return true; + if (failed) { + throw jsi::JSError(rt, "Failed to load '" + libraryName + "' addon from '" + + libraryPath + "': " + message); + } } } // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index e71df553..92f07c95 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -4,9 +4,12 @@ #include #include -#include "AddonLoaders.hpp" #include "HermesNapiHost.hpp" +#include +#include +#include + namespace callstack::react_native_node_api { class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { @@ -24,8 +27,8 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { protected: struct NodeAddon { - void *moduleHandle; - napi_addon_register_func init; + // The name the addon's exports object is stored under on the JavaScript + // global, which is how the napi_value crosses over to JSI. std::string generatedName; // The Node-API environment for this addon, created when the addon is @@ -42,11 +45,8 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { // Also retained process-wide, as the envs outlive this module on teardown. std::shared_ptr hostContext_; - using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround - // for my lazyness (work on iOS and Android) - - bool loadNodeAddon(NodeAddon &addon, const std::string &path) const; - bool initializeNodeModule(facebook::jsi::Runtime &rt, NodeAddon &addon); + void loadNodeAddon(facebook::jsi::Runtime &rt, NodeAddon &addon, + const std::string &libraryName); }; } // namespace callstack::react_native_node_api diff --git a/packages/host/cpp/HermesNapiHost.hpp b/packages/host/cpp/HermesNapiHost.hpp index 3e8b1ab3..6165515b 100644 --- a/packages/host/cpp/HermesNapiHost.hpp +++ b/packages/host/cpp/HermesNapiHost.hpp @@ -56,6 +56,16 @@ struct hermes_napi_host { }; napi_env hermes_napi_create_env(void *hermes_runtime, hermes_napi_host *host); + +/// Opens the shared library at `path`, resolves its init function (either the +/// exported `napi_register_module_v1` or, failing that, the `napi_module` +/// passed to a `napi_module_register` call made while loading), calls it with +/// a fresh `exports` object and hands the result back through `result`. +/// +/// Requires an open handle scope on `env`. On failure it returns a non-`ok` +/// status and leaves an exception pending on `env`. +napi_status hermes_napi_load_module(napi_env env, const char *path, + napi_value *result); } namespace callstack::react_native_node_api { From 0a29fbdc3a5ad61f4ed636bc5661c40553b57812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Fri, 14 Aug 2026 00:15:54 +0200 Subject: [PATCH 24/24] Add a fixture registering via the deprecated `napi_module_register` (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a fixture registering via the deprecated napi_module_register The host gained support for addons that register themselves by calling napi_module_register while their library loads (#445), but nothing in the repo exercises that path — every other addon here exports napi_register_module_v1, which the loader finds first. This addon exports no such symbol, so it only loads if the fallback works. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX * Trigger the label-gated CI jobs The check workflow only re-evaluates its label conditions on opened, synchronize and reopened events, so the labels added after opening this PR need a push to take effect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX * Drop the gyp file from the module-register fixture Nothing builds this from binding.gyp — cmake-rn drives the CMake project directly. The sibling fixtures keep theirs to stay close to upstream sources they were derived from, which does not apply to an addon written here. CMakeLists.txt is now hand-maintained rather than regenerated by gyp-to-cmake, which skips the directory now that there is no binding.gyp. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX * Require the addon directly instead of through bindings The bindings package earns its place when an addon has to be found across the several output directories node-gyp might have used. This addon is built by cmake-rn to one known location, so a plain require says the same thing with one less dependency — and it exercises the Babel plugin's ordinary require path rather than its bindings special case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX --------- Co-authored-by: Claude --- packages/node-addon-examples/src/index.ts | 2 + .../tests/module-register/CMakeLists.txt | 28 +++++++++++++ .../tests/module-register/addon.c | 42 +++++++++++++++++++ .../tests/module-register/addon.js | 8 ++++ .../tests/module-register/package.json | 7 ++++ 5 files changed, 87 insertions(+) create mode 100644 packages/node-addon-examples/tests/module-register/CMakeLists.txt create mode 100644 packages/node-addon-examples/tests/module-register/addon.c create mode 100644 packages/node-addon-examples/tests/module-register/addon.js create mode 100644 packages/node-addon-examples/tests/module-register/package.json diff --git a/packages/node-addon-examples/src/index.ts b/packages/node-addon-examples/src/index.ts index 68bac88a..09c27c88 100644 --- a/packages/node-addon-examples/src/index.ts +++ b/packages/node-addon-examples/src/index.ts @@ -86,6 +86,8 @@ export const suites: Record< require("../tests/buffers/addon.js"); }, async: () => require("../tests/async/addon.js") as () => Promise, + "module-register": () => + require("../tests/module-register/addon.js") as () => void, "threadsafe-function": () => require("../tests/threadsafe-function/addon.js") as () => Promise, }, diff --git a/packages/node-addon-examples/tests/module-register/CMakeLists.txt b/packages/node-addon-examples/tests/module-register/CMakeLists.txt new file mode 100644 index 00000000..fbaa096e --- /dev/null +++ b/packages/node-addon-examples/tests/module-register/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.15...3.31) +project(module-register-test) + +find_package(weak-node-api REQUIRED CONFIG) + +add_library(module-register-test-addon SHARED addon.c) + +option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) + +if(APPLE AND BUILD_APPLE_FRAMEWORK) + set_target_properties(module-register-test-addon PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER module-register-test.addon + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + XCODE_ATTRIBUTE_SKIP_INSTALL NO + OUTPUT_NAME addon + ) +else() + set_target_properties(module-register-test-addon PROPERTIES + PREFIX "" + SUFFIX .node + OUTPUT_NAME addon + ) +endif() + +target_link_libraries(module-register-test-addon PRIVATE weak-node-api) +target_compile_features(module-register-test-addon PRIVATE cxx_std_17) \ No newline at end of file diff --git a/packages/node-addon-examples/tests/module-register/addon.c b/packages/node-addon-examples/tests/module-register/addon.c new file mode 100644 index 00000000..83d3d503 --- /dev/null +++ b/packages/node-addon-examples/tests/module-register/addon.c @@ -0,0 +1,42 @@ +#include + +// This addon registers itself the deprecated way — a napi_module_register call +// made while the library loads — and deliberately exports no +// napi_register_module_v1 symbol, so a host that only looks for that symbol +// cannot load it. +// +// The constructor is hand-rolled because node_api.h no longer offers a macro +// that emits one: NAPI_MODULE_X is now an alias of the symbol-based +// NAPI_MODULE. + +static napi_value Registration(napi_env env, napi_callback_info info) { + (void)info; + napi_value result; + if (napi_create_string_utf8(env, "napi_module_register", NAPI_AUTO_LENGTH, + &result) != napi_ok) { + return NULL; + } + return result; +} + +static napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor properties[] = { + {"registration", NULL, Registration, NULL, NULL, NULL, napi_default, + NULL}, + }; + if (napi_define_properties(env, exports, + sizeof(properties) / sizeof(properties[0]), + properties) != napi_ok) { + return NULL; + } + return exports; +} + +static napi_module addon_module = { + NAPI_MODULE_VERSION, 0, __FILE__, Init, "module-register-test", + NULL, {0}, +}; + +__attribute__((constructor)) static void RegisterAddon(void) { + napi_module_register(&addon_module); +} diff --git a/packages/node-addon-examples/tests/module-register/addon.js b/packages/node-addon-examples/tests/module-register/addon.js new file mode 100644 index 00000000..c06fe88e --- /dev/null +++ b/packages/node-addon-examples/tests/module-register/addon.js @@ -0,0 +1,8 @@ +const assert = require("assert"); +// cmake-rn emits to {targetSourceDir}/build/{configuration}, and this package's +// build script pins the configuration. +const addon = require("./build/RelWithDebInfo/addon.node"); + +module.exports = () => { + assert.strictEqual(addon.registration(), "napi_module_register"); +}; diff --git a/packages/node-addon-examples/tests/module-register/package.json b/packages/node-addon-examples/tests/module-register/package.json new file mode 100644 index 00000000..13a9c9f4 --- /dev/null +++ b/packages/node-addon-examples/tests/module-register/package.json @@ -0,0 +1,7 @@ +{ + "name": "module-register-test", + "version": "0.0.0", + "description": "Tests of the deprecated napi_module_register registration", + "main": "addon.js", + "private": true +}