3 |
4 | TEST_CASE("inject_weak_node_api_host") {
5 | SECTION("is callable") {
6 | NodeApiHost host{};
7 | inject_weak_node_api_host(host);
8 | }
9 |
10 | SECTION("propagates calls to napi_create_object") {
11 | static bool called = false;
12 | auto my_create_object = [](napi_env env,
13 | napi_value *result) -> napi_status {
14 | called = true;
15 | return napi_status::napi_ok;
16 | };
17 | NodeApiHost host{.napi_create_object = my_create_object};
18 | inject_weak_node_api_host(host);
19 |
20 | napi_value result;
21 | napi_create_object({}, &result);
22 |
23 | REQUIRE(called);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/weak-node-api/src/weak-node-api.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import fs from "node:fs";
3 |
4 | export const weakNodeApiPath = path.resolve(import.meta.dirname, "..");
5 |
6 | const debugOutputPath = path.resolve(weakNodeApiPath, "build", "Debug");
7 | const releaseOutputPath = path.resolve(weakNodeApiPath, "build", "Release");
8 |
9 | export const outputPath = fs.existsSync(debugOutputPath)
10 | ? debugOutputPath
11 | : releaseOutputPath;
12 |
13 | export const applePrebuildPath = path.resolve(
14 | outputPath,
15 | "weak-node-api.xcframework",
16 | );
17 |
18 | export const androidPrebuildPath = path.resolve(
19 | outputPath,
20 | "weak-node-api.android.node",
21 | );
22 |
23 | export const weakNodeApiCmakePath = path.resolve(
24 | weakNodeApiPath,
25 | "weak-node-api-config.cmake",
26 | );
27 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/tests/async/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.15...3.31)
2 | project(async-test)
3 |
4 | find_package(weak-node-api REQUIRED CONFIG)
5 |
6 | add_library(addon SHARED addon.c)
7 |
8 | option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON)
9 |
10 | if(APPLE AND BUILD_APPLE_FRAMEWORK)
11 | set_target_properties(addon PROPERTIES
12 | FRAMEWORK TRUE
13 | MACOSX_FRAMEWORK_IDENTIFIER async-test.addon
14 | MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0
15 | MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0
16 | XCODE_ATTRIBUTE_SKIP_INSTALL NO
17 | )
18 | else()
19 | set_target_properties(addon PROPERTIES
20 | PREFIX ""
21 | SUFFIX .node
22 | )
23 | endif()
24 |
25 | target_link_libraries(addon PRIVATE weak-node-api)
26 | target_compile_features(addon PRIVATE cxx_std_17)
--------------------------------------------------------------------------------
/packages/node-addon-examples/tests/buffers/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.15...3.31)
2 | project(buffers-test)
3 |
4 | find_package(weak-node-api REQUIRED CONFIG)
5 |
6 | add_library(addon SHARED addon.c)
7 |
8 | option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON)
9 |
10 | if(APPLE AND BUILD_APPLE_FRAMEWORK)
11 | set_target_properties(addon PROPERTIES
12 | FRAMEWORK TRUE
13 | MACOSX_FRAMEWORK_IDENTIFIER buffers-test.addon
14 | MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0
15 | MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0
16 | XCODE_ATTRIBUTE_SKIP_INSTALL NO
17 | )
18 | else()
19 | set_target_properties(addon PROPERTIES
20 | PREFIX ""
21 | SUFFIX .node
22 | )
23 | endif()
24 |
25 | target_link_libraries(addon PRIVATE weak-node-api)
26 | target_compile_features(addon PRIVATE cxx_std_17)
--------------------------------------------------------------------------------
/packages/cli-utils/src/errors.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 |
3 | export type Fix =
4 | | {
5 | instructions: string;
6 | command?: never;
7 | }
8 | | {
9 | instructions?: never;
10 | command: string;
11 | };
12 |
13 | export class UsageError extends Error {
14 | public readonly fix?: Fix;
15 |
16 | constructor(
17 | message: string,
18 | { fix, cause }: { cause?: unknown; fix?: Fix } = {},
19 | ) {
20 | super(message, { cause });
21 | this.fix = fix;
22 | }
23 | }
24 |
25 | export function assertFixable(
26 | value: unknown,
27 | message: string,
28 | fix: Fix,
29 | ): asserts value {
30 | try {
31 | assert(value, message);
32 | } catch (error) {
33 | assert(error instanceof Error);
34 | throw new UsageError(message, { fix });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/ferric-example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@react-native-node-api/ferric-example",
3 | "version": "0.1.1",
4 | "private": true,
5 | "type": "commonjs",
6 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
10 | "directory": "packages/ferric-example"
11 | },
12 | "main": "ferric_example.js",
13 | "types": "ferric_example.d.ts",
14 | "files": [
15 | "ferric_example.js",
16 | "ferric_example.d.ts",
17 | "ferric_example.apple.node",
18 | "ferric_example.android.node"
19 | ],
20 | "scripts": {
21 | "build": "ferric build",
22 | "bootstrap": "node --run build"
23 | },
24 | "devDependencies": {
25 | "ferric-cli": "*"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/weak-node-api/tests/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | Include(FetchContent)
2 |
3 | FetchContent_Declare(
4 | Catch2
5 | GIT_REPOSITORY https://github.com/catchorg/Catch2.git
6 | GIT_TAG v3.11.0
7 | )
8 |
9 | FetchContent_MakeAvailable(Catch2)
10 |
11 | add_executable(weak-node-api-tests
12 | test_inject.cpp
13 | )
14 | target_link_libraries(weak-node-api-tests
15 | PRIVATE
16 | weak-node-api
17 | Catch2::Catch2WithMain
18 | )
19 |
20 | target_compile_features(weak-node-api-tests PRIVATE cxx_std_20)
21 | target_compile_definitions(weak-node-api-tests PRIVATE NAPI_VERSION=8)
22 |
23 | # As per https://github.com/catchorg/Catch2/blob/devel/docs/cmake-integration.md#catchcmake-and-catchaddtestscmake
24 | list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
25 | include(CTest)
26 | include(Catch)
27 | catch_discover_tests(weak-node-api-tests)
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node22",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "allowJs": true
6 | },
7 | "files": ["prettier.config.js", "eslint.config.js"],
8 | "references": [
9 | { "path": "./tsconfig.scripts.json" },
10 | { "path": "./packages/cli-utils/tsconfig.json" },
11 | { "path": "./packages/cmake-file-api/tsconfig.json" },
12 | { "path": "./packages/cmake-file-api/tsconfig.tests.json" },
13 | { "path": "./packages/host/tsconfig.json" },
14 | { "path": "./packages/gyp-to-cmake/tsconfig.json" },
15 | { "path": "./packages/cmake-rn/tsconfig.json" },
16 | { "path": "./packages/ferric/tsconfig.json" },
17 | { "path": "./packages/node-addon-examples/tsconfig.json" },
18 | { "path": "./packages/node-tests/tsconfig.json" },
19 | { "path": "./packages/weak-node-api/tsconfig.json" }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/docs/WEAK-NODE-API.md:
--------------------------------------------------------------------------------
1 | # The `weak-node-api` library
2 |
3 | Android's dynamic linker imposes restrictions on the access to global symbols (such as the Node-API free functions): A dynamic library must explicitly declare any dependency bringing symbols it needs as `DT_NEEDED`.
4 |
5 | The implementation of Node-API is split between Hermes and our host package and to avoid addons having to explicitly link against either, we've introduced a `weak-node-api` library (published in `react-native-node-api` package). This library exposes only Node-API and will have its implementation injected by the host.
6 |
7 | While technically not a requirement on non-Android platforms, we choose to make this the general approach across React Native platforms. This keeps things aligned across platforms, while exposing just the Node-API without forcing libraries to build with suppression of errors for undefined symbols.
8 |
--------------------------------------------------------------------------------
/packages/host/src/node/podspec.test.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import { describe, it } from "node:test";
3 | import cp from "node:child_process";
4 |
5 | describe("Podspec", () => {
6 | // We cannot support prebuilds of React Native Core since we're patching JSI
7 | it(
8 | "should error when RCT_USE_PREBUILT_RNCORE is set",
9 | // We cannot call `pod` on non-macOS systems
10 | { skip: process.platform !== "darwin" },
11 | () => {
12 | const { status, stdout } = cp.spawnSync("pod", ["spec", "lint"], {
13 | env: { ...process.env, RCT_USE_PREBUILT_RNCORE: "1" },
14 | encoding: "utf-8",
15 | });
16 |
17 | assert.notEqual(status, 0);
18 | assert.match(
19 | stdout,
20 | /React Native Node-API cannot reliably patch JSI when React Native Core is prebuilt/,
21 | );
22 | },
23 | );
24 | });
25 |
--------------------------------------------------------------------------------
/packages/weak-node-api/scripts/generators/shared.ts:
--------------------------------------------------------------------------------
1 | import type { FunctionDecl } from "../../src/node-api-functions.js";
2 |
3 | type FunctionOptions = FunctionDecl & {
4 | extern?: true;
5 | static?: true;
6 | namespace?: string;
7 | body?: string;
8 | argumentNames?: string[];
9 | };
10 |
11 | export function generateFunction({
12 | extern,
13 | static: staticMember,
14 | returnType,
15 | namespace,
16 | name,
17 | argumentTypes,
18 | argumentNames = [],
19 | noReturn,
20 | body,
21 | }: FunctionOptions) {
22 | return `
23 | ${staticMember ? "static " : ""}${extern ? 'extern "C" ' : ""}${returnType} ${namespace ? namespace + "::" : ""}${name}(
24 | ${argumentTypes.map((type, index) => `${type} ` + (argumentNames[index] ?? `arg${index}`)).join(", ")}
25 | ) ${body ? `{ ${body} ${noReturn ? "WEAK_NODE_API_UNREACHABLE;" : ""}\n}` : ""}
26 | ;
27 | `;
28 | }
29 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/tests/buffers/addon.js:
--------------------------------------------------------------------------------
1 | const assert = require("assert");
2 | const addon = require("bindings")("addon.node");
3 |
4 | const toLocale = (text) => {
5 | return text
6 | .toString()
7 | .split(",")
8 | .map((code) => String.fromCharCode(parseInt(code, 10)))
9 | .join("");
10 | };
11 |
12 | module.exports = () => {
13 | assert.strictEqual(toLocale(addon.newBuffer()), addon.theText);
14 | assert.strictEqual(toLocale(addon.newExternalBuffer()), addon.theText);
15 | assert.strictEqual(toLocale(addon.copyBuffer()), addon.theText);
16 | let buffer = addon.staticBuffer();
17 | assert.strictEqual(addon.bufferHasInstance(buffer), true);
18 | assert.strictEqual(addon.bufferInfo(buffer), true);
19 | addon.invalidObjectAsBuffer({});
20 |
21 | // TODO: Add gc tests
22 | // @see
23 | // https://github.com/callstackincubator/react-native-node-api/issues/182
24 | };
25 |
--------------------------------------------------------------------------------
/packages/host/android/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.13)
2 |
3 | project(react-native-node-api)
4 | set(CMAKE_CXX_STANDARD 20)
5 |
6 | find_package(ReactAndroid REQUIRED CONFIG)
7 | find_package(hermes-engine REQUIRED CONFIG)
8 | find_package(weak-node-api REQUIRED CONFIG)
9 |
10 | add_library(node-api-host SHARED
11 | src/main/cpp/OnLoad.cpp
12 | ../cpp/Logger.cpp
13 | ../cpp/CxxNodeApiHostModule.cpp
14 | ../cpp/WeakNodeApiInjector.cpp
15 | ../cpp/RuntimeNodeApi.cpp
16 | ../cpp/RuntimeNodeApi.hpp
17 | ../cpp/RuntimeNodeApiAsync.cpp
18 | ../cpp/RuntimeNodeApiAsync.hpp
19 | )
20 |
21 | target_include_directories(node-api-host PRIVATE
22 | ../cpp
23 | )
24 |
25 | target_link_libraries(node-api-host
26 | PRIVATE
27 | # android
28 | log
29 | ReactAndroid::reactnative
30 | ReactAndroid::jsi
31 | hermes-engine::libhermes
32 | weak-node-api
33 | # react_codegen_NodeApiHostSpec
34 | )
35 |
--------------------------------------------------------------------------------
/packages/ferric/src/banner.ts:
--------------------------------------------------------------------------------
1 | import { chalk } from "@react-native-node-api/cli-utils";
2 |
3 | const LINES = [
4 | // Pagga on https://www.asciiart.eu/text-to-ascii-art
5 | // Box elements from https://www.compart.com/en/unicode/block/U+2500
6 | "╭─────────────────────────╮",
7 | "│░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│",
8 | "│░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│",
9 | "│░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│",
10 | "╰─────────────────────────╯",
11 | ];
12 |
13 | export function getBlockComment() {
14 | return (
15 | "/**\n" +
16 | ["This file was generated by", ...LINES, "Powered by napi.rs"]
17 | .map((line) => ` * ${line}`)
18 | .join("\n") +
19 | "\n */"
20 | );
21 | }
22 |
23 | export function printBanner() {
24 | console.log(
25 | LINES.map((line, lineNumber, lines) => {
26 | const ratio = lineNumber / lines.length;
27 | return chalk.rgb(Math.round(250 - 100 * ratio), 0, 0)(line);
28 | }).join("\n"),
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/scripts/cmake-projects.mts:
--------------------------------------------------------------------------------
1 | import { readdirSync, statSync } from "node:fs";
2 | import path from "node:path";
3 |
4 | export const EXAMPLES_DIR = path.resolve(import.meta.dirname, "../examples");
5 | export const TESTS_DIR = path.resolve(import.meta.dirname, "../tests");
6 | export const DIRS = [EXAMPLES_DIR, TESTS_DIR];
7 |
8 | export function findCMakeProjectsRecursively(dir: string): string[] {
9 | let results: string[] = [];
10 | const files = readdirSync(dir);
11 |
12 | for (const file of files) {
13 | const fullPath = path.join(dir, file);
14 | if (statSync(fullPath).isDirectory()) {
15 | results = results.concat(findCMakeProjectsRecursively(fullPath));
16 | } else if (file === "CMakeLists.txt") {
17 | results.push(dir);
18 | }
19 | }
20 |
21 | return results;
22 | }
23 |
24 | export function findCMakeProjects(): string[] {
25 | return DIRS.flatMap(findCMakeProjectsRecursively);
26 | }
27 |
--------------------------------------------------------------------------------
/packages/gyp-to-cmake/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gyp-to-cmake",
3 | "version": "0.5.1",
4 | "description": "Convert binding.gyp files to CMakeLists.txt",
5 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
9 | "directory": "packages/gyp-to-cmake"
10 | },
11 | "type": "module",
12 | "files": [
13 | "bin",
14 | "dist"
15 | ],
16 | "bin": {
17 | "gyp-to-cmake": "./bin/gyp-to-cmake.js"
18 | },
19 | "scripts": {
20 | "build": "tsc",
21 | "start": "tsx src/run.ts",
22 | "test": "tsx --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout"
23 | },
24 | "dependencies": {
25 | "@react-native-node-api/cli-utils": "0.1.2",
26 | "gyp-parser": "^1.0.4",
27 | "pkg-dir": "^8.0.0",
28 | "read-pkg": "^9.0.1"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/gyp-to-cmake/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # gyp-to-cmake
2 |
3 | ## 0.5.1
4 |
5 | ### Patch Changes
6 |
7 | - Updated dependencies [7ff2c2b]
8 | - @react-native-node-api/cli-utils@0.1.2
9 |
10 | ## 0.5.0
11 |
12 | ### Minor Changes
13 |
14 | - 60fae96: Use `find_package` instead of `include` to locate "weak-node-api"
15 |
16 | ## 0.4.0
17 |
18 | ### Minor Changes
19 |
20 | - 5156d35: Use of CMake targets producing Apple frameworks instead of free dylibs is now supported
21 |
22 | ### Patch Changes
23 |
24 | - 5156d35: Refactored moving prettyPath util to CLI utils package
25 | - Updated dependencies [5156d35]
26 | - @react-native-node-api/cli-utils@0.1.1
27 |
28 | ## 0.3.0
29 |
30 | ### Minor Changes
31 |
32 | - ff34c45: Add --weak-node-api option to emit CMake configuration for use with cmake-rn's default way of Node-API linkage.
33 |
34 | ### Patch Changes
35 |
36 | - 2a30d8d: Refactored CLIs to use a shared utility package
37 |
38 | ## 0.2.0
39 |
40 | ### Minor Changes
41 |
42 | - 4379d8c: Initial release
43 |
--------------------------------------------------------------------------------
/packages/cmake-rn/src/platforms.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 |
3 | import { platform as android } from "./platforms/android.js";
4 | import { platform as apple } from "./platforms/apple.js";
5 | import { Platform } from "./platforms/types.js";
6 |
7 | export const platforms: Platform[] = [android, apple] as const;
8 | export const allTriplets = [...android.triplets, ...apple.triplets] as const;
9 |
10 | export function platformHasTriplet(
11 | platform: P,
12 | triplet: unknown,
13 | ): triplet is P["triplets"][number] {
14 | return (platform.triplets as unknown[]).includes(triplet);
15 | }
16 |
17 | export function findPlatformForTriplet(triplet: unknown) {
18 | const platform = Object.values(platforms).find((platform) =>
19 | platformHasTriplet(platform, triplet),
20 | );
21 | assert(
22 | platform,
23 | `Unable to determine platform from triplet: ${
24 | typeof triplet === "string" ? triplet : JSON.stringify(triplet)
25 | }`,
26 | );
27 | return platform;
28 | }
29 |
--------------------------------------------------------------------------------
/packages/cmake-file-api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cmake-file-api",
3 | "version": "0.1.1",
4 | "type": "module",
5 | "description": "TypeScript wrapper around the CMake File API",
6 | "homepage": "https://cmake.org/cmake/help/latest/manual/cmake-file-api.7.html",
7 | "scripts": {
8 | "build": "tsc --build",
9 | "lint": "eslint 'src/**/*.ts'",
10 | "test": "tsx --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout"
11 | },
12 | "files": [
13 | "dist/",
14 | "!*.test.d.ts",
15 | "!*.test.d.ts.map"
16 | ],
17 | "exports": {
18 | ".": "./dist/index.js"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
23 | "directory": "packages/cmake-file-api"
24 | },
25 | "author": {
26 | "name": "Kræn Hansen",
27 | "url": "https://github.com/kraenhansen"
28 | },
29 | "dependencies": {
30 | "zod": "^4.1.11"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/host/src/node/cli/options.ts:
--------------------------------------------------------------------------------
1 | import { Option } from "@react-native-node-api/cli-utils";
2 |
3 | import {
4 | assertLibraryNamingChoice,
5 | LIBRARY_NAMING_CHOICES,
6 | } from "../path-utils";
7 |
8 | const { NODE_API_PACKAGE_NAME, NODE_API_PATH_SUFFIX } = process.env;
9 | if (typeof NODE_API_PACKAGE_NAME === "string") {
10 | assertLibraryNamingChoice(NODE_API_PACKAGE_NAME);
11 | }
12 | if (typeof NODE_API_PATH_SUFFIX === "string") {
13 | assertLibraryNamingChoice(NODE_API_PATH_SUFFIX);
14 | }
15 |
16 | export const packageNameOption = new Option(
17 | "--package-name ",
18 | "Controls how the package name is transformed into a library name",
19 | )
20 | .choices(LIBRARY_NAMING_CHOICES)
21 | .default(NODE_API_PACKAGE_NAME || "strip");
22 |
23 | export const pathSuffixOption = new Option(
24 | "--path-suffix ",
25 | "Controls how the path of the addon inside a package is transformed into a library name",
26 | )
27 | .choices(LIBRARY_NAMING_CHOICES)
28 | .default(NODE_API_PATH_SUFFIX || "strip");
29 |
--------------------------------------------------------------------------------
/packages/host/android/src/main/java/com/callstack/react_native_node_api/NodeApiHostPackage.kt:
--------------------------------------------------------------------------------
1 | package com.callstack.react_native_node_api
2 |
3 | import com.facebook.hermes.reactexecutor.HermesExecutor
4 | import com.facebook.react.BaseReactPackage
5 | import com.facebook.react.bridge.NativeModule
6 | import com.facebook.react.bridge.ReactApplicationContext
7 | import com.facebook.react.module.model.ReactModuleInfo
8 | import com.facebook.react.module.model.ReactModuleInfoProvider
9 | import com.facebook.soloader.SoLoader
10 |
11 | import java.util.HashMap
12 |
13 | class NodeApiHostPackage : BaseReactPackage() {
14 | init {
15 | SoLoader.loadLibrary("node-api-host")
16 | }
17 |
18 | override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
19 | return null
20 | }
21 |
22 | override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
23 | return ReactModuleInfoProvider {
24 | val moduleInfos: MutableMap = HashMap()
25 | moduleInfos
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/scripts/run-in-published.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import cp from "node:child_process";
3 |
4 | console.log("Run command in all non-private packages of the monorepo");
5 |
6 | function getWorkspaces() {
7 | const workspaces = JSON.parse(
8 | cp.execFileSync("npm", ["query", ".workspace"], { encoding: "utf8" }),
9 | ) as unknown;
10 | assert(Array.isArray(workspaces));
11 | for (const workspace of workspaces) {
12 | assert(typeof workspace === "object" && workspace !== null);
13 | }
14 | return workspaces as Record[];
15 | }
16 |
17 | const publishedPackagePaths = getWorkspaces()
18 | .filter((w) => !w.private)
19 | .map((p) => {
20 | assert(typeof p.path === "string");
21 | return p.path;
22 | });
23 |
24 | const [, , command, ...argv] = process.argv;
25 |
26 | for (const packagePath of publishedPackagePaths) {
27 | const { status } = cp.spawnSync(command, argv, {
28 | cwd: packagePath,
29 | stdio: "inherit",
30 | });
31 | assert.equal(status, 0, `Command failed (status = ${status})`);
32 | }
33 |
--------------------------------------------------------------------------------
/packages/weak-node-api/scripts/generators/NodeApiHost.ts:
--------------------------------------------------------------------------------
1 | import type { FunctionDecl } from "../../src/node-api-functions.js";
2 |
3 | export function generateFunctionDecl({
4 | returnType,
5 | name,
6 | argumentTypes,
7 | }: FunctionDecl) {
8 | return `${returnType} (*${name})(${argumentTypes.join(", ")});`;
9 | }
10 |
11 | export function generateHeader(functions: FunctionDecl[]) {
12 | return `
13 | #pragma once
14 |
15 | #include
16 |
17 | // Ideally we would have just used NAPI_NO_RETURN, but
18 | // __declspec(noreturn) (when building with Microsoft Visual C++) cannot be used on members of a struct
19 | // TODO: If we targeted C++23 we could use std::unreachable()
20 |
21 | #if defined(__GNUC__)
22 | #define WEAK_NODE_API_UNREACHABLE __builtin_unreachable()
23 | #else
24 | #define WEAK_NODE_API_UNREACHABLE __assume(0)
25 | #endif
26 |
27 | // Generate the struct of function pointers
28 | struct NodeApiHost {
29 | ${functions.map(generateFunctionDecl).join("\n")}
30 | };
31 | `;
32 | }
33 |
--------------------------------------------------------------------------------
/packages/host/cpp/RuntimeNodeApiAsync.hpp:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include "node_api.h"
4 | #include
5 | #include
6 |
7 | namespace callstack::react_native_node_api {
8 | void setCallInvoker(
9 | napi_env env, const std::shared_ptr &invoker);
10 |
11 | napi_status napi_create_async_work(napi_env env, napi_value async_resource,
12 | napi_value async_resource_name,
13 | napi_async_execute_callback execute,
14 | napi_async_complete_callback complete,
15 | void *data, napi_async_work *result);
16 |
17 | napi_status napi_queue_async_work(node_api_basic_env env, napi_async_work work);
18 |
19 | napi_status napi_delete_async_work(node_api_basic_env env,
20 | napi_async_work work);
21 |
22 | napi_status napi_cancel_async_work(node_api_basic_env env,
23 | napi_async_work work);
24 | } // namespace callstack::react_native_node_api
25 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/README.md:
--------------------------------------------------------------------------------
1 | # Node Addon Examples (`@react-native-node-api/node-addon-examples`)
2 |
3 | We're using the [nodejs/node-addon-examples](https://github.com/nodejs/node-addon-examples) repository from the Node.js project as tests for our Node-API implementation and this package is a wrapper around those, using `gyp-to-cmake` and `cmake-rn` to prepare prebuilds and scaffolding for loading the addons.
4 |
5 | The main purpose is to use these as tests to verify the implementation: We choose to use this as our first signal for compliance, over the [js-native-api](https://github.com/nodejs/node/tree/main/test/js-native-api) tests in the Node.js project, because the examples depends much less on Node.js built-in runtime APIs. A drawback is that these examples were not built as tests with assertions, but examples using console logging to signal functionality and we work around this limitation by wrapping the loading of the example JS code with a console.log stub implementation which buffer and asserts messages printed by the addon.
6 |
7 | This package is imported by our [test app](../../apps/test-app).
8 |
--------------------------------------------------------------------------------
/packages/cmake-rn/src/platforms.test.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import { describe, it } from "node:test";
3 |
4 | import {
5 | platforms,
6 | platformHasTriplet,
7 | findPlatformForTriplet,
8 | } from "./platforms.js";
9 | import { Platform } from "./platforms/types.js";
10 |
11 | const mockPlatform = {
12 | triplets: ["triplet1", "triplet2"],
13 | } as unknown as Platform;
14 |
15 | describe("platformHasTriplet", () => {
16 | it("returns true when platform has triplet", () => {
17 | assert.equal(platformHasTriplet(mockPlatform, "triplet1"), true);
18 | });
19 |
20 | it("returns false when platform doesn't have triplet", () => {
21 | assert.equal(platformHasTriplet(mockPlatform, "triplet3"), false);
22 | });
23 | });
24 |
25 | describe("findPlatformForTriplet", () => {
26 | it("returns platform when triplet is found", () => {
27 | assert(platforms.length >= 2, "Expects at least two platforms");
28 | const [platform1, platform2] = platforms;
29 | const platform = findPlatformForTriplet(platform1.triplets[0]);
30 | assert.equal(platform, platform1);
31 | assert.notEqual(platform, platform2);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/packages/host/src/node/test-utils.ts:
--------------------------------------------------------------------------------
1 | import { TestContext } from "node:test";
2 | import os from "node:os";
3 | import fs from "node:fs";
4 | import path from "node:path";
5 |
6 | export interface FileMap {
7 | [key: string]: string | FileMap;
8 | }
9 |
10 | function writeFiles(fromPath: string, files: FileMap) {
11 | for (const [filePath, content] of Object.entries(files)) {
12 | const fullPath = path.join(fromPath, filePath);
13 | fs.mkdirSync(path.dirname(fullPath), { recursive: true });
14 | if (typeof content === "string") {
15 | fs.writeFileSync(fullPath, content, "utf8");
16 | } else {
17 | writeFiles(fullPath, content);
18 | }
19 | }
20 | }
21 |
22 | export function setupTempDirectory(context: TestContext, files: FileMap) {
23 | const tempDirectoryPath = fs.realpathSync(
24 | fs.mkdtempSync(path.join(os.tmpdir(), "react-native-node-api-test-")),
25 | );
26 |
27 | context.after(() => {
28 | if (!process.env.KEEP_TEMP_DIRS) {
29 | fs.rmSync(tempDirectoryPath, { recursive: true, force: true });
30 | }
31 | });
32 |
33 | writeFiles(tempDirectoryPath, files);
34 |
35 | return tempDirectoryPath;
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025-present, Callstack and React Native Node API contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/cmake-rn/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cmake-rn",
3 | "version": "0.6.1",
4 | "description": "Build React Native Node API modules with CMake",
5 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
9 | "directory": "packages/cmake-rn"
10 | },
11 | "type": "module",
12 | "bin": {
13 | "cmake-rn": "./bin/cmake-rn.js"
14 | },
15 | "files": [
16 | "bin",
17 | "dist",
18 | "!dist/**/*.test.d.ts",
19 | "!dist/**/*.test.d.ts.map"
20 | ],
21 | "scripts": {
22 | "build": "tsc",
23 | "start": "tsx src/run.ts",
24 | "test": "tsx --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout"
25 | },
26 | "dependencies": {
27 | "@react-native-node-api/cli-utils": "0.1.2",
28 | "cmake-file-api": "0.1.1",
29 | "react-native-node-api": "0.7.1",
30 | "zod": "^4.1.11",
31 | "weak-node-api": "0.0.3"
32 | },
33 | "peerDependencies": {
34 | "node-addon-api": "^8.3.1",
35 | "node-api-headers": "^1.5.0"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/cmake-file-api/src/schemas/objects/ToolchainsV1.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | const ToolchainCompilerImplicit = z.object({
4 | includeDirectories: z.array(z.string()).optional(),
5 | linkDirectories: z.array(z.string()).optional(),
6 | linkFrameworkDirectories: z.array(z.string()).optional(),
7 | linkLibraries: z.array(z.string()).optional(),
8 | });
9 |
10 | const ToolchainCompiler = z.object({
11 | path: z.string().optional(),
12 | id: z.string().optional(),
13 | version: z.string().optional(),
14 | target: z.string().optional(),
15 | implicit: ToolchainCompilerImplicit,
16 | });
17 |
18 | const Toolchain = z.object({
19 | language: z.string(),
20 | compiler: ToolchainCompiler,
21 | sourceFileExtensions: z.array(z.string()).optional(),
22 | });
23 |
24 | export const ToolchainsV1_0 = z.object({
25 | kind: z.literal("toolchains"),
26 | version: z.object({
27 | major: z.literal(1),
28 | minor: z.number().int().nonnegative(),
29 | }),
30 | toolchains: z.array(Toolchain),
31 | });
32 |
33 | export const ToolchainsV1 = z.union([ToolchainsV1_0]);
34 |
35 | export const toolchainsSchemaPerVersion = {
36 | "1.0": ToolchainsV1_0,
37 | } as const satisfies Record;
38 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/tests/async/addon.js:
--------------------------------------------------------------------------------
1 | const assert = require("assert");
2 | const test_async = require("bindings")("addon.node");
3 |
4 | const test = () =>
5 | new Promise((resolve, reject) => {
6 | test_async.Test(5, {}, (err, val) => {
7 | if (err) {
8 | reject(err);
9 | return;
10 | }
11 | try {
12 | assert.strictEqual(err, null);
13 | assert.strictEqual(val, 10);
14 | } catch (e) {
15 | reject(e);
16 | }
17 | resolve();
18 | });
19 | });
20 |
21 | const testCancel = () =>
22 | new Promise((resolve) => {
23 | test_async.TestCancel(() => resolve());
24 | });
25 |
26 | const doRepeatedWork = (count = 0) =>
27 | new Promise((resolve, reject) => {
28 | const iterations = 100;
29 | const workDone = (status) => {
30 | try {
31 | assert.strictEqual(status, 0);
32 | } catch (e) {
33 | reject(e);
34 | }
35 | if (++count < iterations) {
36 | test_async.DoRepeatedWork(workDone);
37 | } else {
38 | resolve();
39 | }
40 | };
41 | test_async.DoRepeatedWork(workDone);
42 | });
43 |
44 | module.exports = () => {
45 | return Promise.all([test(), testCancel(), doRepeatedWork()]);
46 | };
47 |
--------------------------------------------------------------------------------
/packages/gyp-to-cmake/src/gyp.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from "node:test";
2 | import assert from "node:assert/strict";
3 |
4 | import { assertBinding } from "./gyp.js";
5 |
6 | describe("gyp.assertRoot", () => {
7 | it("should throw if input is malformed", () => {
8 | assert.throws(() => {
9 | assertBinding("not an object");
10 | }, /Expected an object/);
11 |
12 | assert.throws(() => {
13 | assertBinding({});
14 | }, /Expected a 'targets' property/);
15 |
16 | assert.throws(() => {
17 | assertBinding({ targets: "not an array" });
18 | }, /Expected a 'targets' array/);
19 | });
20 |
21 | it("should throw if input has extra properties", () => {
22 | assert.throws(() => {
23 | assertBinding({ targets: [], extra: "not allowed" }, true);
24 | }, /Unexpected property: extra/);
25 |
26 | assert.throws(() => {
27 | assertBinding(
28 | {
29 | targets: [{ target_name: "", sources: [], extra: "not allowed" }],
30 | },
31 | true,
32 | );
33 | }, /Unexpected property: extra/);
34 | });
35 |
36 | it("should parse a file with no targets", () => {
37 | const input: unknown = { targets: [] };
38 | assertBinding(input);
39 | assert(Array.isArray(input.targets));
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/packages/node-tests/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@react-native-node-api/node-tests",
3 | "version": "0.1.0",
4 | "description": "Harness for running the Node.js tests from https://github.com/nodejs/node/tree/main/test",
5 | "type": "commonjs",
6 | "main": "tests.generated.js",
7 | "files": [
8 | "dist",
9 | "tests/**/*.js",
10 | "**/*.apple.node/**",
11 | "**/*.android.node/**"
12 | ],
13 | "private": true,
14 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
18 | "directory": "packages/node-tests"
19 | },
20 | "scripts": {
21 | "copy-tests": "tsx scripts/copy-tests.mts",
22 | "gyp-to-cmake": "gyp-to-cmake ./tests",
23 | "build-tests": "tsx scripts/build-tests.mts",
24 | "bundle": "rolldown -c rolldown.config.mts",
25 | "generate-entrypoint": "tsx scripts/generate-entrypoint.mts",
26 | "bootstrap": "node --run copy-tests && node --run gyp-to-cmake && node --run build-tests && node --run bundle && node --run generate-entrypoint"
27 | },
28 | "devDependencies": {
29 | "cmake-rn": "*",
30 | "gyp-to-cmake": "*",
31 | "react-native-node-api": "^0.7.0",
32 | "read-pkg": "^9.0.1",
33 | "rolldown": "1.0.0-beta.29"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | env:
4 | # Version here should match the one in React Native template and packages/cmake-rn/src/cli.ts
5 | NDK_VERSION: 27.1.12297006
6 |
7 | on:
8 | push:
9 | branches:
10 | - main
11 |
12 | concurrency: ${{ github.workflow }}-${{ github.ref }}
13 |
14 | jobs:
15 | release:
16 | name: Release
17 | runs-on: macos-latest
18 | steps:
19 | - uses: actions/checkout@v4
20 | - uses: actions/setup-node@v4
21 | with:
22 | node-version: lts/jod
23 | - name: Set up JDK 17
24 | uses: actions/setup-java@v3
25 | with:
26 | java-version: "17"
27 | distribution: "temurin"
28 | - name: Setup Android SDK
29 | uses: android-actions/setup-android@v3
30 | with:
31 | packages: tools platform-tools ndk;${{ env.NDK_VERSION }}
32 | - run: rustup target add x86_64-linux-android aarch64-linux-android armv7-linux-androideabi i686-linux-android aarch64-apple-ios-sim
33 | - run: npm install
34 |
35 | - name: Create Release Pull Request or Publish to npm
36 | id: changesets
37 | uses: changesets/action@v1
38 | with:
39 | publish: npm run release
40 | env:
41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
43 |
--------------------------------------------------------------------------------
/packages/cmake-file-api/src/schemas/objects/CmakeFilesV1.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | const CmakeFilesInput = z.object({
4 | path: z.string(),
5 | isGenerated: z.boolean().optional(),
6 | isExternal: z.boolean().optional(),
7 | isCMake: z.boolean().optional(),
8 | });
9 |
10 | const CmakeFilesGlobDependent = z.object({
11 | expression: z.string(),
12 | recurse: z.boolean().optional(),
13 | listDirectories: z.boolean().optional(),
14 | followSymlinks: z.boolean().optional(),
15 | relative: z.string().optional(),
16 | paths: z.array(z.string()),
17 | });
18 |
19 | export const CmakeFilesV1_0 = z.object({
20 | kind: z.literal("cmakeFiles"),
21 | version: z.object({
22 | major: z.literal(1),
23 | minor: z.number().max(0),
24 | }),
25 | paths: z.object({
26 | source: z.string(),
27 | build: z.string(),
28 | }),
29 | inputs: z.array(CmakeFilesInput),
30 | });
31 |
32 | export const CmakeFilesV1_1 = CmakeFilesV1_0.extend({
33 | version: z.object({
34 | major: z.literal(1),
35 | minor: z.number().min(1),
36 | }),
37 | globsDependent: z.array(CmakeFilesGlobDependent).optional(),
38 | });
39 |
40 | export const CmakeFilesV1 = z.union([CmakeFilesV1_0, CmakeFilesV1_1]);
41 |
42 | export const cmakeFilesSchemaPerVersion = {
43 | "1.0": CmakeFilesV1_0,
44 | "1.1": CmakeFilesV1_1,
45 | } as const satisfies Record;
46 |
--------------------------------------------------------------------------------
/packages/weak-node-api/weak-node-api.podspec:
--------------------------------------------------------------------------------
1 | require "json"
2 |
3 | package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4 |
5 | # We need to restore symlinks in the versioned framework directories,
6 | # as these are not preserved when in the archive uploaded to NPM
7 | unless defined?(@restored)
8 | RESTORE_COMMAND = "node '#{File.join(__dir__, "dist/restore-xcframework-symlinks.js")}'"
9 | Pod::UI.info("[weak-node-api] ".green + "Restoring symbolic links in Xcframework")
10 | system(RESTORE_COMMAND) or raise "Failed to restore symlinks in Xcframework"
11 | # Setting a flag to avoid running this command on every require
12 | @restored = true
13 | end
14 |
15 | Pod::Spec.new do |s|
16 | s.name = package["name"]
17 | s.version = package["version"]
18 | s.summary = package["description"]
19 | s.homepage = package["homepage"]
20 | s.license = package["license"]
21 | s.authors = package["author"]
22 |
23 | s.source = { :git => "https://github.com/callstackincubator/react-native-node-api.git", :tag => "#{s.version}" }
24 |
25 | s.source_files = "generated/*.hpp", "include/*.h"
26 | s.public_header_files = "generated/*.hpp", "include/*.h"
27 | s.vendored_frameworks = "build/*/weak-node-api.xcframework"
28 |
29 | # Avoiding the header dir to allow for idiomatic Node-API includes
30 | s.header_dir = nil
31 | end
--------------------------------------------------------------------------------
/apps/test-app/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # react-native-node-api-test-app
2 |
3 | ## 0.2.1
4 |
5 | ### Patch Changes
6 |
7 | - 7ff2c2b: Fix minor package issues.
8 | - Updated dependencies [7ff2c2b]
9 | - Updated dependencies [7ff2c2b]
10 | - weak-node-api@0.0.3
11 | - react-native-node-api@0.7.1
12 |
13 | ## 0.2.0
14 |
15 | ### Minor Changes
16 |
17 | - a0212c8: Add explicit support for React Native 0.81.1 (0.79.6, 0.80.0, 0.80.1, 0.80.2 & 0.81.0)
18 |
19 | ### Patch Changes
20 |
21 | - a0212c8: Renamed ferric-example in test app to match mono-repo packages
22 | - Updated dependencies [a0212c8]
23 | - Updated dependencies [a0212c8]
24 | - react-native-node-api@0.4.0
25 | - @react-native-node-api/node-tests@undefined
26 | - @react-native-node-api/node-addon-examples@undefined
27 |
28 | ## 0.1.2
29 |
30 | ### Patch Changes
31 |
32 | - dc33f3c: Added implementation of async work runtime functions
33 | - Updated dependencies [a477b84]
34 | - Updated dependencies [dc33f3c]
35 | - Updated dependencies [4924f66]
36 | - Updated dependencies [acf1a7c]
37 | - react-native-node-api@0.3.3
38 | - @react-native-node-api/node-tests@undefined
39 | - @react-native-node-api/node-addon-examples@undefined
40 |
41 | ## 0.1.1
42 |
43 | ### Patch Changes
44 |
45 | - 7ad62f7: Adding support for React Native 0.79.3, 0.79.4 & 0.79.5
46 | - Updated dependencies [7ad62f7]
47 | - react-native-node-api@0.3.1
48 |
--------------------------------------------------------------------------------
/apps/test-app/android/build.gradle:
--------------------------------------------------------------------------------
1 | buildscript {
2 | apply(from: {
3 | def searchDir = rootDir.toPath()
4 | do {
5 | def p = searchDir.resolve("node_modules/react-native-test-app/android/dependencies.gradle")
6 | if (p.toFile().exists()) {
7 | return p.toRealPath().toString()
8 | }
9 | } while (searchDir = searchDir.getParent())
10 | throw new GradleException("Could not find `react-native-test-app`");
11 | }())
12 |
13 | repositories {
14 | mavenCentral()
15 | google()
16 | }
17 |
18 | dependencies {
19 | getReactNativeDependencies().each { dependency ->
20 | classpath(dependency)
21 | }
22 | }
23 | }
24 |
25 | allprojects {
26 | repositories {
27 | {
28 | def searchDir = rootDir.toPath()
29 | do {
30 | def p = searchDir.resolve("node_modules/react-native/android")
31 | if (p.toFile().exists()) {
32 | maven {
33 | url(p.toRealPath().toString())
34 | }
35 | break
36 | }
37 | } while (searchDir = searchDir.getParent())
38 | // As of 0.80, React Native is no longer installed from npm
39 | }()
40 | mavenCentral()
41 | google()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@react-native-node-api/node-addon-examples",
3 | "version": "0.1.0",
4 | "type": "commonjs",
5 | "main": "dist/index.js",
6 | "files": [
7 | "dist",
8 | "examples/**/package.json",
9 | "examples/**/*.js",
10 | "tests/**/package.json",
11 | "tests/**/*.js",
12 | "**/*.apple.node/**",
13 | "**/*.android.node/**"
14 | ],
15 | "private": true,
16 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
20 | "directory": "packages/node-addon-examples"
21 | },
22 | "scripts": {
23 | "copy-examples": "tsx scripts/copy-examples.mts",
24 | "gyp-to-cmake": "gyp-to-cmake --weak-node-api .",
25 | "build": "tsx scripts/build-examples.mts",
26 | "copy-and-build": "node --run copy-examples && node --run gyp-to-cmake && node --run build",
27 | "verify": "tsx scripts/verify-prebuilds.mts",
28 | "test": "node --run copy-and-build && node --run verify",
29 | "bootstrap": "node --run copy-and-build"
30 | },
31 | "devDependencies": {
32 | "cmake-rn": "*",
33 | "node-addon-examples": "github:nodejs/node-addon-examples#4213d4c9d07996ae68629c67926251e117f8e52a",
34 | "gyp-to-cmake": "*",
35 | "read-pkg": "^9.0.1"
36 | },
37 | "dependencies": {
38 | "assert": "^2.1.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/host/cpp/RuntimeNodeApi.hpp:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include "node_api.h"
4 |
5 | namespace callstack::react_native_node_api {
6 | napi_status napi_create_buffer(napi_env env, size_t length, void **data,
7 | napi_value *result);
8 |
9 | napi_status napi_create_buffer_copy(napi_env env, size_t length,
10 | const void *data, void **result_data,
11 | napi_value *result);
12 |
13 | napi_status napi_is_buffer(napi_env env, napi_value value, bool *result);
14 |
15 | napi_status napi_get_buffer_info(napi_env env, napi_value value, void **data,
16 | size_t *length);
17 |
18 | napi_status
19 | napi_create_external_buffer(napi_env env, size_t length, void *data,
20 | node_api_basic_finalize basic_finalize_cb,
21 | void *finalize_hint, napi_value *result);
22 |
23 | void __attribute__((noreturn)) napi_fatal_error(const char *location,
24 | size_t location_len,
25 | const char *message,
26 | size_t message_len);
27 |
28 | napi_status napi_get_node_version(node_api_basic_env env,
29 | const napi_node_version **result);
30 |
31 | napi_status napi_get_version(node_api_basic_env env, uint32_t *result);
32 | } // namespace callstack::react_native_node_api
33 |
--------------------------------------------------------------------------------
/packages/weak-node-api/README.md:
--------------------------------------------------------------------------------
1 | # Weak Node-API
2 |
3 | A clean linkable interface for Node-API and with runtime-injectable implementation.
4 |
5 | This package is part of the [Node-API for React Native](https://github.com/callstackincubator/react-native-node-api) project, which brings Node-API support to React Native applications. However, it can be used independently in any context where an indirect / weak Node-API implementation is needed.
6 |
7 | ## Why is this needed?
8 |
9 | Android's dynamic linker restricts access to global symbols—dynamic libraries must explicitly declare dependencies as `DT_NEEDED` to access symbols. In the context of React Native, the Node-API implementation is split between Hermes and a host runtime, native addons built for Android would otherwise need to explicitly link against both - which is not ideal for multiple reasons.
10 |
11 | This library provides a solution by:
12 |
13 | - Exposing only Node-API functions without implementation
14 | - Allowing runtime injection of the actual implementation by the host
15 | - Eliminating the need for addons to suppress undefined symbol errors
16 |
17 | ## Is this usable in the context of Node.js?
18 |
19 | While originally designed for React Native's split Node-API implementation, this approach could potentially be adapted for Node.js scenarios where addons need to link with undefined symbols allowed. Usage patterns and examples for Node.js contexts are being explored and this pattern could eventually be upstreamed to Node.js itself, benefiting the broader Node-API ecosystem.
20 |
--------------------------------------------------------------------------------
/packages/host/cpp/CxxNodeApiHostModule.hpp:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 | #include
5 | #include
6 |
7 | #include "AddonLoaders.hpp"
8 |
9 | namespace callstack::react_native_node_api {
10 |
11 | class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule {
12 | public:
13 | static constexpr const char *kModuleName = "NodeApiHost";
14 |
15 | CxxNodeApiHostModule(std::shared_ptr jsInvoker);
16 |
17 | static facebook::jsi::Value
18 | requireNodeAddon(facebook::jsi::Runtime &rt,
19 | facebook::react::TurboModule &turboModule,
20 | const facebook::jsi::Value args[], size_t count);
21 | facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt,
22 | const facebook::jsi::String path);
23 |
24 | protected:
25 | struct NodeAddon {
26 | void *moduleHandle;
27 | napi_addon_register_func init;
28 | std::string generatedName;
29 | };
30 | std::unordered_map nodeAddons_;
31 | std::shared_ptr callInvoker_;
32 |
33 | using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround
34 | // for my lazyness (work on iOS and Android)
35 |
36 | bool loadNodeAddon(NodeAddon &addon, const std::string &path) const;
37 | bool initializeNodeModule(facebook::jsi::Runtime &rt, NodeAddon &addon);
38 | };
39 |
40 | } // namespace callstack::react_native_node_api
41 |
--------------------------------------------------------------------------------
/packages/cmake-rn/src/headers.ts:
--------------------------------------------------------------------------------
1 | import { createRequire } from "node:module";
2 | import path from "node:path";
3 | import fs from "node:fs";
4 | import assert from "node:assert/strict";
5 |
6 | const require = createRequire(import.meta.url);
7 |
8 | /**
9 | * @returns path of the directory containing the headers which provide the Node-API C API (node_api.h and js_native_api.h)
10 | */
11 | export function getNodeApiHeadersPath(): string {
12 | try {
13 | const packagePath = path.dirname(
14 | require.resolve("node-api-headers/package.json"),
15 | );
16 | const result = path.join(packagePath, "include");
17 | const stat = fs.statSync(packagePath);
18 | assert(stat.isDirectory(), `Expected ${packagePath} to be a directory`);
19 | return result;
20 | } catch (error) {
21 | throw new Error(
22 | `Failed resolve Node-API headers: Did you install the 'node-api-headers' package?`,
23 | {
24 | cause: error,
25 | },
26 | );
27 | }
28 | }
29 |
30 | /**
31 | * @returns path of the directory containing the headers which provide the Node-API C++ wrapper (napi.h)
32 | */
33 | export function getNodeAddonHeadersPath(): string {
34 | try {
35 | const packagePath = path.dirname(
36 | require.resolve("node-addon-api/package.json"),
37 | );
38 | return packagePath;
39 | } catch (error) {
40 | throw new Error(
41 | `Failed resolve Node-API addon headers: Did you install the 'node-addon-api' package?`,
42 | {
43 | cause: error,
44 | },
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/host/scripts/patch-hermes.rb:
--------------------------------------------------------------------------------
1 | Pod::UI.warn "!!! PATCHING HERMES WITH NODE-API SUPPORT !!!"
2 |
3 | if ENV['RCT_USE_PREBUILT_RNCORE'] == '1'
4 | raise "React Native Node-API cannot reliably patch JSI when React Native Core is prebuilt."
5 | end
6 |
7 | def get_react_native_package
8 | if caller.any? { |frame| frame.include?("node_modules/react-native-macos/") }
9 | return "react-native-macos"
10 | elsif caller.any? { |frame| frame.include?("node_modules/react-native/") }
11 | return "react-native"
12 | else
13 | raise "Unable to determine React Native package from call stack."
14 | end
15 | end
16 |
17 | if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].nil?
18 | VENDORED_HERMES_DIR ||= `npx react-native-node-api vendor-hermes --react-native-package '#{get_react_native_package()}' --silent '#{Pod::Config.instance.installation_root}'`.strip
19 | # Signal the patched Hermes to React Native
20 | ENV['BUILD_FROM_SOURCE'] = 'true'
21 | ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] = VENDORED_HERMES_DIR
22 | elsif Dir.exist?(ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'])
23 | # Setting an override path implies building from source
24 | ENV['BUILD_FROM_SOURCE'] = 'true'
25 | end
26 |
27 | if !ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].empty?
28 | if Dir.exist?(ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'])
29 | Pod::UI.info "[Node-API] Using overridden Hermes in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}"
30 | else
31 | raise "Hermes patching failed: Expected override to exist in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}"
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/docs/AUTO-LINKING.md:
--------------------------------------------------------------------------------
1 | # Auto-linking
2 |
3 | The `react-native-node-api` package (sometimes referred to as "the host package") has mechanisms to automatically find and link prebuilt binaries with Node-API modules.
4 |
5 | When auto-linking, prebuilt binaries are copied (sometimes referred to as vendored) from dependencies of the app into the host package. As they're copied, they get renamed to avoid conflicts in naming as the library files across multiple dependency packages will be sharing a namespace when building the app.
6 |
7 | ## Naming scheme of libraries when linked into the host
8 |
9 | The name of the library when linked / copied into the host is based on two things:
10 |
11 | - The package name of the encapsulating package: The directory tree is walked from the original library path to the nearest `package.json` (this is the Node-API module's package root).
12 | - The relative path of the library to the package root:
13 | - Normalized (any "lib" prefix or file extension is stripped from the filename).
14 | - Escaped (any non-alphanumeric character is replaced with "-").
15 |
16 | ## How do I link Node-API module libraries into my app?
17 |
18 | Linking will run when you `pod install` and as part of building your app with Gradle as long as your app has a dependency on the `react-native-node-api` package.
19 |
20 | You can also manually link by running the following in your app directory:
21 |
22 | ```bash
23 | npx react-native-node-api link --android --apple
24 | ```
25 |
26 | > [!NOTE]
27 | > Because vendored frameworks must be present when running `pod install`, you have to run `pod install` if you add or remove a dependency with a Node-API module (or after creation if you're doing active development on it).
28 |
--------------------------------------------------------------------------------
/packages/cli-utils/src/actions.ts:
--------------------------------------------------------------------------------
1 | import { SpawnFailure } from "bufout";
2 | import chalk from "chalk";
3 | import * as commander from "@commander-js/extra-typings";
4 |
5 | import { UsageError } from "./errors.js";
6 |
7 | export function wrapAction<
8 | Args extends unknown[],
9 | Opts extends commander.OptionValues,
10 | GlobalOpts extends commander.OptionValues,
11 | Command extends commander.Command,
12 | ActionArgs extends unknown[],
13 | >(fn: (this: Command, ...args: ActionArgs) => void | Promise) {
14 | return async function (this: Command, ...args: ActionArgs) {
15 | try {
16 | await fn.call(this, ...args);
17 | } catch (error) {
18 | process.exitCode = 1;
19 | if (error instanceof SpawnFailure) {
20 | error.flushOutput("both");
21 | } else if (
22 | error instanceof Error &&
23 | error.cause instanceof SpawnFailure
24 | ) {
25 | error.cause.flushOutput("both");
26 | }
27 | // Ensure some visual distance to the previous output
28 | console.error();
29 | if (error instanceof UsageError || error instanceof SpawnFailure) {
30 | console.error(chalk.red("ERROR"), error.message);
31 | if (error.cause instanceof Error) {
32 | console.error(chalk.blue("CAUSE"), error.cause.message);
33 | }
34 | if (error instanceof UsageError && error.fix) {
35 | console.error(
36 | chalk.green("FIX"),
37 | error.fix.command
38 | ? chalk.dim("Run: ") + error.fix.command
39 | : error.fix.instructions,
40 | );
41 | }
42 | } else {
43 | throw error;
44 | }
45 | }
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/packages/host/src/node/prebuilds/triplets.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * https://developer.android.com/ndk/guides/other_build_systems
3 | */
4 | export const ANDROID_TRIPLETS = [
5 | "aarch64-linux-android",
6 | "armv7a-linux-androideabi",
7 | "i686-linux-android",
8 | "x86_64-linux-android",
9 | ] as const;
10 |
11 | export type AndroidTriplet = (typeof ANDROID_TRIPLETS)[number];
12 |
13 | export const APPLE_TRIPLETS = [
14 | "x86_64-apple-darwin",
15 | "arm64-apple-darwin",
16 | "arm64;x86_64-apple-darwin",
17 |
18 | "arm64-apple-ios",
19 | "x86_64-apple-ios-sim",
20 | "arm64-apple-ios-sim",
21 | "arm64;x86_64-apple-ios-sim",
22 |
23 | "arm64-apple-tvos",
24 | // "x86_64-apple-tvos",
25 | "x86_64-apple-tvos-sim",
26 | "arm64-apple-tvos-sim",
27 | "arm64;x86_64-apple-tvos-sim",
28 |
29 | "arm64-apple-visionos",
30 | "x86_64-apple-visionos-sim",
31 | "arm64-apple-visionos-sim",
32 | "arm64;x86_64-apple-visionos-sim",
33 | ] as const;
34 |
35 | export type AppleTriplet = (typeof APPLE_TRIPLETS)[number];
36 |
37 | export const SUPPORTED_TRIPLETS = [
38 | ...APPLE_TRIPLETS,
39 | ...ANDROID_TRIPLETS,
40 | ] as const;
41 |
42 | export type SupportedTriplet = (typeof SUPPORTED_TRIPLETS)[number];
43 |
44 | export function isSupportedTriplet(
45 | triplet: unknown,
46 | ): triplet is SupportedTriplet {
47 | return (SUPPORTED_TRIPLETS as readonly unknown[]).includes(triplet);
48 | }
49 |
50 | export function isAndroidTriplet(
51 | triplet: SupportedTriplet,
52 | ): triplet is AndroidTriplet {
53 | return (ANDROID_TRIPLETS as readonly unknown[]).includes(triplet);
54 | }
55 |
56 | export function isAppleTriplet(
57 | triplet: SupportedTriplet,
58 | ): triplet is AppleTriplet {
59 | return (APPLE_TRIPLETS as readonly unknown[]).includes(triplet);
60 | }
61 |
--------------------------------------------------------------------------------
/packages/host/src/node/cli/bin.test.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import { describe, it } from "node:test";
3 | import cp from "node:child_process";
4 | import path from "node:path";
5 |
6 | const PACKAGE_ROOT = path.join(__dirname, "../../..");
7 | const BIN_PATH = path.join(PACKAGE_ROOT, "bin/react-native-node-api.mjs");
8 |
9 | describe("bin", () => {
10 | describe("help command", () => {
11 | it("should succeed with a mention of usage", () => {
12 | const { status, stdout, stderr } = cp.spawnSync(
13 | process.execPath,
14 | [BIN_PATH, "help"],
15 | {
16 | cwd: PACKAGE_ROOT,
17 | encoding: "utf8",
18 | },
19 | );
20 |
21 | assert.equal(
22 | status,
23 | 0,
24 | `Expected success (got ${status}): ${stdout} ${stderr}`,
25 | );
26 | assert.match(
27 | stdout,
28 | /Usage: react-native-node-api/,
29 | `Failed to find expected output (stdout: ${stdout} stderr: ${stderr})`,
30 | );
31 | });
32 | });
33 |
34 | describe("link command", () => {
35 | it("should succeed with a mention of Node-API modules", () => {
36 | const { status, stdout, stderr } = cp.spawnSync(
37 | process.execPath,
38 | [BIN_PATH, "link", "--android", "--apple"],
39 | {
40 | cwd: PACKAGE_ROOT,
41 | encoding: "utf8",
42 | },
43 | );
44 |
45 | assert.equal(
46 | status,
47 | 0,
48 | `Expected success (got ${status}): ${stdout} ${stderr}`,
49 | );
50 | assert.match(
51 | stdout + stderr,
52 | /Auto-linking Node-API modules/,
53 | `Failed to find expected output (stdout: ${stdout} stderr: ${stderr})`,
54 | );
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/packages/weak-node-api/scripts/generators/weak-node-api.ts:
--------------------------------------------------------------------------------
1 | import type { FunctionDecl } from "../../src/node-api-functions.js";
2 | import { generateFunction } from "./shared.js";
3 |
4 | export function generateHeader() {
5 | return `
6 | #pragma once
7 |
8 | #include
9 | #include // fprintf()
10 | #include // abort()
11 |
12 | #include "NodeApiHost.hpp"
13 |
14 | typedef void(*InjectHostFunction)(const NodeApiHost&);
15 | extern "C" void inject_weak_node_api_host(const NodeApiHost& host);
16 | `;
17 | }
18 |
19 | function generateFunctionImpl(fn: FunctionDecl) {
20 | const { name, returnType, argumentTypes } = fn;
21 | return generateFunction({
22 | ...fn,
23 | extern: true,
24 | body: `
25 | if (g_host.${name} == nullptr) {
26 | fprintf(stderr, "Node-API function '${name}' called before it was injected!\\n");
27 | abort();
28 | }
29 | ${returnType === "void" ? "" : "return "} g_host.${name}(
30 | ${argumentTypes.map((_, index) => `arg${index}`).join(", ")}
31 | );
32 | `,
33 | });
34 | }
35 |
36 | export function generateSource(functions: FunctionDecl[]) {
37 | return `
38 | #include "weak_node_api.hpp"
39 |
40 | /**
41 | * @brief Global instance of the injected Node-API host.
42 | *
43 | * This variable holds the function table for Node-API calls.
44 | * It is set via inject_weak_node_api_host() before any Node-API function is dispatched.
45 | * All Node-API calls are routed through this host.
46 | */
47 | NodeApiHost g_host;
48 | void inject_weak_node_api_host(const NodeApiHost& host) {
49 | g_host = host;
50 | };
51 |
52 | // Generate function calling into the host
53 | ${functions.map(generateFunctionImpl).join("\n")}
54 | `;
55 | }
56 |
--------------------------------------------------------------------------------
/packages/node-tests/scripts/generate-entrypoint.mts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 |
5 | const packageRoot = path.join(import.meta.dirname, "..");
6 | const entrypointPath = path.join(packageRoot, "tests.generated.js");
7 |
8 | const testPaths = fs.globSync("**/*.bundle.js", {
9 | cwd: path.join(packageRoot, "tests"),
10 | });
11 |
12 | interface TestSuite {
13 | [key: string]: string | TestSuite;
14 | }
15 |
16 | const suites: TestSuite = {};
17 |
18 | for (const testPath of testPaths) {
19 | const paths = testPath.split(path.sep);
20 | const testName = paths.pop();
21 | assert(typeof testName === "string");
22 | let parent: TestSuite = suites;
23 | for (const part of paths) {
24 | if (!parent[part]) {
25 | // Init if missing
26 | parent[part] = {};
27 | }
28 | assert(typeof parent[part] === "object");
29 | parent = parent[part];
30 | }
31 | parent[path.basename(testName, ".bundle.js")] = path.join("tests", testPath);
32 | }
33 |
34 | function suiteToString(suite: TestSuite, indent = 1): string {
35 | const padding = " ".repeat(indent);
36 | return Object.entries(suite)
37 | .map(([key, value]) => {
38 | if (typeof value === "string") {
39 | return `${padding}"${key}": require("./${value}")`;
40 | } else {
41 | return `${padding}"${key}": {\n${suiteToString(
42 | value,
43 | indent + 1,
44 | )}\n${padding}}`;
45 | }
46 | })
47 | .join(", ");
48 | }
49 |
50 | const comment = "Generated by ./scripts/generate-entrypoint.mts";
51 |
52 | console.log(
53 | `Writing entrypoint to ${path.relative(
54 | import.meta.dirname,
55 | entrypointPath,
56 | )} for ${testPaths.length} tests ...`,
57 | );
58 |
59 | fs.writeFileSync(
60 | entrypointPath,
61 | `/* ${comment} */\nmodule.exports.suites = {\n${suiteToString(suites)}\n};`,
62 | );
63 |
--------------------------------------------------------------------------------
/docs/ANDROID.md:
--------------------------------------------------------------------------------
1 | # Android support
2 |
3 | ## Building Hermes from source
4 |
5 | Because we're using a version of Hermes patched with Node-API support, we need to build React Native from source.
6 |
7 | 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).
8 |
9 | In particular, you will have to edit the `android/settings.gradle` file as follows:
10 |
11 | > ```diff
12 | > // ...
13 | > include ':app'
14 | > includeBuild('../node_modules/@react-native/gradle-plugin')
15 | >
16 | > + includeBuild('../node_modules/react-native') {
17 | > + dependencySubstitution {
18 | > + substitute(module("com.facebook.react:react-android")).using(project(":packages:react-native:ReactAndroid"))
19 | > + substitute(module("com.facebook.react:react-native")).using(project(":packages:react-native:ReactAndroid"))
20 | > + substitute(module("com.facebook.react:hermes-android")).using(project(":packages:react-native:ReactAndroid:hermes-engine"))
21 | > + substitute(module("com.facebook.react:hermes-engine")).using(project(":packages:react-native:ReactAndroid:hermes-engine"))
22 | > + }
23 | > + }
24 | > ```
25 |
26 | To download our custom version of Hermes, you need to run from your app package:
27 |
28 | ```
29 | npx react-native-node-api vendor-hermes
30 | ```
31 |
32 | This will print a path which needs to be stored in `REACT_NATIVE_OVERRIDE_HERMES_DIR` to instruct the React Native Gradle scripts to use it.
33 |
34 | This can be combined into a single line:
35 |
36 | ```
37 | export REACT_NATIVE_OVERRIDE_HERMES_DIR=$(npx react-native-node-api vendor-hermes --silent)
38 | ```
39 |
40 | ## Cleaning your React Native build folders
41 |
42 | If you've accidentally built your app without Hermes patched, you can clean things up by deleting the `ReactAndroid` build folder.
43 |
44 | ```
45 | rm -rf node_modules/react-native/ReactAndroid/build
46 | ```
47 |
--------------------------------------------------------------------------------
/packages/weak-node-api/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.19)
2 | project(weak-node-api)
3 |
4 | # Read version from package.json
5 | file(READ "${CMAKE_CURRENT_SOURCE_DIR}/package.json" PACKAGE_JSON)
6 | string(JSON PACKAGE_VERSION GET ${PACKAGE_JSON} version)
7 |
8 | add_library(${PROJECT_NAME} SHARED)
9 |
10 | set(INCLUDE_DIR "include")
11 | set(GENERATED_SOURCE_DIR "generated")
12 |
13 | target_sources(${PROJECT_NAME}
14 | PUBLIC
15 | ${GENERATED_SOURCE_DIR}/weak_node_api.cpp
16 | PUBLIC FILE_SET HEADERS
17 | BASE_DIRS ${GENERATED_SOURCE_DIR} ${INCLUDE_DIR} FILES
18 | ${GENERATED_SOURCE_DIR}/weak_node_api.hpp
19 | ${GENERATED_SOURCE_DIR}/NodeApiHost.hpp
20 | ${INCLUDE_DIR}/js_native_api_types.h
21 | ${INCLUDE_DIR}/js_native_api.h
22 | ${INCLUDE_DIR}/node_api_types.h
23 | ${INCLUDE_DIR}/node_api.h
24 | )
25 |
26 | get_target_property(PUBLIC_HEADER_FILES ${PROJECT_NAME} HEADER_SET)
27 |
28 | # Stripping the prefix from the library name
29 | # to make sure the name of the XCFramework will match the name of the library
30 | if(APPLE)
31 | set_target_properties(${PROJECT_NAME} PROPERTIES
32 | FRAMEWORK TRUE
33 | MACOSX_FRAMEWORK_IDENTIFIER com.callstack.${PROJECT_NAME}
34 | MACOSX_FRAMEWORK_SHORT_VERSION_STRING ${PACKAGE_VERSION}
35 | MACOSX_FRAMEWORK_BUNDLE_VERSION ${PACKAGE_VERSION}
36 | VERSION ${PACKAGE_VERSION}
37 | XCODE_ATTRIBUTE_SKIP_INSTALL NO
38 | PUBLIC_HEADER "${PUBLIC_HEADER_FILES}"
39 | )
40 | endif()
41 |
42 | # C++20 is needed to use designated initializers
43 | target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_20)
44 | target_compile_definitions(${PROJECT_NAME} PRIVATE NAPI_VERSION=8)
45 |
46 | target_compile_options(${PROJECT_NAME} PRIVATE
47 | $<$:/W4 /WX>
48 | $<$>:-Wall -Wextra -Werror>
49 | )
50 |
51 | option(BUILD_TESTS "Build the tests" OFF)
52 | if(BUILD_TESTS)
53 | enable_testing()
54 | add_subdirectory(tests)
55 | endif()
56 |
--------------------------------------------------------------------------------
/packages/weak-node-api/weak-node-api-config.cmake:
--------------------------------------------------------------------------------
1 |
2 | # Get the current file directory
3 | get_filename_component(WEAK_NODE_API_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY)
4 |
5 | if(NOT DEFINED WEAK_NODE_API_LIB)
6 | # Auto-detect library path for Android NDK builds
7 | if(ANDROID)
8 | # Define the library path pattern for Android
9 | set(WEAK_NODE_API_LIB_PATH "weak-node-api.android.node/${ANDROID_ABI}/libweak-node-api.so")
10 |
11 | # Try Debug first, then Release using the packaged Android node structure
12 | set(WEAK_NODE_API_LIB_DEBUG "${WEAK_NODE_API_CMAKE_DIR}/build/Debug/${WEAK_NODE_API_LIB_PATH}")
13 | set(WEAK_NODE_API_LIB_RELEASE "${WEAK_NODE_API_CMAKE_DIR}/build/Release/${WEAK_NODE_API_LIB_PATH}")
14 |
15 | if(EXISTS "${WEAK_NODE_API_LIB_DEBUG}")
16 | set(WEAK_NODE_API_LIB "${WEAK_NODE_API_LIB_DEBUG}")
17 | message(STATUS "Using Debug weak-node-api library: ${WEAK_NODE_API_LIB}")
18 | elseif(EXISTS "${WEAK_NODE_API_LIB_RELEASE}")
19 | set(WEAK_NODE_API_LIB "${WEAK_NODE_API_LIB_RELEASE}")
20 | message(STATUS "Using Release weak-node-api library: ${WEAK_NODE_API_LIB}")
21 | else()
22 | message(FATAL_ERROR "Could not find weak-node-api library for Android ABI ${ANDROID_ABI}. Expected at:\n ${WEAK_NODE_API_LIB_DEBUG}\n ${WEAK_NODE_API_LIB_RELEASE}")
23 | endif()
24 | else()
25 | message(FATAL_ERROR "WEAK_NODE_API_LIB is not set")
26 | endif()
27 | endif()
28 |
29 | if(NOT DEFINED WEAK_NODE_API_INC)
30 | set(WEAK_NODE_API_INC "${WEAK_NODE_API_CMAKE_DIR}/include;${WEAK_NODE_API_CMAKE_DIR}/generated")
31 | message(STATUS "Using weak-node-api include directories: ${WEAK_NODE_API_INC}")
32 | endif()
33 |
34 | add_library(weak-node-api SHARED IMPORTED)
35 |
36 | set_target_properties(weak-node-api PROPERTIES
37 | IMPORTED_LOCATION "${WEAK_NODE_API_LIB}"
38 | INTERFACE_INCLUDE_DIRECTORIES "${WEAK_NODE_API_INC}"
39 | )
40 |
--------------------------------------------------------------------------------
/packages/weak-node-api/src/restore-xcframework-symlinks.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 |
5 | import { applePrebuildPath } from "./weak-node-api.js";
6 |
7 | async function restoreSymlink(target: string, path: string) {
8 | if (!fs.existsSync(path)) {
9 | await fs.promises.symlink(target, path);
10 | }
11 | }
12 |
13 | async function guessCurrentFrameworkVersion(frameworkPath: string) {
14 | const versionsPath = path.join(frameworkPath, "Versions");
15 | assert(fs.existsSync(versionsPath));
16 |
17 | const versionDirectoryEntries = await fs.promises.readdir(versionsPath, {
18 | withFileTypes: true,
19 | });
20 | const versions = versionDirectoryEntries
21 | .filter((dirent) => dirent.isDirectory())
22 | .map((dirent) => dirent.name);
23 | assert.equal(
24 | versions.length,
25 | 1,
26 | `Expected exactly one directory in ${versionsPath}, found ${JSON.stringify(versions)}`,
27 | );
28 | const [version] = versions;
29 | return version;
30 | }
31 |
32 | async function restoreVersionedFrameworkSymlinks(frameworkPath: string) {
33 | const currentVersionName = await guessCurrentFrameworkVersion(frameworkPath);
34 | await restoreSymlink(
35 | currentVersionName,
36 | path.join(frameworkPath, "Versions", "Current"),
37 | );
38 | await restoreSymlink(
39 | "Versions/Current/weak-node-api",
40 | path.join(frameworkPath, "weak-node-api"),
41 | );
42 | await restoreSymlink(
43 | "Versions/Current/Resources",
44 | path.join(frameworkPath, "Resources"),
45 | );
46 | await restoreSymlink(
47 | "Versions/Current/Headers",
48 | path.join(frameworkPath, "Headers"),
49 | );
50 | }
51 |
52 | if (process.platform === "darwin") {
53 | assert(
54 | fs.existsSync(applePrebuildPath),
55 | `Expected an Xcframework at ${applePrebuildPath}`,
56 | );
57 |
58 | const macosFrameworkPath = path.join(
59 | applePrebuildPath,
60 | "macos-arm64_x86_64",
61 | "weak-node-api.framework",
62 | );
63 |
64 | if (fs.existsSync(macosFrameworkPath)) {
65 | await restoreVersionedFrameworkSymlinks(macosFrameworkPath);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/packages/cmake-file-api/src/schemas/objects/CodemodelV2.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | const index = z.number().int().nonnegative();
4 |
5 | const MinimumCMakeVersion = z.object({
6 | string: z.string(),
7 | });
8 |
9 | const DirectoryV2_0 = z.object({
10 | source: z.string(),
11 | build: z.string(),
12 | parentIndex: index.optional(),
13 | childIndexes: z.array(index).optional(),
14 | projectIndex: index,
15 | targetIndexes: z.array(index).optional(),
16 | minimumCMakeVersion: MinimumCMakeVersion.optional(),
17 | hasInstallRule: z.boolean().optional(),
18 | });
19 |
20 | const DirectoryV2_3 = DirectoryV2_0.extend({
21 | jsonFile: z.string(),
22 | });
23 |
24 | const Project = z.object({
25 | name: z.string(),
26 | parentIndex: index.optional(),
27 | childIndexes: z.array(index).optional(),
28 | directoryIndexes: z.array(index),
29 | targetIndexes: z.array(index).optional(),
30 | });
31 |
32 | const Target = z.object({
33 | name: z.string(),
34 | id: z.string(),
35 | directoryIndex: index,
36 | projectIndex: index,
37 | jsonFile: z.string(),
38 | });
39 |
40 | const ConfigurationV2_0 = z.object({
41 | name: z.string(),
42 | directories: z.array(DirectoryV2_0),
43 | projects: z.array(Project),
44 | targets: z.array(Target),
45 | });
46 |
47 | const ConfigurationV2_3 = ConfigurationV2_0.extend({
48 | directories: z.array(DirectoryV2_3),
49 | });
50 |
51 | export const CodemodelV2_0 = z.object({
52 | kind: z.literal("codemodel"),
53 | version: z.object({
54 | major: z.literal(2),
55 | minor: z.number().max(2),
56 | }),
57 | paths: z.object({
58 | source: z.string(),
59 | build: z.string(),
60 | }),
61 | configurations: z.array(ConfigurationV2_0),
62 | });
63 |
64 | export const CodemodelV2_3 = CodemodelV2_0.extend({
65 | version: z.object({
66 | major: z.literal(2),
67 | minor: z.number().min(3),
68 | }),
69 | configurations: z.array(ConfigurationV2_3),
70 | });
71 |
72 | export const CodemodelV2 = z.union([CodemodelV2_0, CodemodelV2_3]);
73 |
74 | export const codemodelFilesSchemaPerVersion = {
75 | "2.0": CodemodelV2_0,
76 | "2.3": CodemodelV2_3,
77 | } as const satisfies Record;
78 |
--------------------------------------------------------------------------------
/packages/cmake-rn/README.md:
--------------------------------------------------------------------------------
1 | # `cmake-rn`
2 |
3 | A wrapper around Cmake making it easier to produce prebuilt binaries targeting iOS and Android matching [the prebuilt binary specification](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/PREBUILDS.md).
4 |
5 | Serves the same purpose as `cmake-js` does for the Node.js community and could potentially be upstreamed into `cmake-js` eventually.
6 |
7 | ## Linking against Node-API
8 |
9 | Android's dynamic linker imposes restrictions on the access to global symbols (such as the Node-API free functions): A dynamic library must explicitly declare any dependency bringing symbols it needs as `DT_NEEDED`.
10 |
11 | The implementation of Node-API is split between Hermes and our host package and to avoid addons having to explicitly link against either, we've introduced a `weak-node-api` library (published in `react-native-node-api` package). This library exposes only Node-API and will have its implementation injected by the host.
12 |
13 | To link against `weak-node-api` just use `find_package` to import the `weak-node-api` target and add it to the `target_link_libraries` of the addon's library target.
14 |
15 | ```cmake
16 | cmake_minimum_required(VERSION 3.15...3.31)
17 | project(tests-buffers)
18 |
19 | # Defines the "weak-node-api" target
20 | find_package(weak-node-api REQUIRED CONFIG)
21 |
22 | add_library(addon SHARED addon.c)
23 | target_link_libraries(addon PRIVATE weak-node-api)
24 | target_compile_features(addon PRIVATE cxx_std_20)
25 |
26 | if(APPLE)
27 | # Build frameworks when building for Apple (optional)
28 | set_target_properties(addon PROPERTIES
29 | FRAMEWORK TRUE
30 | MACOSX_FRAMEWORK_IDENTIFIER async_test.addon
31 | MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0
32 | MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0
33 | XCODE_ATTRIBUTE_SKIP_INSTALL NO
34 | )
35 | else()
36 | set_target_properties(addon PROPERTIES
37 | PREFIX ""
38 | SUFFIX .node
39 | )
40 | endif()
41 | ```
42 |
43 | 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`.
44 |
--------------------------------------------------------------------------------
/packages/node-tests/scripts/copy-tests.mts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import path from "node:path";
3 | import cp from "node:child_process";
4 |
5 | import { TESTS_DIR } from "./utils.mjs";
6 |
7 | const NODE_REPO_URL = "https://github.com/nodejs/node.git";
8 | const NODE_REPO_DIR = path.resolve(import.meta.dirname, "../node");
9 |
10 | const ALLOW_LIST = [
11 | "js-native-api/common.h",
12 | "js-native-api/common-inl.h",
13 | "js-native-api/entry_point.h",
14 | "js-native-api/2_function_arguments",
15 | // "node-api/test_async",
16 | // "node-api/test_buffer",
17 | ];
18 |
19 | console.log("Copying files to", TESTS_DIR);
20 |
21 | // Clean up the destination directory before copying
22 | // fs.rmSync(EXAMPLES_DIR, { recursive: true, force: true });
23 |
24 | if (!fs.existsSync(NODE_REPO_DIR)) {
25 | console.log(
26 | "Sparse and shallow cloning Node.js repository to",
27 | NODE_REPO_DIR,
28 | );
29 |
30 | // Init a new git repository
31 | cp.execFileSync("git", ["init", NODE_REPO_DIR], {
32 | stdio: "inherit",
33 | });
34 | // Set the remote origin to the Node.js repository
35 | cp.execFileSync("git", ["remote", "add", "origin", NODE_REPO_URL], {
36 | stdio: "inherit",
37 | cwd: NODE_REPO_DIR,
38 | });
39 | // Enable sparse checkout
40 | cp.execFileSync(
41 | "git",
42 | ["sparse-checkout", "set", "test/js-native-api", "test/node-api"],
43 | {
44 | stdio: "inherit",
45 | cwd: NODE_REPO_DIR,
46 | },
47 | );
48 | // Pull the latest changes from the master branch
49 | console.log("Pulling latest changes from Node.js repository...");
50 | cp.execFileSync("git", ["pull", "--depth=1", "origin", "main"], {
51 | stdio: "inherit",
52 | cwd: NODE_REPO_DIR,
53 | });
54 | }
55 | const SRC_DIR = path.join(NODE_REPO_DIR, "test");
56 | console.log("Copying files from", SRC_DIR);
57 |
58 | for (const src of ALLOW_LIST) {
59 | const srcPath = path.join(SRC_DIR, src);
60 | const destPath = path.join(TESTS_DIR, src);
61 |
62 | if (fs.existsSync(destPath)) {
63 | console.warn(
64 | `Destination path ${destPath} already exists - skipping copy of ${src}.`,
65 | );
66 | continue;
67 | }
68 |
69 | console.log("Copying from", srcPath, "to", destPath);
70 | fs.cpSync(srcPath, destPath, { recursive: true });
71 | }
72 |
--------------------------------------------------------------------------------
/packages/host/react-native-node-api.podspec:
--------------------------------------------------------------------------------
1 | require "json"
2 |
3 | package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4 |
5 | require_relative "./scripts/patch-hermes"
6 |
7 | NODE_PATH ||= `which node`.strip
8 | CLI_COMMAND ||= "'#{NODE_PATH}' '#{File.join(__dir__, "dist/node/cli/run.js")}'"
9 | COPY_FRAMEWORKS_COMMAND ||= "#{CLI_COMMAND} link --apple '#{Pod::Config.instance.installation_root}'"
10 |
11 | # We need to run this now to ensure the xcframeworks are copied vendored_frameworks are considered
12 | XCFRAMEWORKS_DIR ||= File.join(__dir__, "xcframeworks")
13 | unless defined?(@xcframeworks_copied)
14 | puts "Executing #{COPY_FRAMEWORKS_COMMAND}"
15 | system(COPY_FRAMEWORKS_COMMAND) or raise "Failed to copy xcframeworks"
16 | # Setting a flag to avoid running this command on every require
17 | @xcframeworks_copied = true
18 | end
19 |
20 | if ENV['RCT_NEW_ARCH_ENABLED'] == '0'
21 | Pod::UI.warn "React Native Node-API doesn't support the legacy architecture (but RCT_NEW_ARCH_ENABLED == '0')"
22 | end
23 |
24 | Pod::Spec.new do |s|
25 | s.name = package["name"]
26 | s.version = package["version"]
27 | s.summary = package["description"]
28 | s.homepage = package["homepage"]
29 | s.license = package["license"]
30 | s.authors = package["author"]
31 |
32 | s.source = { :git => "https://github.com/callstackincubator/react-native-node-api.git", :tag => "#{s.version}" }
33 |
34 | s.source_files = "apple/**/*.{h,m,mm}", "cpp/**/*.{hpp,cpp,c,h}"
35 |
36 | s.dependency "weak-node-api"
37 |
38 | s.vendored_frameworks = "auto-linked/apple/*.xcframework"
39 | s.script_phase = {
40 | :name => 'Copy Node-API xcframeworks',
41 | :execution_position => :before_compile,
42 | :script => <<-CMD
43 | set -e
44 | #{COPY_FRAMEWORKS_COMMAND}
45 | CMD
46 | }
47 |
48 | # Use install_modules_dependencies helper to install the dependencies (requires React Native version >=0.71.0).
49 | # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
50 | if respond_to?(:install_modules_dependencies, true)
51 | install_modules_dependencies(s)
52 | else
53 | raise "This version of React Native is too old for React Native Node-API."
54 | end
55 | end
--------------------------------------------------------------------------------
/packages/host/src/node/cli/android.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 |
5 | import { getLatestMtime, getLibraryName, MAGIC_FILENAME } from "../path-utils";
6 | import {
7 | getLinkedModuleOutputPath,
8 | LinkModuleResult,
9 | type LinkModuleOptions,
10 | } from "./link-modules";
11 |
12 | const ANDROID_ARCHITECTURES = [
13 | "arm64-v8a",
14 | "armeabi-v7a",
15 | "x86_64",
16 | "x86",
17 | ] as const;
18 |
19 | export async function linkAndroidDir({
20 | incremental,
21 | modulePath,
22 | naming,
23 | platform,
24 | }: LinkModuleOptions): Promise {
25 | const libraryName = getLibraryName(modulePath, naming);
26 | const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming);
27 |
28 | if (incremental && fs.existsSync(outputPath)) {
29 | const moduleModified = getLatestMtime(modulePath);
30 | const outputModified = getLatestMtime(outputPath);
31 | if (moduleModified < outputModified) {
32 | return {
33 | originalPath: modulePath,
34 | libraryName,
35 | outputPath,
36 | skipped: true,
37 | };
38 | }
39 | }
40 |
41 | await fs.promises.rm(outputPath, { recursive: true, force: true });
42 | await fs.promises.cp(modulePath, outputPath, { recursive: true });
43 | for (const arch of ANDROID_ARCHITECTURES) {
44 | const archPath = path.join(outputPath, arch);
45 | if (!fs.existsSync(archPath)) {
46 | // Skip missing architectures
47 | continue;
48 | }
49 | const libraryDirents = await fs.promises.readdir(archPath, {
50 | withFileTypes: true,
51 | });
52 | assert(libraryDirents.length === 1, "Expected exactly one library file");
53 | const [libraryDirent] = libraryDirents;
54 | assert(libraryDirent.isFile(), "Expected a library file");
55 | const libraryPath = path.join(libraryDirent.parentPath, libraryDirent.name);
56 | await fs.promises.rename(
57 | libraryPath,
58 | path.join(archPath, `lib${libraryName}.so`),
59 | );
60 | }
61 | await fs.promises.rm(path.join(outputPath, MAGIC_FILENAME), {
62 | recursive: true,
63 | });
64 |
65 | // TODO: Update the DT_NEEDED entry in the .so files
66 |
67 | return {
68 | originalPath: modulePath,
69 | outputPath,
70 | libraryName,
71 | skipped: false,
72 | };
73 | }
74 |
--------------------------------------------------------------------------------
/packages/host/cpp/Logger.cpp:
--------------------------------------------------------------------------------
1 | #include "Logger.hpp"
2 | #include
3 | #include
4 |
5 | #if defined(__ANDROID__)
6 | #include
7 | #define LOG_TAG "NodeApiHost"
8 | #elif defined(__APPLE__)
9 | #include
10 | #endif
11 |
12 | namespace {
13 | constexpr auto LineFormat = "[%s] [NodeApiHost] ";
14 |
15 | enum class LogLevel { Debug, Warning, Error };
16 |
17 | constexpr std::string_view levelToString(LogLevel level) {
18 | switch (level) {
19 | case LogLevel::Debug:
20 | return "DEBUG";
21 | case LogLevel::Warning:
22 | return "WARNING";
23 | case LogLevel::Error:
24 | return "ERROR";
25 | default:
26 | return "UNKNOWN";
27 | }
28 | }
29 |
30 | #if defined(__ANDROID__)
31 | constexpr int androidLogLevel(LogLevel level) {
32 | switch (level) {
33 | case LogLevel::Debug:
34 | return ANDROID_LOG_DEBUG;
35 | case LogLevel::Warning:
36 | return ANDROID_LOG_WARN;
37 | case LogLevel::Error:
38 | return ANDROID_LOG_ERROR;
39 | default:
40 | return ANDROID_LOG_UNKNOWN;
41 | }
42 | }
43 | #endif
44 |
45 | void log_message_internal(LogLevel level, const char *format, va_list args) {
46 | #if defined(__ANDROID__)
47 | __android_log_vprint(androidLogLevel(level), LOG_TAG, format, args);
48 | #elif defined(__APPLE__)
49 | // iOS or macOS
50 | const auto level_str = levelToString(level);
51 | fprintf(stderr, LineFormat, level_str.data());
52 | vfprintf(stderr, format, args);
53 | fprintf(stderr, "\n");
54 | #else
55 | // Fallback for other platforms
56 | const auto level_str = levelToString(level);
57 | fprintf(stdout, LineFormat, level_str.data());
58 | vfprintf(stdout, format, args);
59 | fprintf(stdout, "\n");
60 | #endif
61 | }
62 | } // anonymous namespace
63 |
64 | namespace callstack::react_native_node_api {
65 |
66 | void log_debug(const char *format, ...) {
67 | // TODO: Disable logging in release builds
68 | va_list args;
69 | va_start(args, format);
70 | log_message_internal(LogLevel::Debug, format, args);
71 | va_end(args);
72 | }
73 | void log_warning(const char *format, ...) {
74 | va_list args;
75 | va_start(args, format);
76 | log_message_internal(LogLevel::Warning, format, args);
77 | va_end(args);
78 | }
79 | void log_error(const char *format, ...) {
80 | va_list args;
81 | va_start(args, format);
82 | log_message_internal(LogLevel::Error, format, args);
83 | va_end(args);
84 | }
85 | } // namespace callstack::react_native_node_api
86 |
--------------------------------------------------------------------------------
/packages/weak-node-api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "weak-node-api",
3 | "version": "0.0.3",
4 | "description": "A linkable and runtime-injectable Node-API",
5 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
9 | "directory": "packages/weak-node-api"
10 | },
11 | "type": "module",
12 | "exports": {
13 | ".": "./dist/index.js"
14 | },
15 | "files": [
16 | "dist",
17 | "!dist/**/*.test.d.ts",
18 | "!dist/**/*.test.d.ts.map",
19 | "include",
20 | "generated",
21 | "build/Debug",
22 | "build/Release",
23 | "*.podspec",
24 | "*.cmake"
25 | ],
26 | "scripts": {
27 | "build": "tsc --build",
28 | "copy-node-api-headers": "tsx scripts/copy-node-api-headers.ts",
29 | "generate": "tsx scripts/generate.ts",
30 | "prebuild:prepare": "node --run copy-node-api-headers && node --run generate",
31 | "prebuild:build": "cmake-rn --no-auto-link --no-weak-node-api-linkage --xcframework-extension",
32 | "prebuild:build:android": "node --run prebuild:build -- --android",
33 | "prebuild:build:apple": "node --run prebuild:build -- --apple",
34 | "prebuild:build:all": "node --run prebuild:build -- --android --apple",
35 | "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",
36 | "test:configure": "cmake -S . -B build-tests -DBUILD_TESTS=ON",
37 | "test:build": "cmake --build build-tests",
38 | "test:run": "ctest --test-dir build-tests --output-on-failure",
39 | "bootstrap": "node --run prebuild:prepare && node --run prebuild:build"
40 | },
41 | "keywords": [
42 | "react-native",
43 | "napi",
44 | "node-api",
45 | "node-addon-api",
46 | "native",
47 | "addon",
48 | "module",
49 | "c",
50 | "c++",
51 | "bindings",
52 | "buildtools",
53 | "cmake"
54 | ],
55 | "author": {
56 | "name": "Callstack",
57 | "url": "https://github.com/callstackincubator"
58 | },
59 | "contributors": [
60 | {
61 | "name": "Kræn Hansen",
62 | "url": "https://github.com/kraenhansen"
63 | }
64 | ],
65 | "license": "MIT",
66 | "dependencies": {
67 | "node-api-headers": "^1.5.0"
68 | },
69 | "devDependencies": {
70 | "zod": "^4.1.11"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/packages/ferric/src/napi-rs.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 |
5 | import { NapiCli } from "@napi-rs/cli";
6 |
7 | const napiCli = new NapiCli();
8 |
9 | import { getBlockComment } from "./banner.js";
10 |
11 | const PACKAGE_ROOT = path.join(import.meta.dirname, "..");
12 |
13 | type TypeScriptDeclarationsOptions = {
14 | /**
15 | * Path to the directory containing the Cargo.toml file.
16 | */
17 | createPath: string;
18 | /**
19 | * Path to the output directory where the TypeScript declarations will be copied into.
20 | */
21 | outputPath: string;
22 | /**
23 | * File name of the generated TypeScript declarations (including .d.ts).
24 | */
25 | outputFilename: string;
26 | };
27 |
28 | export async function generateTypeScriptDeclarations({
29 | createPath,
30 | outputPath,
31 | outputFilename,
32 | }: TypeScriptDeclarationsOptions) {
33 | // Using a temporary directory to avoid polluting crate with any other side-effects for generating TypeScript declarations
34 | const tempPath = fs.realpathSync(
35 | fs.mkdtempSync(path.join(PACKAGE_ROOT, "dts-tmp-")),
36 | );
37 | const finalOutputPath = path.join(outputPath, outputFilename);
38 | try {
39 | // Write a dummy package.json file to avoid errors from napi-rs
40 | await fs.promises.writeFile(
41 | path.join(tempPath, "package.json"),
42 | "{}",
43 | "utf8",
44 | );
45 | const tempOutputPath = path.join(tempPath, outputFilename);
46 | // Call into napi.rs to generate TypeScript declarations
47 | const { task } = await napiCli.build({
48 | verbose: false,
49 | dts: outputFilename,
50 | outputDir: tempPath,
51 | cwd: createPath,
52 | cargoOptions: ["--quiet"],
53 | });
54 | await task;
55 | // Override the banner
56 | assert(
57 | fs.existsSync(tempOutputPath),
58 | `Expected napi.rs to emit ${tempOutputPath}`,
59 | );
60 | const contents = await fs.promises.readFile(tempOutputPath, "utf8");
61 | const patchedContents = contents.replace(
62 | "/* auto-generated by NAPI-RS */",
63 | getBlockComment(),
64 | );
65 | // Copy out the generated TypeScript declarations
66 | await fs.promises.writeFile(finalOutputPath, patchedContents, {
67 | encoding: "utf8",
68 | });
69 | return finalOutputPath;
70 | } finally {
71 | await fs.promises.rm(tempPath, { recursive: true, force: true });
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/docs/PREBUILDS.md:
--------------------------------------------------------------------------------
1 | # Prebuilds
2 |
3 | This document codifies the naming and directory structure of prebuilt binaries, expected by the auto-linking mechanism.
4 |
5 | At the time of writing, our auto-linking host package (`react-native-node-api`) support two kinds of prebuilds:
6 |
7 | ## `*.android.node` (for Android)
8 |
9 | A jniLibs-like directory structure of CPU-architecture specific directories containing a single `.so` library file.
10 |
11 | The name of all the `.so` library files:
12 |
13 | - must be the same across all CPU-architectures
14 | - can have a "lib" prefix, but doesn't have to
15 | - must have an `.so` or `.node` file extension
16 |
17 | > [!NOTE]
18 | > The `SONAME` doesn't have to match and is not updated as the .so is copied into the host package.
19 | > This might cause trouble if you're trying to link with the library from other native code.
20 | > We're tracking [#14](https://github.com/callstackincubator/react-native-node-api/issues/14) to fix this 🤞
21 |
22 | The directory must have a `react-native-node-api-module` file (the content doesn't matter), to signal that the directory is intended for auto-linking by the `react-native-node-api-module` package.
23 |
24 | ## `*.apple.node` (for Apple)
25 |
26 | An XCFramework of dynamic libraries wrapped in `.framework` bundles, renamed from `.xcframework` to `.apple.node` to ease discoverability.
27 |
28 | The Apple Developer documentation on ["Creating a multiplatform binary framework bundle"](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle#Avoid-issues-when-using-alternate-build-systems) mentions:
29 |
30 | > An XCFramework can include dynamic library files, but only macOS supports these libraries for dynamic linking. Dynamic linking on iOS, watchOS, and tvOS requires the XCFramework to contain .framework bundles.
31 |
32 | The directory must have a `react-native-node-api-module` file (the content doesn't matter), to signal that the directory is intended for auto-linking by the `react-native-node-api-module` package.
33 |
34 | ## Why did we choose this naming scheme?
35 |
36 | To align with prior art and established patterns around the distribution of Node-API modules for Node.js, we've chosen to use the ".node" filename extension for prebuilds of Node-API modules, targeting React Native.
37 |
38 | To enable distribution of packages with multiple co-existing platform-specific prebuilts, we've chosen to lean into the pattern of platform-specific filename extensions, used by the Metro bundler.
39 |
--------------------------------------------------------------------------------
/apps/test-app/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the Gradle Daemon. The setting is
11 | # particularly useful for configuring JVM memory settings for build performance.
12 | # This does not affect the JVM settings for the Gradle client VM.
13 | # The default is `-Xmx512m -XX:MaxMetaspaceSize=256m`.
14 | org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
15 |
16 | # When configured, Gradle will fork up to org.gradle.workers.max JVMs to execute
17 | # projects in parallel. To learn more about parallel task execution, see the
18 | # section on Gradle build performance:
19 | # https://docs.gradle.org/current/userguide/performance.html#parallel_execution.
20 | # Default is `false`.
21 | #org.gradle.parallel=true
22 |
23 | # AndroidX package structure to make it clearer which packages are bundled with the
24 | # Android operating system, and which are packaged with your app's APK
25 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
26 | android.useAndroidX=true
27 | # Automatically convert third-party libraries to use AndroidX
28 | #android.enableJetifier=true
29 | # Jetifier randomly fails on these libraries
30 | #android.jetifier.ignorelist=hermes-android,react-android
31 |
32 | # Use this property to specify which architecture you want to build.
33 | # You can also override it from the CLI using
34 | # ./gradlew -PreactNativeArchitectures=x86_64
35 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
36 |
37 | # Use this property to enable support to the new architecture.
38 | # This will allow you to use TurboModules and the Fabric render in
39 | # your application. You should enable this flag either if you want
40 | # to write custom TurboModules/Fabric components OR use libraries that
41 | # are providing them.
42 | # Note that this is incompatible with web debugging.
43 | newArchEnabled=true
44 | #bridgelessEnabled=true
45 |
46 | # Uncomment the line below to build React Native from source.
47 | react.buildFromSource=true
48 |
49 | # Version of Android NDK to build against.
50 | #ANDROID_NDK_VERSION=26.1.10909125
51 |
52 | # Version of Kotlin to build against.
53 | #KOTLIN_VERSION=1.8.22
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import { globalIgnores } from "eslint/config";
4 | import globals from "globals";
5 | import eslint from "@eslint/js";
6 | import tseslint from "typescript-eslint";
7 | import eslintConfigPrettier from "eslint-config-prettier/flat";
8 |
9 | export default tseslint.config(
10 | globalIgnores([
11 | "**/dist/**",
12 | "**/build/**",
13 | "apps/test-app/ios/**",
14 | "packages/host/hermes/**",
15 | "packages/node-addon-examples/examples/**",
16 | "packages/ferric-example/ferric_example.js",
17 | "packages/ferric-example/ferric_example.d.ts",
18 | "packages/ferric-example/target/**",
19 | "packages/node-tests/node/**",
20 | "packages/node-tests/tests/**",
21 | "packages/node-tests/*.generated.js",
22 | "packages/node-tests/*.generated.d.ts",
23 | ]),
24 | eslint.configs.recommended,
25 | tseslint.configs.recommendedTypeChecked,
26 | {
27 | rules: {
28 | "@typescript-eslint/no-floating-promises": [
29 | "error",
30 | {
31 | allowForKnownSafeCalls: [
32 | { from: "package", name: ["suite", "test"], package: "node:test" },
33 | ],
34 | },
35 | ],
36 | },
37 | },
38 | {
39 | languageOptions: {
40 | parserOptions: {
41 | projectService: true,
42 | tsconfigRootDir: import.meta.dirname,
43 | },
44 | },
45 | },
46 | eslintConfigPrettier,
47 | {
48 | files: [
49 | "apps/test-app/*.js",
50 | "apps/macos-test-app/*.js",
51 | "packages/node-addon-examples/**/*.js",
52 | "packages/host/babel-plugin.js",
53 | "packages/host/react-native.config.js",
54 | "packages/node-tests/tests.generated.js",
55 | ],
56 | extends: [tseslint.configs.disableTypeChecked],
57 | languageOptions: {
58 | parserOptions: {
59 | sourceType: "commonjs",
60 | },
61 | globals: {
62 | ...globals.commonjs,
63 | },
64 | },
65 | rules: {
66 | // We're using CommonJS here for Node.js backwards compatibility
67 | "@typescript-eslint/no-require-imports": "off",
68 | },
69 | },
70 | {
71 | files: [
72 | "**/metro.config.js",
73 | "packages/gyp-to-cmake/bin/*.js",
74 | "packages/host/bin/*.mjs",
75 | "packages/host/scripts/*.mjs",
76 | "packages/ferric/bin/*.js",
77 | "packages/cmake-rn/bin/*.js",
78 | ],
79 | extends: [tseslint.configs.disableTypeChecked],
80 | languageOptions: {
81 | globals: {
82 | ...globals.node,
83 | },
84 | },
85 | },
86 | );
87 |
--------------------------------------------------------------------------------
/packages/cmake-rn/src/weak-node-api.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import assert from "node:assert/strict";
3 | import path from "node:path";
4 |
5 | import {
6 | isAndroidTriplet,
7 | isAppleTriplet,
8 | SupportedTriplet,
9 | } from "react-native-node-api";
10 |
11 | import {
12 | applePrebuildPath,
13 | androidPrebuildPath,
14 | weakNodeApiCmakePath,
15 | } from "weak-node-api";
16 |
17 | import { ANDROID_ARCHITECTURES } from "./platforms/android.js";
18 | import { getNodeAddonHeadersPath, getNodeApiHeadersPath } from "./headers.js";
19 |
20 | export function toCmakePath(input: string) {
21 | return input.split(path.win32.sep).join(path.posix.sep);
22 | }
23 |
24 | export function getWeakNodeApiPath(
25 | triplet: SupportedTriplet | "apple",
26 | ): string {
27 | if (triplet === "apple" || isAppleTriplet(triplet)) {
28 | assert(
29 | fs.existsSync(applePrebuildPath),
30 | `Expected an XCFramework at ${applePrebuildPath}`,
31 | );
32 | return applePrebuildPath;
33 | } else if (isAndroidTriplet(triplet)) {
34 | const libraryPath = path.join(
35 | androidPrebuildPath,
36 | ANDROID_ARCHITECTURES[triplet],
37 | "libweak-node-api.so",
38 | );
39 | assert(fs.existsSync(libraryPath), `Expected library at ${libraryPath}`);
40 | return libraryPath;
41 | } else {
42 | throw new Error(`Unexpected triplet: ${triplet as string}`);
43 | }
44 | }
45 |
46 | function getNodeApiIncludePaths() {
47 | const includePaths = [getNodeApiHeadersPath(), getNodeAddonHeadersPath()];
48 | for (const includePath of includePaths) {
49 | assert(
50 | !includePath.includes(";"),
51 | `Include path with a ';' is not supported: ${includePath}`,
52 | );
53 | }
54 | return includePaths;
55 | }
56 |
57 | export function getWeakNodeApiVariables(
58 | triplet: SupportedTriplet | "apple",
59 | ): Record {
60 | return {
61 | // Enable use of `find_package(weak-node-api REQUIRED CONFIG)`
62 | "weak-node-api_DIR": path.dirname(weakNodeApiCmakePath),
63 | // Enable use of `include(${WEAK_NODE_API_CONFIG})`
64 | WEAK_NODE_API_CONFIG: weakNodeApiCmakePath,
65 | WEAK_NODE_API_INC: getNodeApiIncludePaths().join(";"),
66 | WEAK_NODE_API_LIB: getWeakNodeApiPath(triplet),
67 | };
68 | }
69 |
70 | /**
71 | * For compatibility with cmake-js
72 | */
73 | export function getCmakeJSVariables(
74 | triplet: SupportedTriplet | "apple",
75 | ): Record {
76 | return {
77 | CMAKE_JS_INC: getNodeApiIncludePaths().join(";"),
78 | CMAKE_JS_LIB: getWeakNodeApiPath(triplet),
79 | };
80 | }
81 |
--------------------------------------------------------------------------------
/apps/test-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@react-native-node-api/test-app",
3 | "private": true,
4 | "type": "commonjs",
5 | "version": "0.2.1",
6 | "scripts": {
7 | "metro": "react-native start --no-interactive",
8 | "android": "react-native run-android --no-packager --active-arch-only",
9 | "ios": "react-native run-ios --no-packager",
10 | "pod-install": "cd ios && pod install",
11 | "mocha-and-metro": "mocha-remote --watch -- react-native start",
12 | "test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' --",
13 | "test:android:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:android -- ",
14 | "test:android:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:android -- ",
15 | "test:android:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:android -- ",
16 | "test:android:ferricExample": "MOCHA_REMOTE_CONTEXT=ferricExample node --run test:android -- ",
17 | "test:ios": "mocha-remote --exit-on-error -- concurrently --passthrough-arguments --kill-others-on-fail npm:metro 'npm:ios -- {@}' --",
18 | "test:ios:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:ios -- ",
19 | "test:ios:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:ios -- ",
20 | "test:ios:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:ios -- ",
21 | "test:ios:ferricExample": "MOCHA_REMOTE_CONTEXT=ferricExample node --run test:ios -- "
22 | },
23 | "dependencies": {
24 | "@babel/core": "^7.26.10",
25 | "@babel/preset-env": "^7.26.9",
26 | "@babel/runtime": "^7.27.0",
27 | "@react-native-community/cli": "^20.0.2",
28 | "@react-native-community/cli-platform-android": "^20.0.2",
29 | "@react-native-community/cli-platform-ios": "^20.0.2",
30 | "@react-native-node-api/ferric-example": "*",
31 | "@react-native-node-api/node-addon-examples": "*",
32 | "@react-native-node-api/node-tests": "*",
33 | "@react-native/babel-preset": "0.81.4",
34 | "@react-native/metro-config": "0.81.4",
35 | "@react-native/typescript-config": "0.81.4",
36 | "@rnx-kit/metro-config": "^2.1.1",
37 | "@types/mocha": "^10.0.10",
38 | "@types/react": "^19.1.0",
39 | "concurrently": "^9.1.2",
40 | "mocha": "^11.6.0",
41 | "mocha-remote-cli": "^1.13.2",
42 | "mocha-remote-react-native": "^1.13.2",
43 | "react": "19.1.0",
44 | "react-native": "0.81.4",
45 | "react-native-node-api": "*",
46 | "react-native-test-app": "^4.4.7",
47 | "weak-node-api": "*"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/host/src/node/gradle.test.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import { describe, it } from "node:test";
3 | import cp from "node:child_process";
4 | import path from "node:path";
5 |
6 | const PACKAGE_ROOT = path.join(__dirname, "../..");
7 | const MONOREPO_ROOT = path.join(PACKAGE_ROOT, "../..");
8 | const TEST_APP_ANDROID_PATH = path.join(MONOREPO_ROOT, "apps/test-app/android");
9 |
10 | describe(
11 | "Gradle tasks",
12 | // Skipping these tests by default, as they download a lot and takes a long time
13 | { skip: process.env.ENABLE_GRADLE_TESTS !== "true" },
14 | () => {
15 | describe("linkNodeApiModules task", () => {
16 | it("should fail if REACT_NATIVE_OVERRIDE_HERMES_DIR is not set", () => {
17 | const { status, stdout, stderr } = cp.spawnSync(
18 | "sh",
19 | ["gradlew", "react-native-node-api:linkNodeApiModules"],
20 | {
21 | cwd: TEST_APP_ANDROID_PATH,
22 | env: {
23 | ...process.env,
24 | REACT_NATIVE_OVERRIDE_HERMES_DIR: undefined,
25 | },
26 | encoding: "utf-8",
27 | },
28 | );
29 |
30 | assert.notEqual(status, 0, `Expected failure: ${stdout} ${stderr}`);
31 | assert.match(
32 | stderr,
33 | /React Native Node-API needs a custom version of Hermes with Node-API enabled/,
34 | );
35 | assert.match(
36 | stderr,
37 | /Run the following in your Bash- or Zsh-compatible terminal, to clone Hermes and instruct React Native to use it/,
38 | );
39 | assert.match(
40 | stderr,
41 | /export REACT_NATIVE_OVERRIDE_HERMES_DIR=\$\(npx react-native-node-api vendor-hermes --silent\)/,
42 | );
43 | assert.match(
44 | stderr,
45 | /And follow this guide to build React Native from source/,
46 | );
47 | });
48 |
49 | it("should call the CLI to autolink", () => {
50 | const { status, stdout, stderr } = cp.spawnSync(
51 | "sh",
52 | ["gradlew", "react-native-node-api:linkNodeApiModules"],
53 | {
54 | cwd: TEST_APP_ANDROID_PATH,
55 | env: {
56 | ...process.env,
57 | // We're passing some directory which exists
58 | REACT_NATIVE_OVERRIDE_HERMES_DIR: __dirname,
59 | },
60 | encoding: "utf-8",
61 | },
62 | );
63 |
64 | assert.equal(status, 0, `Expected success: ${stdout} ${stderr}`);
65 | assert.match(stdout, /Auto-linking Node-API modules/);
66 | });
67 | });
68 | },
69 | );
70 |
--------------------------------------------------------------------------------
/packages/cmake-rn/src/platforms/types.ts:
--------------------------------------------------------------------------------
1 | import * as cli from "@react-native-node-api/cli-utils";
2 |
3 | import type { program } from "../cli.js";
4 |
5 | type InferOptionValues = ReturnType<
6 | Command["opts"]
7 | >;
8 |
9 | type BaseCommand = typeof program;
10 | type ExtendedCommand = cli.Command<
11 | [],
12 | Opts & InferOptionValues,
13 | Record // Global opts are not supported
14 | >;
15 |
16 | export type BaseOpts = Omit, "triplet">;
17 |
18 | export type TripletContext = {
19 | triplet: Triplet;
20 | /**
21 | * Spawn a command in the context of this triplet
22 | */
23 | spawn: Spawn;
24 | };
25 |
26 | export type Spawn = (
27 | command: string,
28 | args: string[],
29 | cwd?: string,
30 | ) => Promise;
31 |
32 | export type Platform<
33 | Triplets extends string[] = string[],
34 | Opts extends cli.OptionValues = Record,
35 | Command = ExtendedCommand,
36 | Triplet extends string = Triplets[number],
37 | > = {
38 | /**
39 | * Used to identify the platform in the CLI.
40 | */
41 | id: string;
42 | /**
43 | * Name of the platform, used for display purposes.
44 | */
45 | name: string;
46 | /**
47 | * All the triplets supported by this platform.
48 | */
49 | triplets: Readonly;
50 | /**
51 | * Get the limited subset of triplets that should be built by default for this platform.
52 | */
53 | defaultTriplets(
54 | mode: "current-development" | "all",
55 | ): Triplet[] | Promise;
56 | /**
57 | * Implement this to add any platform specific options to the command.
58 | */
59 | amendCommand(command: BaseCommand): Command;
60 | /**
61 | * Check if the platform is supported by the host system, running the build.
62 | */
63 | isSupportedByHost(): boolean | Promise;
64 | /**
65 | * Configure all projects for this platform.
66 | */
67 | configure(
68 | triplets: TripletContext[],
69 | options: BaseOpts & Opts,
70 | spawn: Spawn,
71 | ): Promise;
72 | /**
73 | * Platform specific command to build a triplet project.
74 | */
75 | build(
76 | context: TripletContext,
77 | options: BaseOpts & Opts,
78 | ): Promise;
79 | /**
80 | * Called to combine multiple triplets into a single prebuilt artefact.
81 | */
82 | postBuild(
83 | /**
84 | * Location of the final prebuilt artefact.
85 | */
86 | outputPath: string,
87 | triplets: TripletContext[],
88 | options: BaseOpts & Opts,
89 | ): Promise;
90 | };
91 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@react-native-node-api/root",
3 | "description": "Node-API Modules for React Native",
4 | "type": "module",
5 | "private": true,
6 | "workspaces": [
7 | "packages/cli-utils",
8 | "packages/cmake-file-api",
9 | "packages/weak-node-api",
10 | "packages/cmake-rn",
11 | "packages/ferric",
12 | "packages/gyp-to-cmake",
13 | "packages/host",
14 | "packages/node-addon-examples",
15 | "packages/node-tests",
16 | "packages/ferric-example",
17 | "apps/test-app"
18 | ],
19 | "homepage": "https://github.com/callstackincubator/react-native-node-api#readme",
20 | "scripts": {
21 | "build": "tsc --build",
22 | "clean": "tsc --build --clean && git clean -fdx -e node_modules",
23 | "dev": "tsc --build --watch",
24 | "lint": "eslint .",
25 | "depcheck": "node scripts/depcheck.ts",
26 | "publint": "node scripts/run-in-published.ts npx publint --strict",
27 | "prettier:check": "prettier --experimental-cli --check .",
28 | "prettier:write": "prettier --experimental-cli --write .",
29 | "test": "npm test --workspace react-native-node-api --workspace cmake-rn --workspace gyp-to-cmake --workspace node-addon-examples",
30 | "bootstrap": "node --run build && npm run bootstrap --workspaces --if-present",
31 | "changeset": "changeset",
32 | "release": "changeset publish",
33 | "init-macos-test-app": "node scripts/init-macos-test-app.ts"
34 | },
35 | "author": {
36 | "name": "Callstack",
37 | "url": "https://github.com/callstackincubator"
38 | },
39 | "contributors": [
40 | {
41 | "name": "Kræn Hansen",
42 | "url": "https://github.com/kraenhansen"
43 | },
44 | {
45 | "name": "Jamie Birch",
46 | "url": "https://github.com/shirakaba"
47 | },
48 | {
49 | "name": "Mariusz Pasiński",
50 | "url": "https://github.com/mani3xis"
51 | },
52 | {
53 | "name": "Kamil Paradowski",
54 | "url": "https://github.com/paradowstack"
55 | }
56 | ],
57 | "license": "MIT",
58 | "devDependencies": {
59 | "@changesets/cli": "^2.29.5",
60 | "@eslint/js": "^9.32.0",
61 | "@prettier/plugin-oxc": "^0.0.4",
62 | "@reporters/github": "^1.7.2",
63 | "@tsconfig/node22": "^22.0.0",
64 | "@tsconfig/react-native": "3.0.6",
65 | "@types/node": "^22",
66 | "depcheck": "^1.4.7",
67 | "eslint": "^9.32.0",
68 | "eslint-config-prettier": "^10.1.8",
69 | "globals": "^16.0.0",
70 | "prettier": "^3.6.2",
71 | "publint": "^0.3.15",
72 | "react-native": "0.81.4",
73 | "read-pkg": "^9.0.1",
74 | "tsx": "^4.20.6",
75 | "typescript": "^5.8.0",
76 | "typescript-eslint": "^8.38.0"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/host/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-node-api",
3 | "version": "0.7.1",
4 | "description": "Node-API for React Native",
5 | "homepage": "https://github.com/callstackincubator/react-native-node-api",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
9 | "directory": "packages/host"
10 | },
11 | "main": "dist/react-native/index.js",
12 | "type": "commonjs",
13 | "bin": {
14 | "react-native-node-api": "./bin/react-native-node-api.mjs"
15 | },
16 | "exports": {
17 | ".": {
18 | "node": "./dist/node/index.js",
19 | "react-native": "./dist/react-native/index.js"
20 | },
21 | "./babel-plugin": "./dist/node/babel-plugin/index.js",
22 | "./cli": "./dist/node/cli/run.js"
23 | },
24 | "files": [
25 | "logo.svg",
26 | "bin",
27 | "dist",
28 | "!dist/**/*.test.d.ts",
29 | "!dist/**/*.test.d.ts.map",
30 | "cpp",
31 | "android",
32 | "!android/.cxx",
33 | "!android/build",
34 | "apple",
35 | "include",
36 | "babel-plugin.js",
37 | "scripts/patch-hermes.rb",
38 | "weak-node-api/**",
39 | "!weak-node-api/build/",
40 | "*.js",
41 | "*.podspec"
42 | ],
43 | "scripts": {
44 | "build": "tsc --build",
45 | "injector:generate": "node scripts/generate-injector.mts",
46 | "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",
47 | "test:gradle": "ENABLE_GRADLE_TESTS=true node --run test",
48 | "bootstrap": "node --run injector:generate"
49 | },
50 | "keywords": [
51 | "node-api",
52 | "napi",
53 | "node-addon-api",
54 | "native",
55 | "addon"
56 | ],
57 | "author": {
58 | "name": "Callstack",
59 | "url": "https://github.com/callstackincubator"
60 | },
61 | "contributors": [
62 | {
63 | "name": "Kræn Hansen",
64 | "url": "https://github.com/kraenhansen"
65 | }
66 | ],
67 | "license": "MIT",
68 | "dependencies": {
69 | "@expo/plist": "^0.4.7",
70 | "@react-native-node-api/cli-utils": "0.1.2",
71 | "pkg-dir": "^8.0.0",
72 | "read-pkg": "^9.0.1",
73 | "zod": "^4.1.11"
74 | },
75 | "devDependencies": {
76 | "@babel/core": "^7.26.10",
77 | "@babel/types": "^7.27.0",
78 | "fswin": "^3.24.829"
79 | },
80 | "peerDependencies": {
81 | "@babel/core": "^7.26.10",
82 | "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",
83 | "weak-node-api": "0.0.3"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/packages/gyp-to-cmake/src/gyp.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 |
4 | import { parse } from "gyp-parser";
5 |
6 | export type GypTarget = {
7 | target_name: string;
8 | sources: string[];
9 | include_dirs?: string[];
10 | defines?: string[];
11 | };
12 |
13 | export type GypBinding = {
14 | targets: GypTarget[];
15 | };
16 |
17 | function assertNoExtraProperties(
18 | input: T,
19 | expectedKeys: string[],
20 | ) {
21 | for (const key of Object.keys(input)) {
22 | if (!expectedKeys.includes(key)) {
23 | throw new Error(`Unexpected property: ${key}`);
24 | }
25 | }
26 | }
27 |
28 | export function assertTarget(
29 | target: unknown,
30 | disallowUnknownProperties = false,
31 | ): asserts target is GypTarget {
32 | assert(typeof target === "object" && target !== null, "Expected an object");
33 | assert("target_name" in target, "Expected a 'target_name' property");
34 | assert("sources" in target, "Expected a 'sources' property");
35 | const { sources } = target;
36 | assert(Array.isArray(sources), "Expected a 'sources' array");
37 | assert(
38 | sources.every((source) => typeof source === "string"),
39 | "Expected all sources to be strings",
40 | );
41 | if ("include_dirs" in target) {
42 | const { include_dirs } = target;
43 | assert(
44 | Array.isArray(include_dirs),
45 | "Expected 'include_dirs' to be an array",
46 | );
47 | assert(
48 | include_dirs.every((dir) => typeof dir === "string"),
49 | "Expected all include_dirs to be strings",
50 | );
51 | }
52 | if (disallowUnknownProperties) {
53 | assertNoExtraProperties(target, ["target_name", "sources", "include_dirs"]);
54 | }
55 | }
56 |
57 | export function assertBinding(
58 | json: unknown,
59 | disallowUnknownProperties = false,
60 | ): asserts json is GypBinding {
61 | assert(typeof json === "object" && json !== null, "Expected an object");
62 | assert("targets" in json, "Expected a 'targets' property");
63 | const { targets } = json;
64 | assert(Array.isArray(targets), "Expected a 'targets' array");
65 | for (const target of targets) {
66 | assertTarget(target, disallowUnknownProperties);
67 | }
68 | if (disallowUnknownProperties) {
69 | assertNoExtraProperties(json, ["targets"]);
70 | }
71 | }
72 |
73 | export function readBindingFile(
74 | path: string,
75 | disallowUnknownProperties = false,
76 | ): GypBinding {
77 | try {
78 | const contents = fs.readFileSync(path, "utf-8");
79 | const json = parse(contents);
80 | assertBinding(json, disallowUnknownProperties);
81 | return json;
82 | } catch (err) {
83 | throw new Error("Failed to parse binding.gyp file", { cause: err });
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/scripts/depcheck.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import assert from "node:assert/strict";
3 | import cp from "node:child_process";
4 | import fs from "node:fs";
5 |
6 | import depcheck from "depcheck";
7 |
8 | function getWorkspaces() {
9 | const workspaces = JSON.parse(
10 | cp.execFileSync("npm", ["query", ".workspace"], { encoding: "utf8" }),
11 | ) as unknown;
12 | assert(Array.isArray(workspaces));
13 | for (const workspace of workspaces) {
14 | assert(typeof workspace === "object" && workspace !== null);
15 | }
16 | return workspaces as Record[];
17 | }
18 |
19 | const rootDir = path.resolve(import.meta.dirname, "..");
20 | const root = await depcheck(rootDir, {});
21 |
22 | const rootPackage = JSON.parse(
23 | await fs.promises.readFile(path.join(rootDir, "package.json"), {
24 | encoding: "utf8",
25 | }),
26 | ) as unknown;
27 |
28 | assert(
29 | typeof rootPackage === "object" &&
30 | rootPackage !== null &&
31 | "devDependencies" in rootPackage &&
32 | typeof rootPackage.devDependencies === "object" &&
33 | rootPackage.devDependencies !== null,
34 | );
35 |
36 | const rootDevDependencies = new Set(Object.keys(rootPackage.devDependencies));
37 | for (const packageName of [...rootDevDependencies.values()]) {
38 | rootDevDependencies.add(`@types/${packageName}`);
39 | }
40 |
41 | for (const {
42 | name: workspaceName,
43 | path: workspacePath,
44 | private: workspacePrivate,
45 | } of getWorkspaces()) {
46 | assert(typeof workspaceName === "string");
47 | assert(typeof workspacePath === "string");
48 | assert(
49 | typeof workspacePrivate === "boolean" ||
50 | typeof workspacePrivate === "undefined",
51 | );
52 | if (workspacePrivate) {
53 | console.warn(`Skipping private package '${workspaceName}'`);
54 | continue;
55 | }
56 | const result = await depcheck(workspacePath, {
57 | ignoreMatches: [...rootDevDependencies],
58 | });
59 | for (const [name, filePaths] of Object.entries(result.missing)) {
60 | if (!rootDevDependencies.has(name)) {
61 | console.error(`Missing '${name}' in '${workspaceName}':`);
62 | for (const filePath of filePaths) {
63 | console.error("↳", path.relative(workspacePath, filePath));
64 | }
65 | console.error();
66 | process.exitCode = 1;
67 | }
68 | }
69 | for (const name of result.dependencies) {
70 | console.error(`Unused dependency '${name}' in '${workspaceName}'`);
71 | console.error();
72 | process.exitCode = 1;
73 | }
74 | for (const name of result.devDependencies) {
75 | console.error(`Unused dev-dependency '${name}' in '${workspaceName}'`);
76 | console.error();
77 | process.exitCode = 1;
78 | }
79 | }
80 |
81 | assert.deepEqual(root.dependencies, [], "Found unused dependencies");
82 |
--------------------------------------------------------------------------------
/packages/host/src/node/prebuilds/android.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 |
5 | import { AndroidTriplet } from "./triplets.js";
6 | import { determineLibraryBasename } from "../path-utils.js";
7 |
8 | export const DEFAULT_ANDROID_TRIPLETS = [
9 | "aarch64-linux-android",
10 | "armv7a-linux-androideabi",
11 | "i686-linux-android",
12 | "x86_64-linux-android",
13 | ] as const satisfies AndroidTriplet[];
14 |
15 | type AndroidArchitecture = "armeabi-v7a" | "arm64-v8a" | "x86" | "x86_64";
16 |
17 | export const ANDROID_ARCHITECTURES = {
18 | "armv7a-linux-androideabi": "armeabi-v7a",
19 | "aarch64-linux-android": "arm64-v8a",
20 | "i686-linux-android": "x86",
21 | "x86_64-linux-android": "x86_64",
22 | } satisfies Record;
23 |
24 | /**
25 | * Determine the filename of the Android libs directory based on the framework paths.
26 | * Ensuring that all framework paths have the same base name.
27 | */
28 | export function determineAndroidLibsFilename(libraryPaths: string[]) {
29 | const libraryName = determineLibraryBasename(libraryPaths);
30 | return `${libraryName}.android.node`;
31 | }
32 |
33 | type AndroidLibsDirectoryOptions = {
34 | outputPath: string;
35 | libraries: { triplet: AndroidTriplet; libraryPath: string }[];
36 | autoLink: boolean;
37 | };
38 |
39 | export async function createAndroidLibsDirectory({
40 | outputPath,
41 | libraries,
42 | autoLink,
43 | }: AndroidLibsDirectoryOptions) {
44 | // Delete and recreate any existing output directory
45 | await fs.promises.rm(outputPath, { recursive: true, force: true });
46 | await fs.promises.mkdir(outputPath, { recursive: true });
47 | for (const { triplet, libraryPath } of libraries) {
48 | assert(
49 | fs.existsSync(libraryPath),
50 | `Library not found: ${libraryPath} for triplet ${triplet}`,
51 | );
52 | const arch = ANDROID_ARCHITECTURES[triplet];
53 | const archOutputPath = path.join(outputPath, arch);
54 | await fs.promises.mkdir(archOutputPath, { recursive: true });
55 | // Strip the ".node" extension from the library name
56 | const libraryName = path.basename(libraryPath, ".node");
57 | const soSuffixedName =
58 | path.extname(libraryName) === ".so" ? libraryName : `${libraryName}.so`;
59 | const finalLibraryName = libraryName.startsWith("lib")
60 | ? soSuffixedName
61 | : `lib${soSuffixedName}`;
62 | const libraryOutputPath = path.join(archOutputPath, finalLibraryName);
63 | await fs.promises.copyFile(libraryPath, libraryOutputPath);
64 | // TODO: Update the install path in the library file
65 | }
66 | if (autoLink) {
67 | // Write a file to mark the Android libs directory is a Node-API module
68 | await fs.promises.writeFile(
69 | path.join(outputPath, "react-native-node-api-module"),
70 | "",
71 | "utf8",
72 | );
73 | }
74 | return outputPath;
75 | }
76 |
--------------------------------------------------------------------------------
/packages/weak-node-api/scripts/generate.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 | import cp from "node:child_process";
5 |
6 | import {
7 | FunctionDecl,
8 | getNodeApiFunctions,
9 | } from "../src/node-api-functions.js";
10 |
11 | import * as weakNodeApiGenerator from "./generators/weak-node-api.js";
12 | import * as hostGenerator from "./generators/NodeApiHost.js";
13 |
14 | export const OUTPUT_PATH = path.join(import.meta.dirname, "../generated");
15 |
16 | type GenerateFileOptions = {
17 | functions: FunctionDecl[];
18 | fileName: string;
19 | generator: (functions: FunctionDecl[]) => string;
20 | headingComment?: string;
21 | };
22 |
23 | async function generateFile({
24 | functions,
25 | fileName,
26 | generator,
27 | headingComment = "",
28 | }: GenerateFileOptions) {
29 | const generated = generator(functions);
30 | const output = `
31 | /**
32 | * @file ${fileName}
33 | * ${headingComment
34 | .trim()
35 | .split("\n")
36 | .map((l) => l.trim())
37 | .join("\n* ")}
38 | *
39 | * @note This file is generated - don't edit it directly
40 | */
41 |
42 | ${generated}
43 | `;
44 | const outputPath = path.join(OUTPUT_PATH, fileName);
45 | await fs.promises.writeFile(outputPath, output.trim(), "utf-8");
46 | const { status, stderr = "No error output" } = cp.spawnSync(
47 | "clang-format",
48 | ["-i", outputPath],
49 | {
50 | encoding: "utf8",
51 | },
52 | );
53 | assert.equal(status, 0, `Failed to format ${fileName}: ${stderr}`);
54 | }
55 |
56 | async function run() {
57 | await fs.promises.mkdir(OUTPUT_PATH, { recursive: true });
58 |
59 | const functions = getNodeApiFunctions();
60 | await generateFile({
61 | functions,
62 | fileName: "NodeApiHost.hpp",
63 | generator: hostGenerator.generateHeader,
64 | headingComment: `
65 | @brief NodeApiHost struct.
66 |
67 | This header provides a struct of Node-API functions implemented by a host to inject its implementations.
68 | `,
69 | });
70 | await generateFile({
71 | functions,
72 | fileName: "weak_node_api.hpp",
73 | generator: weakNodeApiGenerator.generateHeader,
74 | headingComment: `
75 | @brief Weak Node-API host injection interface.
76 |
77 | This header provides the struct and injection function for deferring Node-API function calls from addons into a Node-API host.
78 | `,
79 | });
80 | await generateFile({
81 | functions,
82 | fileName: "weak_node_api.cpp",
83 | generator: weakNodeApiGenerator.generateSource,
84 | headingComment: `
85 | @brief Weak Node-API host injection implementation.
86 |
87 | Provides the implementation for deferring Node-API function calls from addons into a Node-API host.
88 | `,
89 | });
90 | }
91 |
92 | run().catch((err) => {
93 | console.error(err);
94 | process.exitCode = 1;
95 | });
96 |
--------------------------------------------------------------------------------
/packages/host/scripts/generate-injector.mts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import path from "node:path";
3 | import cp from "node:child_process";
4 |
5 | import { type FunctionDecl, getNodeApiFunctions } from "weak-node-api";
6 |
7 | export const CPP_SOURCE_PATH = path.join(import.meta.dirname, "../cpp");
8 |
9 | // TODO: Remove when all runtime Node API functions are implemented
10 | const IMPLEMENTED_RUNTIME_FUNCTIONS = [
11 | "napi_create_buffer",
12 | "napi_create_buffer_copy",
13 | "napi_is_buffer",
14 | "napi_get_buffer_info",
15 | "napi_create_external_buffer",
16 | "napi_create_async_work",
17 | "napi_queue_async_work",
18 | "napi_delete_async_work",
19 | "napi_cancel_async_work",
20 | "napi_fatal_error",
21 | "napi_get_node_version",
22 | "napi_get_version",
23 | ];
24 |
25 | /**
26 | * Generates source code which injects the Node API functions from the host.
27 | */
28 | export function generateSource(functions: FunctionDecl[]) {
29 | return `
30 | // This file is generated by react-native-node-api
31 | #include
32 | #include
33 |
34 | #include
35 | #include
36 | #include
37 |
38 | #if defined(__APPLE__)
39 | #define WEAK_NODE_API_LIBRARY_NAME "@rpath/weak-node-api.framework/weak-node-api"
40 | #elif defined(__ANDROID__)
41 | #define WEAK_NODE_API_LIBRARY_NAME "libweak-node-api.so"
42 | #else
43 | #error "WEAK_NODE_API_LIBRARY_NAME cannot be defined for this platform"
44 | #endif
45 |
46 | namespace callstack::react_native_node_api {
47 |
48 | void injectIntoWeakNodeApi() {
49 | void *module = dlopen(WEAK_NODE_API_LIBRARY_NAME, RTLD_NOW | RTLD_LOCAL);
50 | if (nullptr == module) {
51 | log_debug("NapiHost: Failed to load weak-node-api: %s", dlerror());
52 | abort();
53 | }
54 |
55 | auto inject_weak_node_api_host = (InjectHostFunction)dlsym(
56 | module, "inject_weak_node_api_host");
57 | if (nullptr == inject_weak_node_api_host) {
58 | log_debug("NapiHost: Failed to find 'inject_weak_node_api_host' function: %s", dlerror());
59 | abort();
60 | }
61 |
62 | log_debug("Injecting NodeApiHost");
63 | inject_weak_node_api_host(NodeApiHost {
64 | ${functions
65 | .filter(
66 | ({ kind, name }) =>
67 | kind === "engine" || IMPLEMENTED_RUNTIME_FUNCTIONS.includes(name),
68 | )
69 | .flatMap(({ name }) => `.${name} = ${name},`)
70 | .join("\n")}
71 | });
72 | }
73 | } // namespace callstack::react_native_node_api
74 | `;
75 | }
76 |
77 | async function run() {
78 | const nodeApiFunctions = getNodeApiFunctions();
79 |
80 | const source = generateSource(nodeApiFunctions);
81 | const sourcePath = path.join(CPP_SOURCE_PATH, "WeakNodeApiInjector.cpp");
82 | await fs.promises.writeFile(sourcePath, source, "utf-8");
83 | cp.spawnSync("clang-format", ["-i", sourcePath], { stdio: "inherit" });
84 | }
85 |
86 | run().catch((err) => {
87 | console.error(err);
88 | process.exitCode = 1;
89 | });
90 |
--------------------------------------------------------------------------------
/packages/cmake-file-api/src/schemas/ReplyIndexV1.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const ReplyFileReferenceV1 = z.object({
4 | kind: z.enum([
5 | "codemodel",
6 | "configureLog",
7 | "cache",
8 | "cmakeFiles",
9 | "toolchains",
10 | ]),
11 | version: z.object({
12 | major: z.number(),
13 | minor: z.number(),
14 | }),
15 | jsonFile: z.string(),
16 | });
17 |
18 | const ReplyErrorObject = z.object({
19 | error: z.string(),
20 | });
21 |
22 | const VersionNumber = z.number();
23 |
24 | const VersionObject = z.object({
25 | major: z.number(),
26 | minor: z.number().optional(),
27 | });
28 |
29 | const VersionSpec = z.union([
30 | VersionNumber,
31 | VersionObject,
32 | z.array(z.union([VersionNumber, VersionObject])),
33 | ]);
34 |
35 | const QueryRequest = z.object({
36 | kind: z.string(),
37 | version: VersionSpec.optional(),
38 | client: z.unknown().optional(),
39 | });
40 |
41 | const ClientStatefulQueryReply = z.object({
42 | client: z.unknown().optional(),
43 | requests: z.array(QueryRequest).optional(),
44 | responses: z.array(ReplyFileReferenceV1).optional(),
45 | });
46 |
47 | export const IndexReplyV1 = z.object({
48 | cmake: z.object({
49 | version: z.object({
50 | major: z.number(),
51 | minor: z.number(),
52 | patch: z.number(),
53 | suffix: z.string(),
54 | string: z.string(),
55 | isDirty: z.boolean(),
56 | }),
57 | paths: z.object({
58 | cmake: z.string(),
59 | ctest: z.string(),
60 | cpack: z.string(),
61 | root: z.string(),
62 | }),
63 | generator: z.object({
64 | multiConfig: z.boolean(),
65 | name: z.string(),
66 | platform: z.string().optional(),
67 | }),
68 | }),
69 | objects: z.array(ReplyFileReferenceV1),
70 | reply: z.record(
71 | z.string(),
72 | z
73 | .union([
74 | ReplyFileReferenceV1,
75 | ReplyErrorObject,
76 | z.record(
77 | z.string(),
78 | z.union([
79 | ReplyFileReferenceV1,
80 | ReplyErrorObject,
81 | ClientStatefulQueryReply,
82 | ]),
83 | ),
84 | ])
85 | .optional(),
86 | ),
87 | });
88 |
89 | const ReplyErrorIndexFileReference = ReplyFileReferenceV1.extend({
90 | kind: z.enum(["configureLog"]),
91 | });
92 |
93 | const ClientStatefulQueryReplyForErrorIndex = ClientStatefulQueryReply.extend({
94 | responses: z.array(ReplyErrorIndexFileReference).optional(),
95 | });
96 |
97 | export const ReplyErrorIndex = IndexReplyV1.extend({
98 | objects: z.array(ReplyErrorIndexFileReference),
99 | reply: z.record(
100 | z.string(),
101 | z
102 | .union([
103 | ReplyErrorIndexFileReference,
104 | ReplyErrorObject,
105 | z.record(
106 | z.string(),
107 | z.union([
108 | ReplyErrorIndexFileReference,
109 | ReplyErrorObject,
110 | ClientStatefulQueryReplyForErrorIndex,
111 | ]),
112 | ),
113 | ])
114 | .optional(),
115 | ),
116 | });
117 |
--------------------------------------------------------------------------------
/packages/host/cpp/AddonLoaders.hpp:
--------------------------------------------------------------------------------
1 | #pragma once
2 | #include "Logger.hpp"
3 |
4 | #include
5 |
6 | #if defined(__APPLE__) || defined(__ANDROID__)
7 | #include
8 | #include
9 |
10 | using callstack::react_native_node_api::log_debug;
11 |
12 | struct PosixLoader {
13 | using Module = void *;
14 | using Symbol = void *;
15 |
16 | static Module loadLibrary(const char *filePath) {
17 | assert(NULL != filePath);
18 |
19 | Module result = dlopen(filePath, RTLD_NOW | RTLD_LOCAL);
20 | if (NULL == result) {
21 | log_debug("NapiHost: Failed to load library '%s': %s", filePath,
22 | dlerror());
23 | }
24 | return result;
25 | }
26 |
27 | static Symbol getSymbol(Module library, const char *name) {
28 | assert(NULL != library);
29 | assert(NULL != name);
30 | Symbol result = dlsym(library, name);
31 | // if (NULL == result) {
32 | // NSLog(@"NapiHost: Cannot find '%s' symbol!", name);
33 | // }
34 | return result;
35 | }
36 |
37 | static void unloadLibrary(Module library) {
38 | if (NULL != library) {
39 | dlclose(library);
40 | }
41 | }
42 | };
43 | #endif
44 |
45 | #if defined(_WIN32)
46 | struct Win32Loader {
47 | using Module = HMODULE;
48 | using Symbol = void *;
49 |
50 | static Module loadLibrary(const char *filePath) {
51 | assert(NULL != filePath);
52 | Module result = LoadLibrary(filePath);
53 | if (NULL == result) {
54 | // TODO: Handle the error case... call GetLastError() that gives us error
55 | // code as DWORD
56 | }
57 | return result;
58 | }
59 |
60 | static Symbol getSymbol(Module library, const char *name) {
61 | assert(NULL != library);
62 | assert(NULL != name);
63 | Symbol result = GetProcAddress(library, name);
64 | if (NULL == result) {
65 | // TODO: Handle the error case... call GetLastError() that gives us error
66 | // code as DWORD
67 | }
68 | return result;
69 | }
70 |
71 | static void unloadLibrary(Module library) {
72 | if (NULL != library) {
73 | FreeLibrary(library);
74 | }
75 | }
76 | };
77 |
78 | struct WinRTLoader {
79 | using Module = HMODULE;
80 | using Symbol = void *;
81 |
82 | static Module loadLibrary(const char *filePath) {
83 | assert(NULL != filePath);
84 | Module result = LoadPackagedLibrary(filePath);
85 | if (NULL == result) {
86 | // TODO: Handle the error case... call GetLastError() that gives us error
87 | // code as DWORD
88 | }
89 | return result;
90 | }
91 |
92 | static Symbol getSymbol(Module library, const char *name) {
93 | assert(NULL != library);
94 | assert(NULL != name);
95 | Symbol result = GetProcAddress(library, name);
96 | if (NULL == result) {
97 | // TODO: Handle the error case... call GetLastError() that gives us error
98 | // code as DWORD
99 | }
100 | return result;
101 | }
102 |
103 | static void unloadLibrary(Module library) {
104 | if (NULL != library) {
105 | FreeLibrary(library);
106 | }
107 | }
108 | };
109 | #endif
--------------------------------------------------------------------------------
/packages/node-addon-examples/scripts/verify-prebuilds.mts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import assert from "node:assert/strict";
3 | import path from "node:path";
4 |
5 | import { EXAMPLES_DIR } from "./cmake-projects.mjs";
6 |
7 | const EXPECTED_ANDROID_ARCHS = ["armeabi-v7a", "arm64-v8a", "x86_64", "x86"];
8 |
9 | const EXPECTED_XCFRAMEWORK_PLATFORMS = [
10 | "ios-arm64",
11 | "ios-arm64-simulator",
12 | "macos-arm64_x86_64",
13 | "tvos-arm64",
14 | "tvos-arm64-simulator",
15 | "xros-arm64",
16 | "xros-arm64-simulator",
17 | ];
18 |
19 | async function verifyAndroidPrebuild(dirent: fs.Dirent) {
20 | console.log(
21 | "Verifying Android prebuild",
22 | dirent.name,
23 | "in",
24 | dirent.parentPath,
25 | );
26 | for (const arch of EXPECTED_ANDROID_ARCHS) {
27 | const archDir = path.join(dirent.parentPath, dirent.name, arch);
28 | for (const file of await fs.promises.readdir(archDir, {
29 | withFileTypes: true,
30 | })) {
31 | assert(file.isFile());
32 | assert(
33 | !file.name.endsWith(".node"),
34 | `Unexpected .node file: ${path.join(file.parentPath, file.name)}`,
35 | );
36 | }
37 | }
38 | }
39 |
40 | async function verifyApplePrebuild(dirent: fs.Dirent) {
41 | console.log("Verifying Apple prebuild", dirent.name, "in", dirent.parentPath);
42 | for (const arch of EXPECTED_XCFRAMEWORK_PLATFORMS) {
43 | const archDir = path.join(dirent.parentPath, dirent.name, arch);
44 | for (const file of await fs.promises.readdir(archDir, {
45 | withFileTypes: true,
46 | })) {
47 | assert(
48 | file.isDirectory(),
49 | "Expected only directories in xcframework arch directory",
50 | );
51 | assert(file.name.endsWith(".framework"), "Expected framework directory");
52 | const frameworkDir = path.join(file.parentPath, file.name);
53 | for (const file of await fs.promises.readdir(frameworkDir, {
54 | withFileTypes: true,
55 | })) {
56 | if (file.isDirectory()) {
57 | assert.equal(
58 | file.name,
59 | "Headers",
60 | "Unexpected directory in xcframework",
61 | );
62 | } else {
63 | assert(
64 | file.isFile(),
65 | "Expected only directory and files in framework",
66 | );
67 | if (file.name === "Info.plist") {
68 | // TODO: Verify the contents of the Info.plist file
69 | continue;
70 | } else {
71 | assert(
72 | !file.name.endsWith(".node"),
73 | `Didn't expected a .node file in xcframework: ${path.join(
74 | frameworkDir,
75 | file.name,
76 | )}`,
77 | );
78 | }
79 | }
80 | }
81 | }
82 | }
83 | }
84 |
85 | for await (const dirent of fs.promises.glob("**/*.*.node", {
86 | cwd: EXAMPLES_DIR,
87 | withFileTypes: true,
88 | })) {
89 | if (dirent.name.endsWith(".android.node")) {
90 | await verifyAndroidPrebuild(dirent);
91 | } else if (dirent.name.endsWith(".apple.node")) {
92 | await verifyApplePrebuild(dirent);
93 | } else {
94 | throw new Error(
95 | `Unexpected prebuild file: ${dirent.name} in ${dirent.parentPath}`,
96 | );
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/apps/test-app/android/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo. 1>&2
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
48 | echo. 1>&2
49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
50 | echo location of your Java installation. 1>&2
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo. 1>&2
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
62 | echo. 1>&2
63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
64 | echo location of your Java installation. 1>&2
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/packages/cmake-file-api/src/query.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import path from "node:path";
3 |
4 | /**
5 | * Creates a shared stateless query file for the specified object kind and major version.
6 | * These are stateless shared queries not owned by any specific client.
7 | *
8 | * @param buildPath Path to the build directory
9 | * @param kind Object kind to query for
10 | * @param majorVersion Major version number as string
11 | */
12 | export async function createSharedStatelessQuery(
13 | buildPath: string,
14 | kind: "codemodel" | "configureLog" | "cache" | "cmakeFiles" | "toolchains",
15 | majorVersion: string,
16 | ) {
17 | const queryPath = path.join(
18 | buildPath,
19 | `.cmake/api/v1/query/${kind}-v${majorVersion}`,
20 | );
21 | await fs.promises.mkdir(path.dirname(queryPath), { recursive: true });
22 | await fs.promises.writeFile(queryPath, "");
23 | }
24 |
25 | /**
26 | * Creates a client stateless query file for the specified client, object kind and major version.
27 | * These are stateless queries owned by the specified client.
28 | *
29 | * @param buildPath Path to the build directory
30 | * @param clientName Unique identifier for the client
31 | * @param kind Object kind to query for
32 | * @param majorVersion Major version number as string
33 | */
34 | export async function createClientStatelessQuery(
35 | buildPath: string,
36 | clientName: string,
37 | kind: "codemodel" | "configureLog" | "cache" | "cmakeFiles" | "toolchains",
38 | majorVersion: string,
39 | ) {
40 | const queryPath = path.join(
41 | buildPath,
42 | `.cmake/api/v1/query/client-${clientName}/${kind}-v${majorVersion}`,
43 | );
44 | await fs.promises.mkdir(path.dirname(queryPath), { recursive: true });
45 | await fs.promises.writeFile(queryPath, "");
46 | }
47 |
48 | /**
49 | * Version specification for stateful queries
50 | */
51 | export type VersionSpec =
52 | | number // major version only
53 | | { major: number; minor?: number } // major with optional minor
54 | | (number | { major: number; minor?: number })[]; // array of version specs
55 |
56 | /**
57 | * Request specification for stateful queries
58 | */
59 | export interface QueryRequest {
60 | kind: "codemodel" | "configureLog" | "cache" | "cmakeFiles" | "toolchains";
61 | version?: VersionSpec;
62 | client?: unknown; // Reserved for client use
63 | }
64 |
65 | /**
66 | * Stateful query specification
67 | */
68 | export interface StatefulQuery {
69 | requests: QueryRequest[];
70 | client?: unknown; // Reserved for client use
71 | }
72 |
73 | /**
74 | * Creates a client stateful query file (query.json) for the specified client.
75 | * These are stateful queries owned by the specified client that can request
76 | * specific versions and get only the most recent version recognized by CMake.
77 | *
78 | * @param buildPath Path to the build directory
79 | * @param clientName Unique identifier for the client
80 | * @param query Stateful query specification
81 | */
82 | export async function createClientStatefulQuery(
83 | buildPath: string,
84 | clientName: string,
85 | query: StatefulQuery,
86 | ) {
87 | const queryPath = path.join(
88 | buildPath,
89 | `.cmake/api/v1/query/client-${clientName}/query.json`,
90 | );
91 | await fs.promises.mkdir(path.dirname(queryPath), { recursive: true });
92 | await fs.promises.writeFile(queryPath, JSON.stringify(query, null, 2));
93 | }
94 |
--------------------------------------------------------------------------------
/packages/node-addon-examples/src/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-require-imports */
2 |
3 | function assertLogs(cb: () => void, expectedMessages: string[]) {
4 | const errors: Error[] = [];
5 | // Spying on the console.log function, as the examples don't assert anything themselves
6 | const originalLog = console.log;
7 | console.log = (message: string, ...args: unknown[]) => {
8 | const nextMessage = expectedMessages.shift();
9 | const combinedMessage = [message, ...args].map(String).join(" ");
10 | if (nextMessage !== combinedMessage) {
11 | errors.push(new Error(`Unexpected log message '${combinedMessage}'`));
12 | }
13 | };
14 | try {
15 | cb();
16 | if (expectedMessages.length > 0) {
17 | errors.push(
18 | new Error(
19 | `Missing expected message(s): ${expectedMessages.join(", ")}`,
20 | ),
21 | );
22 | }
23 | } finally {
24 | console.log = originalLog;
25 | }
26 | // Throw and first error
27 | const [firstError] = errors;
28 | if (firstError) {
29 | throw firstError;
30 | }
31 | }
32 |
33 | export const suites: Record<
34 | string,
35 | Record void | (() => void | Promise)>
36 | > = {
37 | "1-getting-started": {
38 | "1_hello_world/napi": () =>
39 | assertLogs(() => {
40 | require("../examples/1-getting-started/1_hello_world/napi/hello.js");
41 | }, ["world"]),
42 | "1_hello_world/node-addon-api": () =>
43 | assertLogs(() => {
44 | require("../examples/1-getting-started/1_hello_world/node-addon-api/hello.js");
45 | }, ["world"]),
46 | "1_hello_world/node-addon-api-addon-class": () =>
47 | assertLogs(() => {
48 | require("../examples/1-getting-started/1_hello_world/node-addon-api-addon-class/hello.js");
49 | }, ["world"]),
50 | "2_function_arguments/napi": () =>
51 | assertLogs(() => {
52 | require("../examples/1-getting-started/2_function_arguments/napi/addon.js");
53 | }, ["This should be eight: 8"]),
54 | "2_function_arguments/node-addon-api": () =>
55 | assertLogs(() => {
56 | require("../examples/1-getting-started/2_function_arguments/node-addon-api/addon.js");
57 | }, ["This should be eight: 8"]),
58 | "3_callbacks/napi": () =>
59 | assertLogs(() => {
60 | require("../examples/1-getting-started/3_callbacks/napi/addon.js");
61 | }, ["hello world"]),
62 | "3_callbacks/node-addon-api": () =>
63 | assertLogs(() => {
64 | require("../examples/1-getting-started/3_callbacks/node-addon-api/addon.js");
65 | }, ["hello world"]),
66 | "4_object_factory/napi": () =>
67 | assertLogs(() => {
68 | require("../examples/1-getting-started/4_object_factory/napi/addon.js");
69 | }, ["hello world"]),
70 | "4_object_factory/node-addon-api": () =>
71 | assertLogs(() => {
72 | require("../examples/1-getting-started/4_object_factory/node-addon-api/addon.js");
73 | }, ["hello world"]),
74 | "5_function_factory": () =>
75 | assertLogs(() => {
76 | require("../examples/1-getting-started/5_function_factory/napi/addon.js");
77 | }, ["hello world"]),
78 | },
79 | "5-async-work": {
80 | // TODO: This crashes (SIGABRT)
81 | // "async_work_thread_safe_function": () => require("../examples/5-async-work/async_work_thread_safe_function/napi/index.js"),
82 | },
83 | tests: {
84 | buffers: () => {
85 | require("../tests/buffers/addon.js");
86 | },
87 | async: () => require("../tests/async/addon.js") as () => Promise,
88 | },
89 | };
90 |
--------------------------------------------------------------------------------
/docs/HOW-IT-WORKS.md:
--------------------------------------------------------------------------------
1 | # How it works
2 |
3 | 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).
4 |
5 |
6 |
7 |
8 |
9 | ## `my-app` makes an `import`
10 |
11 | Everything starts from the consuming app importing the `calculator-lib`.
12 | Metro handles the resolution and the `calculator-lib`'s entrypoint is added to the JavaScript-bundle when bundling.
13 |
14 | ## `calculator-lib` does `require("./prebuild.node")` which is transformed into a call into the host TurboModule
15 |
16 | The library has a require call to a `.node` file, which would normally not have any special meaning:
17 |
18 | ```javascript
19 | module.exports = require("./prebuild.node");
20 | ```
21 |
22 | Since the app developer has added the `react-native-node-api/babel-plugin` to their Babel configuration, the require statement gets transformed when the app is being bundled by Metro, into a `requireNodeAddon` call on our TurboModule.
23 |
24 | The generated code looks something like this:
25 |
26 | ```javascript
27 | module.exports = require("react-native-node-api").requireNodeAddon(
28 | "calculator-lib--prebuild",
29 | );
30 | ```
31 |
32 | > [!NOTE]
33 | > In the time of writing, this code only supports iOS as passes the path to the library with its .framework.
34 | > We plan on generalizing this soon 🤞
35 |
36 | ## Transformed code calls into `react-native-node-api`, loading the platform specific dynamic library
37 |
38 | 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.
39 |
40 | In any case the native code stores the initialization function in a data-structure.
41 |
42 | ## `react-native-node-api` creates a `node_env` and initialize the Node-API module
43 |
44 | The initialization function of a Node-API module expects a `node_env`, which we create by calling `createNodeApiEnv` on the `jsi::Runtime`.
45 |
46 | ## The library's C++ code initialize the `exports` object
47 |
48 | 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:
49 |
50 | - 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).
51 | - 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`.
52 |
53 | ## `my-app` regain control and call `add`
54 |
55 | When the `exports` object is populated by `calculator-lib`'s Node-API module, control is returned to `react-native-node-api` which returns the `exports` object to JavaScript, with the `add` function defined on it.
56 |
57 | ```javascript
58 | import { add } from "calculator-lib";
59 | console.log("1 + 2 =", add(1, 2));
60 | ```
61 |
62 | ## The library's C++ code execute the native function
63 |
64 | Now that the app's JavaScript call the `add` function, the JavaScript engine will know to call the associated native function, which was setup during the initialization of the Node-API module and the native `Add` function is executed and control returned to JavaScript again.
65 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | # Copilot Instructions for React Native Node-API
2 |
3 | This is a **monorepo** that brings Node-API support to React Native, enabling native addons written in C/C++/Rust to run on React Native across iOS and Android.
4 |
5 | ## Package-Specific Instructions
6 |
7 | **IMPORTANT**: Before working on any package, always check for and read package-specific `copilot-instructions.md` files in the package directory. These contain critical preferences and patterns for that specific package.
8 |
9 | ## Architecture Overview
10 |
11 | **Core Flow**: JS `require("./addon.node")` → Babel transform → `requireNodeAddon()` TurboModule call → native library loading → Node-API module initialization
12 |
13 | ### Package Architecture
14 |
15 | See the [README.md](../README.md#packages) for detailed descriptions of each package and their roles in the system. Key packages include:
16 |
17 | - `packages/host` - Core Node-API runtime and Babel plugin
18 | - `packages/cmake-rn` - CMake wrapper for native builds
19 | - `packages/cmake-file-api` - TypeScript wrapper for CMake File API with Zod validation
20 | - `packages/ferric` - Rust/Cargo wrapper with napi-rs integration
21 | - `packages/gyp-to-cmake` - Legacy binding.gyp compatibility
22 | - `apps/test-app` - Integration testing harness
23 |
24 | ## Critical Build Dependencies
25 |
26 | - **Custom Hermes**: Currently depends on a patched Hermes with Node-API support (see [facebook/hermes#1377](https://github.com/facebook/hermes/pull/1377))
27 | - **Prebuilt Binary Spec**: All tools must output to the exact naming scheme:
28 | - Android: `*.android.node/` with jniLibs structure + `react-native-node-api-module` marker file
29 | - iOS: `*.apple.node` (XCFramework renamed) + marker file
30 |
31 | ## Essential Workflows
32 |
33 | ### Development Setup
34 |
35 | ```bash
36 | npm ci && npm run build # Install deps and build all packages
37 | npm run bootstrap # Build native components (weak-node-api, examples)
38 | ```
39 |
40 | ### Package Development
41 |
42 | - **TypeScript project references**: Use `tsc --build` for incremental compilation
43 | - **Workspace scripts**: Most build/test commands use npm workspaces (`--workspace` flag)
44 | - **Focus on Node.js packages**: AI development primarily targets the Node.js tooling packages rather than native mobile code
45 | - **No TypeScript type asserts**: You have to ask explicitly and justify if you want to add `as` type assertions.
46 |
47 | ## Key Patterns
48 |
49 | ### Babel Transformation
50 |
51 | The core magic happens in `packages/host/src/node/babel-plugin/plugin.ts`:
52 |
53 | ```js
54 | // Input: require("./addon.node")
55 | // Output: require("react-native-node-api").requireNodeAddon("pkg-name--addon")
56 | ```
57 |
58 | ### CMake Integration
59 |
60 | For linking against Node-API in CMakeLists.txt:
61 |
62 | ```cmake
63 | include(${WEAK_NODE_API_CONFIG})
64 | target_link_libraries(addon PRIVATE weak-node-api)
65 | ```
66 |
67 | ### Cross-Platform Naming
68 |
69 | Library names use double-dash separation: `package-name--path-component--addon-name`
70 |
71 | ### Testing
72 |
73 | - **Individual packages**: Some packages have VS Code test tasks and others have their own `npm test` scripts for focused iteration (e.g., `npm test --workspace cmake-rn`). Use the latter only if the former is missing.
74 | - **Cross-package**: Use root-level `npm test` for cross-package testing once individual package tests pass
75 | - **Mobile integration**: Available but not the primary AI development focus - ask the developer to run those tests as needed
76 |
77 | **Documentation**: Integration details, platform setup, and toolchain configuration are covered in existing repo documentation files.
78 |
--------------------------------------------------------------------------------
/packages/ferric/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # ferric-cli
2 |
3 | ## 0.3.9
4 |
5 | ### Patch Changes
6 |
7 | - Updated dependencies [7ff2c2b]
8 | - Updated dependencies [7ff2c2b]
9 | - weak-node-api@0.0.3
10 | - @react-native-node-api/cli-utils@0.1.2
11 | - react-native-node-api@0.7.1
12 |
13 | ## 0.3.8
14 |
15 | ### Patch Changes
16 |
17 | - 61fff3f: Allow passing --apple-bundle-identifier to specify the bundle identifiers used when creating Apple frameworks.
18 | - Updated dependencies [60fae96]
19 | - Updated dependencies [61fff3f]
20 | - Updated dependencies [61fff3f]
21 | - Updated dependencies [5dea205]
22 | - Updated dependencies [60fae96]
23 | - Updated dependencies [60fae96]
24 | - Updated dependencies [eca721e]
25 | - Updated dependencies [60fae96]
26 | - react-native-node-api@0.7.0
27 | - weak-node-api@0.0.2
28 |
29 | ## 0.3.7
30 |
31 | ### Patch Changes
32 |
33 | - 9411a8c: Add x86_64 ios simulator target and output universal libraries for iOS simulators.
34 | - 9411a8c: It's no longer required to pass "build" to ferric, as this is default now
35 | - b661176: Add support for visionOS and tvOS targets
36 | - Updated dependencies [07ea9dc]
37 | - Updated dependencies [7536c6c]
38 | - Updated dependencies [c698698]
39 | - Updated dependencies [a2fd422]
40 | - Updated dependencies [bdc172e]
41 | - Updated dependencies [4672e01]
42 | - react-native-node-api@0.6.2
43 |
44 | ## 0.3.6
45 |
46 | ### Patch Changes
47 |
48 | - Updated dependencies [5c3de89]
49 | - Updated dependencies [bb9a78c]
50 | - react-native-node-api@0.6.1
51 |
52 | ## 0.3.5
53 |
54 | ### Patch Changes
55 |
56 | - 5156d35: Refactored moving prettyPath util to CLI utils package
57 | - Updated dependencies [acd06f2]
58 | - Updated dependencies [5156d35]
59 | - Updated dependencies [9f1a301]
60 | - Updated dependencies [5016ed2]
61 | - Updated dependencies [5156d35]
62 | - react-native-node-api@0.6.0
63 | - @react-native-node-api/cli-utils@0.1.1
64 |
65 | ## 0.3.4
66 |
67 | ### Patch Changes
68 |
69 | - Updated dependencies [2b9a538]
70 | - react-native-node-api@0.5.2
71 |
72 | ## 0.3.3
73 |
74 | ### Patch Changes
75 |
76 | - 2a30d8d: Refactored CLIs to use a shared utility package
77 | - Updated dependencies [2a30d8d]
78 | - Updated dependencies [c72970f]
79 | - react-native-node-api@0.5.1
80 |
81 | ## 0.3.2
82 |
83 | ### Patch Changes
84 |
85 | - Updated dependencies [90a1471]
86 | - Updated dependencies [75aaed1]
87 | - Updated dependencies [90a1471]
88 | - react-native-node-api@0.5.0
89 |
90 | ## 0.3.1
91 |
92 | ### Patch Changes
93 |
94 | - Updated dependencies [a0212c8]
95 | - Updated dependencies [a0212c8]
96 | - react-native-node-api@0.4.0
97 |
98 | ## 0.3.0
99 |
100 | ### Minor Changes
101 |
102 | - 8557768: Derive default targets from the FERRIC_TARGETS environment variable
103 |
104 | ### Patch Changes
105 |
106 | - e613efe: Fixed cargo build release flag
107 | - a7cc35a: Updated napi packages.
108 | - Updated dependencies [a477b84]
109 | - Updated dependencies [dc33f3c]
110 | - Updated dependencies [4924f66]
111 | - Updated dependencies [acf1a7c]
112 | - react-native-node-api@0.3.3
113 |
114 | ## 0.2.3
115 |
116 | ### Patch Changes
117 |
118 | - Updated dependencies [045e9e5]
119 | - react-native-node-api@0.3.2
120 |
121 | ## 0.2.2
122 |
123 | ### Patch Changes
124 |
125 | - Updated dependencies [7ad62f7]
126 | - react-native-node-api@0.3.1
127 |
128 | ## 0.2.1
129 |
130 | ### Patch Changes
131 |
132 | - Updated dependencies [bd733b8]
133 | - Updated dependencies [b771a27]
134 | - react-native-node-api@0.3.0
135 |
136 | ## 0.2.0
137 |
138 | ### Minor Changes
139 |
140 | - 4379d8c: Initial release
141 |
142 | ### Patch Changes
143 |
144 | - Updated dependencies [4379d8c]
145 | - react-native-node-api@0.2.0
146 |
--------------------------------------------------------------------------------
/apps/test-app/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, View, SafeAreaView } from "react-native";
3 |
4 | import {
5 | MochaRemoteProvider,
6 | ConnectionText,
7 | StatusEmoji,
8 | StatusText,
9 | } from "mocha-remote-react-native";
10 |
11 | import { suites as nodeAddonExamplesSuites } from "@react-native-node-api/node-addon-examples";
12 | import { suites as nodeTestsSuites } from "@react-native-node-api/node-tests";
13 |
14 | function describeIf(
15 | condition: boolean,
16 | title: string,
17 | fn: (this: Mocha.Suite) => void,
18 | ) {
19 | return condition ? describe(title, fn) : describe.skip(title, fn);
20 | }
21 |
22 | type Context = {
23 | allTests?: boolean;
24 | nodeAddonExamples?: boolean;
25 | nodeTests?: boolean;
26 | ferricExample?: boolean;
27 | };
28 |
29 | function loadTests({
30 | allTests = false,
31 | nodeAddonExamples = allTests,
32 | nodeTests = allTests,
33 | ferricExample = allTests,
34 | }: Context) {
35 | describeIf(nodeAddonExamples, "Node Addon Examples", () => {
36 | for (const [suiteName, examples] of Object.entries(
37 | nodeAddonExamplesSuites,
38 | )) {
39 | describe(suiteName, () => {
40 | for (const [exampleName, requireExample] of Object.entries(examples)) {
41 | it(exampleName, async () => {
42 | const test = requireExample();
43 | if (test instanceof Function) {
44 | const result = test();
45 | if (result instanceof Promise) {
46 | await result;
47 | }
48 | }
49 | });
50 | }
51 | });
52 | }
53 | });
54 |
55 | describeIf(nodeTests, "Node Tests", () => {
56 | function registerTestSuite(suite: typeof nodeTestsSuites) {
57 | for (const [name, suiteOrTest] of Object.entries(suite)) {
58 | if (typeof suiteOrTest === "function") {
59 | it(name, suiteOrTest);
60 | } else {
61 | describe(name, () => {
62 | registerTestSuite(suiteOrTest);
63 | });
64 | }
65 | }
66 | }
67 |
68 | registerTestSuite(nodeTestsSuites);
69 | });
70 |
71 | describeIf(ferricExample, "ferric-example", () => {
72 | it("exports a callable sum function", () => {
73 | const exampleAddon =
74 | /* eslint-disable-next-line @typescript-eslint/no-require-imports -- TODO: Determine why a dynamic import doesn't work on Android */
75 | require("@react-native-node-api/ferric-example") as typeof import("@react-native-node-api/ferric-example");
76 | const result = exampleAddon.sum(1, 3);
77 | if (result !== 4) {
78 | throw new Error(`Expected 1 + 3 to equal 4, but got ${result}`);
79 | }
80 | });
81 | });
82 | }
83 |
84 | export default function App() {
85 | return (
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
98 | const styles = StyleSheet.create({
99 | container: {
100 | flex: 1,
101 | backgroundColor: "#fff",
102 | },
103 | statusContainer: {
104 | flex: 1,
105 | alignItems: "center",
106 | justifyContent: "center",
107 | },
108 | statusEmoji: {
109 | fontSize: 30,
110 | margin: 30,
111 | textAlign: "center",
112 | },
113 | statusText: {
114 | fontSize: 20,
115 | margin: 20,
116 | textAlign: "center",
117 | },
118 | connectionText: {
119 | textAlign: "center",
120 | },
121 | });
122 |
--------------------------------------------------------------------------------
/packages/gyp-to-cmake/src/transformer.test.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert";
2 | import { describe, it } from "node:test";
3 |
4 | import { bindingGypToCmakeLists } from "./transformer.js";
5 |
6 | describe("bindingGypToCmakeLists", () => {
7 | it("should declare a project name", () => {
8 | const output = bindingGypToCmakeLists({
9 | projectName: "some-project",
10 | gyp: { targets: [] },
11 | });
12 | assert(output.includes("project(some-project)"));
13 | });
14 |
15 | it("should declare target libraries", () => {
16 | const output = bindingGypToCmakeLists({
17 | projectName: "some-project",
18 | gyp: {
19 | targets: [
20 | {
21 | target_name: "foo",
22 | sources: ["foo.cc"],
23 | },
24 | {
25 | target_name: "bar",
26 | sources: ["bar.cc"],
27 | },
28 | ],
29 | },
30 | });
31 |
32 | assert(output.includes("add_library(foo SHARED foo.cc"));
33 | assert(output.includes("add_library(bar SHARED bar.cc"));
34 | });
35 |
36 | it("transform \\ to / in source filenames", () => {
37 | const output = bindingGypToCmakeLists({
38 | projectName: "some-project",
39 | gyp: {
40 | targets: [
41 | {
42 | target_name: "foo",
43 | sources: ["file\\with\\win32\\separator.cc"],
44 | },
45 | ],
46 | },
47 | });
48 |
49 | assert(
50 | output.includes("add_library(foo SHARED file/with/win32/separator.cc"),
51 | );
52 | });
53 |
54 | it("escapes spaces in source filenames", () => {
55 | const output = bindingGypToCmakeLists({
56 | projectName: "some-project",
57 | gyp: {
58 | targets: [
59 | {
60 | target_name: "foo",
61 | sources: ["file with spaces.cc"],
62 | },
63 | ],
64 | },
65 | });
66 |
67 | assert(output.includes("add_library(foo SHARED file\\ with\\ spaces.cc"));
68 | });
69 |
70 | describe("command expansions", () => {
71 | it("should expand", () => {
72 | const output = bindingGypToCmakeLists({
73 | projectName: "some-project",
74 | gyp: {
75 | targets: [
76 | {
77 | target_name: "foo",
78 | sources: [" {
89 | const output = bindingGypToCmakeLists({
90 | projectName: "some-project",
91 | gyp: {
92 | targets: [
93 | {
94 | target_name: "foo",
95 | sources: [" {
106 | it("should add defines as target-specific compile definitions", () => {
107 | const output = bindingGypToCmakeLists({
108 | projectName: "some-project",
109 | gyp: {
110 | targets: [
111 | {
112 | target_name: "foo",
113 | sources: ["foo.cc"],
114 | defines: ["FOO", "BAR=value"],
115 | },
116 | ],
117 | },
118 | });
119 |
120 | assert(
121 | output.includes(
122 | "target_compile_definitions(foo PRIVATE FOO BAR=value)",
123 | ),
124 | `Expected output to include target_compile_definitions:\n${output}`,
125 | );
126 | });
127 | });
128 | });
129 |
--------------------------------------------------------------------------------
/packages/node-tests/rolldown.config.mts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import fs from "node:fs";
3 | import path from "node:path";
4 |
5 | import { defineConfig, type RolldownOptions } from "rolldown";
6 | import { aliasPlugin, replacePlugin } from "rolldown/experimental";
7 |
8 | function readGypTargetNames(gypFilePath: string): string[] {
9 | const contents = JSON.parse(fs.readFileSync(gypFilePath, "utf-8")) as unknown;
10 | assert(
11 | typeof contents === "object" && contents !== null,
12 | "Expected gyp file to contain a valid JSON object",
13 | );
14 | assert("targets" in contents, "Expected targets in gyp file");
15 | const { targets } = contents;
16 | assert(Array.isArray(targets), "Expected targets to be an array");
17 | return targets.map(({ target_name }) => {
18 | assert(
19 | typeof target_name === "string",
20 | "Expected target_name to be a string",
21 | );
22 | return target_name;
23 | });
24 | }
25 |
26 | function testSuiteConfig(suitePath: string): RolldownOptions[] {
27 | const testFiles = fs.globSync("*.js", {
28 | cwd: suitePath,
29 | exclude: ["*.bundle.js"],
30 | });
31 | const gypFilePath = path.join(suitePath, "binding.gyp");
32 | const targetNames = readGypTargetNames(gypFilePath);
33 | return testFiles.map((testFile) => ({
34 | input: path.join(suitePath, testFile),
35 | output: {
36 | file: path.join(suitePath, path.basename(testFile, ".js") + ".bundle.js"),
37 | },
38 | resolve: {
39 | conditionNames: ["react-native"],
40 | },
41 | polyfillRequire: false,
42 | plugins: [
43 | // Replace dynamic require statements for addon targets to allow the babel plugin to handle them correctly
44 | replacePlugin(
45 | Object.fromEntries(
46 | targetNames.map((targetName) => [
47 | `require(\`./build/\${common.buildType}/${targetName}\`)`,
48 | `require("./build/Release/${targetName}")`,
49 | ]),
50 | ),
51 | {
52 | delimiters: ["", ""],
53 | },
54 | ),
55 | replacePlugin(
56 | Object.fromEntries(
57 | targetNames.map((targetName) => [
58 | // Replace "__require" statement with a regular "require" to allow Metro to resolve it
59 | `__require("./build/Release/${targetName}")`,
60 | `require("./build/Release/${targetName}")`,
61 | ]),
62 | ),
63 | {
64 | delimiters: ["", ""],
65 | },
66 | ),
67 | replacePlugin(
68 | {
69 | // Replace the default export to return a function instead of initializing the addon immediately
70 | // This allows the test runner to intercept any errors which would normally be thrown when importing
71 | // to work around Metro's `guardedLoadModule` swallowing errors during module initialization
72 | // See https://github.com/facebook/metro/blob/34bb8913ec4b5b02690b39d2246599faf094f721/packages/metro-runtime/src/polyfills/require.js#L348-L353
73 | "export default require_test();": "export default require_test;",
74 | },
75 | {
76 | delimiters: ["", ""],
77 | },
78 | ),
79 | aliasPlugin({
80 | entries: [
81 | {
82 | find: "../../common",
83 | replacement: "./common.ts",
84 | },
85 | ],
86 | }),
87 | ],
88 | external: targetNames.map((targetName) => `./build/Release/${targetName}`),
89 | }));
90 | }
91 |
92 | const suitePaths = fs
93 | .globSync("tests/*/*", {
94 | cwd: import.meta.dirname,
95 | withFileTypes: true,
96 | })
97 | .filter((dirent) => dirent.isDirectory())
98 | .map((dirent) =>
99 | path.join(
100 | path.relative(import.meta.dirname, dirent.parentPath),
101 | dirent.name,
102 | ),
103 | );
104 |
105 | export default defineConfig(suitePaths.flatMap(testSuiteConfig));
106 |
--------------------------------------------------------------------------------