Skip to content

Multi addon projects - #413

Merged
kraenhansen merged 5 commits into
nextfrom
claude/pr-363-feedback-qqu312
Aug 13, 2026
Merged

Multi addon projects#413
kraenhansen merged 5 commits into
nextfrom
claude/pr-363-feedback-qqu312

Conversation

@kraenhansen

@kraenhansen kraenhansen commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Adds an ability for cmake-rn to build projects declaring multiple addons in a single invocation.

Supersedes #363, which this started as a follow-up to. It carries that PR's commits, rebased onto next, plus the work needed to resolve the question it was drafted on — where the final output should go.

Building many addons at once

gyp-to-cmake gains --namespaced-targets. CMake requires target names to be unique across a project tree, so sub-projects that each declare an addon target cannot be added to one root project. The flag prefixes the target name with the project name.

cmake-rn then handles a project with more than one shared library target, rather than asserting there is exactly one. packages/node-addon-examples uses this: instead of running cmake-rn once per example, a generated root project adds every example as a sub-directory and a single invocation builds them all.

Where the output goes

Naming the artifacts after the (now namespaced) CMake target and writing them all into one directory breaks the consumer side in two ways:

  • Location. The Babel plugin resolves requires literally against the requiring file's directory (plugin.ts, and findNodeAddonForBindings in path-utils.ts), so a prebuild collected into one shared directory is invisible to it.
  • Name. getLibraryName derives the library name from the prebuild's path within its package, and the auto-linker renames the .so to lib${libraryName}.so. The plugin derives the same name from the require path. Move or rename the prebuild and the two disagree.

So both are derived per target from the CMake File API instead:

  • The output directory defaults to {targetSourceDir}/build/{configuration}. The new {targetSourceDir} placeholder expands to target.paths.source, resolved against --source. A project declaring a single addon at the top level reports ., so it resolves to the same path as before.
  • The prebuild is named after the artifact on disk — the target's OUTPUT_NAME, which --namespaced-targets sets back to the original addon name — rather than the CMake target name. Any lib prefix CMake adds is stripped, so a target that does not clear PREFIX (weak-node-api builds a libweak-node-api.so) keeps emitting the prebuild name it does today — the one packages/host/android/build.gradle points its jniLibs at.

The net effect is that each addon lands exactly where building it on its own would have put it, and --namespaced-targets stays an internal CMake concern that nothing downstream can observe.

Grouping is keyed by CMake target name, which CMake guarantees unique within a project. The artifact name is deliberately not used as the key, since every addon may well build an addon.node.

The Apple build directory has to be per target

