Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/ANDROID.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Building Hermes from source
# Android support

## Building Hermes from source

Because we're using a version of Hermes patched with Node-API support, we need to build React Native from source.

Expand All @@ -8,6 +10,8 @@ export REACT_NATIVE_OVERRIDE_HERMES_DIR=`npx react-native-node-api-modules vendo

## Cleaning your React Native build folders

If you've accidentally built your app without Hermes patched, you can clean things up by deleting the `ReactAndroid` build folder.

```
rm -rf node_modules/react-native/ReactAndroid/build
```
27 changes: 27 additions & 0 deletions docs/AUTO-LINKING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Auto-linking

The `react-native-node-api-modules` package (sometimes referred to as "the host package") has mechanisms to automatically find and link prebuilt binaries with Node-API modules.

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.

## Naming scheme of libraries when linked into the host

The name of the library when linked / copied into the host is based on two things:

- 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).
- The relative path of the library to the package root:
- Normalized (any "lib" prefix or file extension is stripped from the filename).
- Escaped (any non-alphanumeric character is replaced with "-").

## How do I link Node-API module libraries into my app?

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-modules` package.

You can also manually link by running the following in your app directory:

```bash
npx react-native-node-api-modules link --android --apple
```

> [!NOTE]
> 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).
33 changes: 27 additions & 6 deletions docs/PREBUILDS.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
# Prebuilds

This document will codify the naming and directory structure of prebuilt binaries, expected by the `react-native-node-api-modules` package and tools.
This document codifies the naming and directory structure of prebuilt binaries, expected by the auto-linking mechanism.

To align with prior art and established patterns around the distribution of Node-API modules for Node.js (and other engines supporting this),
At the time of writing, our auto-linking host package (`react-native-node-api-modules`) support two kinds of prebuilds:

## Apple: XCFrameworks of dynamic libraries in frameworks
## `*.android.node` (for Android)

A jniLibs-like directory structure of CPU-architecture specific directories containing a single `.so` library file.

The name of all the `.so` library files:

- must be the same across all CPU-architectures
- can have a "lib" prefix, but doesn't have to
- must have an `.so` or `.node` file extension

> [!NOTE]
> The `SONAME` doesn't have to match and is not updated as the .so is copied into the host package.
> This might cause trouble if you're trying to link with the library from other native code.
> We're tracking [#14](https://github.com/callstackincubator/react-native-node-api-modules/issues/14) to fix this 🤞

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.

## `*.apple.node` (for Apple)

An XCFramework of dynamic libraries wrapped in `.framework` bundles, renamed from `.xcframework` to `.apple.node` to ease discoverability.

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:

> 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.

<!-- TODO: Write this -->
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.

## Why did we choose this naming scheme?

## Android: Directory of architecture specific directories of shared object library files.
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.

<!-- TODO: Write this -->
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.
1 change: 1 addition & 0 deletions packages/ferric-example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Cargo.lock

/*.xcframework/
/*.apple.node/
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Shouldn't .node be reserved for binary files (shared libraries)? Aren't we breaking this assumption here, by making it a directory? In the Node-API docs I've found this quote (emphasis mine):

The string passed to require() is the name of the target in binding.gyp responsible for creating the .node file.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We are definitely walking a fine line here and I agree it could cause confusion.

The "problem" is that one of the use-cases we want to support is taking an existing library (with source-code packaged in) and building the Apple and Android binaries for it.
If we were true to the spirit of the docs you referenced, we would have to override the ".node" file which was already included in the package and we couldn't use that for iOS and Android simultaneously. We would have to "swap out" the .node file for the right (platform+arch) at build time, just before it's copied into the app bundle. As an example, I know this is also what a tool like electron-builder is doing when it's building the final app bundle.

☝️ I feel that approach is not the right one, and I would like the prebuilds for different platforms and architectures to co-exist on the filesystem.

To my understanding, Node.js has no standardized way to publish multiple .node files (for multiple platforms / architectures) - we could spent some more time digging into the output formats of tools like:

(slightly unrelated, I found this extension point in node-gyp-build where a platform can implement require.addon https://github.com/prebuild/node-gyp-build/blob/6822ec52423a2b3ed48ef8960a9fe05902e9e1a3/index.js#L3 ... somewhat similar to our requireNodeAddon 🙂)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Could you help me formulate a problem statement?

@shirakaba also wrote about this in this thread on discord: https://discord.com/channels/426714625279524876/1343407685462130780

/*.android.node/

# Generated files
Expand Down
13 changes: 11 additions & 2 deletions packages/ferric/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ const ndkVersionOption = new Option(
"--ndk-version <version>",
"The NDK version to use for Android builds"
).default(DEFAULT_NDK_VERSION);
const xcframeworkExtensionOption = new Option(
"--xcframework-extension",
"Don't rename the xcframework to .apple.node"
).default(false);

const outputPathOption = new Option(
"--output <path>",
"Writing outputs to this directory"
Expand All @@ -85,6 +90,7 @@ export const buildCommand = new Command("build")
.addOption(ndkVersionOption)
.addOption(outputPathOption)
.addOption(configurationOption)
.addOption(xcframeworkExtensionOption)
.action(
async ({
target: targetArg,
Expand All @@ -93,6 +99,7 @@ export const buildCommand = new Command("build")
ndkVersion,
output: outputPath,
configuration,
xcframeworkExtension,
}) => {
try {
const targets = new Set([...targetArg]);
Expand Down Expand Up @@ -221,8 +228,10 @@ export const buildCommand = new Command("build")
if (appleLibraries.length > 0) {
const libraryPaths = await combineLibraries(appleLibraries);
const frameworkPaths = libraryPaths.map(createAppleFramework);
const xcframeworkFilename =
determineXCFrameworkFilename(frameworkPaths);
const xcframeworkFilename = determineXCFrameworkFilename(
frameworkPaths,
xcframeworkExtension ? ".xcframework" : ".apple.node"
);

// Create the xcframework
const xcframeworkOutputPath = path.resolve(
Expand Down
28 changes: 18 additions & 10 deletions packages/node-addon-examples/scripts/verify-prebuilds.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ const EXPECTED_XCFRAMEWORK_PLATFORMS = [
"xros-arm64-simulator",
];

async function verifyAndroidDirectory(dirent: fs.Dirent) {
async function verifyAndroidPrebuild(dirent: fs.Dirent) {
console.log(
"Verifying Android prebuild",
dirent.name,
"in",
dirent.parentPath
);
for (const arch of EXPECTED_ANDROID_ARCHS) {
const archDir = path.join(dirent.parentPath, dirent.name, arch);
for (const file of await fs.promises.readdir(archDir, {
Expand All @@ -31,7 +37,8 @@ async function verifyAndroidDirectory(dirent: fs.Dirent) {
}
}

async function verifyXcframework(dirent: fs.Dirent) {
async function verifyApplePrebuild(dirent: fs.Dirent) {
console.log("Verifying Apple prebuild", dirent.name, "in", dirent.parentPath);
for (const arch of EXPECTED_XCFRAMEWORK_PLATFORMS) {
const archDir = path.join(dirent.parentPath, dirent.name, arch);
for (const file of await fs.promises.readdir(archDir, {
Expand Down Expand Up @@ -75,16 +82,17 @@ async function verifyXcframework(dirent: fs.Dirent) {
}
}

for (const dirent of await fs.promises.readdir(EXAMPLES_DIR, {
for await (const dirent of fs.promises.glob("**/*.*.node", {
cwd: EXAMPLES_DIR,
withFileTypes: true,
recursive: true,
})) {
if (!dirent.isDirectory()) {
continue;
}
if (dirent.name.endsWith(".android.node")) {
await verifyAndroidDirectory(dirent);
} else if (dirent.name.endsWith(".xcframework")) {
await verifyXcframework(dirent);
await verifyAndroidPrebuild(dirent);
} else if (dirent.name.endsWith(".apple.node")) {
await verifyApplePrebuild(dirent);
} else {
throw new Error(
`Unexpected prebuild file: ${dirent.name} in ${dirent.parentPath}`
);
}
}
12 changes: 10 additions & 2 deletions packages/react-native-node-api-cmake/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ const noWeakNodeApiLinkageOption = new Option(
"Don't pass the path of the weak-node-api library from react-native-node-api-modules"
);

