Skip to content

Commit 1ab6a11

Browse files
kraenhansenclaude
andauthored
Multi addon projects (#413)
* Add --namespaced-targets to gyp-to-cmake * Use namespaced targets in node-addon-examples * Support building multiple addons * Limit spawn concurrency * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48fa7fc commit 1ab6a11

21 files changed

Lines changed: 729 additions & 309 deletions

.changeset/chilly-trains-nail.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"cmake-rn": minor
3+
---
4+
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"gyp-to-cmake": minor
3+
---
4+
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: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from "node:assert/strict";
22
import path from "node:path";
33
import fs from "node:fs";
4+
import os from "node:os";
45

56
import {
67
chalk,
@@ -10,6 +11,8 @@ import {
1011
oraPromise,
1112
assertFixable,
1213
wrapAction,
14+
pLimit,
15+
InvalidArgumentError,
1316
} from "@react-native-node-api/cli-utils";
1417

1518
import {
@@ -20,6 +23,7 @@ import {
2023
} from "./platforms.js";
2124
import { Platform } from "./platforms/types.js";
2225
import { getCcachePath } from "./ccache.js";
26+
import { createOutputPathResolver, expandTemplate } from "./output-path.js";
2327

2428
const verboseOption = new Option(
2529
"--verbose",
@@ -71,8 +75,8 @@ const cleanOption = new Option(
7175

7276
const outPathOption = new Option(
7377
"--out <path>",
74-
"Specify the output directory to store the final build artifacts",
75-
).default("{build}/{configuration}");
78+
"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",
79+
).default("{targetSourceDir}/build/{configuration}");
7680

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

132+
const concurrencyOption = new Option(
133+
"--concurrency <limit>",
134+
"Limit the number of concurrent tasks",
135+
)
136+
.argParser((value) => {
137+
const result = Number(value);
138+
if (!Number.isSafeInteger(result) || result < 1) {
139+
throw new InvalidArgumentError("Expected a positive integer.");
140+
}
141+
return result;
142+
})
143+
.default(
144+
undefined,
145+
`${os.availableParallelism()} or 1 when --verbose is enabled`,
146+
);
147+
128148
let program = new Command("cmake-rn")
129149
.description("Build React Native Node API modules with CMake")
130150
.addOption(tripletOption)
@@ -140,7 +160,8 @@ let program = new Command("cmake-rn")
140160
.addOption(noAutoLinkOption)
141161
.addOption(noWeakNodeApiLinkageOption)
142162
.addOption(cmakeJsOption)
143-
.addOption(ccachePathOption);
163+
.addOption(ccachePathOption)
164+
.addOption(concurrencyOption);
144165

145166
for (const platform of platforms) {
146167
const allOption = new Option(
@@ -151,25 +172,15 @@ for (const platform of platforms) {
151172
program = platform.amendCommand(program);
152173
}
153174

154-
function expandTemplate(
155-
input: string,
156-
values: Record<string, unknown>,
157-
): string {
158-
return input.replaceAll(/{([^}]+)}/g, (_, key: string) =>
159-
typeof values[key] === "string" ? values[key] : "",
160-
);
161-
}
162-
163175
program = program.action(
164176
wrapAction(async ({ triplet: requestedTriplets, ...baseOptions }) => {
165177
baseOptions.build = path.resolve(
166178
process.cwd(),
167179
expandTemplate(baseOptions.build, baseOptions),
168180
);
169-
baseOptions.out = path.resolve(
170-
process.cwd(),
171-
expandTemplate(baseOptions.out, baseOptions),
172-
);
181+
// Note: {targetSourceDir} is deliberately left unexpanded here, as it is
182+
// only known per target, once the CMake File API has been read.
183+
baseOptions.out = expandTemplate(baseOptions.out, baseOptions);
173184
const {
174185
verbose,
175186
clean,
@@ -228,6 +239,13 @@ program = program.action(
228239
}
229240
}
230241

242+
// Interleaved output from concurrent builds is unreadable, so verbose
243+
// builds default to running one task at a time.
244+
const concurrency =
245+
baseOptions.concurrency ?? (verbose ? 1 : os.availableParallelism());
246+
const limit = pLimit(concurrency);
247+
const resolveOutputPath = createOutputPathResolver(out, source);
248+
231249
const tripletContexts = [...triplets].map((triplet) => {
232250
const platform = findPlatformForTriplet(triplet);
233251

@@ -240,17 +258,21 @@ program = program.action(
240258
triplet,
241259
platform,
242260
async spawn(command: string, args: string[], cwd?: string) {
243-
const outputPrefix = verbose ? chalk.dim(`[${triplet}] `) : undefined;
244-
if (verbose) {
245-
console.log(
246-
`${outputPrefix}» ${command} ${args.map((arg) => chalk.dim(`${arg}`)).join(" ")}`,
247-
cwd ? `(in ${chalk.dim(cwd)})` : "",
248-
);
249-
}
250-
await spawn(command, args, {
251-
outputMode: verbose ? "inherit" : "buffered",
252-
outputPrefix,
253-
cwd,
261+
await limit(async () => {
262+
const outputPrefix = verbose
263+
? chalk.dim(`[${triplet}] `)
264+
: undefined;
265+
if (verbose) {
266+
console.log(
267+
`${outputPrefix}» ${command} ${args.map((arg) => chalk.dim(`${arg}`)).join(" ")}`,
268+
cwd ? `(in ${chalk.dim(cwd)})` : "",
269+
);
270+
}
271+
await spawn(command, args, {
272+
outputMode: verbose ? "inherit" : "buffered",
273+
outputPrefix,
274+
cwd,
275+
});
254276
});
255277
},
256278
};
@@ -276,13 +298,15 @@ program = program.action(
276298
relevantTriplets,
277299
baseOptions,
278300
(command, args, cwd) =>
279-
spawn(command, args, {
280-
outputMode: verbose ? "inherit" : "buffered",
281-
outputPrefix: verbose
282-
? chalk.dim(`[${platform.name}] `)
283-
: undefined,
284-
cwd,
285-
}),
301+
limit(() =>
302+
spawn(command, args, {
303+
outputMode: verbose ? "inherit" : "buffered",
304+
outputPrefix: verbose
305+
? chalk.dim(`[${platform.name}] `)
306+
: undefined,
307+
cwd,
308+
}),
309+
),
286310
);
287311
}
288312
}),
@@ -325,7 +349,11 @@ program = program.action(
325349
if (relevantTriplets.length == 0) {
326350
continue;
327351
}
328-
await platform.postBuild(out, relevantTriplets, baseOptions);
352+
await platform.postBuild(
353+
resolveOutputPath,
354+
relevantTriplets,
355+
baseOptions,
356+
);
329357
}
330358
}),
331359
);

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+
}

0 commit comments

Comments
 (0)