diff --git a/package-lock.json b/package-lock.json index 5806eb49..6bb98bb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9861,6 +9861,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/gyp-to-cmake": { "version": "0.1.0", "dependencies": { @@ -10157,7 +10167,8 @@ "@babel/core": "^7.26.10", "@babel/types": "^7.27.0", "metro-config": "0.81.1", - "node-api-headers": "^1.5.0" + "node-api-headers": "^1.5.0", + "zod": "^3.24.3" }, "peerDependencies": { "@babel/core": "^7.26.10", diff --git a/packages/react-native-node-api-cmake/src/android.ts b/packages/react-native-node-api-cmake/src/android.ts index ed5de290..db8054c9 100644 --- a/packages/react-native-node-api-cmake/src/android.ts +++ b/packages/react-native-node-api-cmake/src/android.ts @@ -1,20 +1,8 @@ -import assert from "node:assert"; +import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { type SupportedTriplet } from "./triplets.js"; - -/** - * https://developer.android.com/ndk/guides/other_build_systems - */ -export const ANDROID_TRIPLETS = [ - "aarch64-linux-android", - "armv7a-linux-androideabi", - "i686-linux-android", - "x86_64-linux-android", -] as const; - -export type AndroidTriplet = (typeof ANDROID_TRIPLETS)[number]; +import { AndroidTriplet } from "./triplets.js"; export const DEFAULT_ANDROID_TRIPLETS = [ "aarch64-linux-android", @@ -23,15 +11,9 @@ export const DEFAULT_ANDROID_TRIPLETS = [ "x86_64-linux-android", ] as const satisfies AndroidTriplet[]; -export function isAndroidTriplet( - triplet: SupportedTriplet -): triplet is AndroidTriplet { - return ANDROID_TRIPLETS.includes(triplet as AndroidTriplet); -} - type AndroidArchitecture = "armeabi-v7a" | "arm64-v8a" | "x86" | "x86_64"; -export const ARCHITECTURES = { +export const ANDROID_ARCHITECTURES = { "armv7a-linux-androideabi": "armeabi-v7a", "aarch64-linux-android": "arm64-v8a", "i686-linux-android": "x86", @@ -64,7 +46,14 @@ export function getAndroidConfigureCmakeArgs({ ndkPath, "build/cmake/android.toolchain.cmake" ); - const architecture = ARCHITECTURES[triplet]; + const architecture = ANDROID_ARCHITECTURES[triplet]; + + const linkerFlags: string[] = [ + // `--no-version-undefined`, + // `--whole-archive`, + // `--no-whole-archive`, + ]; + return [ // Use the XCode as generator for Apple platforms "-G", @@ -73,8 +62,8 @@ export function getAndroidConfigureCmakeArgs({ toolchainPath, "-D", "CMAKE_SYSTEM_NAME=Android", - "-D", - `CPACK_SYSTEM_NAME=Android-${architecture}`, + // "-D", + // `CPACK_SYSTEM_NAME=Android-${architecture}`, // "-D", // `CMAKE_INSTALL_PREFIX=${installPath}`, // "-D", @@ -96,8 +85,66 @@ export function getAndroidConfigureCmakeArgs({ "-D", "ANDROID_STL=c++_shared", // Pass linker flags to avoid errors from undefined symbols - // TODO: Link against a fake libhermes to avoid this (or whatever other lib which will be providing the symbols) + // TODO: Link against a weak-node-api to avoid this (or whatever other lib which will be providing the symbols) + // "-D", + // `CMAKE_SHARED_LINKER_FLAGS="-Wl,--allow-shlib-undefined"`, "-D", - `CMAKE_SHARED_LINKER_FLAGS="-Wl,--allow-shlib-undefined"`, + `CMAKE_SHARED_LINKER_FLAGS=${linkerFlags + .map((flag) => `-Wl,${flag}`) + .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 }); + const libraryName = path.basename(libraryPath, path.extname(libraryPath)); + const libraryOutputPath = path.join(archOutputPath, libraryName); + await fs.promises.copyFile(libraryPath, libraryOutputPath); + } + 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 6e8ef124..ca8303ac 100644 --- a/packages/react-native-node-api-cmake/src/apple.ts +++ b/packages/react-native-node-api-cmake/src/apple.ts @@ -3,23 +3,9 @@ import cp from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import type { SupportedTriplet } from "./triplets.js"; import { spawn } from "bufout"; -export const APPLE_TRIPLETS = [ - "arm64;x86_64-apple-darwin", - "x86_64-apple-darwin", - "arm64-apple-darwin", - "arm64-apple-ios", - "arm64-apple-ios-sim", - "arm64-apple-tvos", - "arm64-apple-tvos-sim", - // "x86_64-apple-tvos", - "arm64-apple-visionos", - "arm64-apple-visionos-sim", -] as const; - -export type AppleTriplet = (typeof APPLE_TRIPLETS)[number]; +import { AppleTriplet, isAppleTriplet } from "./triplets.js"; export const DEFAULT_APPLE_TRIPLETS = [ "arm64;x86_64-apple-darwin", @@ -41,7 +27,7 @@ type XcodeSDKName = | "appletvsimulator" | "macosx"; -const SDK_NAMES = { +const XCODE_SDK_NAMES = { "x86_64-apple-darwin": "macosx", "arm64-apple-darwin": "macosx", "arm64;x86_64-apple-darwin": "macosx", @@ -54,9 +40,24 @@ const SDK_NAMES = { "arm64-apple-visionos-sim": "xrsimulator", } satisfies Record; +type CMakeSystemName = "Darwin" | "iOS" | "tvOS" | "watchOS" | "visionOS"; + +const CMAKE_SYSTEM_NAMES = { + "x86_64-apple-darwin": "Darwin", + "arm64-apple-darwin": "Darwin", + "arm64;x86_64-apple-darwin": "Darwin", + "arm64-apple-ios": "iOS", + "arm64-apple-ios-sim": "iOS", + "arm64-apple-tvos": "tvOS", + // "x86_64-apple-tvos": "appletvos", + "arm64-apple-tvos-sim": "tvOS", + "arm64-apple-visionos": "visionOS", + "arm64-apple-visionos-sim": "visionOS", +} satisfies Record; + type AppleArchitecture = "arm64" | "x86_64" | "arm64;x86_64"; -export const ARCHITECTURES = { +export const APPLE_ARCHITECTURES = { "x86_64-apple-darwin": "x86_64", "arm64-apple-darwin": "arm64", "arm64;x86_64-apple-darwin": "arm64;x86_64", @@ -69,17 +70,15 @@ export const ARCHITECTURES = { "arm64-apple-visionos-sim": "arm64", } satisfies Record; -export function isAppleTriplet( - triplet: SupportedTriplet -): triplet is AppleTriplet { - return APPLE_TRIPLETS.includes(triplet as AppleTriplet); -} - export function getAppleSDKPath(triplet: AppleTriplet) { return cp - .spawnSync("xcrun", ["--sdk", SDK_NAMES[triplet], "--show-sdk-path"], { - encoding: "utf-8", - }) + .spawnSync( + "xcrun", + ["--sdk", XCODE_SDK_NAMES[triplet], "--show-sdk-path"], + { + encoding: "utf-8", + } + ) .stdout.trim(); } @@ -98,23 +97,27 @@ export function createPlistContent(values: Record) { ].join("\n"); } -export function getAppleConfigureCmakeArgs(triplet: AppleTriplet) { +type AppleConfigureOptions = { + triplet: AppleTriplet; +}; + +export function getAppleConfigureCmakeArgs({ triplet }: AppleConfigureOptions) { assert(isAppleTriplet(triplet)); const sdkPath = getAppleSDKPath(triplet); + const systemName = CMAKE_SYSTEM_NAMES[triplet]; return [ // Use the XCode as generator for Apple platforms "-G", "Xcode", - // Pass linker flags to avoid errors from undefined symbols "-D", - `CMAKE_SHARED_LINKER_FLAGS="-Wl,-undefined,dynamic_lookup"`, + `CMAKE_SYSTEM_NAME=${systemName}`, // Set the SDK path for the target platform "-D", `CMAKE_OSX_SYSROOT=${sdkPath}`, // Set the target architecture "-D", - `CMAKE_OSX_ARCHITECTURES=${ARCHITECTURES[triplet]}`, + `CMAKE_OSX_ARCHITECTURES=${APPLE_ARCHITECTURES[triplet]}`, ]; } @@ -126,6 +129,7 @@ export function getAppleBuildArgs() { type XCframeworkOptions = { frameworkPaths: string[]; outputPath: string; + autoLink: boolean; }; export function createFramework(libraryPath: string) { @@ -171,6 +175,7 @@ export function createFramework(libraryPath: string) { 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. @@ -189,13 +194,15 @@ export async function createXCframework({ outputMode: "buffered", } ); - // 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" - ); + 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" + ); + } } /** diff --git a/packages/react-native-node-api-cmake/src/cli.ts b/packages/react-native-node-api-cmake/src/cli.ts index 0c723756..e3c641a1 100644 --- a/packages/react-native-node-api-cmake/src/cli.ts +++ b/packages/react-native-node-api-cmake/src/cli.ts @@ -2,13 +2,20 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs/promises"; import { existsSync, readdirSync, renameSync } from "node:fs"; +import { EventEmitter } from "node:events"; import { Command, Option } from "@commander-js/extra-typings"; import { spawn, SpawnFailure } from "bufout"; import { oraPromise } from "ora"; +import chalk from "chalk"; -import { SUPPORTED_TRIPLETS, SupportedTriplet } from "./triplets.js"; -import { getNodeApiHeadersPath, getNodeAddonHeadersPath } from "./headers.js"; +import { + SUPPORTED_TRIPLETS, + SupportedTriplet, + AndroidTriplet, + isAndroidTriplet, + isAppleTriplet, +} from "./triplets.js"; import { createFramework, createXCframework, @@ -16,18 +23,17 @@ import { determineXCFrameworkFilename, getAppleBuildArgs, getAppleConfigureCmakeArgs, - isAppleTriplet, } from "./apple.js"; -import chalk from "chalk"; import { DEFAULT_ANDROID_TRIPLETS, getAndroidConfigureCmakeArgs, - isAndroidTriplet, + determineAndroidLibsFilename, + createAndroidLibsDirectory, } from "./android.js"; +import { getWeakNodeApiVariables } from "./weak-node-api.js"; // We're attaching a lot of listeners when spawning in parallel -process.stdout.setMaxListeners(100); -process.stderr.setMaxListeners(100); +EventEmitter.defaultMaxListeners = 100; // This should match https://github.com/react-native-community/template/blob/main/template/android/build.gradle#L7 const DEFAULT_NDK_VERSION = "27.1.12297006"; @@ -52,6 +58,9 @@ const tripletOption = new Option( "Triplets to build for" ).choices(SUPPORTED_TRIPLETS); +const androidOption = new Option("--android", "Enable all Android triplets"); +const appleOption = new Option("--apple", "Enable all Apple triplets"); + const buildPathOption = new Option( "--build ", "Specify the build directory to store the configured CMake project" @@ -72,8 +81,15 @@ const ndkVersionOption = new Option( "The NDK version to use for Android builds" ).default(DEFAULT_NDK_VERSION); -const androidOption = new Option("--android", "Enable all Android triplets"); -const appleOption = new Option("--apple", "Enable all Apple triplets"); +const noAutoLinkOption = new Option( + "--no-auto-link", + "Don't mark the output as auto-linkable by react-native-node-api-modules" +); + +const noWeakNodeApiLinkageOption = new Option( + "--no-weak-node-api-linkage", + "Don't pass the path of the weak-node-api library from react-native-node-api-modules" +); export const program = new Command("react-native-node-api-cmake") .description("Build React Native Node API modules with CMake") @@ -86,6 +102,8 @@ export const program = new Command("react-native-node-api-cmake") .addOption(outPathOption) .addOption(cleanOption) .addOption(ndkVersionOption) + .addOption(noAutoLinkOption) + .addOption(noWeakNodeApiLinkageOption) .action(async ({ triplet: tripletValues, ...globalContext }) => { try { const buildPath = getBuildPath(globalContext); @@ -203,6 +221,7 @@ export const program = new Command("react-native-node-api-cmake") createXCframework({ outputPath: xcframeworkOutputPath, frameworkPaths, + autoLink: globalContext.autoLink, }), { text: "Assembling XCFramework", @@ -214,6 +233,57 @@ export const program = new Command("react-native-node-api-cmake") } ); } + + const androidTriplets = tripletContext.filter(({ triplet }) => + isAndroidTriplet(triplet) + ); + if (androidTriplets.length > 0) { + const libraryPathByTriplet = Object.fromEntries( + androidTriplets.map(({ tripletOutputPath, triplet }) => { + assert( + existsSync(tripletOutputPath), + `Expected a directory at ${tripletOutputPath}` + ); + // Expect binary file(s), either .node or .so + const result = readdirSync(tripletOutputPath).map((file) => { + const filePath = path.join(tripletOutputPath, file); + if (file.endsWith(".so") || file.endsWith(".node")) { + return filePath; + } else { + throw new Error( + `Expected a .node or .so file, but found ${file}` + ); + } + }); + assert.equal(result.length, 1, "Expected exactly library file"); + return [triplet, result[0]] as const; + }) + ) as Record; + const androidLibsFilename = determineAndroidLibsFilename( + Object.values(libraryPathByTriplet) + ); + const androidLibsOutputPath = path.resolve( + // Defaults to storing the xcframework next to the CMakeLists.txt file + globalContext.out || globalContext.source, + androidLibsFilename + ); + + await oraPromise( + createAndroidLibsDirectory({ + outputPath: androidLibsOutputPath, + libraryPathByTriplet, + autoLink: globalContext.autoLink, + }), + { + text: "Assembling Android libs directory", + successText: `Android libs directory assembled into ${chalk.dim( + path.relative(process.cwd(), androidLibsOutputPath) + )}`, + failText: ({ message }) => + `Failed to assemble Android libs directory: ${message}`, + } + ); + } } catch (error) { if (error instanceof SpawnFailure) { error.flushOutput("both"); @@ -249,12 +319,15 @@ function getTripletBuildPath(buildPath: string, triplet: SupportedTriplet) { function getTripletConfigureCmakeArgs( triplet: SupportedTriplet, - { ndkVersion }: Pick + { ndkVersion }: Pick ) { if (isAndroidTriplet(triplet)) { - return getAndroidConfigureCmakeArgs({ triplet, ndkVersion }); + return getAndroidConfigureCmakeArgs({ + triplet, + ndkVersion, + }); } else if (isAppleTriplet(triplet)) { - return getAppleConfigureCmakeArgs(triplet); + return getAppleConfigureCmakeArgs({ triplet }); } else { throw new Error(`Support for '${triplet}' is not implemented yet`); } @@ -271,13 +344,8 @@ function getBuildArgs(triplet: SupportedTriplet) { } async function configureProject(context: TripletScopedContext) { - const { triplet, tripletBuildPath, source, ndkVersion } = context; - const variables = getVariables(context); - const variablesArgs = Object.entries(variables).flatMap(([key, value]) => [ - "-D", - `${key}=${value}`, - ]); - + const { triplet, tripletBuildPath, source, ndkVersion, weakNodeApiLinkage } = + context; await spawn( "cmake", [ @@ -285,8 +353,11 @@ async function configureProject(context: TripletScopedContext) { source, "-B", tripletBuildPath, - ...variablesArgs, - ...getTripletConfigureCmakeArgs(triplet, { ndkVersion }), + ...getVariablesArgs(getVariables(context)), + ...getTripletConfigureCmakeArgs(triplet, { + ndkVersion, + weakNodeApiLinkage, + }), ], { outputMode: "buffered", @@ -313,15 +384,15 @@ async function buildProject(context: TripletScopedContext) { } function getVariables(context: TripletScopedContext): Record { - const includePaths = [getNodeApiHeadersPath(), getNodeAddonHeadersPath()]; - for (const includePath of includePaths) { - assert( - !includePath.includes(";"), - `Include path with a ';' is not supported: ${includePath}` - ); - } return { - CMAKE_JS_INC: includePaths.join(";"), + ...(context.weakNodeApiLinkage && getWeakNodeApiVariables(context.triplet)), CMAKE_LIBRARY_OUTPUT_DIRECTORY: context.tripletOutputPath, }; } + +function getVariablesArgs(variables: Record) { + return Object.entries(variables).flatMap(([key, value]) => [ + "-D", + `${key}=${value}`, + ]); +} diff --git a/packages/react-native-node-api-cmake/src/triplets.ts b/packages/react-native-node-api-cmake/src/triplets.ts index 71e7dd15..fcfbf6a9 100644 --- a/packages/react-native-node-api-cmake/src/triplets.ts +++ b/packages/react-native-node-api-cmake/src/triplets.ts @@ -1,5 +1,29 @@ -import { ANDROID_TRIPLETS } from "./android.js"; -import { APPLE_TRIPLETS } from "./apple.js"; +/** + * https://developer.android.com/ndk/guides/other_build_systems + */ +export const ANDROID_TRIPLETS = [ + "aarch64-linux-android", + "armv7a-linux-androideabi", + "i686-linux-android", + "x86_64-linux-android", +] as const; + +export type AndroidTriplet = (typeof ANDROID_TRIPLETS)[number]; + +export const APPLE_TRIPLETS = [ + "arm64;x86_64-apple-darwin", + "x86_64-apple-darwin", + "arm64-apple-darwin", + "arm64-apple-ios", + "arm64-apple-ios-sim", + "arm64-apple-tvos", + "arm64-apple-tvos-sim", + // "x86_64-apple-tvos", + "arm64-apple-visionos", + "arm64-apple-visionos-sim", +] as const; + +export type AppleTriplet = (typeof APPLE_TRIPLETS)[number]; export const SUPPORTED_TRIPLETS = [ ...APPLE_TRIPLETS, @@ -7,3 +31,15 @@ export const SUPPORTED_TRIPLETS = [ ] as const; export type SupportedTriplet = (typeof SUPPORTED_TRIPLETS)[number]; + +export function isAndroidTriplet( + triplet: SupportedTriplet +): triplet is AndroidTriplet { + return ANDROID_TRIPLETS.includes(triplet as AndroidTriplet); +} + +export function isAppleTriplet( + triplet: SupportedTriplet +): triplet is AppleTriplet { + return APPLE_TRIPLETS.includes(triplet as AppleTriplet); +} 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 new file mode 100644 index 00000000..ac3e288e --- /dev/null +++ b/packages/react-native-node-api-cmake/src/weak-node-api.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import assert from "node:assert/strict"; +import path from "node:path"; + +import { + isAndroidTriplet, + isAppleTriplet, + SupportedTriplet, +} from "./triplets.js"; +import { ANDROID_ARCHITECTURES } from "./android.js"; +import { getNodeAddonHeadersPath, getNodeApiHeadersPath } from "./headers.js"; + +export function getWeakNodeApiPath(triplet: SupportedTriplet): string { + const { pathname } = new URL( + import.meta.resolve("react-native-node-api-modules/weak-node-api") + ); + assert(fs.existsSync(pathname), "Weak Node API path does not exist"); + if (isAppleTriplet(triplet)) { + const xcframeworkPath = path.join(pathname, "weak-node-api.xcframework"); + assert( + fs.existsSync(xcframeworkPath), + `Expected an XCFramework at ${xcframeworkPath}` + ); + return xcframeworkPath; + } else if (isAndroidTriplet(triplet)) { + const libraryPath = path.join( + pathname, + "weak-node-api.android.node", + ANDROID_ARCHITECTURES[triplet], + "weak-node-api" + ); + assert(fs.existsSync(libraryPath), `Expected library at ${libraryPath}`); + return libraryPath; + } + return pathname; +} + +export function getWeakNodeApiVariables(triplet: SupportedTriplet) { + const includePaths = [getNodeApiHeadersPath(), getNodeAddonHeadersPath()]; + for (const includePath of includePaths) { + assert( + !includePath.includes(";"), + `Include path with a ';' is not supported: ${includePath}` + ); + } + return { + CMAKE_JS_INC: includePaths.join(";"), + CMAKE_JS_LIB: getWeakNodeApiPath(triplet), + }; +} diff --git a/packages/react-native-node-api-modules/.gitignore b/packages/react-native-node-api-modules/.gitignore index 9f7e7433..26da3310 100644 --- a/packages/react-native-node-api-modules/.gitignore +++ b/packages/react-native-node-api-modules/.gitignore @@ -15,3 +15,9 @@ xcframeworks/ # Android build artifacts android/.cxx/ android/build/ + +# Everything in weak-node-api is generated, except for the configurations +weak-node-api/build/ +weak-node-api/weak-node-api.xcframework +weak-node-api/weak-node-api.android.node +weak-node-api.cpp diff --git a/packages/react-native-node-api-modules/package.json b/packages/react-native-node-api-modules/package.json index 57aa8cf8..4db2024b 100644 --- a/packages/react-native-node-api-modules/package.json +++ b/packages/react-native-node-api-modules/package.json @@ -11,11 +11,13 @@ "exports": { ".": "./dist/react-native/index.js", "./babel-plugin": "./dist/node/babel-plugin/index.js", - "./cli": "./dist/node/cli/run.js" + "./cli": "./dist/node/cli/run.js", + "./weak-node-api": "./weak-node-api" }, "scripts": { "build": "tsc --build", - "copy-node-api-headers": "node scripts/copy-node-api-headers.mjs", + "copy-node-api-headers": "tsx scripts/copy-node-api-headers.ts", + "build-weak-node-api": "tsx scripts/build-weak-node-api.ts", "test": "tsx --test src/node/**/*.test.ts src/node/*.test.ts" }, "keywords": [ @@ -57,7 +59,8 @@ "@babel/core": "^7.26.10", "@babel/types": "^7.27.0", "metro-config": "0.81.1", - "node-api-headers": "^1.5.0" + "node-api-headers": "^1.5.0", + "zod": "^3.24.3" }, "peerDependencies": { "@babel/core": "^7.26.10", diff --git a/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts b/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts new file mode 100644 index 00000000..bbe75c3b --- /dev/null +++ b/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import cp from "node:child_process"; + +import { + type NodeApiVersion, + symbols, + include_dir as nodeApiIncludePath, +} from "node-api-headers"; +import { z } from "zod"; + +export const WEAK_NODE_API_PATH = path.join(__dirname, "../weak-node-api"); + +export function getNodeApiSymbols( + version: NodeApiVersion, + filter?: "js_native_api_symbols" | "node_api_symbols" +) { + const symbolsPerInterface = symbols[version]; + if (filter === "js_native_api_symbols") { + return symbolsPerInterface.js_native_api_symbols; + } else if (filter === "node_api_symbols") { + return symbolsPerInterface.node_api_symbols; + } else { + return [ + ...symbolsPerInterface.js_native_api_symbols, + ...symbolsPerInterface.node_api_symbols, + ]; + } +} + +export function generateVersionScript( + libraryName: string, + globalSymbols: string[] +) { + return [ + `${libraryName} {`, + ` global:`, + ...globalSymbols.map((symbol) => ` ${symbol};`), + ` local: *;`, + `};`, + ].join("\n"); +} + +const clangAstDump = z.object({ + kind: z.literal("TranslationUnitDecl"), + inner: z.array( + z.object({ + kind: z.string(), + name: z.string().optional(), + type: z + .object({ + qualType: z.string(), + }) + .optional(), + }) + ), +}); + +/** + * Generates source code for a version script for the given Node API version. + * @param version + */ +export function getNodeApiHeaderAST(version: NodeApiVersion) { + const output = cp.execFileSync( + "clang", + [ + // Declare the Node API version + "-D", + `NAPI_VERSION=${version.replace(/^v/, "")}`, + // Pass the next option directly to the Clang frontend + "-Xclang", + // Ask the Clang frontend to dump the AST + "-ast-dump=json", + // Parse and analyze the source file but not compile it + "-fsyntax-only", + // Include from the node-api-headers package + `-I${nodeApiIncludePath}`, + path.join(nodeApiIncludePath, "node_api.h"), + ], + { + encoding: "utf-8", + // Emitting the AST can produce a lot of output + maxBuffer: 1024 * 1024 * 10, + } + ); + const parsed = JSON.parse(output); + return clangAstDump.parse(parsed); +} + +/** + * Generates source code for a version script for the given Node API version. + * @param version + */ +export function generateFakeNodeApiSource(version: NodeApiVersion) { + const lines = [ + "// This file is generated by react-native-node-api-modules", + "#include ", + ]; + const root = getNodeApiHeaderAST(version); + assert.equal(root.kind, "TranslationUnitDecl"); + assert(Array.isArray(root.inner)); + const foundSymbols = new Set(); + const nodeApiSymbols = new Set(getNodeApiSymbols(version)); + for (const node of root.inner) { + if ( + node.kind === "FunctionDecl" && + node.name && + nodeApiSymbols.has(node.name) + ) { + foundSymbols.add(node.name); + + assert(node.type, `Expected type for ${node.name}`); + + const match = node.type.qualType.match( + /^(?[^(]+) \((?[^)]+)\)/ + ); + assert( + match && match.groups, + `Failed to parse function type: ${node.type.qualType}` + ); + const { returnType, argumentTypes } = match.groups; + assert( + returnType, + `Failed to get return type from ${node.type.qualType}` + ); + assert( + argumentTypes, + `Failed to get argument types from ${node.type.qualType}` + ); + assert( + returnType === "napi_status" || returnType === "void", + `Expected return type to be napi_status, got ${returnType}` + ); + lines.push( + `__attribute__((weak)) ${returnType} ${node.name}(${argumentTypes}) {`, + returnType === "void" ? "" : " napi_status::napi_generic_failure;", + "}" + ); + } + } + for (const knownSymbol of nodeApiSymbols) { + if (!foundSymbols.has(knownSymbol)) { + throw new Error( + `Missing symbol '${knownSymbol}' in the AST for Node API ${version}` + ); + } + } + return lines.join("\n"); +} + +export async function ensureNodeApiVersionScript(version: NodeApiVersion) { + const outputPath = path.join(WEAK_NODE_API_PATH, `fakenode-${version}.map`); + if (!fs.existsSync(outputPath)) { + // Make sure the output directory exists + fs.mkdirSync(WEAK_NODE_API_PATH, { recursive: true }); + const symbols = getNodeApiSymbols(version); + const content = generateVersionScript("libfakenode", symbols); + fs.writeFileSync(outputPath, content, "utf-8"); + } + return outputPath; +} + +async function run() { + const sourceCode = generateFakeNodeApiSource("v10"); + await fs.promises.mkdir(WEAK_NODE_API_PATH, { recursive: true }); + await fs.promises.writeFile( + path.join(WEAK_NODE_API_PATH, "weak-node-api.cpp"), + sourceCode, + "utf-8" + ); + // Build for all supported platforms + cp.spawnSync( + "react-native-node-api-cmake", + [ + "--android", + "--apple", + "--no-auto-link", + "--no-weak-node-api-linkage", + "--source", + WEAK_NODE_API_PATH, + ], + { stdio: "inherit" } + ); +} + +run().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/react-native-node-api-modules/scripts/copy-node-api-headers.mjs b/packages/react-native-node-api-modules/scripts/copy-node-api-headers.ts similarity index 65% rename from packages/react-native-node-api-modules/scripts/copy-node-api-headers.mjs rename to packages/react-native-node-api-modules/scripts/copy-node-api-headers.ts index f1da854c..fef957a2 100644 --- a/packages/react-native-node-api-modules/scripts/copy-node-api-headers.mjs +++ b/packages/react-native-node-api-modules/scripts/copy-node-api-headers.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { include_dir as includeSourcePath } from "node-api-headers"; -const includeSourcePath = new URL(import.meta.resolve("node-api-headers/include")).pathname; -const includeDestinationPath = path.join(import.meta.dirname, "../include"); +const includeDestinationPath = path.join(__dirname, "../include"); assert(fs.existsSync(includeSourcePath), `Expected ${includeSourcePath}`); console.log(`Copying ${includeSourcePath} to ${includeDestinationPath}`); fs.cpSync(includeSourcePath, includeDestinationPath, { recursive: true }); diff --git a/packages/react-native-node-api-modules/tsconfig.json b/packages/react-native-node-api-modules/tsconfig.json index 1cfb08e3..00410045 100644 --- a/packages/react-native-node-api-modules/tsconfig.json +++ b/packages/react-native-node-api-modules/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.node-scripts.json" }, { "path": "./tsconfig.node-tests.json" }, { "path": "./tsconfig.react-native.json" } ] diff --git a/packages/react-native-node-api-modules/tsconfig.node-scripts.json b/packages/react-native-node-api-modules/tsconfig.node-scripts.json new file mode 100644 index 00000000..4e11d816 --- /dev/null +++ b/packages/react-native-node-api-modules/tsconfig.node-scripts.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "scripts", + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "types/**/*.d.ts"], + "exclude": [] +} diff --git a/packages/react-native-node-api-modules/tsconfig.node.json b/packages/react-native-node-api-modules/tsconfig.node.json index 9472d4dd..94c6bdf7 100644 --- a/packages/react-native-node-api-modules/tsconfig.node.json +++ b/packages/react-native-node-api-modules/tsconfig.node.json @@ -7,6 +7,6 @@ "rootDir": "src", "types": ["node"] }, - "include": ["src/node/**/*.ts"], + "include": ["src/node/**/*.ts", "types/**/*.d.ts"], "exclude": ["**.test.ts"] } diff --git a/packages/react-native-node-api-modules/types/node-api-headers/index.d.ts b/packages/react-native-node-api-modules/types/node-api-headers/index.d.ts new file mode 100644 index 00000000..dc6ab254 --- /dev/null +++ b/packages/react-native-node-api-modules/types/node-api-headers/index.d.ts @@ -0,0 +1,29 @@ +module "node-api-headers" { + type SymbolsPerInterface = { + js_native_api_symbols: string[]; + node_api_symbols: string[]; + }; + type Exported = { + include_dir: string; + def_paths: { + js_native_api_def: string; + node_api_def: string; + }; + symbols: { + v1: SymbolsPerInterface; + v2: SymbolsPerInterface; + v3: SymbolsPerInterface; + v4: SymbolsPerInterface; + v5: SymbolsPerInterface; + v6: SymbolsPerInterface; + v7: SymbolsPerInterface; + v8: SymbolsPerInterface; + v9: SymbolsPerInterface; + v10: SymbolsPerInterface; + }; + }; + export type NodeApiVersion = keyof Exported["symbols"]; + + const exported: Exported; + export = exported; +} diff --git a/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt b/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt new file mode 100644 index 00000000..918b666f --- /dev/null +++ b/packages/react-native-node-api-modules/weak-node-api/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.15) +project(weak-node-api) + +add_compile_definitions(-DNAPI_VERSION=8) + +add_library(${PROJECT_NAME} SHARED weak-node-api.cpp ${CMAKE_JS_SRC}) +set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node") +target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_SOURCE_DIR}/../include) +target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_17)