Skip to content

Commit f48956e

Browse files
committed
Emit prebuilds per target, next to their sources
A project declaring multiple addons wrote every prebuild into a single output directory, named after the CMake target. Both the location and the name are now derived per target from the CMake File API: - The output directory defaults to {targetSourceDir}/build/{configuration}, where the new {targetSourceDir} placeholder expands to the target's own source directory. A single-addon project reports "." and so resolves to the same path as before. - The prebuild is named after the artifact on disk (the target's OUTPUT_NAME) rather than the target name, so a target renamed to avoid a clash within the project still produces the name the JS require expects. Together this keeps a prebuild where the Babel plugin and auto-linking resolve it from, and reduces --namespaced-targets to an internal concern. Also fixes, in the same area: - gyp-to-cmake emitted OUTPUT_NAME regardless of --namespaced-targets, due to an always-truthy condition, and never emitted it for Apple framework targets, which CMake names after it. - The Apple build ran a full "cmake --build" once per shared library, concurrently against one build tree, and called "xcodebuild -list" (a synchronous spawn) once per library per triplet. - xcodebuild invocations now run in sequence per build directory, as concurrent invocations against a single Xcode project are not reliable. - postBuild looked for "<target name>.framework" while createAppleFramework names it after the artifact, so the two diverged under namespacing. - --concurrency accepted any value, and did not implement the documented fallback to 1 under --verbose. Max listeners is now derived from it. - verify-prebuilds globbed a directory the prebuilds had moved out of, so it passed by finding nothing. It now covers tests/ too and requires a non-zero count. - The root example project globbed recursively, which both missed examples copied in after configure and would add a nested project twice. It is now generated from the same script pipeline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfKQDvEkxNtkSE4aaF9yG8
1 parent 40512a3 commit f48956e

19 files changed

Lines changed: 546 additions & 169 deletions

.changeset/chilly-trains-nail.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,22 @@
22
"cmake-rn": minor
33
---
44

5-
Add support for building projects declaring multiple shared object libraries into Node-API addons
5+
Add support for building projects declaring multiple shared object libraries into Node-API addons.
6+
7+
Each addon is emitted next to the sources it was built from, so that a project
8+
declaring many addons produces the same layout as building each of them on its
9+
own. Both the location and the name of an artifact are derived from the target
10+
that produced it:
11+
12+
- `--out` supports a new `{targetSourceDir}` placeholder, expanding to the source
13+
directory of the target being emitted, and now defaults to
14+
`{targetSourceDir}/build/{configuration}`. This resolves to the same path as
15+
before, unless `--build` is pointed outside of the source directory.
16+
- The artifact is named after the target's `OUTPUT_NAME` rather than the CMake
17+
target name. These are the same unless `OUTPUT_NAME` is set explicitly, which is
18+
how a project can give its targets the unique names CMake requires without
19+
affecting the name of the addon.
20+
21+
Also adds `--concurrency`, limiting how many build tasks run at once. It defaults
22+
to the available parallelism, or to 1 when `--verbose` is enabled, since
23+
interleaved output from concurrent builds is hard to read.

.changeset/real-emus-jam.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@
22
"gyp-to-cmake": minor
33
---
44

5-
Add --namespaced-targets to allow a root project to add many sub-projects
5+
Add --namespaced-targets to allow a root project to add many sub-projects.
6+
7+
CMake requires target names to be unique across a project tree, so sub-projects
8+
that each declare an `addon` target cannot be added to a single root project. This
9+
prefixes the target name with the project name, while setting `OUTPUT_NAME` so the
10+
artifact keeps the name a `require` resolves against.

packages/cmake-rn/src/cli.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
assertFixable,
1414
wrapAction,
1515
pLimit,
16+
InvalidArgumentError,
1617
} from "@react-native-node-api/cli-utils";
1718

1819
import {
@@ -23,9 +24,22 @@ import {
2324
} from "./platforms.js";
2425
import { Platform } from "./platforms/types.js";
2526
import { getCcachePath } from "./ccache.js";
26-
27-
// We're attaching a lot of listeners when spawning in parallel
28-
EventEmitter.defaultMaxListeners = 500;
27+
import { createOutputPathResolver, expandTemplate } from "./output-path.js";
28+
29+
/**
30+
* Every spawned child attaches a "SIGINT", "SIGTERM" and "exit" listener to the
31+
* process, which are removed again once the child exits. The number of live
32+
* listeners therefore tracks the number of *concurrently* running children, so
33+
* the limit is derived from the concurrency rather than being a magic constant.
34+
*/
35+
function raiseMaxListeners(concurrency: number) {
36+
const LISTENERS_PER_CHILD = 3;
37+
const HEADROOM = 10;
38+
EventEmitter.defaultMaxListeners = Math.max(
39+
EventEmitter.defaultMaxListeners,
40+
concurrency * LISTENERS_PER_CHILD + HEADROOM,
41+
);
42+
}
2943

3044
const verboseOption = new Option(
3145
"--verbose",
@@ -77,8 +91,8 @@ const cleanOption = new Option(
7791

7892
const outPathOption = new Option(
7993
"--out <path>",
80-
"Specify the output directory to store the final build artifacts",
81-
).default("{build}/{configuration}");
94+
"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",
95+
).default("{targetSourceDir}/build/{configuration}");
8296

8397
const defineOption = new Option(
8498
"-D,--define <entry...>",
@@ -135,10 +149,16 @@ const concurrencyOption = new Option(
135149
"--concurrency <limit>",
136150
"Limit the number of concurrent tasks",
137151
)
138-
.argParser((value) => parseInt(value, 10))
152+
.argParser((value) => {
153+
const result = Number(value);
154+
if (!Number.isSafeInteger(result) || result < 1) {
155+
throw new InvalidArgumentError("Expected a positive integer.");
156+
}
157+
return result;
158+
})
139159
.default(
140-
os.availableParallelism(),
141-
`${os.availableParallelism()} or 1 when verbose is enabled`,
160+
undefined,
161+
`${os.availableParallelism()} or 1 when --verbose is enabled`,
142162
);
143163

144164
let program = new Command("cmake-rn")
@@ -168,33 +188,22 @@ for (const platform of platforms) {
168188
program = platform.amendCommand(program);
169189
}
170190

171-
function expandTemplate(
172-
input: string,
173-
values: Record<string, unknown>,
174-
): string {
175-
return input.replaceAll(/{([^}]+)}/g, (_, key: string) =>
176-
typeof values[key] === "string" ? values[key] : "",
177-
);
178-
}
179-
180191
program = program.action(
181192
wrapAction(async ({ triplet: requestedTriplets, ...baseOptions }) => {
182193
baseOptions.build = path.resolve(
183194
process.cwd(),
184195
expandTemplate(baseOptions.build, baseOptions),
185196
);
186-
baseOptions.out = path.resolve(
187-
process.cwd(),
188-
expandTemplate(baseOptions.out, baseOptions),
189-
);
197+
// Note: {targetSourceDir} is deliberately left unexpanded here, as it is
198+
// only known per target, once the CMake File API has been read.
199+
baseOptions.out = expandTemplate(baseOptions.out, baseOptions);
190200
const {
191201
verbose,
192202
clean,
193203
source,
194204
out,
195205
build: buildPath,
196206
ccachePath,
197-
concurrency,
198207
} = baseOptions;
199208

200209
assertFixable(
@@ -246,7 +255,13 @@ program = program.action(
246255
}
247256
}
248257

258+
// Interleaved output from concurrent builds is unreadable, so verbose
259+
// builds default to running one task at a time.
260+
const concurrency =
261+
baseOptions.concurrency ?? (verbose ? 1 : os.availableParallelism());
262+
raiseMaxListeners(concurrency);
249263
const limit = pLimit(concurrency);
264+
const resolveOutputPath = createOutputPathResolver(out, source);
250265

251266
const tripletContexts = [...triplets].map((triplet) => {
252267
const platform = findPlatformForTriplet(triplet);
@@ -351,7 +366,11 @@ program = program.action(
351366
if (relevantTriplets.length == 0) {
352367
continue;
353368
}
354-
await platform.postBuild(out, relevantTriplets, baseOptions);
369+
await platform.postBuild(
370+
resolveOutputPath,
371+
relevantTriplets,
372+
baseOptions,
373+
);
355374
}
356375
}),
357376
);

packages/cmake-rn/src/helpers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
import path from "node:path";
2+
3+
/**
4+
* The name of the emitted prebuild is derived from the artifact on disk (i.e.
5+
* the target's OUTPUT_NAME) rather than the CMake target name.
6+
*
7+
* A project declaring multiple addons has to give its targets unique names,
8+
* which for generated projects means namespacing them (see gyp-to-cmake's
9+
* --namespaced-targets). The artifact keeps the name the JS `require` expects,
10+
* so deriving from it keeps the prebuild's name independent of how the target
11+
* had to be named to avoid a clash.
12+
*/
13+
export function getArtifactName(artifactPath: string) {
14+
const basename = path.basename(artifactPath, path.extname(artifactPath));
15+
// Unless a target clears PREFIX (as the generated addon projects do), CMake
16+
// prefixes a shared library with "lib". The prebuild is named after the
17+
// library rather than the file, mirroring how createAndroidLibsDirectory adds
18+
// the prefix back when copying the library into the libs directory.
19+
return basename.startsWith("lib") ? basename.slice("lib".length) : basename;
20+
}
21+
122
export function toDefineArguments(
223
declarations: Array<Record<string, string | undefined>>,
324
) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import assert from "node:assert/strict";
2+
import path from "node:path";
3+
import { describe, it } from "node:test";
4+
5+
import { createOutputPathResolver, expandTemplate } from "./output-path.js";
6+
import { getArtifactName } from "./helpers.js";
7+
8+
describe("expandTemplate", () => {
9+
it("expands known placeholders", () => {
10+
assert.equal(
11+
expandTemplate("{build}/{configuration}", {
12+
build: "/tmp/build",
13+
configuration: "Release",
14+
}),
15+
"/tmp/build/Release",
16+
);
17+
});
18+
19+
it("leaves unknown placeholders untouched, to allow a later pass", () => {
20+
assert.equal(
21+
expandTemplate("{targetSourceDir}/build/{configuration}", {
22+
configuration: "Release",
23+
}),
24+
"{targetSourceDir}/build/Release",
25+
);
26+
});
27+
});
28+
29+
describe("createOutputPathResolver", () => {
30+
const source = path.resolve("/projects/my-app");
31+
32+
it("resolves a top-level target next to the source directory", () => {
33+
const resolve = createOutputPathResolver(
34+
"{targetSourceDir}/build/Release",
35+
source,
36+
);
37+
// A single-addon project reports "." as the target's source directory,
38+
// which has to keep emitting where it always has.
39+
assert.equal(resolve("."), path.join(source, "build/Release"));
40+
});
41+
42+
it("resolves each target of a multi-addon project next to its own sources", () => {
43+
const resolve = createOutputPathResolver(
44+
"{targetSourceDir}/build/Release",
45+
source,
46+
);
47+
assert.equal(
48+
resolve("examples/hello"),
49+
path.join(source, "examples/hello/build/Release"),
50+
);
51+
assert.equal(
52+
resolve("examples/goodbye"),
53+
path.join(source, "examples/goodbye/build/Release"),
54+
);
55+
});
56+
57+
it("handles a target source directory outside the top-level source", () => {
58+
const resolve = createOutputPathResolver(
59+
"{targetSourceDir}/build/Release",
60+
source,
61+
);
62+
const outside = path.resolve("/elsewhere/vendored");
63+
assert.equal(resolve(outside), path.join(outside, "build/Release"));
64+
});
65+
66+
it("supports a template without the placeholder", () => {
67+
const resolve = createOutputPathResolver("/tmp/out", source);
68+
assert.equal(resolve("examples/hello"), path.resolve("/tmp/out"));
69+
});
70+
});
71+
72+
describe("getArtifactName", () => {
73+
it("derives the name from the artifact rather than the target", () => {
74+
// gyp-to-cmake --namespaced-targets builds "addon.node" from a target named
75+
// "<project>-addon", and the prebuild has to keep the artifact's name.
76+
assert.equal(getArtifactName("examples/hello/addon.node"), "addon");
77+
});
78+
79+
it("handles framework artifacts", () => {
80+
assert.equal(getArtifactName("out/addon.framework/addon"), "addon");
81+
});
82+
83+
it("strips the prefix CMake adds to shared libraries", () => {
84+
// weak-node-api does not clear PREFIX, so it builds a "libweak-node-api.so"
85+
// and has to keep emitting a "weak-node-api.android.node" — the path
86+
// packages/host/android/build.gradle points its jniLibs at.
87+
assert.equal(getArtifactName("libweak-node-api.so"), "weak-node-api");
88+
});
89+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import path from "node:path";
2+
3+
/**
4+
* Expand `{placeholder}` occurrences in a template.
5+
*
6+
* Placeholders without a value are left untouched, so a template can be expanded
7+
* in multiple passes as more values become known.
8+
*/
9+
export function expandTemplate(
10+
input: string,
11+
values: Record<string, unknown>,
12+
): string {
13+
return input.replaceAll(/{([^}]+)}/g, (match, key: string) =>
14+
typeof values[key] === "string" ? values[key] : match,
15+
);
16+
}
17+
18+
/**
19+
* The final artifacts are emitted per target, relative to the source directory
20+
* of the target itself. This keeps a target's prebuild next to the sources it
21+
* was built from, even when a single project declares many addons, which is what
22+
* the Babel plugin and auto-linking rely on to resolve a `require`.
23+
*/
24+
export function createOutputPathResolver(outTemplate: string, source: string) {
25+
return function resolveOutputPath(targetSourceDir: string) {
26+
return path.resolve(
27+
process.cwd(),
28+
expandTemplate(outTemplate, {
29+
// `paths.source` is relative to the top-level source directory, unless
30+
// the target lives outside of it, in which case it is already absolute.
31+
targetSourceDir: path.resolve(source, targetSourceDir),
32+
}),
33+
);
34+
};
35+
}

packages/cmake-rn/src/platforms/android.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import * as cmakeFileApi from "cmake-file-api";
1515

1616
import type { BaseOpts, Platform } from "./types.js";
17-
import { toDefineArguments } from "../helpers.js";
17+
import { getArtifactName, toDefineArguments } from "../helpers.js";
1818
import {
1919
getCmakeJSVariables,
2020
getWeakNodeApiVariables,
@@ -208,13 +208,20 @@ export const platform: Platform<Triplet[], AndroidOpts> = {
208208
return typeof ANDROID_HOME === "string" && fs.existsSync(ANDROID_HOME);
209209
},
210210
async postBuild(
211-
outputPath,
211+
resolveOutputPath,
212212
triplets,
213213
{ autoLink, configuration, target, build, strip, ndkVersion },
214214
) {
215+
// Keyed by CMake target name, which CMake guarantees to be unique within a
216+
// project. The artifact name is not: every addon of a multi-addon project
217+
// may well build an "addon.node".
215218
const prebuilds: Record<
216219
string,
217-
{ triplet: Triplet; libraryPath: string }[]
220+
{
221+
artifactName: string;
222+
targetSourceDir: string;
223+
libraries: { triplet: Triplet; libraryPath: string }[];
224+
}
218225
> = {};
219226

220227
for (const { triplet, spawn } of triplets) {
@@ -240,7 +247,11 @@ export const platform: Platform<Triplet[], AndroidOpts> = {
240247
const [artifact] = artifacts;
241248
// Add prebuild entry, creating a new entry if needed
242249
if (!(sharedLibrary.name in prebuilds)) {
243-
prebuilds[sharedLibrary.name] = [];
250+
prebuilds[sharedLibrary.name] = {
251+
artifactName: getArtifactName(artifact.path),
252+
targetSourceDir: sharedLibrary.paths.source,
253+
libraries: [],
254+
};
244255
}
245256
const libraryPath = path.join(buildPath, artifact.path);
246257
assert(
@@ -257,18 +268,20 @@ export const platform: Platform<Triplet[], AndroidOpts> = {
257268
);
258269
await spawn(stripToolPath, [libraryPath]);
259270
}
260-
prebuilds[sharedLibrary.name].push({
271+
prebuilds[sharedLibrary.name].libraries.push({
261272
triplet,
262273
libraryPath,
263274
});
264275
}),
265276
);
266277
}
267278

268-
for (const [libraryName, libraries] of Object.entries(prebuilds)) {
279+
for (const { artifactName, targetSourceDir, libraries } of Object.values(
280+
prebuilds,
281+
)) {
269282
const prebuildOutputPath = path.resolve(
270-
outputPath,
271-
`${libraryName}.android.node`,
283+
resolveOutputPath(targetSourceDir),
284+
`${artifactName}.android.node`,
272285
);
273286
await oraPromise(
274287
createAndroidLibsDirectory({
@@ -277,10 +290,10 @@ export const platform: Platform<Triplet[], AndroidOpts> = {
277290
autoLink,
278291
}),
279292
{
280-
text: `Assembling Android libs directory (${libraryName})`,
281-
successText: `Android libs directory (${libraryName}) assembled into ${prettyPath(prebuildOutputPath)}`,
293+
text: `Assembling Android libs directory (${artifactName})`,
294+
successText: `Android libs directory (${artifactName}) assembled into ${prettyPath(prebuildOutputPath)}`,
282295
failText: ({ message }) =>
283-
`Failed to assemble Android libs directory (${libraryName}): ${message}`,
296+
`Failed to assemble Android libs directory (${artifactName}): ${message}`,
284297
},
285298
);
286299
}

0 commit comments

Comments
 (0)