diff --git a/apps/test-app/App.tsx b/apps/test-app/App.tsx
index 884e385e..59125577 100644
--- a/apps/test-app/App.tsx
+++ b/apps/test-app/App.tsx
@@ -1,8 +1,11 @@
import React from "react";
import { StyleSheet, Text, View, Button } from "react-native";
+/* eslint-disable @typescript-eslint/no-require-imports -- We're using require to defer crashes */
+
// import { requireNodeAddon } from "react-native-node-api-modules";
import nodeAddonExamples from "react-native-node-addon-examples";
+// import * as ferricExample from "ferric-example";
function App(): React.JSX.Element {
return (
@@ -20,6 +23,15 @@ function App(): React.JSX.Element {
))}
))}
+
+ ferric-example
+
);
}
diff --git a/apps/test-app/package.json b/apps/test-app/package.json
index d7195d0a..ef798be8 100644
--- a/apps/test-app/package.json
+++ b/apps/test-app/package.json
@@ -21,6 +21,7 @@
"@react-native/typescript-config": "0.79.0",
"@rnx-kit/metro-config": "^2.0.1",
"@types/react": "^19.0.0",
+ "ferric-example": "^0.1.0",
"react": "19.0.0",
"react-native": "0.79.1",
"react-native-node-addon-examples": "*",
diff --git a/package-lock.json b/package-lock.json
index 6bb98bb0..795969df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"@react-native/typescript-config": "0.79.0",
"@rnx-kit/metro-config": "^2.0.1",
"@types/react": "^19.0.0",
+ "ferric-example": "^0.1.0",
"react": "19.0.0",
"react-native": "0.79.1",
"react-native-node-addon-examples": "*",
@@ -5813,6 +5814,14 @@
"bser": "2.1.1"
}
},
+ "node_modules/ferric-example": {
+ "resolved": "packages/ferric-example",
+ "link": true
+ },
+ "node_modules/ferric-modules": {
+ "resolved": "packages/ferric",
+ "link": true
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -9871,6 +9880,256 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
+ "packages/ferric": {
+ "name": "ferric-modules",
+ "version": "0.1.0",
+ "dependencies": {
+ "@commander-js/extra-typings": "^13.1.0",
+ "@napi-rs/cli": "^2.18.4",
+ "bufout": "^0.3.1",
+ "chalk": "^5.4.1",
+ "commander": "^13.1.0",
+ "ora": "^8.2.0"
+ },
+ "bin": {
+ "ferric": "bin/ferric.js"
+ }
+ },
+ "packages/ferric-example": {
+ "version": "0.1.0",
+ "devDependencies": {
+ "ferric-modules": "^0.1.0"
+ }
+ },
+ "packages/ferric/node_modules/@commander-js/extra-typings": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz",
+ "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "commander": "~13.1.0"
+ }
+ },
+ "packages/ferric/node_modules/@napi-rs/cli": {
+ "version": "2.18.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
+ "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
+ "license": "MIT",
+ "bin": {
+ "napi": "scripts/index.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "packages/ferric/node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+ "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "packages/ferric/node_modules/chalk": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
+ "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "packages/ferric/node_modules/cli-cursor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+ "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/ferric/node_modules/emoji-regex": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
+ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+ "license": "MIT"
+ },
+ "packages/ferric/node_modules/is-interactive": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
+ "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/log-symbols": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
+ "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "is-unicode-supported": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/log-symbols/node_modules/is-unicode-supported": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
+ "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/ora": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
+ "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "cli-cursor": "^5.0.0",
+ "cli-spinners": "^2.9.2",
+ "is-interactive": "^2.0.0",
+ "is-unicode-supported": "^2.0.0",
+ "log-symbols": "^6.0.0",
+ "stdin-discarder": "^0.2.2",
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/restore-cursor": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+ "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^7.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "packages/ferric/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/ferric/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"packages/gyp-to-cmake": {
"version": "0.1.0",
"dependencies": {
diff --git a/packages/ferric-example/.gitignore b/packages/ferric-example/.gitignore
new file mode 100644
index 00000000..a08b424c
--- /dev/null
+++ b/packages/ferric-example/.gitignore
@@ -0,0 +1,9 @@
+/target
+Cargo.lock
+
+/*.xcframework/
+/*.android.node/
+
+# Generated files
+/libferric_example.d.ts
+/libferric_example.js
diff --git a/packages/ferric-example/Cargo.toml b/packages/ferric-example/Cargo.toml
new file mode 100644
index 00000000..7ef1d108
--- /dev/null
+++ b/packages/ferric-example/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "ferric-example"
+version = "1.0.0"
+edition = "2021"
+license = "MIT"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies.napi]
+version = "2"
+default-features = false
+# see https://nodejs.org/api/n-api.html#node-api-version-matrix
+features = ["napi3"]
+
+[dependencies.napi-derive]
+version = "2"
+features = ["type-def"]
+
+[build-dependencies]
+napi-build = "2"
+
+[profile.release]
+lto = true
+strip = "symbols"
diff --git a/packages/ferric-example/build.rs b/packages/ferric-example/build.rs
new file mode 100644
index 00000000..bbfc9e4b
--- /dev/null
+++ b/packages/ferric-example/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ napi_build::setup();
+}
diff --git a/packages/ferric-example/package.json b/packages/ferric-example/package.json
new file mode 100644
index 00000000..313af2bb
--- /dev/null
+++ b/packages/ferric-example/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "ferric-example",
+ "version": "0.1.0",
+ "main": "libferric_example.js",
+ "types": "libferric_example.d.ts",
+ "scripts": {
+ "build": "ferric build --android --apple"
+ },
+ "devDependencies": {
+ "ferric-modules": "^0.1.0"
+ }
+}
diff --git a/packages/ferric-example/src/lib.rs b/packages/ferric-example/src/lib.rs
new file mode 100644
index 00000000..ecfd7ab1
--- /dev/null
+++ b/packages/ferric-example/src/lib.rs
@@ -0,0 +1,6 @@
+use napi_derive::napi;
+
+#[napi]
+pub fn sum(a: i32, b: i32) -> i32 {
+ a + b
+}
diff --git a/packages/ferric/bin/ferric.js b/packages/ferric/bin/ferric.js
new file mode 100755
index 00000000..c5319d91
--- /dev/null
+++ b/packages/ferric/bin/ferric.js
@@ -0,0 +1,2 @@
+#!/usr/bin/env node
+import "../dist/run.js";
diff --git a/packages/ferric/package.json b/packages/ferric/package.json
new file mode 100644
index 00000000..a8d25782
--- /dev/null
+++ b/packages/ferric/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "ferric-modules",
+ "version": "0.1.0",
+ "description": "Rust Node-API Modules for React Native",
+ "type": "module",
+ "bin": {
+ "ferric": "./bin/ferric.js"
+ },
+ "scripts": {
+ "start": "tsx src/run.ts"
+ },
+ "dependencies": {
+ "@napi-rs/cli": "^2.18.4",
+ "@commander-js/extra-typings": "^13.1.0",
+ "bufout": "^0.3.1",
+ "chalk": "^5.4.1",
+ "commander": "^13.1.0",
+ "ora": "^8.2.0"
+ }
+}
diff --git a/packages/ferric/src/banner.ts b/packages/ferric/src/banner.ts
new file mode 100644
index 00000000..95edab04
--- /dev/null
+++ b/packages/ferric/src/banner.ts
@@ -0,0 +1,20 @@
+import chalk from "chalk";
+
+const LINES = [
+ // Pagga on https://www.asciiart.eu/text-to-ascii-art
+ // Box elements from https://www.compart.com/en/unicode/block/U+2500
+ "╭─────────────────────────╮",
+ "│░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│",
+ "│░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│",
+ "│░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│",
+ "╰─────────────────────────╯",
+];
+
+export function printBanner() {
+ console.log(
+ LINES.map((line, lineNumber, lines) => {
+ const ratio = lineNumber / lines.length;
+ return chalk.rgb(Math.round(250 - 100 * ratio), 0, 0)(line);
+ }).join("\n")
+ );
+}
diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts
new file mode 100644
index 00000000..80084228
--- /dev/null
+++ b/packages/ferric/src/build.ts
@@ -0,0 +1,301 @@
+import path from "node:path";
+import fs from "node:fs";
+
+import { Command, Option } from "@commander-js/extra-typings";
+import chalk from "chalk";
+import { SpawnFailure } from "bufout";
+import { oraPromise } from "ora";
+
+import {
+ determineAndroidLibsFilename,
+ createAndroidLibsDirectory,
+ AndroidTriplet,
+ createAppleFramework,
+ determineXCFrameworkFilename,
+ createXCframework,
+ createUniversalAppleLibrary,
+ determineLibraryFilename,
+ prettyPath,
+} from "react-native-node-api-modules";
+
+import { UsageError } from "./errors.js";
+import { ensureCargo, build } from "./cargo.js";
+import {
+ ALL_TARGETS,
+ ANDROID_TARGETS,
+ AndroidTargetName,
+ APPLE_TARGETS,
+ AppleTargetName,
+ ensureInstalledTargets,
+ filterTargetsByPlatform,
+} from "./targets.js";
+import { generateTypeScriptDeclarations } from "./napi-rs.js";
+
+type EntrypointOptions = {
+ outputPath: string;
+ libraryName: string;
+};
+async function generateEntrypoint({
+ outputPath,
+ libraryName,
+}: EntrypointOptions) {
+ await fs.promises.writeFile(
+ outputPath,
+ "module.exports = require('./" + libraryName + ".node');",
+ "utf8"
+ );
+}
+
+const ANDROID_TRIPLET_PER_TARGET: Record = {
+ "aarch64-linux-android": "aarch64-linux-android",
+ "armv7-linux-androideabi": "armv7a-linux-androideabi",
+ "i686-linux-android": "i686-linux-android",
+ "x86_64-linux-android": "x86_64-linux-android",
+};
+
+// This should match https://github.com/react-native-community/template/blob/main/template/android/build.gradle#L7
+const DEFAULT_NDK_VERSION = "27.1.12297006";
+const ANDROID_API_LEVEL = 24;
+
+const targetOption = new Option("--target ", "Target triple")
+ .choices(ALL_TARGETS)
+ .default([]);
+const appleTarget = new Option("--apple", "Use all Apple targets");
+const androidTarget = new Option("--android", "Use all Android targets");
+const ndkVersionOption = new Option(
+ "--ndk-version ",
+ "The NDK version to use for Android builds"
+).default(DEFAULT_NDK_VERSION);
+const outputPathOption = new Option(
+ "--output ",
+ "Writing outputs to this directory"
+).default(process.cwd());
+const configurationOption = new Option(
+ "--configuration ",
+ "Build configuration"
+)
+ .choices(["debug", "release"])
+ .default("debug");
+
+export const buildCommand = new Command("build")
+ .description("Build Rust Node-API module")
+ .addOption(targetOption)
+ .addOption(appleTarget)
+ .addOption(androidTarget)
+ .addOption(ndkVersionOption)
+ .addOption(outputPathOption)
+ .addOption(configurationOption)
+ .action(
+ async ({
+ target: targetArg,
+ apple,
+ android,
+ ndkVersion,
+ output: outputPath,
+ configuration,
+ }) => {
+ try {
+ const targets = new Set([...targetArg]);
+ if (apple) {
+ for (const target of APPLE_TARGETS) {
+ targets.add(target);
+ }
+ }
+ if (android) {
+ for (const target of ANDROID_TARGETS) {
+ targets.add(target);
+ }
+ }
+ ensureCargo();
+ ensureInstalledTargets(targets);
+
+ const appleTargets = filterTargetsByPlatform(targets, "apple");
+ const androidTargets = filterTargetsByPlatform(targets, "android");
+
+ const targetsDescription =
+ targets.size +
+ (targets.size === 1 ? " target" : " targets") +
+ chalk.dim(" (" + [...targets].join(", ") + ")");
+ const [appleLibraries, androidLibraries] = await oraPromise(
+ Promise.all([
+ Promise.all(
+ appleTargets.map(
+ async (target) =>
+ [target, await build({ configuration, target })] as const
+ )
+ ),
+ Promise.all(
+ androidTargets.map(
+ async (target) =>
+ [
+ target,
+ await build({
+ configuration,
+ target,
+ ndkVersion,
+ androidApiLevel: ANDROID_API_LEVEL,
+ }),
+ ] as const
+ )
+ ),
+ ]),
+ {
+ text: `Building ${targetsDescription}`,
+ successText: `Built ${targetsDescription}`,
+ failText: (error: Error) => `Failed to build: ${error.message}`,
+ }
+ );
+
+ const libraryName = determineLibraryFilename([
+ ...androidLibraries.map(([, outputPath]) => outputPath),
+ ]);
+
+ const declarationsFilename = `${libraryName}.d.ts`;
+ const declarationsPath = path.join(outputPath, declarationsFilename);
+ await oraPromise(
+ generateTypeScriptDeclarations({
+ outputFilename: declarationsFilename,
+ createPath: process.cwd(),
+ outputPath,
+ }),
+ {
+ text: "Generating TypeScript declarations",
+ successText: `Generated TypeScript declarations ${prettyPath(
+ declarationsPath
+ )}`,
+ failText: (error) =>
+ `Failed to generate TypeScript declarations: ${error.message}`,
+ }
+ );
+
+ const entrypointPath = path.join(outputPath, `${libraryName}.js`);
+
+ await oraPromise(
+ generateEntrypoint({
+ libraryName,
+ outputPath: entrypointPath,
+ }),
+ {
+ text: `Generating entrypoint`,
+ successText: `Generated entrypoint into ${prettyPath(
+ entrypointPath
+ )}`,
+ failText: (error) =>
+ `Failed to generate entrypoint: ${error.message}`,
+ }
+ );
+
+ if (androidLibraries.length > 0) {
+ const libraryPathByTriplet = Object.fromEntries(
+ androidLibraries.map(([target, outputPath]) => [
+ ANDROID_TRIPLET_PER_TARGET[target],
+ outputPath,
+ ])
+ ) as Record;
+
+ const androidLibsFilename = determineAndroidLibsFilename(
+ Object.values(libraryPathByTriplet)
+ );
+ const androidLibsOutputPath = path.resolve(
+ outputPath,
+ androidLibsFilename
+ );
+
+ await oraPromise(
+ createAndroidLibsDirectory({
+ outputPath: androidLibsOutputPath,
+ libraryPathByTriplet,
+ autoLink: true,
+ }),
+ {
+ text: "Assembling Android libs directory",
+ successText: `Android libs directory assembled into ${prettyPath(
+ androidLibsOutputPath
+ )}`,
+ failText: ({ message }) =>
+ `Failed to assemble Android libs directory: ${message}`,
+ }
+ );
+ }
+
+ if (appleLibraries.length > 0) {
+ const libraryPaths = await combineLibraries(appleLibraries);
+ const frameworkPaths = libraryPaths.map(createAppleFramework);
+ const xcframeworkFilename =
+ determineXCFrameworkFilename(frameworkPaths);
+
+ // Create the xcframework
+ const xcframeworkOutputPath = path.resolve(
+ outputPath,
+ xcframeworkFilename
+ );
+
+ await oraPromise(
+ createXCframework({
+ outputPath: xcframeworkOutputPath,
+ frameworkPaths,
+ autoLink: true,
+ }),
+ {
+ text: "Assembling XCFramework",
+ successText: `XCFramework assembled into ${chalk.dim(
+ path.relative(process.cwd(), xcframeworkOutputPath)
+ )}`,
+ failText: ({ message }) =>
+ `Failed to assemble XCFramework: ${message}`,
+ }
+ );
+ }
+ } catch (error) {
+ if (error instanceof SpawnFailure) {
+ error.flushOutput("both");
+ }
+ if (error instanceof UsageError || error instanceof SpawnFailure) {
+ console.error(chalk.red("ERROR"), error.message);
+ if (error.cause instanceof Error) {
+ console.error(chalk.red("CAUSE"), error.cause.message);
+ }
+ if (error instanceof UsageError && error.fix) {
+ console.error(
+ chalk.green("FIX"),
+ error.fix.command
+ ? chalk.dim("Run: ") + error.fix.command
+ : error.fix.instructions
+ );
+ }
+ } else {
+ throw error;
+ }
+ }
+ }
+ );
+
+async function combineLibraries(
+ libraries: Readonly<[AppleTargetName, string]>[]
+): Promise {
+ const result = [];
+ const darwinLibraries = [];
+ for (const [target, libraryPath] of libraries) {
+ if (target.endsWith("-darwin")) {
+ darwinLibraries.push(libraryPath);
+ } else {
+ result.push(libraryPath);
+ }
+ }
+ if (darwinLibraries.length === 0) {
+ return result;
+ } else if (darwinLibraries.length === 1) {
+ return [...result, darwinLibraries[0]];
+ } else {
+ const universalPath = await oraPromise(
+ createUniversalAppleLibrary(darwinLibraries),
+ {
+ text: "Combining Darwin libraries into a universal library",
+ successText: "Combined Darwin libraries into a universal library",
+ failText: (error) =>
+ `Failed to combine Darwin libraries: ${error.message}`,
+ }
+ );
+ return [...result, universalPath];
+ }
+}
diff --git a/packages/ferric/src/cargo.ts b/packages/ferric/src/cargo.ts
new file mode 100644
index 00000000..38cbf159
--- /dev/null
+++ b/packages/ferric/src/cargo.ts
@@ -0,0 +1,194 @@
+import assert from "node:assert/strict";
+import cp from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+import { spawn } from "bufout";
+import chalk from "chalk";
+
+import { assertFixable, UsageError } from "./errors.js";
+import {
+ AndroidTargetName,
+ AppleTargetName,
+ isAndroidTarget,
+ isAppleTarget,
+} from "./targets.js";
+
+const APPLE_XCFRAMEWORK_CHILDS_PER_TARGET: Record = {
+ "aarch64-apple-darwin": "macos-arm64_x86_64", // Universal
+ "x86_64-apple-darwin": "macos-arm64_x86_64", // Universal
+ "aarch64-apple-ios": "ios-arm64",
+ "aarch64-apple-ios-sim": "ios-arm64-simulator",
+ // "aarch64-apple-ios-macabi": "", // Catalyst
+ // "x86_64-apple-ios": "ios-x86_64",
+ // "x86_64-apple-ios-macabi": "ios-x86_64-simulator",
+ // "aarch64-apple-tvos": "tvos-arm64",
+ // "aarch64-apple-tvos-sim": "tvos-arm64-simulator",
+ // "aarch64-apple-visionos": "xros-arm64",
+ // "aarch64-apple-visionos-sim": "xros-arm64-simulator",
+};
+
+export function joinPathAndAssertExistence(...pathSegments: string[]) {
+ const joinedPath = path.join(...pathSegments);
+ assert(fs.existsSync(joinedPath), `Expected ${joinedPath} to exist`);
+ return joinedPath;
+}
+
+export function ensureCargo() {
+ try {
+ const cargoVersion = cp
+ .execFileSync("cargo", ["--version"], {
+ encoding: "utf-8",
+ })
+ .trim();
+ console.log(chalk.dim(`Using ${cargoVersion}`));
+ } catch (error) {
+ throw new UsageError(
+ "You need a Rust toolchain: https://doc.rust-lang.org/cargo/getting-started/installation.html#install-rust-and-cargo",
+ { cause: error }
+ );
+ }
+}
+
+type BuildOptions = {
+ configuration: "debug" | "release";
+} & (
+ | {
+ target: AndroidTargetName;
+ ndkVersion: string;
+ androidApiLevel: number;
+ }
+ | {
+ target: AppleTargetName;
+ ndkVersion?: never;
+ androidApiLevel?: number;
+ }
+);
+
+export async function build(options: BuildOptions) {
+ const { target, configuration } = options;
+ await spawn("cargo", ["build", "--target", target], {
+ outputMode: "buffered",
+ env: {
+ ...process.env,
+ ...getTargetEnvironmentVariables(options),
+ },
+ });
+ const targetOutputPath = joinPathAndAssertExistence(
+ process.cwd(),
+ "target",
+ target,
+ configuration
+ );
+ const dynamicLibraryFile = fs
+ .readdirSync(targetOutputPath)
+ .filter((file) => file.endsWith(".so") || file.endsWith(".dylib"));
+ assert(
+ dynamicLibraryFile.length === 1,
+ `Expected a single shared object file in ${targetOutputPath}`
+ );
+ return joinPathAndAssertExistence(targetOutputPath, dynamicLibraryFile[0]);
+}
+
+export function getLLVMToolchainBinPath(ndkPath: string) {
+ const prebuiltPath = path.join(ndkPath, "toolchains", "llvm", "prebuilt");
+ const candidates = fs.readdirSync(prebuiltPath);
+ if (candidates.length === 0) {
+ throw new Error("Expected LLVM toolchain to be installed");
+ } else if (candidates.length > 1) {
+ throw new Error("Expected a single LLVM toolchain to be installed");
+ } else {
+ return path.join(prebuiltPath, candidates[0], "bin");
+ }
+}
+
+export function getTargetAndroidArch(target: AndroidTargetName) {
+ const [first] = target.split("-");
+ return first === "armv7" ? "armv7a" : first;
+}
+
+export function getTargetAndroidPlatform(target: AndroidTargetName) {
+ return getTargetAndroidArch(target) === "armv7a"
+ ? "androideabi24"
+ : "android24";
+}
+
+export function getWeakNodeApiFrameworkPath(target: AppleTargetName) {
+ const weakNodeApiPath = new URL(
+ import.meta.resolve("react-native-node-api-modules/weak-node-api")
+ ).pathname;
+ assert(fs.existsSync(weakNodeApiPath), "Expected weak-node-api to exist");
+ return joinPathAndAssertExistence(
+ weakNodeApiPath,
+ "libweak-node-api.xcframework",
+ APPLE_XCFRAMEWORK_CHILDS_PER_TARGET[target]
+ );
+}
+
+export function getTargetEnvironmentVariables({
+ target,
+ ndkVersion,
+ androidApiLevel,
+}: BuildOptions): Record {
+ if (isAndroidTarget(target)) {
+ assert(ndkVersion, "Expected ndkVersion to be set for Android targets");
+
+ const { ANDROID_HOME } = process.env;
+ assertFixable(
+ ANDROID_HOME && fs.existsSync(ANDROID_HOME),
+ `Missing ANDROID_HOME environment variable`,
+ {
+ instructions: "Set ANDROID_HOME to the Android SDK directory",
+ }
+ );
+ const ndkPath = path.join(ANDROID_HOME, "ndk", ndkVersion);
+ assertFixable(fs.existsSync(ndkPath), `Expected NDK at ${ndkPath}`, {
+ command: `sdkmanager --install "ndk;${ndkVersion}"`,
+ });
+
+ const toolchainBinPath = getLLVMToolchainBinPath(ndkPath);
+ const targetArch = getTargetAndroidArch(target);
+ const targetPlatform = getTargetAndroidPlatform(target);
+
+ return {
+ CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `aarch64-linux-android${androidApiLevel}-clang`
+ ),
+ CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `armv7a-linux-androideabi${androidApiLevel}-clang`
+ ),
+ CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `x86_64-linux-android${androidApiLevel}-clang`
+ ),
+ CARGO_TARGET_I686_LINUX_ANDROID_LINKER: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `i686-linux-android${androidApiLevel}-clang`
+ ),
+ TARGET_CC: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `${targetArch}-linux-${targetPlatform}-clang`
+ ),
+ TARGET_CXX: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `${targetArch}-linux-${targetPlatform}-clang++`
+ ),
+ TARGET_AR: joinPathAndAssertExistence(toolchainBinPath, `llvm-ar`),
+ TARGET_RANLIB: joinPathAndAssertExistence(
+ toolchainBinPath,
+ `llvm-ranlib`
+ ),
+ ANDROID_NDK: ndkPath,
+ PATH: `${toolchainBinPath}:${process.env.PATH}`,
+ };
+ } else if (isAppleTarget(target)) {
+ const weakNodeApiFrameworkPath = getWeakNodeApiFrameworkPath(target);
+ return {
+ RUSTFLAGS: `-L framework=${weakNodeApiFrameworkPath} -l framework=libweak-node-api`,
+ };
+ } else {
+ throw new Error(`Unexpected target: ${target}`);
+ }
+}
diff --git a/packages/ferric/src/errors.ts b/packages/ferric/src/errors.ts
new file mode 100644
index 00000000..b27489b9
--- /dev/null
+++ b/packages/ferric/src/errors.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+
+export type Fix =
+ | {
+ instructions: string;
+ command?: never;
+ }
+ | {
+ instructions?: never;
+ command: string;
+ };
+
+export class UsageError extends Error {
+ public readonly fix?: Fix;
+
+ constructor(
+ message: string,
+ { fix, cause }: { cause?: unknown; fix?: Fix } = {}
+ ) {
+ super(message, { cause });
+ this.fix = fix;
+ }
+}
+
+export function assertFixable(
+ value: unknown,
+ message: string,
+ fix: Fix
+): asserts value {
+ try {
+ assert(value, message);
+ } catch (error) {
+ assert(error instanceof Error);
+ throw new UsageError(message, { fix });
+ }
+}
diff --git a/packages/ferric/src/napi-rs.ts b/packages/ferric/src/napi-rs.ts
new file mode 100644
index 00000000..804e38c2
--- /dev/null
+++ b/packages/ferric/src/napi-rs.ts
@@ -0,0 +1,67 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import { spawn } from "bufout";
+
+const PACKAGE_ROOT = path.join(import.meta.dirname, "..");
+
+type TypeScriptDeclarationsOptions = {
+ /**
+ * Path to the directory containing the Cargo.toml file.
+ */
+ createPath: string;
+ /**
+ * Path to the output directory where the TypeScript declarations will be copied into.
+ */
+ outputPath: string;
+ /**
+ * File name of the generated TypeScript declarations (including .d.ts).
+ */
+ outputFilename: string;
+};
+
+export async function generateTypeScriptDeclarations({
+ createPath,
+ outputPath,
+ outputFilename,
+}: TypeScriptDeclarationsOptions) {
+ // Using a temporary directory to avoid polluting crate with any other side-effects for generating TypeScript declarations
+ const tempPath = fs.realpathSync(
+ fs.mkdtempSync(path.join(PACKAGE_ROOT, "dts-tmp-"))
+ );
+ try {
+ // Write a dummy package.json file to avoid errors from napi-rs
+ await fs.promises.writeFile(
+ path.join(tempPath, "package.json"),
+ "{}",
+ "utf8"
+ );
+ // Call into napi.rs to generate TypeScript declarations
+ const napiCliPath = new URL(
+ import.meta.resolve("@napi-rs/cli/scripts/index.js")
+ ).pathname;
+ await spawn(
+ // TODO: Resolve the CLI path (not using npx because we don't want to npx to mess up the cwd)
+ napiCliPath,
+ [
+ "build",
+ "--dts",
+ outputFilename,
+ "--cargo-cwd",
+ // This doesn't understand absolute paths
+ path.relative(tempPath, createPath),
+ ],
+ {
+ outputMode: "buffered",
+ cwd: tempPath,
+ }
+ );
+ // Copy out the generated TypeScript declarations
+ await fs.promises.copyFile(
+ path.join(tempPath, outputFilename),
+ path.join(outputPath, outputFilename)
+ );
+ } finally {
+ await fs.promises.rm(tempPath, { recursive: true, force: true });
+ }
+}
diff --git a/packages/ferric/src/program.ts b/packages/ferric/src/program.ts
new file mode 100644
index 00000000..f8059c48
--- /dev/null
+++ b/packages/ferric/src/program.ts
@@ -0,0 +1,9 @@
+import { Command } from "@commander-js/extra-typings";
+
+import { printBanner } from "./banner.js";
+import { buildCommand } from "./build.js";
+
+export const program = new Command("ferric")
+ .hook("preAction", () => printBanner())
+ .description("Rust Node-API Modules for React Native")
+ .addCommand(buildCommand);
diff --git a/packages/ferric/src/run.ts b/packages/ferric/src/run.ts
new file mode 100644
index 00000000..7b390d6c
--- /dev/null
+++ b/packages/ferric/src/run.ts
@@ -0,0 +1,7 @@
+import EventEmitter from "node:events";
+import { program } from "./program.js";
+
+// We're attaching a lot of listeners when spawning in parallel
+EventEmitter.defaultMaxListeners = 100;
+
+program.parseAsync(process.argv).catch(console.error);
diff --git a/packages/ferric/src/rustup.ts b/packages/ferric/src/rustup.ts
new file mode 100644
index 00000000..c4b101a6
--- /dev/null
+++ b/packages/ferric/src/rustup.ts
@@ -0,0 +1,20 @@
+import cp from "child_process";
+
+import { UsageError } from "./errors.js";
+
+export function getInstalledTargets() {
+ try {
+ return new Set(
+ cp
+ .execFileSync("rustup", ["target", "list", "--installed"], {
+ encoding: "utf-8",
+ })
+ .split("\n")
+ );
+ } catch (error) {
+ throw new UsageError(
+ "You need a Rust toolchain: https://doc.rust-lang.org/cargo/getting-started/installation.html#install-rust-and-cargo",
+ { cause: error }
+ );
+ }
+}
diff --git a/packages/ferric/src/targets.ts b/packages/ferric/src/targets.ts
new file mode 100644
index 00000000..0ad090a4
--- /dev/null
+++ b/packages/ferric/src/targets.ts
@@ -0,0 +1,104 @@
+import chalk from "chalk";
+
+import { UsageError } from "./errors.js";
+import { getInstalledTargets } from "./rustup.js";
+
+export const ANDROID_TARGETS = [
+ "aarch64-linux-android",
+ "armv7-linux-androideabi",
+ "i686-linux-android",
+ "x86_64-linux-android",
+ // "arm-linux-androideabi",
+ // "thumbv7neon-linux-androideabi",
+] as const;
+
+export type AndroidTargetName = (typeof ANDROID_TARGETS)[number];
+
+// TODO: Consider calling out to rustup to generate this list or just use @napi-rs/triples
+export const APPLE_TARGETS = [
+ "aarch64-apple-darwin",
+ "x86_64-apple-darwin",
+ "aarch64-apple-ios",
+ "aarch64-apple-ios-sim",
+ // "aarch64-apple-ios-macabi", // Catalyst
+ // "x86_64-apple-ios",
+ // "x86_64-apple-ios-macabi", // Catalyst
+
+ // TODO: Re-enabled these when we know how to install them 🙈
+ /*
+ "aarch64-apple-tvos",
+ "aarch64-apple-tvos-sim",
+ "aarch64-apple-visionos",
+ "aarch64-apple-visionos-sim",
+ */
+
+ // "aarch64-apple-watchos",
+ // "aarch64-apple-watchos-sim",
+ // "arm64_32-apple-watchos",
+ // "arm64e-apple-darwin",
+ // "arm64e-apple-ios",
+ // "arm64e-apple-tvos",
+ // "armv7k-apple-watchos",
+ // "armv7s-apple-ios",
+ // "i386-apple-ios",
+ // "i686-apple-darwin",
+ // "x86_64-apple-tvos",
+ // "x86_64-apple-watchos-sim",
+ // "x86_64h-apple-darwin",
+] as const;
+export type AppleTargetName = (typeof APPLE_TARGETS)[number];
+
+export const ALL_TARGETS = [...ANDROID_TARGETS, ...APPLE_TARGETS] as const;
+export type TargetName = (typeof ALL_TARGETS)[number];
+
+/**
+ * Ensure the targets are installed into the Rust toolchain
+ * We do this up-front because the error message and fix is very unclear from the failure when missing.
+ */
+export function ensureInstalledTargets(expectedTargets: Set) {
+ const installedTargets = getInstalledTargets();
+ const missingTargets = new Set([
+ ...[...expectedTargets].filter((target) => !installedTargets.has(target)),
+ ]);
+ if (missingTargets.size > 0) {
+ // TODO: Ask the user if they want to run this
+ throw new UsageError(
+ `You're missing ${
+ missingTargets.size
+ } targets - to fix this, run:\n\n${chalk.italic(
+ `rustup target add ${[...missingTargets].join(" ")}`
+ )}`
+ );
+ }
+}
+
+export function isAndroidTarget(
+ target: TargetName
+): target is AndroidTargetName {
+ return ANDROID_TARGETS.includes(target as (typeof ANDROID_TARGETS)[number]);
+}
+
+export function isAppleTarget(target: TargetName): target is AppleTargetName {
+ return APPLE_TARGETS.includes(target as (typeof APPLE_TARGETS)[number]);
+}
+
+export function filterTargetsByPlatform(
+ targets: Set,
+ platform: "android"
+): AndroidTargetName[];
+export function filterTargetsByPlatform(
+ targets: Set,
+ platform: "apple"
+): AppleTargetName[];
+export function filterTargetsByPlatform(
+ targets: Set,
+ platform: "apple" | "android"
+) {
+ if (platform === "android") {
+ return [...targets].filter(isAndroidTarget);
+ } else if (platform === "apple") {
+ return [...targets].filter(isAppleTarget);
+ } else {
+ throw new Error(`Unexpected platform ${platform}`);
+ }
+}
diff --git a/packages/ferric/tsconfig.json b/packages/ferric/tsconfig.json
new file mode 100644
index 00000000..a678ba53
--- /dev/null
+++ b/packages/ferric/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "declarationMap": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["**.test.ts"]
+}
diff --git a/packages/ferric/tsconfig.tests.json b/packages/ferric/tsconfig.tests.json
new file mode 100644
index 00000000..17ee7c84
--- /dev/null
+++ b/packages/ferric/tsconfig.tests.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.node.json",
+ "compilerOptions": {
+ "composite": true,
+ "emitDeclarationOnly": true
+ },
+ "include": ["src/**/*.test.ts"],
+ "exclude": [],
+ "references": [
+ {
+ "path": "./tsconfig.json"
+ }
+ ]
+}
diff --git a/packages/react-native-node-api-cmake/src/android.ts b/packages/react-native-node-api-cmake/src/android.ts
index b46d7119..88f14877 100644
--- a/packages/react-native-node-api-cmake/src/android.ts
+++ b/packages/react-native-node-api-cmake/src/android.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
-import { AndroidTriplet } from "./triplets.js";
+import { AndroidTriplet } from "react-native-node-api-modules";
export const DEFAULT_ANDROID_TRIPLETS = [
"aarch64-linux-android",
@@ -94,64 +94,3 @@ export function getAndroidConfigureCmakeArgs({
.join(" ")}`,
];
}
-
-/**
- * Determine the filename of the Android libs directory based on the framework paths.
- * Ensuring that all framework paths have the same base name.
- */
-export function determineAndroidLibsFilename(frameworkPaths: string[]) {
- const frameworkNames = frameworkPaths.map((p) =>
- path.basename(p, path.extname(p))
- );
- const candidates = new Set(frameworkNames);
- assert(
- candidates.size === 1,
- "Expected all frameworks to have the same name"
- );
- const [name] = candidates;
- return `${name}.android.node`;
-}
-
-type AndroidLibsDirectoryOptions = {
- outputPath: string;
- libraryPathByTriplet: Record;
- autoLink: boolean;
-};
-
-export async function createAndroidLibsDirectory({
- outputPath,
- libraryPathByTriplet,
- autoLink,
-}: AndroidLibsDirectoryOptions) {
- // Delete and recreate any existing output directory
- await fs.promises.rm(outputPath, { recursive: true, force: true });
- await fs.promises.mkdir(outputPath, { recursive: true });
- for (const [triplet] of Object.entries(libraryPathByTriplet)) {
- const libraryPath = libraryPathByTriplet[triplet as AndroidTriplet];
- assert(
- fs.existsSync(libraryPath),
- `Library not found: ${libraryPath} for triplet ${triplet}`
- );
- const arch = ANDROID_ARCHITECTURES[triplet as AndroidTriplet];
- const archOutputPath = path.join(outputPath, arch);
- await fs.promises.mkdir(archOutputPath, { recursive: true });
- // Strip the ".node" extension from the library name
- const libraryName = path.basename(libraryPath, ".node");
- const soSuffixedName =
- path.extname(libraryName) === ".so" ? libraryName : `${libraryName}.so`;
- const finalLibraryName = libraryName.startsWith("lib")
- ? soSuffixedName
- : `lib${soSuffixedName}`;
- const libraryOutputPath = path.join(archOutputPath, finalLibraryName);
- await fs.promises.copyFile(libraryPath, libraryOutputPath);
- // TODO: Update the install path in the library file
- }
- if (autoLink) {
- // Write a file to mark the Android libs directory is a Node-API module
- await fs.promises.writeFile(
- path.join(outputPath, "react-native-node-api-module"),
- "",
- "utf8"
- );
- }
-}
diff --git a/packages/react-native-node-api-cmake/src/apple.ts b/packages/react-native-node-api-cmake/src/apple.ts
index 6b437c20..fb5a0811 100644
--- a/packages/react-native-node-api-cmake/src/apple.ts
+++ b/packages/react-native-node-api-cmake/src/apple.ts
@@ -1,10 +1,6 @@
import assert from "node:assert/strict";
-import fs from "node:fs";
-import path from "node:path";
-import { spawn } from "bufout";
-
-import { AppleTriplet, isAppleTriplet } from "./triplets.js";
+import { AppleTriplet, isAppleTriplet } from "react-native-node-api-modules";
export const DEFAULT_APPLE_TRIPLETS = [
"arm64;x86_64-apple-darwin",
@@ -111,99 +107,3 @@ export function getAppleBuildArgs() {
// We expect the final application to sign these binaries
return ["CODE_SIGNING_ALLOWED=NO"];
}
-
-type XCframeworkOptions = {
- frameworkPaths: string[];
- outputPath: string;
- autoLink: boolean;
-};
-
-export function createFramework(libraryPath: string) {
- assert(fs.existsSync(libraryPath), `Library not found: ${libraryPath}`);
- // Write a info.plist file to the framework
- const libraryName = path.basename(libraryPath, path.extname(libraryPath));
- const frameworkPath = path.join(
- path.dirname(libraryPath),
- `${libraryName}.framework`
- );
- // Create the framework from scratch
- fs.rmSync(frameworkPath, { recursive: true, force: true });
- fs.mkdirSync(frameworkPath);
- fs.mkdirSync(path.join(frameworkPath, "Headers"));
- // Create an empty Info.plist file
- fs.writeFileSync(
- path.join(frameworkPath, "Info.plist"),
- createPlistContent({
- CFBundleDevelopmentRegion: "en",
- CFBundleExecutable: libraryName,
- CFBundleIdentifier: `com.callstackincubator.node-api.${libraryName}`,
- CFBundleInfoDictionaryVersion: "6.0",
- CFBundleName: libraryName,
- CFBundlePackageType: "FMWK",
- CFBundleShortVersionString: "1.0",
- CFBundleVersion: "1",
- NSPrincipalClass: "",
- }),
- "utf8"
- );
- const newLibraryPath = path.join(frameworkPath, libraryName);
- fs.renameSync(libraryPath, newLibraryPath);
- // Update the name of the library
- // Leaving this out for now, since it will be renamed when copied anyway
- // cp.spawnSync("install_name_tool", [
- // "-id",
- // `@rpath/${libraryName}.framework/${libraryName}`,
- // newLibraryPath,
- // ]);
- return frameworkPath;
-}
-
-export async function createXCframework({
- frameworkPaths,
- outputPath,
- autoLink,
-}: XCframeworkOptions) {
- // Delete any existing xcframework to prevent the error:
- // - A library with the identifier 'macos-arm64' already exists.
- // Ideally, it would only be necessary to delete the specific platform+arch, to allow selectively building from source.
- fs.rmSync(outputPath, { recursive: true, force: true });
-
- await spawn(
- "xcodebuild",
- [
- "-create-xcframework",
- ...frameworkPaths.flatMap((p) => ["-framework", p]),
- "-output",
- outputPath,
- ],
- {
- outputMode: "buffered",
- }
- );
- if (autoLink) {
- // Write a file to mark the xcframework is a Node-API module
- // TODO: Consider including this in the Info.plist file instead
- fs.writeFileSync(
- path.join(outputPath, "react-native-node-api-module"),
- "",
- "utf8"
- );
- }
-}
-
-/**
- * Determine the filename of the xcframework based on the framework paths.
- * Ensuring that all framework paths have the same base name.
- */
-export function determineXCFrameworkFilename(frameworkPaths: string[]) {
- const frameworkNames = frameworkPaths.map((p) =>
- path.basename(p, path.extname(p))
- );
- const candidates = new Set(frameworkNames);
- assert(
- candidates.size === 1,
- "Expected all frameworks to have the same name"
- );
- const [name] = candidates;
- return `${name}.xcframework`;
-}
diff --git a/packages/react-native-node-api-cmake/src/cli.ts b/packages/react-native-node-api-cmake/src/cli.ts
index 4d7fac21..46ce29fa 100644
--- a/packages/react-native-node-api-cmake/src/cli.ts
+++ b/packages/react-native-node-api-cmake/src/cli.ts
@@ -10,28 +10,29 @@ import { oraPromise } from "ora";
import chalk from "chalk";
import {
- SUPPORTED_TRIPLETS,
- SupportedTriplet,
- AndroidTriplet,
- isAndroidTriplet,
- isAppleTriplet,
-} from "./triplets.js";
-import {
- createFramework,
- createXCframework,
DEFAULT_APPLE_TRIPLETS,
- determineXCFrameworkFilename,
getAppleBuildArgs,
getAppleConfigureCmakeArgs,
} from "./apple.js";
import {
DEFAULT_ANDROID_TRIPLETS,
getAndroidConfigureCmakeArgs,
- determineAndroidLibsFilename,
- createAndroidLibsDirectory,
} from "./android.js";
import { getWeakNodeApiVariables } from "./weak-node-api.js";
+import {
+ SUPPORTED_TRIPLETS,
+ SupportedTriplet,
+ AndroidTriplet,
+ isAndroidTriplet,
+ isAppleTriplet,
+ determineAndroidLibsFilename,
+ createAndroidLibsDirectory,
+ createAppleFramework,
+ createXCframework,
+ determineXCFrameworkFilename,
+} from "react-native-node-api-modules";
+
// We're attaching a lot of listeners when spawning in parallel
EventEmitter.defaultMaxListeners = 100;
@@ -206,13 +207,12 @@ export const program = new Command("react-native-node-api-cmake")
}
});
});
- const frameworkPaths = libraryPaths.map(createFramework);
+ const frameworkPaths = libraryPaths.map(createAppleFramework);
const xcframeworkFilename =
determineXCFrameworkFilename(frameworkPaths);
// Create the xcframework
const xcframeworkOutputPath = path.resolve(
- // Defaults to storing the xcframework next to the CMakeLists.txt file
globalContext.out || globalContext.source,
xcframeworkFilename
);
@@ -263,7 +263,6 @@ export const program = new Command("react-native-node-api-cmake")
Object.values(libraryPathByTriplet)
);
const androidLibsOutputPath = path.resolve(
- // Defaults to storing the xcframework next to the CMakeLists.txt file
globalContext.out || globalContext.source,
androidLibsFilename
);
diff --git a/packages/react-native-node-api-cmake/src/weak-node-api.ts b/packages/react-native-node-api-cmake/src/weak-node-api.ts
index 16b429b7..7d831137 100644
--- a/packages/react-native-node-api-cmake/src/weak-node-api.ts
+++ b/packages/react-native-node-api-cmake/src/weak-node-api.ts
@@ -6,7 +6,8 @@ import {
isAndroidTriplet,
isAppleTriplet,
SupportedTriplet,
-} from "./triplets.js";
+} from "react-native-node-api-modules";
+
import { ANDROID_ARCHITECTURES } from "./android.js";
import { getNodeAddonHeadersPath, getNodeApiHeadersPath } from "./headers.js";
diff --git a/packages/react-native-node-api-modules/package.json b/packages/react-native-node-api-modules/package.json
index 0d0e6d64..4050abc8 100644
--- a/packages/react-native-node-api-modules/package.json
+++ b/packages/react-native-node-api-modules/package.json
@@ -9,7 +9,10 @@
"react-native-node-api-modules": "./bin/react-native-node-api-modules.mjs"
},
"exports": {
- ".": "./dist/react-native/index.js",
+ ".": {
+ "node": "./dist/node/index.js",
+ "react-native": "./dist/react-native/index.js"
+ },
"./babel-plugin": "./dist/node/babel-plugin/index.js",
"./cli": "./dist/node/cli/run.js",
"./weak-node-api": "./weak-node-api"
diff --git a/packages/react-native-node-api-modules/react-native-node-api-modules.podspec b/packages/react-native-node-api-modules/react-native-node-api-modules.podspec
index c4bb2eac..7ab81982 100644
--- a/packages/react-native-node-api-modules/react-native-node-api-modules.podspec
+++ b/packages/react-native-node-api-modules/react-native-node-api-modules.podspec
@@ -32,7 +32,7 @@ Pod::Spec.new do |s|
s.source_files = "ios/**/*.{h,m,mm}", "cpp/**/*.{hpp,cpp,c,h}", "include/*.h"
s.public_header_files = "include/*.h"
- s.vendored_frameworks = "auto-linked/xcframeworks/*.xcframework"
+ s.vendored_frameworks = "auto-linked/apple/*.xcframework", "weak-node-api/libweak-node-api.xcframework"
s.script_phase = {
:name => 'Copy Node-API xcframeworks',
:execution_position => :before_compile,
diff --git a/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts b/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts
index f033d1cd..1eb810f3 100644
--- a/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts
+++ b/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts
@@ -100,13 +100,13 @@ export function generateNodeApiFunctionStubBody({
if (!real_func) {
void* handle = dlopen("${libraryPath}", RTLD_LAZY | RTLD_GLOBAL);
if (!handle) {
- fprintf(stderr, "Failed to load ${libraryPath}: %s\\n", dlerror());
+ fprintf(stderr, "Failed to load ${libraryPath} while deferring ${name}: %s\\n", dlerror());
${fallbackReturnStatement}
}
real_func = (${name}_t)dlsym(handle, "${name}");
if (!real_func) {
- fprintf(stderr, "Failed to find symbol: %s\\n", dlerror());
+ fprintf(stderr, "Failed to find symbol while deferring ${name}: %s\\n", dlerror());
${fallbackReturnStatement}
}
}
@@ -122,9 +122,11 @@ typedef ${returnType} (*${name}_t)(${argumentTypes.join(", ")});
${returnType} ${name}(${argumentTypes
.map((type, index) => `${type} arg${index}`)
.join(", ")}) {
+ fprintf(stdout, "Calling ${name} [weak-node-api]\\n");
#ifdef NODE_API_REEXPORT
${generateNodeApiFunctionStubBody(decl)}
#else
+ fprintf(stderr, "Returning generic error for ${name}\\n");
${fallbackReturnStatement}
#endif
}`;
diff --git a/packages/react-native-node-api-modules/src/node/index.ts b/packages/react-native-node-api-modules/src/node/index.ts
new file mode 100644
index 00000000..7b4ee6e1
--- /dev/null
+++ b/packages/react-native-node-api-modules/src/node/index.ts
@@ -0,0 +1,24 @@
+export {
+ ANDROID_TRIPLETS,
+ APPLE_TRIPLETS,
+ SUPPORTED_TRIPLETS,
+ type AndroidTriplet,
+ type AppleTriplet,
+ type SupportedTriplet,
+ isAppleTriplet,
+ isAndroidTriplet,
+} from "./prebuilds/triplets.js";
+
+export {
+ determineAndroidLibsFilename,
+ createAndroidLibsDirectory,
+} from "./prebuilds/android.js";
+
+export {
+ createAppleFramework,
+ createXCframework,
+ createUniversalAppleLibrary,
+ determineXCFrameworkFilename,
+} from "./prebuilds/apple.js";
+
+export { determineLibraryFilename, prettyPath } from "./path-utils.js";
diff --git a/packages/react-native-node-api-modules/src/node/path-utils.ts b/packages/react-native-node-api-modules/src/node/path-utils.ts
index c3eff84d..5b6e77da 100644
--- a/packages/react-native-node-api-modules/src/node/path-utils.ts
+++ b/packages/react-native-node-api-modules/src/node/path-utils.ts
@@ -307,6 +307,20 @@ export function findNodeApiModulePathsByDependency({
);
}
+/**
+ * Determine the library filename based on the library paths.
+ * Ensuring that all framework paths have the same base name.
+ */
+export function determineLibraryFilename(libraryPaths: string[]) {
+ const libraryNames = libraryPaths.map((p) =>
+ path.basename(p, path.extname(p))
+ );
+ const candidates = new Set(libraryNames);
+ assert(candidates.size === 1, "Expected all libraries to have the same name");
+ const [name] = candidates;
+ return name;
+}
+
export function getAutolinkPath(platform: PlatformName) {
const result = path.resolve(__dirname, "../../auto-linked", platform);
fs.mkdirSync(result, { recursive: true });
diff --git a/packages/react-native-node-api-modules/src/node/prebuilds/android.ts b/packages/react-native-node-api-modules/src/node/prebuilds/android.ts
new file mode 100644
index 00000000..13097660
--- /dev/null
+++ b/packages/react-native-node-api-modules/src/node/prebuilds/android.ts
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+
+import { AndroidTriplet } from "./triplets.js";
+import { determineLibraryFilename } from "../path-utils.js";
+
+export const DEFAULT_ANDROID_TRIPLETS = [
+ "aarch64-linux-android",
+ "armv7a-linux-androideabi",
+ "i686-linux-android",
+ "x86_64-linux-android",
+] as const satisfies AndroidTriplet[];
+
+type AndroidArchitecture = "armeabi-v7a" | "arm64-v8a" | "x86" | "x86_64";
+
+export const ANDROID_ARCHITECTURES = {
+ "armv7a-linux-androideabi": "armeabi-v7a",
+ "aarch64-linux-android": "arm64-v8a",
+ "i686-linux-android": "x86",
+ "x86_64-linux-android": "x86_64",
+} satisfies Record;
+
+/**
+ * Determine the filename of the Android libs directory based on the framework paths.
+ * Ensuring that all framework paths have the same base name.
+ */
+export function determineAndroidLibsFilename(libraryPaths: string[]) {
+ const libraryName = determineLibraryFilename(libraryPaths);
+ return `${libraryName}.android.node`;
+}
+
+type AndroidLibsDirectoryOptions = {
+ outputPath: string;
+ libraryPathByTriplet: Record;
+ autoLink: boolean;
+};
+
+export async function createAndroidLibsDirectory({
+ outputPath,
+ libraryPathByTriplet,
+ autoLink,
+}: AndroidLibsDirectoryOptions) {
+ // Delete and recreate any existing output directory
+ await fs.promises.rm(outputPath, { recursive: true, force: true });
+ await fs.promises.mkdir(outputPath, { recursive: true });
+ for (const [triplet] of Object.entries(libraryPathByTriplet)) {
+ const libraryPath = libraryPathByTriplet[triplet as AndroidTriplet];
+ assert(
+ fs.existsSync(libraryPath),
+ `Library not found: ${libraryPath} for triplet ${triplet}`
+ );
+ const arch = ANDROID_ARCHITECTURES[triplet as AndroidTriplet];
+ const archOutputPath = path.join(outputPath, arch);
+ await fs.promises.mkdir(archOutputPath, { recursive: true });
+ // Strip the ".node" extension from the library name
+ const libraryName = path.basename(libraryPath, ".node");
+ const soSuffixedName =
+ path.extname(libraryName) === ".so" ? libraryName : `${libraryName}.so`;
+ const finalLibraryName = libraryName.startsWith("lib")
+ ? soSuffixedName
+ : `lib${soSuffixedName}`;
+ const libraryOutputPath = path.join(archOutputPath, finalLibraryName);
+ await fs.promises.copyFile(libraryPath, libraryOutputPath);
+ // TODO: Update the install path in the library file
+ }
+ if (autoLink) {
+ // Write a file to mark the Android libs directory is a Node-API module
+ await fs.promises.writeFile(
+ path.join(outputPath, "react-native-node-api-module"),
+ "",
+ "utf8"
+ );
+ }
+ return outputPath;
+}
diff --git a/packages/react-native-node-api-modules/src/node/prebuilds/apple.ts b/packages/react-native-node-api-modules/src/node/prebuilds/apple.ts
new file mode 100644
index 00000000..86ac5a09
--- /dev/null
+++ b/packages/react-native-node-api-modules/src/node/prebuilds/apple.ts
@@ -0,0 +1,154 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import cp from "node:child_process";
+
+import { spawn } from "bufout";
+
+import { AppleTriplet } from "react-native-node-api-modules";
+
+type AppleArchitecture = "arm64" | "x86_64" | "arm64;x86_64";
+
+export const APPLE_ARCHITECTURES = {
+ "x86_64-apple-darwin": "x86_64",
+ "arm64-apple-darwin": "arm64",
+ "arm64;x86_64-apple-darwin": "arm64;x86_64",
+ "arm64-apple-ios": "arm64",
+ "arm64-apple-ios-sim": "arm64",
+ "arm64-apple-tvos": "arm64",
+ // "x86_64-apple-tvos": "x86_64",
+ "arm64-apple-tvos-sim": "arm64",
+ "arm64-apple-visionos": "arm64",
+ "arm64-apple-visionos-sim": "arm64",
+} satisfies Record;
+
+export function createPlistContent(values: Record) {
+ return [
+ '',
+ '',
+ '',
+ " ",
+ ...Object.entries(values).flatMap(([key, value]) => [
+ ` ${key}`,
+ ` ${value}`,
+ ]),
+ " ",
+ "",
+ ].join("\n");
+}
+
+type XCframeworkOptions = {
+ frameworkPaths: string[];
+ outputPath: string;
+ autoLink: boolean;
+};
+
+export function createAppleFramework(libraryPath: string) {
+ assert(fs.existsSync(libraryPath), `Library not found: ${libraryPath}`);
+ // Write a info.plist file to the framework
+ const libraryName = path.basename(libraryPath, path.extname(libraryPath));
+ const frameworkPath = path.join(
+ path.dirname(libraryPath),
+ `${libraryName}.framework`
+ );
+ // Create the framework from scratch
+ fs.rmSync(frameworkPath, { recursive: true, force: true });
+ fs.mkdirSync(frameworkPath);
+ fs.mkdirSync(path.join(frameworkPath, "Headers"));
+ // Create an empty Info.plist file
+ fs.writeFileSync(
+ path.join(frameworkPath, "Info.plist"),
+ createPlistContent({
+ CFBundleDevelopmentRegion: "en",
+ CFBundleExecutable: libraryName,
+ CFBundleIdentifier: `com.callstackincubator.node-api.${libraryName}`,
+ CFBundleInfoDictionaryVersion: "6.0",
+ CFBundleName: libraryName,
+ CFBundlePackageType: "FMWK",
+ CFBundleShortVersionString: "1.0",
+ CFBundleVersion: "1",
+ NSPrincipalClass: "",
+ }),
+ "utf8"
+ );
+ const newLibraryPath = path.join(frameworkPath, libraryName);
+ // TODO: Consider copying the library instead of renaming it
+ fs.renameSync(libraryPath, newLibraryPath);
+ // Update the name of the library
+ cp.spawnSync("install_name_tool", [
+ "-id",
+ `@rpath/${libraryName}.framework/${libraryName}`,
+ newLibraryPath,
+ ]);
+ return frameworkPath;
+}
+
+export async function createXCframework({
+ frameworkPaths,
+ outputPath,
+ autoLink,
+}: XCframeworkOptions) {
+ // Delete any existing xcframework to prevent the error:
+ // - A library with the identifier 'macos-arm64' already exists.
+ // Ideally, it would only be necessary to delete the specific platform+arch, to allow selectively building from source.
+ fs.rmSync(outputPath, { recursive: true, force: true });
+
+ await spawn(
+ "xcodebuild",
+ [
+ "-create-xcframework",
+ ...frameworkPaths.flatMap((p) => ["-framework", p]),
+ "-output",
+ outputPath,
+ ],
+ {
+ outputMode: "buffered",
+ }
+ );
+ if (autoLink) {
+ // Write a file to mark the xcframework is a Node-API module
+ // TODO: Consider including this in the Info.plist file instead
+ fs.writeFileSync(
+ path.join(outputPath, "react-native-node-api-module"),
+ "",
+ "utf8"
+ );
+ }
+}
+
+/**
+ * Determine the filename of the xcframework based on the framework paths.
+ * Ensuring that all framework paths have the same base name.
+ */
+export function determineXCFrameworkFilename(frameworkPaths: string[]) {
+ const frameworkNames = frameworkPaths.map((p) =>
+ path.basename(p, path.extname(p))
+ );
+ const candidates = new Set(frameworkNames);
+ assert(
+ candidates.size === 1,
+ "Expected all frameworks to have the same name"
+ );
+ const [name] = candidates;
+ return `${name}.xcframework`;
+}
+
+export async function createUniversalAppleLibrary(libraryPaths: string[]) {
+ // Determine the output path
+ const filenames = new Set(libraryPaths.map((p) => path.basename(p)));
+ assert(
+ filenames.size === 1,
+ "Expected all darwin libraries to have the same name"
+ );
+ const [filename] = filenames;
+ const lipoParentPath = fs.realpathSync(
+ fs.mkdtempSync(path.join(os.tmpdir(), "ferric-lipo-output-"))
+ );
+ const outputPath = path.join(lipoParentPath, filename);
+ await spawn("lipo", ["-create", "-output", outputPath, ...libraryPaths], {
+ outputMode: "buffered",
+ });
+ assert(fs.existsSync(outputPath), "Expected lipo output path to exist");
+ return outputPath;
+}
diff --git a/packages/react-native-node-api-cmake/src/triplets.ts b/packages/react-native-node-api-modules/src/node/prebuilds/triplets.ts
similarity index 100%
rename from packages/react-native-node-api-cmake/src/triplets.ts
rename to packages/react-native-node-api-modules/src/node/prebuilds/triplets.ts
diff --git a/packages/react-native-node-api-modules/tsconfig.node.json b/packages/react-native-node-api-modules/tsconfig.node.json
index 94c6bdf7..bf847c8c 100644
--- a/packages/react-native-node-api-modules/tsconfig.node.json
+++ b/packages/react-native-node-api-modules/tsconfig.node.json
@@ -8,5 +8,5 @@
"types": ["node"]
},
"include": ["src/node/**/*.ts", "types/**/*.d.ts"],
- "exclude": ["**.test.ts"]
+ "exclude": ["**/*.test.ts"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 4f0638bb..8bf7d5f4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,8 +1,9 @@
{
"files": [],
"references": [
+ { "path": "./packages/react-native-node-api-modules/tsconfig.json" },
{ "path": "./packages/gyp-to-cmake/tsconfig.json" },
{ "path": "./packages/react-native-node-api-cmake/tsconfig.json" },
- { "path": "./packages/react-native-node-api-modules/tsconfig.json" }
+ { "path": "./packages/ferric/tsconfig.json" }
]
}