diff --git a/.changeset/node-api-linkage.md b/.changeset/node-api-linkage.md new file mode 100644 index 00000000..3e5f4028 --- /dev/null +++ b/.changeset/node-api-linkage.md @@ -0,0 +1,12 @@ +--- +"cmake-rn": major +"react-native-node-api": minor +"weak-node-api": minor +"node-api-stub": minor +--- + +Add `--node-api-linkage ` to `cmake-rn`, replacing `--no-weak-node-api-linkage` (use `--node-api-linkage none`). + +Addons linking the new `node-api` CMake target (`find_package(node-api REQUIRED CONFIG)`) link whatever the mode selects. With `hermes` they link a link-time stub of `libhermesvm`, built along with the addon, so they record `hermesvm` and call Hermes directly instead of going through `weak-node-api`, which doesn't need to be built. The stub never ships in an app. Addons naming the `weak-node-api` target directly keep building alongside. The default stays `weak-node-api`. + +The new `node-api-stub` package holds the stub and `getNodeApiFunctions()`, which moved there from `weak-node-api`. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3bf7be7d..8ffd4b68 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -318,6 +318,13 @@ jobs: - name: Build test app run: xcodebuild archive -workspace ReactTestApp.xcworkspace -configuration Release -scheme ReactTestApp -destination "generic/platform=iOS Simulator" -archivePath ./build/test-app.xcarchive CODE_SIGN_IDENTITY="-" CODE_SIGNING_ALLOWED=YES C_COMPILER_LAUNCHER=ccache CLANG_ENABLE_EXPLICIT_MODULES_WITH_COMPILER_LAUNCHER=YES working-directory: apps/test-app/ios + # The async test addon is built with `--node-api-linkage hermes` (#494). + - name: Verify the async addon links Hermes directly + run: | + node --run verify:hermes-linkage -- \ + tests/async/build/RelWithDebInfo/addon.apple.node/ios-*-simulator/addon.framework/addon \ + ../../apps/test-app/ios/build/test-app.xcarchive/Products/Applications/ReactTestApp.app/Frameworks/hermesvm.framework/hermesvm + working-directory: packages/node-addon-examples - name: Run test app run: | # Install the app @@ -467,6 +474,22 @@ jobs: sleep 5 # Stop logcat kill $LOGCAT_PID || true + # The async test addon is built with `--node-api-linkage hermes` (#494). + - name: Verify the async addon links Hermes directly + run: | + RNTA_DIR=$(node -p "path.dirname(require.resolve('react-native-test-app/package.json', { paths: ['../../apps/test-app'] }))") + APK=$(find ../../apps/test-app/android "$RNTA_DIR/android" -name '*.apk' 2>/dev/null | head -n 1) + if [ -z "$APK" ]; then + echo "::error::Found no APK of the test app" + exit 1 + fi + echo "Extracting Hermes from $APK" + unzip -o -j "$APK" lib/x86_64/libhermesvm.so -d "$RUNNER_TEMP/apk" + export LLVM_BIN="$ANDROID_HOME/ndk/$NDK_VERSION/toolchains/llvm/prebuilt/linux-x86_64/bin" + node --run verify:hermes-linkage -- \ + tests/async/build/RelWithDebInfo/addon.android.node/x86_64/libaddon.so \ + "$RUNNER_TEMP/apk/libhermesvm.so" + working-directory: packages/node-addon-examples - name: Upload device logs if: always() uses: actions/upload-artifact@v7 diff --git a/packages/cmake-rn/README.md b/packages/cmake-rn/README.md index d3584a29..58be78ef 100644 --- a/packages/cmake-rn/README.md +++ b/packages/cmake-rn/README.md @@ -86,4 +86,21 @@ else() endif() ``` +### Choosing what to link with `--node-api-linkage` + +An addon can instead link the `node-api` target, which links whatever `--node-api-linkage ` selects: + +- `weak-node-api` (the default): the `weak-node-api` library described above. +- `hermes`: a link-time stub of Hermes' `hermesvm` library from [`node-api-stub`](../node-api-stub), built along with the addon, so the addon records `hermesvm` and calls Hermes' Node-API directly. The stub is never shipped: the app's own Hermes provides the implementation at runtime. This doesn't need `weak-node-api` to be built, but requires a Hermes exporting Node-API (such as the one vendored by `react-native-node-api`), and the addon gets Hermes' own `napi_fatal_error` instead of the host's. +- `none`: neither target is defined. + +```cmake +# Defines the "node-api" target +find_package(node-api REQUIRED CONFIG) + +target_link_libraries(addon PRIVATE node-api) +``` + +Addons linking the `weak-node-api` target directly can be built alongside: it's defined in the `weak-node-api` mode, and in the `hermes` mode whenever `weak-node-api` is built. + This is different from how `cmake-js` "injects" the Node-API for linking (via `${CMAKE_JS_INC}`, `${CMAKE_JS_SRC}` and `${CMAKE_JS_LIB}`). To allow for interoperability between these tools, we inject these when you pass `--cmake-js` to `cmake-rn`. diff --git a/packages/cmake-rn/cmake/node-api-config.cmake b/packages/cmake-rn/cmake/node-api-config.cmake new file mode 100644 index 00000000..dcce56d4 --- /dev/null +++ b/packages/cmake-rn/cmake/node-api-config.cmake @@ -0,0 +1,32 @@ +# Defines the `node-api` target, linking what cmake-rn's --node-api-linkage +# selected (NODE_API_LINKAGE). + +if(TARGET node-api) + return() +endif() + +foreach(variable NODE_API_LINKAGE NODE_API_INC) + if(NOT DEFINED ${variable}) + message(FATAL_ERROR "${variable} is not set: build with cmake-rn and a --node-api-linkage other than 'none'") + endif() +endforeach() + +if(NODE_API_LINKAGE STREQUAL "hermes") + if(NOT TARGET node-api-stub) + add_subdirectory("${NODE_API_STUB_DIR}" "${CMAKE_BINARY_DIR}/node-api-stub") + endif() + add_library(node-api INTERFACE) + target_link_libraries(node-api INTERFACE node-api-stub) + target_include_directories(node-api INTERFACE ${NODE_API_INC}) + message(STATUS "Linking Node-API against a stub of Hermes") +else() + if(NOT DEFINED NODE_API_LIB) + message(FATAL_ERROR "NODE_API_LIB is not set") + endif() + add_library(node-api SHARED IMPORTED) + set_target_properties(node-api PROPERTIES + IMPORTED_LOCATION "${NODE_API_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${NODE_API_INC}" + ) + message(STATUS "Linking Node-API against ${NODE_API_LINKAGE}: ${NODE_API_LIB}") +endif() diff --git a/packages/cmake-rn/package.json b/packages/cmake-rn/package.json index 1b8df35e..ac88854a 100644 --- a/packages/cmake-rn/package.json +++ b/packages/cmake-rn/package.json @@ -17,7 +17,8 @@ "dist", "!dist/**/*.test.d.ts", "!dist/**/*.test.d.ts.map", - "templates" + "templates", + "cmake" ], "scripts": { "build": "tsc", @@ -29,7 +30,8 @@ "cmake-file-api": "workspace:*", "react-native-node-api": "workspace:*", "zod": "^4.1.11", - "weak-node-api": "workspace:*" + "weak-node-api": "workspace:*", + "node-api-stub": "workspace:*" }, "peerDependencies": { "node-addon-api": "^8.3.1", diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index 41a362a9..d00a38b1 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -14,6 +14,7 @@ import { pLimit, InvalidArgumentError, } from "@react-native-node-api/cli-utils"; +import { nodeApiLinkageOption } from "react-native-node-api"; import { platforms, @@ -115,11 +116,6 @@ const noAutoLinkOption = new Option( "Don't mark the output as auto-linkable by react-native-node-api", ); -const noWeakNodeApiLinkageOption = new Option( - "--no-weak-node-api-linkage", - "Don't pass the path of the weak-node-api library from react-native-node-api", -); - const cmakeJsOption = new Option( "--cmake-js", "Define CMAKE_JS_* variables used for compatibility with cmake-js", @@ -159,7 +155,7 @@ let buildCommand = new Command("build") .addOption(targetOption) .addOption(stripOption) .addOption(noAutoLinkOption) - .addOption(noWeakNodeApiLinkageOption) + .addOption(nodeApiLinkageOption) .addOption(cmakeJsOption) .addOption(ccachePathOption) .addOption(concurrencyOption); diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts index 61dc0fc1..788b9dbf 100644 --- a/packages/cmake-rn/src/platforms/android.ts +++ b/packages/cmake-rn/src/platforms/android.ts @@ -13,12 +13,13 @@ import { AndroidTriplet as Triplet, } from "react-native-node-api"; import * as cmakeFileApi from "cmake-file-api"; +import { NODE_API_STUB_TARGET } from "node-api-stub"; import type { BaseOpts, Platform } from "./types.js"; import { getArtifactName, toDefineArguments } from "../helpers.js"; import { getCmakeJSVariables, - getWeakNodeApiVariables, + getNodeApiLinkageVariables, } from "../weak-node-api.js"; import { isNinjaAvailable } from "../ninja.js"; @@ -183,7 +184,7 @@ export const platform: Platform = { source, define, build, - weakNodeApiLinkage, + nodeApiLinkage, cmakeJs, ccachePath, }, @@ -228,7 +229,7 @@ export const platform: Platform = { "--toolchain", toolchainPath, ...toDefineArguments([ - ...(weakNodeApiLinkage ? [getWeakNodeApiVariables(triplet)] : []), + getNodeApiLinkageVariables(nodeApiLinkage, triplet), ...(cmakeJs ? [getCmakeJSVariables(triplet)] : []), ...commonDefinitions, { @@ -280,6 +281,7 @@ export const platform: Platform = { const sharedLibraries = targets.filter( ({ type, name }) => type === "SHARED_LIBRARY" && + name !== NODE_API_STUB_TARGET && (target.length === 0 || target.includes(name)), ); await Promise.all( diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts index 82740793..90ca9481 100644 --- a/packages/cmake-rn/src/platforms/apple.ts +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -19,10 +19,11 @@ import { import type { Platform } from "./types.js"; import * as cmakeFileApi from "cmake-file-api"; +import { NODE_API_STUB_TARGET } from "node-api-stub"; import { getArtifactName, toDefineArguments } from "../helpers.js"; import { getCmakeJSVariables, - getWeakNodeApiVariables, + getNodeApiLinkageVariables, } from "../weak-node-api.js"; import * as z from "zod"; @@ -189,6 +190,7 @@ async function readCmakeSharedLibraryTargets( return targets.filter( ({ type, name }) => type === "SHARED_LIBRARY" && + name !== NODE_API_STUB_TARGET && (target.length === 0 || target.includes(name)), ); } @@ -292,7 +294,7 @@ export const platform: Platform = { }, async configure( triplets, - { source, build, define, weakNodeApiLinkage, cmakeJs, ccachePath }, + { source, build, define, nodeApiLinkage, cmakeJs, ccachePath }, ) { // When using ccache, we're creating symlinks for the clang and clang++ binaries to the ccache binary // This is needed for ccache to understand it's being invoked as clang and clang++ respectively. @@ -343,7 +345,7 @@ export const platform: Platform = { "Xcode", ...toDefineArguments([ ...define, - weakNodeApiLinkage ? getWeakNodeApiVariables("apple") : {}, + getNodeApiLinkageVariables(nodeApiLinkage, "apple"), cmakeJs ? getCmakeJSVariables("apple") : {}, compilerDefinitions, { diff --git a/packages/cmake-rn/src/weak-node-api.ts b/packages/cmake-rn/src/weak-node-api.ts index 44b2e79c..d13f3a2b 100644 --- a/packages/cmake-rn/src/weak-node-api.ts +++ b/packages/cmake-rn/src/weak-node-api.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { isAndroidTriplet, isAppleTriplet, + type NodeApiLinkage, SupportedTriplet, } from "react-native-node-api"; @@ -13,6 +14,7 @@ import { androidPrebuildPath, weakNodeApiCmakePath, } from "weak-node-api"; +import { nodeApiStubPath } from "node-api-stub"; import { ANDROID_ARCHITECTURES } from "./platforms/android.js"; import { getNodeAddonHeadersPath, getNodeApiHeadersPath } from "./headers.js"; @@ -21,28 +23,30 @@ export function toCmakePath(input: string) { return input.split(path.win32.sep).join(path.posix.sep); } -export function getWeakNodeApiPath( - triplet: SupportedTriplet | "apple", -): string { +const NODE_API_CONFIG_DIR = path.resolve(import.meta.dirname, "../cmake"); + +function findWeakNodeApiPath(triplet: SupportedTriplet | "apple") { if (triplet === "apple" || isAppleTriplet(triplet)) { - assert( - fs.existsSync(applePrebuildPath), - `Expected an XCFramework at ${applePrebuildPath}`, - ); return applePrebuildPath; } else if (isAndroidTriplet(triplet)) { - const libraryPath = path.join( + return path.join( androidPrebuildPath, ANDROID_ARCHITECTURES[triplet], "libweak-node-api.so", ); - assert(fs.existsSync(libraryPath), `Expected library at ${libraryPath}`); - return libraryPath; } else { throw new Error(`Unexpected triplet: ${triplet as string}`); } } +export function getWeakNodeApiPath( + triplet: SupportedTriplet | "apple", +): string { + const result = findWeakNodeApiPath(triplet); + assert(fs.existsSync(result), `Expected weak-node-api at ${result}`); + return result; +} + function getNodeApiIncludePaths() { const includePaths = [getNodeApiHeadersPath(), getNodeAddonHeadersPath()]; for (const includePath of includePaths) { @@ -67,6 +71,39 @@ export function getWeakNodeApiVariables( }; } +/** + * Variables for the selected --node-api-linkage, which the `node-api` target follows. + */ +export function getNodeApiLinkageVariables( + linkage: NodeApiLinkage, + triplet: SupportedTriplet | "apple", +): Record { + if (linkage === "none") { + return {}; + } + const nodeApiVariables = { + // Enable use of `find_package(node-api REQUIRED CONFIG)` + "node-api_DIR": NODE_API_CONFIG_DIR, + NODE_API_LINKAGE: linkage, + NODE_API_INC: getNodeApiIncludePaths().join(";"), + }; + if (linkage === "hermes") { + return { + // Keeps the `weak-node-api` target available to addons naming it, without requiring it + ...(fs.existsSync(findWeakNodeApiPath(triplet)) + ? getWeakNodeApiVariables(triplet) + : {}), + ...nodeApiVariables, + NODE_API_STUB_DIR: nodeApiStubPath, + }; + } + return { + ...getWeakNodeApiVariables(triplet), + ...nodeApiVariables, + NODE_API_LIB: getWeakNodeApiPath(triplet), + }; +} + /** * For compatibility with cmake-js */ diff --git a/packages/cmake-rn/tsconfig.node.json b/packages/cmake-rn/tsconfig.node.json index d6472ce0..11edc397 100644 --- a/packages/cmake-rn/tsconfig.node.json +++ b/packages/cmake-rn/tsconfig.node.json @@ -8,5 +8,10 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": ["**/*.test.ts"] + "exclude": ["**/*.test.ts"], + "references": [ + { + "path": "../node-api-stub/tsconfig.node.json" + } + ] } diff --git a/packages/host/package.json b/packages/host/package.json index e31b98c1..13784d3e 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -88,6 +88,7 @@ "@babel/types": "^7.27.0", "@types/babel__core": "^7.20.5", "fswin": "^3.24.829", + "node-api-stub": "workspace:*", "weak-node-api": "workspace:*" }, "peerDependencies": { diff --git a/packages/host/scripts/generate-injector.mts b/packages/host/scripts/generate-injector.mts index c58bb2f5..a045c3e3 100644 --- a/packages/host/scripts/generate-injector.mts +++ b/packages/host/scripts/generate-injector.mts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import cp from "node:child_process"; -import { type FunctionDecl, getNodeApiFunctions } from "weak-node-api"; +import { type FunctionDecl, getNodeApiFunctions } from "node-api-stub"; export const CPP_SOURCE_PATH = path.join(import.meta.dirname, "../cpp"); diff --git a/packages/host/src/node/cli/options.ts b/packages/host/src/node/cli/options.ts index eb059b42..7680e475 100644 --- a/packages/host/src/node/cli/options.ts +++ b/packages/host/src/node/cli/options.ts @@ -26,3 +26,14 @@ export const pathSuffixOption = new Option( ) .choices(LIBRARY_NAMING_CHOICES) .default(NODE_API_PATH_SUFFIX || "strip"); + +export const NODE_API_LINKAGES = ["weak-node-api", "hermes", "none"] as const; + +export type NodeApiLinkage = (typeof NODE_API_LINKAGES)[number]; + +export const nodeApiLinkageOption = new Option( + "--node-api-linkage ", + "What addons link against for Node-API: weak-node-api, the Hermes library (via a link-time stub) or nothing at all", +) + .choices(NODE_API_LINKAGES) + .default("weak-node-api" satisfies NodeApiLinkage); diff --git a/packages/host/src/node/index.ts b/packages/host/src/node/index.ts index db13383c..59ae16fa 100644 --- a/packages/host/src/node/index.ts +++ b/packages/host/src/node/index.ts @@ -28,3 +28,9 @@ export { } from "./path-utils.js"; export { readAndParsePlist } from "./plist.js"; + +export { + NODE_API_LINKAGES, + type NodeApiLinkage, + nodeApiLinkageOption, +} from "./cli/options.js"; diff --git a/packages/host/tsconfig.node-scripts.json b/packages/host/tsconfig.node-scripts.json index b3771a38..5addfb19 100644 --- a/packages/host/tsconfig.node-scripts.json +++ b/packages/host/tsconfig.node-scripts.json @@ -11,7 +11,7 @@ "exclude": [], "references": [ { - "path": "../weak-node-api/tsconfig.node.json" + "path": "../node-api-stub/tsconfig.node.json" } ] } diff --git a/packages/node-addon-examples/package.json b/packages/node-addon-examples/package.json index d1961e1f..c03d5bdd 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -23,16 +23,18 @@ "copy-examples": "tsx scripts/copy-examples.mts", "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", + "build": "cmake-rn --configuration RelWithDebInfo --node-api-linkage hermes", "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" + "bootstrap": "node --run copy-and-build", + "verify:hermes-linkage": "tsx scripts/verify-hermes-linkage.mts" }, "devDependencies": { "cmake-rn": "workspace:*", "gyp-to-cmake": "workspace:*", "node-addon-examples": "github:nodejs/node-addon-examples#4b7dd86a85644610e6de80154df9acac9329b509", + "node-api-stub": "workspace:*", "read-pkg": "^9.0.1", "weak-node-api": "workspace:*" }, diff --git a/packages/node-addon-examples/scripts/verify-hermes-linkage.mts b/packages/node-addon-examples/scripts/verify-hermes-linkage.mts new file mode 100644 index 00000000..5f5003a3 --- /dev/null +++ b/packages/node-addon-examples/scripts/verify-hermes-linkage.mts @@ -0,0 +1,110 @@ +/** + * Verifies an addon built with `--node-api-linkage hermes` links Hermes + * directly and that every Node-API function it imports is exported by the real + * Hermes library, reporting Node-API functions that library doesn't export. + * + * Usage: verify-hermes-linkage.mts + * + * On Linux, set LLVM_BIN to a directory with llvm-nm and llvm-readelf (e.g. the NDK's). + */ +import assert from "node:assert/strict"; +import cp from "node:child_process"; +import path from "node:path"; + +import { nodeApiHeaders } from "node-api-stub"; + +const [addonPath, hermesPath] = process.argv.slice(2); +assert(addonPath && hermesPath, "Usage: "); + +const isDarwin = process.platform === "darwin"; + +function llvmTool(name: string) { + const { LLVM_BIN } = process.env; + return LLVM_BIN ? path.join(LLVM_BIN, name) : name; +} + +function run(command: string, args: string[]) { + return cp.execFileSync(command, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); +} + +function getDependencies(binaryPath: string) { + if (isDarwin) { + return run("otool", ["-L", binaryPath]) + .split("\n") + .filter((line) => /^\s/.test(line)) + .map((line) => line.trim().split(" ")[0]); + } else { + return [ + ...run(llvmTool("llvm-readelf"), ["-d", binaryPath]).matchAll( + /Shared library: \[(.+)\]/g, + ), + ].map(([, name]) => name); + } +} + +function getNodeApiSymbols(binaryPath: string, filter: string[]) { + const args = isDarwin ? filter : ["-D", ...filter]; + const output = run(isDarwin ? "nm" : llvmTool("llvm-nm"), [ + ...args, + binaryPath, + ]); + return new Set( + output + .split("\n") + .map((line) => line.trim().split(/\s+/).at(-1) ?? "") + // Mach-O prefixes C symbols with an underscore + .map((name) => (isDarwin ? name.replace(/^_/, "") : name)) + .filter((name) => /^(napi|node_api)_/.test(name)), + ); +} + +function fail(message: string): never { + console.log(`::error::${message}`); + process.exit(1); +} + +const dependencies = getDependencies(addonPath); +console.log(`Dependencies of ${addonPath}:`); +for (const dependency of dependencies) { + console.log(` ${dependency}`); +} +if (!dependencies.some((name) => name.includes("hermesvm"))) { + fail(`Expected ${addonPath} to link hermesvm`); +} +if (dependencies.some((name) => name.includes("weak-node-api"))) { + fail(`Expected ${addonPath} not to link weak-node-api`); +} + +const imported = getNodeApiSymbols(addonPath, ["--undefined-only"]); +const exported = getNodeApiSymbols(hermesPath, [ + "--defined-only", + "--extern-only", +]); + +if (imported.size === 0) { + fail(`Found no Node-API imports in ${addonPath}`); +} +const missing = [...imported].filter((name) => !exported.has(name)); +if (missing.length > 0) { + fail( + `${hermesPath} doesn't export Node-API functions imported by ${addonPath}: ${missing.join(", ")}`, + ); +} +console.log( + `All ${imported.size} Node-API functions imported by the addon are exported by ${hermesPath}`, +); + +const { js_native_api_symbols, node_api_symbols } = nodeApiHeaders.symbols.v10; +const unexported = [...js_native_api_symbols, ...node_api_symbols].filter( + (name) => !exported.has(name), +); +if (unexported.length > 0) { + console.log( + `::warning::${hermesPath} doesn't export Node-API functions the stub does: ${unexported.join(", ")}`, + ); +} else { + console.log(`${hermesPath} exports every Node-API function the stub does`); +} diff --git a/packages/node-addon-examples/tests/async/CMakeLists.txt b/packages/node-addon-examples/tests/async/CMakeLists.txt index ca9532d7..db6fcee6 100644 --- a/packages/node-addon-examples/tests/async/CMakeLists.txt +++ b/packages/node-addon-examples/tests/async/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.15...3.31) project(async-test) -find_package(weak-node-api REQUIRED CONFIG) +find_package(node-api REQUIRED CONFIG) add_library(async-test-addon SHARED addon.c) @@ -24,5 +24,5 @@ else() ) endif() -target_link_libraries(async-test-addon PRIVATE weak-node-api) +target_link_libraries(async-test-addon PRIVATE 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/binding.gyp b/packages/node-addon-examples/tests/async/binding.gyp deleted file mode 100644 index 80f9fa87..00000000 --- a/packages/node-addon-examples/tests/async/binding.gyp +++ /dev/null @@ -1,8 +0,0 @@ -{ - "targets": [ - { - "target_name": "addon", - "sources": [ "addon.c" ] - } - ] -} diff --git a/packages/node-addon-examples/tests/async/package.json b/packages/node-addon-examples/tests/async/package.json index c8016da0..c153ed21 100644 --- a/packages/node-addon-examples/tests/async/package.json +++ b/packages/node-addon-examples/tests/async/package.json @@ -9,6 +9,5 @@ }, "scripts": { "test": "node addon.js" - }, - "gypfile": true + } } diff --git a/packages/node-addon-examples/tsconfig.node-scripts.json b/packages/node-addon-examples/tsconfig.node-scripts.json index e4ca4a5b..d8f6d3d2 100644 --- a/packages/node-addon-examples/tsconfig.node-scripts.json +++ b/packages/node-addon-examples/tsconfig.node-scripts.json @@ -8,5 +8,10 @@ "types": ["node", "read-pkg"] }, "include": ["scripts/**/*.mts"], - "exclude": [] + "exclude": [], + "references": [ + { + "path": "../node-api-stub/tsconfig.node.json" + } + ] } diff --git a/packages/node-api-stub/.gitignore b/packages/node-api-stub/.gitignore new file mode 100644 index 00000000..41a53b37 --- /dev/null +++ b/packages/node-api-stub/.gitignore @@ -0,0 +1,2 @@ +# Generated by `node --run generate` +/generated/ diff --git a/packages/node-api-stub/CMakeLists.txt b/packages/node-api-stub/CMakeLists.txt new file mode 100644 index 00000000..2c2cb2cc --- /dev/null +++ b/packages/node-api-stub/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.19) +project(node-api-stub) + +set(NODE_API_STUB_LIBRARY_NAME "hermesvm" CACHE STRING "Name of the library the stub stands in for") + +if(NOT DEFINED NODE_API_INC) + message(FATAL_ERROR "NODE_API_INC must point at the Node-API headers") +endif() + +add_library(node-api-stub SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/generated/node_api_stub.cpp) + +target_include_directories(node-api-stub PRIVATE ${NODE_API_INC}) +target_compile_definitions(node-api-stub PRIVATE NAPI_VERSION=10) + +if(APPLE) + set_target_properties(node-api-stub PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER com.callstack.node-api-stub.${NODE_API_STUB_LIBRARY_NAME} + OUTPUT_NAME ${NODE_API_STUB_LIBRARY_NAME} + ) +else() + set_target_properties(node-api-stub PROPERTIES + OUTPUT_NAME ${NODE_API_STUB_LIBRARY_NAME} + ) +endif() diff --git a/packages/node-api-stub/README.md b/packages/node-api-stub/README.md new file mode 100644 index 00000000..cb1acc2e --- /dev/null +++ b/packages/node-api-stub/README.md @@ -0,0 +1,11 @@ +# Node-API stub + +Describes the Node-API functions declared by [`node-api-headers`](https://www.npmjs.com/package/node-api-headers) and generates a link-time stub of a library exporting them. + +This package is part of the [Node-API for React Native](https://github.com/callstackincubator/react-native-node-api) project. + +## The stub + +Linking an addon against the stub makes it record the stub's library name (`hermesvm` by default) as a dependency, so it calls that library's Node-API directly at runtime. The stub is only ever linked against: every function aborts, and it's never shipped in an app. + +`CMakeLists.txt` defines the `node-api-stub` target, excluded from `all`. It expects `NODE_API_INC` to point at the Node-API headers, and `NODE_API_STUB_LIBRARY_NAME` picks the library it stands in for. [`cmake-rn`](../cmake-rn) adds it to an addon's build when passed `--node-api-linkage hermes`. diff --git a/packages/node-api-stub/package.json b/packages/node-api-stub/package.json new file mode 100644 index 00000000..485dcb3c --- /dev/null +++ b/packages/node-api-stub/package.json @@ -0,0 +1,50 @@ +{ + "name": "node-api-stub", + "version": "0.0.0", + "description": "Node-API function declarations and a link-time stub of a library exporting Node-API", + "homepage": "https://github.com/callstackincubator/react-native-node-api", + "repository": { + "type": "git", + "url": "git+https://github.com/callstackincubator/react-native-node-api.git", + "directory": "packages/node-api-stub" + }, + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist", + "!dist/**/*.test.d.ts", + "!dist/**/*.test.d.ts.map", + "generated", + "CMakeLists.txt" + ], + "scripts": { + "build": "tsc --build", + "generate": "tsx scripts/generate-stub.ts", + "bootstrap": "node --run generate", + "prerelease": "node --run generate" + }, + "keywords": [ + "node-api", + "napi", + "stub", + "hermes", + "cmake" + ], + "author": { + "name": "Callstack", + "url": "https://github.com/callstackincubator" + }, + "contributors": [ + { + "name": "Kræn Hansen", + "url": "https://github.com/kraenhansen" + } + ], + "license": "MIT", + "dependencies": { + "node-api-headers": "^1.5.0", + "zod": "^4.1.11" + } +} diff --git a/packages/node-api-stub/scripts/generate-stub.ts b/packages/node-api-stub/scripts/generate-stub.ts new file mode 100644 index 00000000..91d73b72 --- /dev/null +++ b/packages/node-api-stub/scripts/generate-stub.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import cp from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { generateFunction, getNodeApiFunctions } from "../src/index.js"; + +const OUTPUT_PATH = path.join(import.meta.dirname, "../generated"); + +const source = ` + /** + * @file node_api_stub.cpp + * @brief Link-time stub of a library exporting Node-API. + * + * Lets an addon link directly against a Node-API provider (such as Hermes) without having it installed. + * + * @note This file is generated - don't edit it directly + */ + + #include + #include // abort() + + ${getNodeApiFunctions() + .map((fn) => generateFunction({ ...fn, extern: true, body: "abort();" })) + .join("\n")} +`; + +await fs.promises.mkdir(OUTPUT_PATH, { recursive: true }); +const outputPath = path.join(OUTPUT_PATH, "node_api_stub.cpp"); +await fs.promises.writeFile(outputPath, source.trim(), "utf-8"); +const { status, stderr } = cp.spawnSync("clang-format", ["-i", outputPath], { + encoding: "utf8", +}); +assert.equal(status, 0, `Failed to format ${outputPath}: ${stderr}`); diff --git a/packages/weak-node-api/scripts/generators/shared.ts b/packages/node-api-stub/src/generate-function.ts similarity index 74% rename from packages/weak-node-api/scripts/generators/shared.ts rename to packages/node-api-stub/src/generate-function.ts index 88104e58..b149d7ad 100644 --- a/packages/weak-node-api/scripts/generators/shared.ts +++ b/packages/node-api-stub/src/generate-function.ts @@ -1,4 +1,4 @@ -import type { FunctionDecl } from "../../src/node-api-functions.js"; +import type { FunctionDecl } from "./node-api-functions.js"; type FunctionOptions = FunctionDecl & { extern?: true; @@ -8,6 +8,9 @@ type FunctionOptions = FunctionDecl & { argumentNames?: string[]; }; +/** + * Generates the C++ declaration of a Node-API function, or its definition when a body is given. + */ export function generateFunction({ extern, static: staticMember, @@ -16,13 +19,12 @@ export function generateFunction({ name, argumentTypes, argumentNames = [], - noReturn, body, }: FunctionOptions) { return ` ${staticMember ? "static " : ""}${extern ? 'extern "C" ' : ""}${returnType} ${namespace ? namespace + "::" : ""}${name}( ${argumentTypes.map((type, index) => `${type} ` + (argumentNames[index] ?? `arg${index}`)).join(", ")} - ) ${body ? `{ ${body} ${noReturn ? "WEAK_NODE_API_UNREACHABLE;" : ""}\n}` : ""} + ) ${body ? `{ ${body}\n}` : ""} ; `; } diff --git a/packages/node-api-stub/src/index.ts b/packages/node-api-stub/src/index.ts new file mode 100644 index 00000000..e887b0af --- /dev/null +++ b/packages/node-api-stub/src/index.ts @@ -0,0 +1,3 @@ +export * from "./node-api-functions.js"; +export * from "./generate-function.js"; +export * from "./node-api-stub.js"; diff --git a/packages/weak-node-api/src/node-api-functions.ts b/packages/node-api-stub/src/node-api-functions.ts similarity index 100% rename from packages/weak-node-api/src/node-api-functions.ts rename to packages/node-api-stub/src/node-api-functions.ts diff --git a/packages/node-api-stub/src/node-api-stub.ts b/packages/node-api-stub/src/node-api-stub.ts new file mode 100644 index 00000000..ea13a136 --- /dev/null +++ b/packages/node-api-stub/src/node-api-stub.ts @@ -0,0 +1,8 @@ +import path from "node:path"; + +export const nodeApiStubPath = path.resolve(import.meta.dirname, ".."); + +/** + * Name of the CMake target building the stub, which is only linked against and never shipped. + */ +export const NODE_API_STUB_TARGET = "node-api-stub"; diff --git a/packages/node-api-stub/tsconfig.json b/packages/node-api-stub/tsconfig.json new file mode 100644 index 00000000..f08015f8 --- /dev/null +++ b/packages/node-api-stub/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true + }, + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.node-scripts.json" } + ] +} diff --git a/packages/node-api-stub/tsconfig.node-scripts.json b/packages/node-api-stub/tsconfig.node-scripts.json new file mode 100644 index 00000000..4bc586fa --- /dev/null +++ b/packages/node-api-stub/tsconfig.node-scripts.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "scripts", + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "types/**/*.d.ts"], + "exclude": [], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/node-api-stub/tsconfig.node.json b/packages/node-api-stub/tsconfig.node.json new file mode 100644 index 00000000..0028899f --- /dev/null +++ b/packages/node-api-stub/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "composite": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts", "types/**/*.d.ts"], + "exclude": ["**/*.test.ts"] +} diff --git a/packages/weak-node-api/types/node-api-headers/index.d.ts b/packages/node-api-stub/types/node-api-headers/index.d.ts similarity index 100% rename from packages/weak-node-api/types/node-api-headers/index.d.ts rename to packages/node-api-stub/types/node-api-headers/index.d.ts diff --git a/packages/weak-node-api/package.json b/packages/weak-node-api/package.json index 163192a6..ff551521 100644 --- a/packages/weak-node-api/package.json +++ b/packages/weak-node-api/package.json @@ -28,7 +28,7 @@ "copy-node-api-headers": "tsx scripts/copy-node-api-headers.ts", "generate": "tsx scripts/generate.ts", "prebuild:prepare": "node --run copy-node-api-headers && node --run generate", - "prebuild:build": "cmake-rn --no-auto-link --no-weak-node-api-linkage --xcframework-extension", + "prebuild:build": "cmake-rn --no-auto-link --node-api-linkage none --xcframework-extension", "prebuild:build:android": "node --run prebuild:build -- --android", "prebuild:build:apple": "node --run prebuild:build -- --apple", "prebuild:build:all": "node --run prebuild:build -- --android --apple", @@ -64,11 +64,8 @@ } ], "license": "MIT", - "dependencies": { - "node-api-headers": "^1.5.0" - }, "devDependencies": { "cmake-rn": "workspace:*", - "zod": "^4.1.11" + "node-api-stub": "workspace:*" } } diff --git a/packages/weak-node-api/scripts/copy-node-api-headers.ts b/packages/weak-node-api/scripts/copy-node-api-headers.ts index 8d90174b..e14ac226 100644 --- a/packages/weak-node-api/scripts/copy-node-api-headers.ts +++ b/packages/weak-node-api/scripts/copy-node-api-headers.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { nodeApiHeaders } from "../src/node-api-functions.js"; +import { nodeApiHeaders } from "node-api-stub"; const { include_dir: includeSourcePath } = nodeApiHeaders; const includeDestinationPath = path.join(import.meta.dirname, "../include"); diff --git a/packages/weak-node-api/scripts/generate.ts b/packages/weak-node-api/scripts/generate.ts index fc21e485..e3ba8852 100644 --- a/packages/weak-node-api/scripts/generate.ts +++ b/packages/weak-node-api/scripts/generate.ts @@ -3,10 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import cp from "node:child_process"; -import { - FunctionDecl, - getNodeApiFunctions, -} from "../src/node-api-functions.js"; +import { type FunctionDecl, getNodeApiFunctions } from "node-api-stub"; import * as weakNodeApiGenerator from "./generators/weak-node-api.js"; import * as hostGenerator from "./generators/NodeApiHost.js"; diff --git a/packages/weak-node-api/scripts/generators/NodeApiHost.ts b/packages/weak-node-api/scripts/generators/NodeApiHost.ts index c273cfe7..1ac547c6 100644 --- a/packages/weak-node-api/scripts/generators/NodeApiHost.ts +++ b/packages/weak-node-api/scripts/generators/NodeApiHost.ts @@ -1,4 +1,4 @@ -import type { FunctionDecl } from "../../src/node-api-functions.js"; +import type { FunctionDecl } from "node-api-stub"; export function generateFunctionDecl({ returnType, diff --git a/packages/weak-node-api/scripts/generators/weak-node-api.ts b/packages/weak-node-api/scripts/generators/weak-node-api.ts index 62a1a026..a72306a3 100644 --- a/packages/weak-node-api/scripts/generators/weak-node-api.ts +++ b/packages/weak-node-api/scripts/generators/weak-node-api.ts @@ -1,5 +1,4 @@ -import type { FunctionDecl } from "../../src/node-api-functions.js"; -import { generateFunction } from "./shared.js"; +import { type FunctionDecl, generateFunction } from "node-api-stub"; export function generateHeader() { return ` @@ -29,6 +28,7 @@ function generateFunctionImpl(fn: FunctionDecl) { ${returnType === "void" ? "" : "return "} g_host.${name}( ${argumentTypes.map((_, index) => `arg${index}`).join(", ")} ); + ${fn.noReturn ? "WEAK_NODE_API_UNREACHABLE;" : ""} `, }); } diff --git a/packages/weak-node-api/src/index.ts b/packages/weak-node-api/src/index.ts index fbd5337a..62e00b28 100644 --- a/packages/weak-node-api/src/index.ts +++ b/packages/weak-node-api/src/index.ts @@ -1,2 +1 @@ export * from "./weak-node-api.js"; -export * from "./node-api-functions.js"; diff --git a/packages/weak-node-api/tsconfig.node-scripts.json b/packages/weak-node-api/tsconfig.node-scripts.json index 4bc586fa..c6bc24ff 100644 --- a/packages/weak-node-api/tsconfig.node-scripts.json +++ b/packages/weak-node-api/tsconfig.node-scripts.json @@ -9,5 +9,8 @@ }, "include": ["scripts/**/*.ts", "types/**/*.d.ts"], "exclude": [], - "references": [{ "path": "./tsconfig.node.json" }] + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "../node-api-stub/tsconfig.node.json" } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc4d7aeb..258b3295 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: node-api-headers: specifier: ^1.5.0 version: 1.9.0 + node-api-stub: + specifier: workspace:* + version: link:../node-api-stub react-native-node-api: specifier: workspace:* version: link:../host @@ -317,6 +320,9 @@ importers: fswin: specifier: ^3.24.829 version: 3.25.1108 + node-api-stub: + specifier: workspace:* + version: link:../node-api-stub weak-node-api: specifier: workspace:* version: link:../weak-node-api @@ -342,6 +348,9 @@ importers: node-addon-examples: specifier: github:nodejs/node-addon-examples#4b7dd86a85644610e6de80154df9acac9329b509 version: https://codeload.github.com/nodejs/node-addon-examples/tar.gz/4b7dd86a85644610e6de80154df9acac9329b509 + node-api-stub: + specifier: workspace:* + version: link:../node-api-stub read-pkg: specifier: ^9.0.1 version: 9.0.1 @@ -349,6 +358,15 @@ importers: specifier: workspace:* version: link:../weak-node-api + packages/node-api-stub: + dependencies: + node-api-headers: + specifier: ^1.5.0 + version: 1.9.0 + zod: + specifier: ^4.1.11 + version: 4.4.3 + packages/node-tests: devDependencies: assert: @@ -374,17 +392,13 @@ importers: version: link:../weak-node-api packages/weak-node-api: - dependencies: - node-api-headers: - specifier: ^1.5.0 - version: 1.9.0 devDependencies: cmake-rn: specifier: workspace:* version: link:../cmake-rn - zod: - specifier: ^4.1.11 - version: 4.4.3 + node-api-stub: + specifier: workspace:* + version: link:../node-api-stub packages: diff --git a/tsconfig.json b/tsconfig.json index 2d49bdb1..f05034b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ { "path": "./packages/cmake-rn/tsconfig.json" }, { "path": "./packages/ferric/tsconfig.json" }, { "path": "./packages/node-addon-examples/tsconfig.json" }, + { "path": "./packages/node-api-stub/tsconfig.json" }, { "path": "./packages/node-tests/tsconfig.json" }, { "path": "./packages/weak-node-api/tsconfig.json" } ]