Skip to content

Build napi as a shared library on Android#183

Open
matthargett wants to merge 2 commits into
BabylonJS:mainfrom
rebeckerspecialties:napi-shared-android
Open

Build napi as a shared library on Android#183
matthargett wants to merge 2 commits into
BabylonJS:mainfrom
rebeckerspecialties:napi-shared-android

Conversation

@matthargett
Copy link
Copy Markdown

@matthargett matthargett commented Jun 5, 2026

Summary

On Android, build the napi target as a shared library (libnapi.so) instead of statically linking it into each consumer:

if(ANDROID)
    add_library(napi SHARED ${SOURCES})
else()
    add_library(napi ${SOURCES})
endif()

Other platforms are unchanged — napi stays a static library there. Small, self-contained packaging change to the Node-API build.

Why

  1. Aligns the native-addon model with nodejs/node-api-cts. With a shared libnapi.so, a native addon can be dlopen'd as a standalone .node module that resolves napi_* through a real DT_NEEDED — exactly what node-api-cts's add_node_api_cts_addon() produces. This lets us run that conformance suite's addons directly instead of duplicating/vendoring parts of it inline in this repo.

  2. More flexible binary app updates. The engine, the app, and individual addons become separately shippable binaries, so an update can target an individual .so rather than relinking one monolith.

  3. Lazy loading of native modules. Addons can be dlopen'd on demand rather than all being bound at process start, which can help app launch time.

  4. Still installs via .apk and stays within Google Play binary-analysis rules. libnapi.so is a normal bundled lib/<abi>/*.so shipped inside the APK's nativeLibraryDir — no runtime code download, no dlopen from an app-writable directory (both of which Android API 29+ / Play restrict). It is the same packaging mechanism already used for libv8android.so.

  5. Static and dynamic can coexist. If a shared napi turns out to be an unwanted hard dependency for some embedders, this can be gated behind a CMake option (default could remain static; opt into shared where the dynamic addon model is needed). The change here is a ~6-line diff, so adding that knob later is trivial.

Compatibility / embedder impact

Android (primary)

  • Packaging. Every Android consumer of napi — directly or transitively (AppRuntime, JsRuntime, Foundation, ScriptLoader, the polyfills) — now carries a DT_NEEDED libnapi.so, so the final app/AAR must bundle libnapi.so in lib/<abi>/.
    • With AGP externalNativeBuild (and the prefab/AAR distribution) this is automatic — AGP packages every SHARED target it builds.
    • Hardship: an embedder with a hand-rolled .so allow-list (manually enumerating which libraries to bundle) must add libnapi.so, or the app fails at load with dlopen failed: library "libnapi.so" not found.
  • APK size: one additional ~300 KB .so. Negligible.
  • Exported ABI surface. libnapi.so exports its default-visibility symbols (all napi_* plus the Napi::/engine glue). That is the point — addons resolve from it — but it widens what the engine exports vs. a statically-internalized napi. An embedder who relied on napi_* being hidden in their final binary would now see them exported. (Unlikely to matter in practice.)
  • Single instance. There is now exactly one libnapi.so → one napi/engine state, which is the correct behavior for sharing an env across the host and addons. An embedder who had accidentally linked two static napi copies converges to one — generally a fix, not a regression.
  • No NDK/AGP minimum bump. SHARED libraries are universally supported.

Android app-store deployability

  • Google Play: bundled lib/<abi>/*.so is the standard, sanctioned mechanism — no policy issue. This change introduces no runtime code download and no dlopen from a writable directory, so it stays clear of the API-29+ / Play native-code restrictions.
  • Meta Quest store: same APK model, same rules — no issue.

iOS (unchanged here, but worth stating)

  • This PR is if(ANDROID)-guarded, so iOS keeps static napi — intentionally. Apple's app-store rules prohibit loading dynamically-linked code that isn't embedded + code-signed in the app bundle, and dlopen of arbitrary dylibs is restricted. A shared napi on iOS would have to be a properly embedded, signed .framework, and the dynamic-addon model would be a meaningfully bigger lift. No change/impact today — flagged so the dynamic model isn't assumed portable to iOS for free. (On Apple, JsRuntimeHost uses system JavaScriptCore and the existing addon model resolves napi_* from the host process via -undefined dynamic_lookup.)

Windows (unchanged here)

  • Also if(ANDROID)-guarded, so Windows keeps static napi. A shared napi on Windows would be napi.dll + an import lib (NAPI_EXTERN already maps to __declspec(dllexport/dllimport) under _WIN32), which is feasible but out of scope. No impact today. (Windows additionally carries the Chakra-engine considerations tracked separately.)

BabylonNative

  • BabylonNative embeds JsRuntimeHost through these same CMake targets. On Android its build will transitively bundle libnapi.so (automatic under AGP; a custom .so allow-list must add it). No source/API changenapi headers and usage are identical; only the link/packaging shape changes.

Net

The one concrete hardship is Android packaging discipline: anyone whose Android bundling is not AGP-automatic must add libnapi.so to their packaged libraries. No API changes, no app-store-rule conflicts, and iOS/Windows are untouched. Static linking remains one CMake conditional away if a shared napi is undesirable for a given embedder.

Verification

Verified on the downstream integration branch this was extracted from (rebased on this main): libnapi.so is built and packaged into the APK (lib/arm64-v8a/libnapi.so, ~300 KB), every consumer links it via DT_NEEDED, and on an arm64 Android emulator (API 29, V8) a standalone .node conformance addon — DT_NEEDED [libnapi.so], its napi_* as undefined imports — dlopens and resolves its napi_register_module_v1 against it, running in-process. This isolated change is just the add_library conditional; CI here exercises it against main.


Landing sequence

Inter-related N-API PRs; intended order (✅ done / ⏳ pending):

  1. Fix Windows/UWP CI build (MSVC C4875 + JSI include path) #185 — Fix Windows/UWP CI build (MSVC C4875 + JSI include path). Independent build/toolchain fix; lands first so CI is green under everything below. (fork twin build: fix pre-existing Windows/UWP CI build failures (MSVC C4875 + JSI include path) rebeckerspecialties/JsRuntimeHost#5 — 22/22 green)
  2. Build napi as a shared library on Android #183 — Build napi as a shared library on Android (so in-process addons resolve napi_*). ← this PR
  3. Add N-API compliance tests #116 — N-API compliance tests (the Android addons resolve napi_* via the shared libnapi.so from the step above).
  4. N-API v7 across runtimes (V8 / JSC / Chakra)N-API v7 across runtimes (V8 / JavaScriptCore / Chakra) #189 (draft) — fork CI twin N-API v7 across runtimes (V8 / JavaScriptCore / Chakra) rebeckerspecialties/JsRuntimeHost#4, 22/22 green.

Motivating RFCs: rebeckerspecialties#6 (jsc-android → maintained JSC) · rebeckerspecialties#7 (WebWorker via N-API → Factotum into BabylonNative).

Link napi as a shared library (libnapi.so) on Android instead of statically into each consumer, so
native addons can be dlopen'd as standalone .node modules and resolve their napi_* imports via a real
DT_NEEDED. The host and every addon then share a single napi instance. Other platforms keep static
napi.
Copilot AI review requested due to automatic review settings June 5, 2026 02:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adjusts how the napi library is built so Android produces a shared libnapi.so, enabling separately dlopen’d native addons to resolve napi_* symbols via dynamic linking.

Changes:

  • Build napi as SHARED on Android, otherwise keep existing default library-type behavior.
  • Add rationale in CMake comments about Android/bionic dlopen and symbol resolution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Core/Node-API/CMakeLists.txt Outdated
Comment thread Core/Node-API/CMakeLists.txt
@matthargett
Copy link
Copy Markdown
Author

cc @vmoroz @kraenhansen , thanks for generalizing your work into the upstream nodejs CTS!

- Don't hard-force SHARED: add a JSR_NAPI_SHARED CMake option (default ON on Android, OFF elsewhere)
  so integrators can keep a static napi via -DJSR_NAPI_SHARED=OFF without patching the project.
- Fix the comment: the non-shared branch keeps add_library(napi ${SOURCES}), which follows the
  project's default library type (BUILD_SHARED_LIBS), not necessarily static.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants