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
23 changes: 23 additions & 0 deletions .changeset/chilly-trains-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"cmake-rn": minor
---

Add support for building projects declaring multiple shared object libraries into Node-API addons.

Each addon is emitted next to the sources it was built from, so that a project
declaring many addons produces the same layout as building each of them on its
own. Both the location and the name of an artifact are derived from the target
that produced it:

- `--out` supports a new `{targetSourceDir}` placeholder, expanding to the source
directory of the target being emitted, and now defaults to
`{targetSourceDir}/build/{configuration}`. This resolves to the same path as
before, unless `--build` is pointed outside of the source directory.
- The artifact is named after the target's `OUTPUT_NAME` rather than the CMake
target name. These are the same unless `OUTPUT_NAME` is set explicitly, which is
how a project can give its targets the unique names CMake requires without
affecting the name of the addon.

Also adds `--concurrency`, limiting how many build tasks run at once. It defaults
to the available parallelism, or to 1 when `--verbose` is enabled, since
interleaved output from concurrent builds is hard to read.
10 changes: 10 additions & 0 deletions .changeset/real-emus-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"gyp-to-cmake": minor
---

Add --namespaced-targets to allow a root project to add many sub-projects.

CMake requires target names to be unique across a project tree, so sub-projects
that each declare an `addon` target cannot be added to a single root project. This
prefixes the target name with the project name, while setting `OUTPUT_NAME` so the
artifact keeps the name a `require` resolves against.
98 changes: 63 additions & 35 deletions packages/cmake-rn/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";

import {
chalk,
Expand All @@ -10,6 +11,8 @@ import {
oraPromise,
assertFixable,
wrapAction,
pLimit,
InvalidArgumentError,
} from "@react-native-node-api/cli-utils";

import {
Expand All @@ -20,6 +23,7 @@ import {
} from "./platforms.js";
import { Platform } from "./platforms/types.js";
import { getCcachePath } from "./ccache.js";
import { createOutputPathResolver, expandTemplate } from "./output-path.js";

const verboseOption = new Option(
"--verbose",
Expand Down Expand Up @@ -71,8 +75,8 @@ const cleanOption = new Option(

const outPathOption = new Option(
"--out <path>",
"Specify the output directory to store the final build artifacts",
).default("{build}/{configuration}");
"Specify the output directory to store the final build artifacts. Supports the {targetSourceDir} placeholder, which expands to the source directory of the target being emitted",
).default("{targetSourceDir}/build/{configuration}");

const defineOption = new Option(
"-D,--define <entry...>",
Expand Down Expand Up @@ -125,6 +129,22 @@ const ccachePathOption = new Option(
"Specify the path to the ccache executable",
).default(getCcachePath());

const concurrencyOption = new Option(
"--concurrency <limit>",
"Limit the number of concurrent tasks",
)
.argParser((value) => {
const result = Number(value);
if (!Number.isSafeInteger(result) || result < 1) {
throw new InvalidArgumentError("Expected a positive integer.");
}
return result;
})
.default(
undefined,
`${os.availableParallelism()} or 1 when --verbose is enabled`,
);

let program = new Command("cmake-rn")
.description("Build React Native Node API modules with CMake")
.addOption(tripletOption)
Expand All @@ -140,7 +160,8 @@ let program = new Command("cmake-rn")
.addOption(noAutoLinkOption)
.addOption(noWeakNodeApiLinkageOption)
.addOption(cmakeJsOption)
.addOption(ccachePathOption);
.addOption(ccachePathOption)
.addOption(concurrencyOption);

for (const platform of platforms) {
const allOption = new Option(
Expand All @@ -151,25 +172,15 @@ for (const platform of platforms) {
program = platform.amendCommand(program);
}

function expandTemplate(
input: string,
values: Record<string, unknown>,
): string {
return input.replaceAll(/{([^}]+)}/g, (_, key: string) =>
typeof values[key] === "string" ? values[key] : "",
);
}

program = program.action(
wrapAction(async ({ triplet: requestedTriplets, ...baseOptions }) => {
baseOptions.build = path.resolve(
process.cwd(),
expandTemplate(baseOptions.build, baseOptions),
);
baseOptions.out = path.resolve(
process.cwd(),
expandTemplate(baseOptions.out, baseOptions),
);
// Note: {targetSourceDir} is deliberately left unexpanded here, as it is
// only known per target, once the CMake File API has been read.
baseOptions.out = expandTemplate(baseOptions.out, baseOptions);
const {
verbose,
clean,
Expand Down Expand Up @@ -228,6 +239,13 @@ program = program.action(
}
}

// Interleaved output from concurrent builds is unreadable, so verbose
// builds default to running one task at a time.
const concurrency =
baseOptions.concurrency ?? (verbose ? 1 : os.availableParallelism());
const limit = pLimit(concurrency);
const resolveOutputPath = createOutputPathResolver(out, source);

const tripletContexts = [...triplets].map((triplet) => {
const platform = findPlatformForTriplet(triplet);

Expand All @@ -240,17 +258,21 @@ program = program.action(
triplet,
platform,
async spawn(command: string, args: string[], cwd?: string) {
const outputPrefix = verbose ? chalk.dim(`[${triplet}] `) : undefined;
if (verbose) {
console.log(
`${outputPrefix}» ${command} ${args.map((arg) => chalk.dim(`${arg}`)).join(" ")}`,
cwd ? `(in ${chalk.dim(cwd)})` : "",
);
}
await spawn(command, args, {
outputMode: verbose ? "inherit" : "buffered",
outputPrefix,
cwd,
await limit(async () => {
const outputPrefix = verbose
? chalk.dim(`[${triplet}] `)
: undefined;
if (verbose) {
console.log(
`${outputPrefix}» ${command} ${args.map((arg) => chalk.dim(`${arg}`)).join(" ")}`,
cwd ? `(in ${chalk.dim(cwd)})` : "",
);
}
await spawn(command, args, {
outputMode: verbose ? "inherit" : "buffered",
outputPrefix,
cwd,
});
});
},
};
Expand All @@ -276,13 +298,15 @@ program = program.action(
relevantTriplets,
baseOptions,
(command, args, cwd) =>
spawn(command, args, {
outputMode: verbose ? "inherit" : "buffered",
outputPrefix: verbose
? chalk.dim(`[${platform.name}] `)
: undefined,
cwd,
}),
limit(() =>
spawn(command, args, {
outputMode: verbose ? "inherit" : "buffered",
outputPrefix: verbose
? chalk.dim(`[${platform.name}] `)
: undefined,
cwd,
}),
),
);
}
}),
Expand Down Expand Up @@ -325,7 +349,11 @@ program = program.action(
if (relevantTriplets.length == 0) {
continue;
}
await platform.postBuild(out, relevantTriplets, baseOptions);
await platform.postBuild(
resolveOutputPath,
relevantTriplets,
baseOptions,
);
}
}),
);
Expand Down
21 changes: 21 additions & 0 deletions packages/cmake-rn/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
import path from "node:path";

/**
* The name of the emitted prebuild is derived from the artifact on disk (i.e.
* the target's OUTPUT_NAME) rather than the CMake target name.
*
* A project declaring multiple addons has to give its targets unique names,
* which for generated projects means namespacing them (see gyp-to-cmake's
* --namespaced-targets). The artifact keeps the name the JS `require` expects,
* so deriving from it keeps the prebuild's name independent of how the target
* had to be named to avoid a clash.
*/
export function getArtifactName(artifactPath: string) {
const basename = path.basename(artifactPath, path.extname(artifactPath));
// Unless a target clears PREFIX (as the generated addon projects do), CMake
// prefixes a shared library with "lib". The prebuild is named after the
// library rather than the file, mirroring how createAndroidLibsDirectory adds
// the prefix back when copying the library into the libs directory.
return basename.startsWith("lib") ? basename.slice("lib".length) : basename;
}

export function toDefineArguments(
declarations: Array<Record<string, string | undefined>>,
) {
Expand Down
89 changes: 89 additions & 0 deletions packages/cmake-rn/src/output-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import path from "node:path";
import { describe, it } from "node:test";

import { createOutputPathResolver, expandTemplate } from "./output-path.js";
import { getArtifactName } from "./helpers.js";

describe("expandTemplate", () => {
it("expands known placeholders", () => {
assert.equal(
expandTemplate("{build}/{configuration}", {
build: "/tmp/build",
configuration: "Release",
}),
"/tmp/build/Release",
);
});

it("leaves unknown placeholders untouched, to allow a later pass", () => {
assert.equal(
expandTemplate("{targetSourceDir}/build/{configuration}", {
configuration: "Release",
}),
"{targetSourceDir}/build/Release",
);
});
});

describe("createOutputPathResolver", () => {
const source = path.resolve("/projects/my-app");

it("resolves a top-level target next to the source directory", () => {
const resolve = createOutputPathResolver(
"{targetSourceDir}/build/Release",
source,
);
// A single-addon project reports "." as the target's source directory,
// which has to keep emitting where it always has.
assert.equal(resolve("."), path.join(source, "build/Release"));
});

it("resolves each target of a multi-addon project next to its own sources", () => {
const resolve = createOutputPathResolver(
"{targetSourceDir}/build/Release",
source,
);
assert.equal(
resolve("examples/hello"),
path.join(source, "examples/hello/build/Release"),
);
assert.equal(
resolve("examples/goodbye"),
path.join(source, "examples/goodbye/build/Release"),
);
});

it("handles a target source directory outside the top-level source", () => {
const resolve = createOutputPathResolver(
"{targetSourceDir}/build/Release",
source,
);
const outside = path.resolve("/elsewhere/vendored");
assert.equal(resolve(outside), path.join(outside, "build/Release"));
});

it("supports a template without the placeholder", () => {
const resolve = createOutputPathResolver("/tmp/out", source);
assert.equal(resolve("examples/hello"), path.resolve("/tmp/out"));
});
});

describe("getArtifactName", () => {
it("derives the name from the artifact rather than the target", () => {
// gyp-to-cmake --namespaced-targets builds "addon.node" from a target named
// "<project>-addon", and the prebuild has to keep the artifact's name.
assert.equal(getArtifactName("examples/hello/addon.node"), "addon");
});

it("handles framework artifacts", () => {
assert.equal(getArtifactName("out/addon.framework/addon"), "addon");
});

it("strips the prefix CMake adds to shared libraries", () => {
// weak-node-api does not clear PREFIX, so it builds a "libweak-node-api.so"
// and has to keep emitting a "weak-node-api.android.node" — the path
// packages/host/android/build.gradle points its jniLibs at.
assert.equal(getArtifactName("libweak-node-api.so"), "weak-node-api");
});
});
35 changes: 35 additions & 0 deletions packages/cmake-rn/src/output-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import path from "node:path";

/**
* Expand `{placeholder}` occurrences in a template.
*
* Placeholders without a value are left untouched, so a template can be expanded
* in multiple passes as more values become known.
*/
export function expandTemplate(
input: string,
values: Record<string, unknown>,
): string {
return input.replaceAll(/{([^}]+)}/g, (match, key: string) =>
typeof values[key] === "string" ? values[key] : match,
);
}

/**
* The final artifacts are emitted per target, relative to the source directory
* of the target itself. This keeps a target's prebuild next to the sources it
* was built from, even when a single project declares many addons, which is what
* the Babel plugin and auto-linking rely on to resolve a `require`.
*/
export function createOutputPathResolver(outTemplate: string, source: string) {
return function resolveOutputPath(targetSourceDir: string) {
return path.resolve(
process.cwd(),
expandTemplate(outTemplate, {
// `paths.source` is relative to the top-level source directory, unless
// the target lives outside of it, in which case it is already absolute.
targetSourceDir: path.resolve(source, targetSourceDir),
}),
);
};
}
Loading
Loading