Because every target now shares one OUTPUT_NAME, the CMAKE_LIBRARY_OUTPUT_DIRECTORY the Apple path sets (to work around CMake #24161) can no longer be shared. With one directory, all 14 example targets resolve to just 3 artifact paths:

BEFORE (shared dir): targets: 14 | unique artifact paths: 3
  collapsed to: out/addon.node, out/binding.node, out/hello.node

Every target then overwrites the previous one's framework, and each prebuild gets assembled from whichever target built last. Test app (iOS) caught this on an earlier push: the app archived fine but simctl install refused it, because two addons carried the same framework identity —

Found bundle at …/Frameworks/example-9--addon.framework with the same identifier
(threadsafe-function-test.addon) as bundle at …/Frameworks/example-6--addon.framework

tests/threadsafe-function sorts last among the sub-projects, so its framework was the one left behind for all of them. Appending $<TARGET_PROPERTY:NAME> to the output directory keeps the #24161 workaround while giving each target its own: 14 targets, 14 distinct artifact paths, verified against real CMake.

Verified against real CMake

Configuring the generated root project and running the resolver over the File API output:

async-test-addon                 -&gt; tests/async/build/RelWithDebInfo/addon.android.node
example-0-hello                  -&gt; examples/1-getting-started/1_hello_world/napi/build/RelWithDebInfo/hello.android.node
example-10-binding               -&gt; examples/5-async-work/async_work_thread_safe_function/napi/build/RelWithDebInfo/binding.android.node
threadsafe-function-test-addon   -&gt; tests/threadsafe-function/build/RelWithDebInfo/addon.android.node
...
targets: 14 | unique outputs: 14

./build/RelWithDebInfo is on the bindings lookup path and the basenames are the original addon names, so require(&#39;bindings&#39;)(&#39;hello&#39;) resolves. Separately confirmed against real CMake: a single-addon project reports paths.source === &#34;.&#34; and emits to exactly the path it does today, and a target that does not clear PREFIX reports a libweak-node-api.so artifact.

Also in here

  • cmake-rn gains --concurrency, bounding how many build tasks run at once. It defaults to the available parallelism, or 1 under --verbose, since interleaved output from concurrent builds is unreadable. EventEmitter.defaultMaxListeners is derived from it — each spawned child attaches three process listeners and removes them on exit, so the live count tracks concurrent children.
  • On Apple, xcodebuild invocations run in sequence per build directory, since concurrent invocations against a single Xcode project and its derived data are not reliable. xcodebuild -list runs once per build directory and asynchronously, rather than once per library as a synchronous spawn. cmake --build builds all requested targets in one invocation.
  • The root example project is generated by scripts/generate-root-project.mts rather than globbed. A file(GLOB_RECURSE ...) is evaluated once at configure time, so it would miss examples copied in afterwards, and being recursive it would add_subdirectory() both a parent and a nested project — three allow-list entries in copy-examples.mts are commented out as "Brings its own CMake project 👀". The generator stops recursing at the first CMakeLists.txt on a path. Being generated from examples/ (gitignored), the file is gitignored too.
  • verify-prebuilds now covers tests/ as well as examples/, and asserts it found a non-zero number of prebuilds, so it cannot pass by simply finding none.
  • findCMakeProjects/findCMakeProjectsRecursively are gone along with build-examples.mts.

Changesets

  • cmake-rn (minor) — multi-addon support, the per-target output location and naming, and --concurrency. Notes the two things an existing user can observe: --out no longer defaults inside --build (same path unless --build points outside the source directory), and the artifact is named after OUTPUT_NAME (the same thing unless it is set explicitly).
  • gyp-to-cmake (minor) — --namespaced-targets.

node-addon-examples is private, so it gets none.

Testing

Run on Linux, against next rebased onto main:

  • pnpm run build, pnpm exec eslint ., pnpm exec prettier --check . — all clean.
  • pnpm --filter gyp-to-cmake run test — 30 pass, including new --namespaced-targets coverage, of which there was none.
  • pnpm --filter cmake-rn run test — 12 pass, including new coverage for the output-path resolution and artifact naming.
  • pnpm --filter react-native-node-api run test — 50 pass, 4 fail. The 4 are path-utils permission tests that cannot pass as root (chmod 000 does not stop root); nothing in packages/host is touched here.
  • Re-ran the example pipeline end to end and configured the resulting 14-project tree against a stubbed weak-node-api config: it configures without duplicate targets, and all 14 targets resolve to distinct prebuild paths next to their own sources.

The native builds could not be run locally — this is a Linux worker, and per AGENTS.md native builds are not bootstrapped there — so Test app (Android) and Test app (iOS), plus the Weak Node-API jobs, are the real end-to-end check. The last of those covers the PREFIX case described above, and the iOS job is what surfaced the shared-output-directory collision.

Test app (macOS) is deliberately not labelled: it stays blocked on react-native-macos shipping a release based on React Native ≥ 0.82, per #392. Enabling it earlier reproduced exactly the Please set CMAKE_BUILD_TYPE Hermesc failure that issue documents, which is unrelated to this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KfKQDvEkxNtkSE4aaF9yG8

@kraenhansen
kraenhansen force-pushed the claude/pr-363-feedback-qqu312 branch from 2ed7ed3 to 01a37f9 Compare August 13, 2026 04:49
@kraenhansen kraenhansen changed the title Emit prebuilds per target, next to their sources Multi addon projects Aug 13, 2026
@kraenhansen
kraenhansen changed the base branch from kh/multi-addon-projects to next August 13, 2026 04:50
@kraenhansen kraenhansen mentioned this pull request Aug 13, 2026
@kraenhansen kraenhansen self-assigned this Aug 13, 2026
@kraenhansen
kraenhansen marked this pull request as ready for review August 13, 2026 04:59
@kraenhansen
kraenhansen force-pushed the claude/pr-363-feedback-qqu312 branch from 01a37f9 to f48956e Compare August 13, 2026 05:03
@kraenhansen kraenhansen added Apple 🍎 Anything related to the Apple platform (iOS, macOS, Cocoapods, Xcode, XCFrameworks, etc.) Android 🤖 Anything related to the Android platform (Gradle, NDK, Android SDK) CMake RN Our `cmake` wrapping CLI MacOS 💻 Anything related to the Apple MacOS platform or React Native MacOS support weak-node-api labels Aug 13, 2026 — with Claude
@kraenhansen kraenhansen reopened this Aug 13, 2026
@kraenhansen kraenhansen removed the MacOS 💻 Anything related to the Apple MacOS platform or React Native MacOS support label Aug 13, 2026 — with Claude
@kraenhansen
kraenhansen force-pushed the claude/pr-363-feedback-qqu312 branch 3 times, most recently from 5c75b42 to 938a205 Compare August 13, 2026 11:49
kraenhansen and others added 5 commits August 13, 2026 12:31
A project declaring multiple addons wrote every prebuild into a single
output directory, named after the CMake target. Both the location and the
name are now derived per target from the CMake File API:

- The output directory defaults to {targetSourceDir}/build/{configuration},
  where the new {targetSourceDir} placeholder expands to the target's own
  source directory. A single-addon project reports "." and so resolves to
  the same path as before.
- The prebuild is named after the artifact on disk (the target's
  OUTPUT_NAME) rather than the target name, so a target renamed to avoid a
  clash within the project still produces the name the JS require expects.

Together this keeps a prebuild where the Babel plugin and auto-linking
resolve it from, and reduces --namespaced-targets to an internal concern.

Also fixes, in the same area:

- gyp-to-cmake emitted OUTPUT_NAME regardless of --namespaced-targets, due
  to an always-truthy condition, and never emitted it for Apple framework
  targets, which CMake names after it.
- The Apple build ran a full "cmake --build" once per shared library,
  concurrently against one build tree, and called "xcodebuild -list" (a
  synchronous spawn) once per library per triplet.
- xcodebuild invocations now run in sequence per build directory, as
  concurrent invocations against a single Xcode project are not reliable.
- postBuild looked for "<target name>.framework" while createAppleFramework
  names it after the artifact, so the two diverged under namespacing.
- --concurrency accepted any value, and did not implement the documented
  fallback to 1 under --verbose. Max listeners is now derived from it.
- verify-prebuilds globbed a directory the prebuilds had moved out of, so
  it passed by finding nothing. It now covers tests/ too and requires a
  non-zero count.
- The root example project globbed recursively, which both missed examples
  copied in after configure and would add a nested project twice. It is
  now generated from the same script pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfKQDvEkxNtkSE4aaF9yG8
@kraenhansen
kraenhansen force-pushed the claude/pr-363-feedback-qqu312 branch from 938a205 to d3dc08f Compare August 13, 2026 12:34
@kraenhansen
kraenhansen merged commit 1ab6a11 into next Aug 13, 2026
18 checks passed
@kraenhansen
kraenhansen deleted the claude/pr-363-feedback-qqu312 branch August 13, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Android 🤖 Anything related to the Android platform (Gradle, NDK, Android SDK) Apple 🍎 Anything related to the Apple platform (iOS, macOS, Cocoapods, Xcode, XCFrameworks, etc.) CMake RN Our `cmake` wrapping CLI weak-node-api

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants