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/.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/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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.github/workflows/check.yml b/.github/workflows/check.yml index 71fcf106..77c0254e 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] @@ -58,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 @@ -122,7 +117,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: @@ -158,8 +153,45 @@ 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@v7 + - uses: actions/setup-node@v7 + with: + node-version: lts/krypton + - 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.23 + 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' || 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: @@ -212,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. @@ -368,6 +421,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 @@ -400,7 +456,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: @@ -435,18 +491,28 @@ 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-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 @@ -456,5 +522,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 diff --git a/.github/workflows/hermes-prebuilt.yml b/.github/workflows/hermes-prebuilt.yml new file mode 100644 index 00000000..128b27d7 --- /dev/null +++ b/.github/workflows/hermes-prebuilt.yml @@ -0,0 +1,148 @@ +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: + inputs: + force: + description: Rebuild and re-upload even when the archive is already published + type: boolean + default: false + 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. + # 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: | + 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" + # 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 + 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 + if: steps.published.outputs.exists != 'true' + working-directory: apps/test-app + 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() && steps.published.outputs.exists != 'true' + 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() && steps.published.outputs.exists != 'true' + 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 + if: steps.published.outputs.exists != 'true' + 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/.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 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/AGENTS.md b/AGENTS.md index 5cb3f1c4..2cc40564 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. 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/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/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/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..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're using a version of Hermes patched with Node-API support, we need to build React Native from source. +## 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,7 +28,15 @@ 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: +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: ``` npx react-native-node-api vendor-hermes @@ -37,9 +50,30 @@ 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 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/CLI.md b/docs/CLI.md index 9156480f..fab4b1a5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,3 +1,95 @@ # 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) 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. This is what the Cocoapods integration uses by default: the path is handed to React Native through the `HERMES_ENGINE_TARBALL_PATH` environment variable, and `hermes-engine.podspec` then vendors the frameworks out of the archive instead of compiling Hermes as part of every app build. + +The archive is looked for in this order, and cached under `~/Library/Caches/react-native-node-api/hermes-prebuilt` (overridable with `REACT_NATIVE_NODE_API_CACHE_PATH`): + +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, 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`. +- `--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. + +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`. +- `--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 cd4ae854..5ebd0951 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` @@ -33,22 +91,28 @@ 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 `node_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 `node_env`, which we create by calling `createNodeApiEnv` on the `jsi::Runtime`. +If the library cannot be opened, or exports no initialization function, `requireNodeAddon` throws. ## 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. + +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`. -- 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`. +`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 df9e3915..7e4d196d 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/**", @@ -61,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/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/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..be687979 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; -import { EventEmitter } from "node:events"; +import os from "node:os"; import { chalk, @@ -11,6 +11,8 @@ import { oraPromise, assertFixable, wrapAction, + pLimit, + InvalidArgumentError, } from "@react-native-node-api/cli-utils"; import { @@ -21,9 +23,7 @@ import { } from "./platforms.js"; 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; +import { createOutputPathResolver, expandTemplate } from "./output-path.js"; const verboseOption = new Option( "--verbose", @@ -75,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 ", @@ -129,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) @@ -144,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( @@ -155,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, @@ -232,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); @@ -244,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, + }); }); }, }; @@ -280,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, + }), + ), ); } }), @@ -329,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.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..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, @@ -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,31 +190,17 @@ 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 }) => { 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, @@ -189,7 +225,6 @@ export const platform: Platform = { ...commonDefinitions, { // "CPACK_SYSTEM_NAME": `Android-${architecture}`, - CMAKE_LIBRARY_OUTPUT_DIRECTORY: outputPath, ANDROID_ABI: ANDROID_ARCHITECTURES[triplet], }, ]), @@ -210,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) { @@ -232,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({ @@ -281,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 36c595b9..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); } @@ -161,16 +161,22 @@ 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) { return path.join(baseBuildPath, triplet.replace(/;/g, "_")); } -async function readCmakeSharedLibraryTarget( +async function readCmakeSharedLibraryTargets( buildPath: string, configuration: string, target: string[], @@ -180,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 = [ @@ -259,7 +258,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) { @@ -356,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", + "$", + ), }, ]), ]); @@ -366,72 +380,60 @@ 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) { - 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, @@ -440,119 +442,151 @@ 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; - 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/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; 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/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/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/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. diff --git a/packages/host/android/CMakeLists.txt b/packages/host/android/CMakeLists.txt index e4c183f7..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 @@ -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/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 0b1961ec..e9e04f51 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -1,11 +1,47 @@ #include "CxxNodeApiHostModule.hpp" #include "Logger.hpp" -#include "RuntimeNodeApiAsync.hpp" + +#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) { @@ -13,6 +49,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 @@ -23,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 @@ -37,98 +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()); + // 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. + // + // 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(); } - 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 - napi_env env = reinterpret_cast(rt.createNodeApiEnv(8)); - - // Create the "exports" object - napi_value exports; - status = napi_create_object(env, &exports); + addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), + hostContext_->host()); + assert(addon.env != nullptr); + napi_env env = addon.env; + + // 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; - callstack::react_native_node_api::setCallInvoker(env, callInvoker_); - 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 4c753cfe..92f07c95 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -4,7 +4,11 @@ #include #include -#include "AddonLoaders.hpp" +#include "HermesNapiHost.hpp" + +#include +#include +#include namespace callstack::react_native_node_api { @@ -23,18 +27,26 @@ 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 + // 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_; + // 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) - - 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.cpp b/packages/host/cpp/HermesNapiHost.cpp new file mode 100644 index 00000000..1ebd17d2 --- /dev/null +++ b/packages/host/cpp/HermesNapiHost.cpp @@ -0,0 +1,325 @@ +#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)"; +} + +// 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) + : 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 *data, napi_env env, + napi_value err) noexcept { + // 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 new file mode 100644 index 00000000..6165515b --- /dev/null +++ b/packages/host/cpp/HermesNapiHost.hpp @@ -0,0 +1,141 @@ +#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); + +/// 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 { + +/// 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_; + // 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 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 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 diff --git a/packages/host/cpp/RuntimeNodeApiAsync.cpp b/packages/host/cpp/RuntimeNodeApiAsync.cpp deleted file mode 100644 index bee380a7..00000000 --- a/packages/host/cpp/RuntimeNodeApiAsync.cpp +++ /dev/null @@ -1,189 +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 { - -void setCallInvoker( - napi_env env, - const std::shared_ptr &invoker) { - callInvokers[env] = invoker; -} - -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/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..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" }, @@ -85,7 +88,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..c58bb2f5 100644 --- a/packages/host/scripts/generate-injector.mts +++ b/packages/host/scripts/generate-injector.mts @@ -6,35 +6,21 @@ 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 +47,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..1e5093cc 100644 --- a/packages/host/scripts/patch-hermes.rb +++ b/packages/host/scripts/patch-hermes.rb @@ -1,10 +1,6 @@ -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 +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/") } @@ -14,20 +10,42 @@ def get_react_native_package 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' +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 + +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'].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}" + 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 patching failed: Expected override to exist in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}" + raise "Hermes setup failed: Expected prebuilt archive to exist at #{ENV['HERMES_ENGINE_TARBALL_PATH'].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-prebuilt.ts b/packages/host/src/node/cli/hermes-prebuilt.ts new file mode 100644 index 00000000..574e24cf --- /dev/null +++ b/packages/host/src/node/cli/hermes-prebuilt.ts @@ -0,0 +1,453 @@ +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"]; + +/** + * 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]; + +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. 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, + 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}-${process.arch}.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[], + 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: { + ...process.env, + ...extraEnv, + JSI_PATH: jsiPath, + BUILD_TYPE: buildType === "debug" ? "Debug" : "Release", + HERMES_OVERRIDE_HERMESC_PATH: importHostCompilersPath, + }, + }); + + // 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", + ".", + "-B", + hermescPath, + `-DJSI_DIR=${jsiPath}`, + "-DCMAKE_BUILD_TYPE=Release", + ]); + await run("cmake", [ + "--build", + hermescPath, + "--target", + "hermesc", + "-j", + os.availableParallelism().toString(), + ]); + } + + for (const platform of platforms) { + await run( + "./utils/build-apple-framework.sh", + [platform], + getDeploymentTarget(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 4b41692c..1c0b4a00 100644 --- a/packages/host/src/node/cli/hermes.ts +++ b/packages/host/src/node/cli/hermes.ts @@ -15,140 +15,169 @@ 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"; +export const HERMES_GIT_URL = "https://github.com/facebook/hermes.git"; -const platformOption = new Option( +// 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". +// +// 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. +export const HERMES_GIT_SHA = "5a795c9f880002c862c9254a26b57199819c97f7"; + +export const reactNativePackageOption = new Option( "--react-native-package ", "The React Native package to vendor Hermes into", ).default("react-native"); -type PatchJSIHeadersOptions = { - reactNativePath: string; - hermesJsiPath: string; - silent: boolean; -}; +export const silentOption = new Option( + "--silent", + "Don't print anything except the final path", +).default(false); -async function patchJsiHeaders({ - reactNativePath, - hermesJsiPath, - silent, -}: PatchJSIHeadersOptions) { - const reactNativeJsiPath = path.join(reactNativePath, "ReactCommon/jsi/jsi/"); - await oraPromise( - fs.promises.cp(hermesJsiPath, reactNativeJsiPath, { - recursive: true, +/** + * 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], }), - { - 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 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 hermesVersionPath = path.join( - reactNativePath, - "sdks", - ".hermesversion", + const reactNativePath = await resolveReactNativePath( + from, + reactNativePackage, ); - 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}`); - } - - 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}`, - isEnabled: !silent, - }, - ); + console.log(`Vendoring Hermes at ${HERMES_GIT_SHA}`); } - if (!fs.existsSync(hermesPath)) { - const patchedTag = `node-api-${hermesVersion}`; - try { - await oraPromise( - spawn( - "git", - [ - "clone", - "--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}`, - isEnabled: !silent, - }, - ); - } catch (error) { - throw new UsageError("Failed to clone custom 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")}.`, - }, - }); - } - } - const hermesJsiPath = path.join(hermesPath, "API/jsi/jsi"); - - assert( - fs.existsSync(hermesJsiPath), - `Hermes JSI path does not exist: ${hermesJsiPath}`, - ); - await patchJsiHeaders({ + const hermesPath = await ensureHermesCheckout({ reactNativePath, - hermesJsiPath, + force, silent, }); console.log(hermesPath); diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index e3c63904..9078d26f 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, @@ -23,17 +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"; -// 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, -); +export const program = new Command("react-native-node-api") + .addCommand(vendorHermes) + .addCommand(prebuiltHermes); async function createLinker(platform: PlatformName): Promise { if (platform === "android") { 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/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/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/.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/src/index.ts b/packages/node-addon-examples/src/index.ts index 869d4825..09c27c88 100644 --- a/packages/node-addon-examples/src/index.ts +++ b/packages/node-addon-examples/src/index.ts @@ -77,13 +77,18 @@ 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, + "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/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/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/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/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 +} 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..40ffb7f8 --- /dev/null +++ b/packages/node-addon-examples/tests/threadsafe-function/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.15...3.31) +project(threadsafe-function-test) + +find_package(weak-node-api REQUIRED CONFIG) + +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(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(threadsafe-function-test-addon PROPERTIES + PREFIX "" + SUFFIX .node + OUTPUT_NAME addon + ) +endif() + +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 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 +} 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..6a99748a 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 @@ -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 @@ -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==} @@ -2742,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==} @@ -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: {} @@ -7760,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: {} @@ -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