Skip to content

Commit 4ed4f1d

Browse files
committed
Add cmake wrapping package
1 parent 7bbff78 commit 4ed4f1d

File tree

10 files changed

+522
-45
lines changed

10 files changed

+522
-45
lines changed

package-lock.json

Lines changed: 63 additions & 45 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
import "../dist/run.js";
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "react-native-node-api-cmake",
3+
"version": "0.1.0",
4+
"description": "Build React Native Node API modules with CMake",
5+
"type": "module",
6+
"bin": {
7+
"react-native-node-api-cmake": "./bin/react-native-node-api-cmake.js"
8+
},
9+
"scripts": {
10+
"build": "tsc",
11+
"start": "tsx src/run.ts",
12+
"test": "tsx --test"
13+
},
14+
"dependencies": {
15+
"@commander-js/extra-typings": "^13.1.0",
16+
"cmake-js": "^7.3.1",
17+
"commander": "^13.1.0"
18+
},
19+
"peerDependencies": {
20+
"node-addon-api": "^8.3.1",
21+
"node-api-headers": "^1.5.0"
22+
}
23+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import assert from "node:assert/strict";
2+
import cp from "node:child_process";
3+
import fs from "node:fs";
4+
5+
import type { SupportedTriplet } from "./triplets.js";
6+
7+
export const APPLE_TRIPLETS = [
8+
"arm64;x86_64-apple-darwin",
9+
"x86_64-apple-darwin",
10+
"arm64-apple-darwin",
11+
"arm64-apple-ios",
12+
"arm64-apple-ios-sim",
13+
"arm64-apple-tvos",
14+
"arm64-apple-tvos-sim",
15+
// "x86_64-apple-tvos",
16+
"arm64-apple-visionos",
17+
"arm64-apple-visionos-sim",
18+
] as const;
19+
20+
export type AppleTriplet = (typeof APPLE_TRIPLETS)[number];
21+
22+
export const DEFAULT_APPLE_TRIPLETS = [
23+
"arm64;x86_64-apple-darwin",
24+
"arm64-apple-ios",
25+
"arm64-apple-ios-sim",
26+
"arm64-apple-tvos",
27+
"arm64-apple-tvos-sim",
28+
"arm64-apple-visionos",
29+
"arm64-apple-visionos-sim",
30+
] as const satisfies AppleTriplet[];
31+
32+
type XcodeSDKName =
33+
| "iphoneos"
34+
| "iphonesimulator"
35+
| "catalyst"
36+
| "xros"
37+
| "xrsimulator"
38+
| "appletvos"
39+
| "appletvsimulator"
40+
| "macosx";
41+
42+
const SDK_NAMES = {
43+
"x86_64-apple-darwin": "macosx",
44+
"arm64-apple-darwin": "macosx",
45+
"arm64;x86_64-apple-darwin": "macosx",
46+
"arm64-apple-ios": "iphoneos",
47+
"arm64-apple-ios-sim": "iphonesimulator",
48+
"arm64-apple-tvos": "appletvos",
49+
// "x86_64-apple-tvos": "appletvos",
50+
"arm64-apple-tvos-sim": "appletvsimulator",
51+
"arm64-apple-visionos": "xros",
52+
"arm64-apple-visionos-sim": "xrsimulator",
53+
} satisfies Record<AppleTriplet, XcodeSDKName>;
54+
55+
type AppleArchitecture = "arm64" | "x86_64" | "arm64;x86_64";
56+
57+
export const ARCHITECTURES = {
58+
"x86_64-apple-darwin": "x86_64",
59+
"arm64-apple-darwin": "arm64",
60+
"arm64;x86_64-apple-darwin": "arm64;x86_64",
61+
"arm64-apple-ios": "arm64",
62+
"arm64-apple-ios-sim": "arm64",
63+
"arm64-apple-tvos": "arm64",
64+
// "x86_64-apple-tvos": "x86_64",
65+
"arm64-apple-tvos-sim": "arm64",
66+
"arm64-apple-visionos": "arm64",
67+
"arm64-apple-visionos-sim": "arm64",
68+
} satisfies Record<AppleTriplet, AppleArchitecture>;
69+
70+
export function isAppleTriplet(
71+
triplet: SupportedTriplet
72+
): triplet is AppleTriplet {
73+
return APPLE_TRIPLETS.includes(triplet as AppleTriplet);
74+
}
75+
76+
export function getAppleSDKPath(triplet: AppleTriplet) {
77+
return cp
78+
.spawnSync("xcrun", ["--sdk", SDK_NAMES[triplet], "--show-sdk-path"], {
79+
encoding: "utf-8",
80+
})
81+
.stdout.trim();
82+
}
83+
84+
export function getAppleConfigureCmakeArgs(triplet: AppleTriplet) {
85+
assert(isAppleTriplet(triplet));
86+
const sdkPath = getAppleSDKPath(triplet);
87+
88+
return [
89+
// Use the XCode as generator for Apple platforms
90+
"-G",
91+
"Xcode",
92+
// Pass linker flags to avoid errors from undefined symbols
93+
"-D",
94+
`CMAKE_SHARED_LINKER_FLAGS="-Wl,-undefined,dynamic_lookup"`,
95+
// Set the SDK path for the target platform
96+
"-D",
97+
`CMAKE_OSX_SYSROOT=${sdkPath}`,
98+
// Set the target architecture
99+
"-D",
100+
`CMAKE_OSX_ARCHITECTURES=${ARCHITECTURES[triplet]}`,
101+
];
102+
}
103+
104+
export function getAppleBuildArgs() {
105+
// We expect the final application to sign these binaries
106+
return ["CODE_SIGNING_ALLOWED=NO"];
107+
}
108+
109+
type XCframeworkOptions = {
110+
libraryPaths: string[];
111+
outputPath: string;
112+
};
113+
114+
export function createXCframework({
115+
libraryPaths,
116+
outputPath,
117+
}: XCframeworkOptions) {
118+
// Delete any existing xcframework to prevent the error:
119+
// - A library with the identifier 'macos-arm64' already exists.
120+
// Ideally, it would only be necessary to delete the specific platform+arch, to allow selectively building from source.
121+
fs.rmSync(outputPath, { recursive: true, force: true });
122+
123+
const { status } = cp.spawnSync(
124+
"xcodebuild",
125+
[
126+
"-create-xcframework",
127+
...libraryPaths.flatMap((libraryPath) => ["-library", libraryPath]),
128+
"-output",
129+
outputPath,
130+
],
131+
{
132+
stdio: "inherit",
133+
}
134+
);
135+
assert.equal(status, 0, "Failed to create xcframework");
136+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import assert from "node:assert/strict";
2+
import cp from "node:child_process";
3+
import path from "node:path";
4+
import fs from "node:fs";
5+
6+
import { Command, Option } from "@commander-js/extra-typings";
7+
import { SUPPORTED_TRIPLETS, SupportedTriplet } from "./triplets.js";
8+
import { getNodeApiHeadersPath, getNodeAddonHeadersPath } from "./headers.js";
9+
import {
10+
createXCframework,
11+
isAppleTriplet,
12+
getAppleConfigureCmakeArgs,
13+
getAppleBuildArgs,
14+
DEFAULT_APPLE_TRIPLETS,
15+
} from "./apple.js";
16+
17+
// TODO: Add automatic ccache support
18+
// TODO: Detect and pipe through xcbeautify if available or use buffered output
19+
20+
const sourcePathOption = new Option(
21+
"--source <path>",
22+
"Specify the source directory containing a CMakeLists.txt file"
23+
).default(process.cwd());
24+
25+
// TODO: Add "MinSizeRel" and "RelWithDebInfo"
26+
const configurationOption = new Option("--configuration <configuration>")
27+
.choices(["Release", "Debug"] as const)
28+
.default("Release");
29+
30+
// TODO: Derive default triplets
31+
// This is especially important when driving the build from within a React Native app package.
32+
33+
const tripletOption = new Option(
34+
"--triplet <triplet...>",
35+
"Triplets to build for"
36+
).choices(SUPPORTED_TRIPLETS);
37+
38+
const buildPathOption = new Option(
39+
"--build <path>",
40+
"Specify the build directory to store the configured CMake project"
41+
);
42+
43+
const cleanOption = new Option(
44+
"--clean",
45+
"Delete the build directory before configuring the project"
46+
);
47+
48+
const outPathOption = new Option(
49+
"--out <path>",
50+
"Specify the output directory to store the final build artifacts"
51+
);
52+
53+
const appleOption = new Option("--apple", "Enable all apple triplets");
54+
55+
export const program = new Command("react-native-node-api-cmake")
56+
.description("Build React Native Node API modules with CMake")
57+
.addOption(sourcePathOption)
58+
.addOption(configurationOption)
59+
.addOption(tripletOption)
60+
.addOption(appleOption)
61+
.addOption(buildPathOption)
62+
.addOption(outPathOption)
63+
.addOption(cleanOption)
64+
.action(({ triplet: triplets = [], ...globalOptions }) => {
65+
const buildPath = getBuildPath(globalOptions);
66+
if (globalOptions.clean) {
67+
fs.rmSync(buildPath, { recursive: true, force: true });
68+
}
69+
if (globalOptions.apple) {
70+
triplets.push(...DEFAULT_APPLE_TRIPLETS);
71+
}
72+
const tripletOptions = triplets.map((triplet) => {
73+
const tripletBuildPath = getTripletBuildPath(buildPath, triplet);
74+
return {
75+
...globalOptions,
76+
triplet,
77+
tripletBuildPath,
78+
tripletOutputPath: path.join(tripletBuildPath, "out"),
79+
};
80+
});
81+
82+
// Configure every triplet project
83+
for (const options of tripletOptions) {
84+
configureProject(options);
85+
}
86+
87+
// Build every triplet project
88+
for (const options of tripletOptions) {
89+
// Delete any stale build artifacts before building
90+
// This is important, since we might rename the output files
91+
fs.rmSync(options.tripletOutputPath, { recursive: true, force: true });
92+
buildProject(options);
93+
}
94+
95+
// Collect triplets in vendor specific containers
96+
const appleTriplets = tripletOptions.filter(({ triplet }) =>
97+
isAppleTriplet(triplet)
98+
);
99+
if (appleTriplets.length > 0) {
100+
const libraryPaths = appleTriplets.flatMap(({ tripletOutputPath }) => {
101+
const configSpecifcPath = path.join(
102+
tripletOutputPath,
103+
globalOptions.configuration
104+
);
105+
assert(
106+
fs.existsSync(configSpecifcPath),
107+
`Expected a directory at ${configSpecifcPath}`
108+
);
109+
// Expect binary file(s), either .node or .dylib
110+
return fs.readdirSync(configSpecifcPath).map((file) => {
111+
const filePath = path.join(configSpecifcPath, file);
112+
if (filePath.endsWith(".dylib")) {
113+
return filePath;
114+
} else if (file.endsWith(".node")) {
115+
// Rename the file to .dylib for xcodebuild to accept it
116+
const newFilePath = filePath.replace(/\.node$/, ".dylib");
117+
fs.renameSync(filePath, newFilePath);
118+
return newFilePath;
119+
} else {
120+
throw new Error(
121+
`Expected a .node or .dylib file, but found ${file}`
122+
);
123+
}
124+
});
125+
});
126+
// Create the xcframework
127+
createXCframework({
128+
outputPath: path.join(
129+
// Defaults to storing the xcframework next to the CMakeLists.txt file
130+
globalOptions.out || globalOptions.source,
131+
"node-api.xcframework"
132+
),
133+
libraryPaths,
134+
});
135+
}
136+
});
137+
138+
type GlobalOptions = ReturnType<typeof program.optsWithGlobals>;
139+
type TripletScopedOptions = Omit<GlobalOptions, "triplet"> & {
140+
triplet: SupportedTriplet;
141+
tripletBuildPath: string;
142+
tripletOutputPath: string;
143+
};
144+
145+
function getBuildPath(options: GlobalOptions) {
146+
// TODO: Add configuration (debug vs release)
147+
return path.resolve(
148+
process.cwd(),
149+
options.build || path.join(options.source, "build")
150+
);
151+
}
152+
153+
/**
154+
* Namespaces the output path with the triplet
155+
*/
156+
function getTripletBuildPath(buildPath: string, triplet: SupportedTriplet) {
157+
return path.join(buildPath, triplet.replace(/;/g, "_"));
158+
}
159+
160+
function getTripletConfigureCmakeArgs(triplet: SupportedTriplet) {
161+
if (isAppleTriplet(triplet)) {
162+
return getAppleConfigureCmakeArgs(triplet);
163+
} else {
164+
throw new Error(`Support for '${triplet}' is not implemented yet`);
165+
}
166+
}
167+
168+
function getBuildArgs(triplet: SupportedTriplet) {
169+
if (isAppleTriplet(triplet)) {
170+
return getAppleBuildArgs();
171+
} else {
172+
throw new Error(`Support for '${triplet}' is not implemented yet`);
173+
}
174+
}
175+
176+
function configureProject(options: TripletScopedOptions) {
177+
const { triplet, tripletBuildPath, source } = options;
178+
console.log(`Configuring project for '${triplet}'`);
179+
const variables = getVariables(options);
180+
const variablesArgs = Object.entries(variables).flatMap(([key, value]) => [
181+
"-D",
182+
`${key}=${value}`,
183+
]);
184+
185+
const { status } = cp.spawnSync(
186+
"cmake",
187+
[
188+
"-S",
189+
source,
190+
"-B",
191+
tripletBuildPath,
192+
...variablesArgs,
193+
...getTripletConfigureCmakeArgs(triplet),
194+
],
195+
{
196+
stdio: "inherit",
197+
}
198+
);
199+
assert.equal(status, 0, `Failed to configure project for '${triplet}'`);
200+
}
201+
202+
function buildProject(options: TripletScopedOptions) {
203+
const { triplet, tripletBuildPath, configuration } = options;
204+
console.log(`Building project for '${triplet}'`);
205+
const { status } = cp.spawnSync(
206+
"cmake",
207+
[
208+
"--build",
209+
tripletBuildPath,
210+
"--config",
211+
configuration,
212+
"--",
213+
...getBuildArgs(triplet),
214+
],
215+
{
216+
stdio: "inherit",
217+
}
218+
);
219+
assert.equal(status, 0, `Failed to build project for '${triplet}'`);
220+
}
221+
222+
function getVariables(options: TripletScopedOptions): Record<string, string> {
223+
const includePaths = [getNodeApiHeadersPath(), getNodeAddonHeadersPath()];
224+
for (const includePath of includePaths) {
225+
assert(
226+
!includePath.includes(";"),
227+
`Include path with a ';' is not supported: ${includePath}`
228+
);
229+
}
230+
return {
231+
CMAKE_JS_INC: includePaths.join(";"),
232+
CMAKE_LIBRARY_OUTPUT_DIRECTORY: options.tripletOutputPath,
233+
};
234+
}

0 commit comments

Comments
 (0)