const xcframeworkExtensionOption = new Option(
"--xcframework-extension",
"Don't rename the xcframework to .apple.node"
).default(false);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm adding this option, since we're using this tool to build weak-node-api and we're manually referencing the output artifact via vendored_frameworks (no linking involved).

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I'm thinking whether this renaming should be something that the users needs to opt-in, instead of asking not to do it. Moreover, I'm a little afraid that the .apple.node extension might suggest that this will also work out-of-the-box on macOS (hinting that it might be compatible with "old-school" N-API addons & node.js). macOS can load dylibs directly and doesn't require packaging into Xcframeworks, and we are currently doing it only for mobile devices, right?


export const program = new Command("react-native-node-api-cmake")
.description("Build React Native Node API modules with CMake")
.addOption(sourcePathOption)
Expand All @@ -104,6 +109,7 @@ export const program = new Command("react-native-node-api-cmake")
.addOption(ndkVersionOption)
.addOption(noAutoLinkOption)
.addOption(noWeakNodeApiLinkageOption)
.addOption(xcframeworkExtensionOption)
.action(async ({ triplet: tripletValues, ...globalContext }) => {
try {
const buildPath = getBuildPath(globalContext);
Expand Down Expand Up @@ -212,8 +218,10 @@ export const program = new Command("react-native-node-api-cmake")
})
);
const frameworkPaths = libraryPaths.map(createAppleFramework);
const xcframeworkFilename =
determineXCFrameworkFilename(frameworkPaths);
const xcframeworkFilename = determineXCFrameworkFilename(
frameworkPaths,
globalContext.xcframeworkExtension ? ".xcframework" : ".apple.node"
);

// Create the xcframework
const xcframeworkOutputPath = path.resolve(
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-node-api-modules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"copy-node-api-headers": "tsx scripts/copy-node-api-headers.ts",
"generate-weak-node-api": "tsx scripts/generate-weak-node-api.ts",
"generate-weak-node-api-injector": "tsx scripts/generate-weak-node-api-injector.ts",
"build-weak-node-api": "npm run generate-weak-node-api && react-native-node-api-cmake --android --apple --no-auto-link --no-weak-node-api-linkage --source ./weak-node-api",
"build-weak-node-api": "npm run generate-weak-node-api && react-native-node-api-cmake --android --apple --no-auto-link --no-weak-node-api-linkage --xcframework-extension --source ./weak-node-api",
"test": "tsx --test src/node/**/*.test.ts src/node/*.test.ts"
},
"keywords": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ describe("plugin", () => {
it("transforms require calls, regardless", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"package.json": `{ "name": "my-package" }`,
"addon-1.xcframework/addon-1.node":
"addon-1.apple.node/addon-1.node":
"// This is supposed to be a binary file",
"addon-2.xcframework/addon-2.node":
"addon-2.apple.node/addon-2.node":
"// This is supposed to be a binary file",
"addon-1.js": `
const addon = require('./addon-1.node');
Expand Down
Loading