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
5 changes: 5 additions & 0 deletions .changeset/ferric-napi-feature-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ferric-cli": minor
---

Check the crate's resolved `napi-sys` features before building and fail if `dyn-symbols` is enabled (it prevents linking against `weak-node-api`) or a `napiN` feature above the supported Node-API version 10. Pass `--skip-feature-check` to bypass.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"publint": "node scripts/run-in-published.ts pnpm exec publint --strict",
"prettier:check": "prettier --experimental-cli --check .",
"prettier:write": "prettier --experimental-cli --write .",
"test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake run test",
"test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake --filter ferric-cli run test",
"bootstrap": "node --run build && pnpm --recursive run bootstrap",
"changeset": "changeset",
"release": "node --run prerelease && changeset publish",
Expand Down
18 changes: 18 additions & 0 deletions packages/ferric/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# `ferric`

A wrapper around Cargo making it easier to produce prebuilt binaries targeting iOS and Android matching [the prebuilt binary specification](https://github.com/callstackincubator/react-native-node-api/blob/main/docs/PREBUILDS.md) as well as [napi.rs](https://napi.rs/) to generate bindings from annotated Rust code.

## Feature check

Before building, `ferric build` asks Cargo for the crate's resolved features for each target and fails if `napi-sys` has either of these enabled:

- `dyn-symbols`, which makes napi-rs look up Node-API functions in the host process at runtime instead of linking them against `weak-node-api`. That doesn't work in React Native ([#331](https://github.com/callstackincubator/react-native-node-api/issues/331)).
- A `napiN` feature above Node-API version 10, the highest version the host supports.

`napi` enables `dyn-symbols` through its default features, so disable those and pick the Node-API version explicitly:

```toml
[dependencies.napi]
version = "3"
default-features = false
features = ["napi3"]
```

Pass `--skip-feature-check` to bypass the check.
6 changes: 4 additions & 2 deletions packages/ferric/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
"ferric": "./bin/ferric.js"
},
"scripts": {
"start": "tsx src/run.ts"
"start": "tsx src/run.ts",
"test": "tsx --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout"
},
"dependencies": {
"@napi-rs/cli": "~3.10.8",
"@react-native-node-api/cli-utils": "workspace:*",
"react-native-node-api": "workspace:*",
"weak-node-api": "workspace:*"
"weak-node-api": "workspace:*",
"zod": "^4.1.11"
}
}
10 changes: 10 additions & 0 deletions packages/ferric/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from "./targets.js";
import { generateTypeScriptDeclarations } from "./napi-rs.js";
import { getBlockComment } from "./banner.js";
import { assertNapiFeatures } from "./features.js";

type EntrypointOptions = {
outputPath: string;
Expand Down Expand Up @@ -104,6 +105,10 @@ const xcframeworkExtensionOption = new Option(
"--xcframework-extension",
"Don't rename the xcframework to .apple.node",
).default(false);
const skipFeatureCheckOption = new Option(
"--skip-feature-check",
"Don't check the crate's resolved napi-sys features before building",
).default(false);
const dtsOnlyOption = new Option(
"--dts-only",
"Only generate the TypeScript declarations and entrypoint, skipping Android/Apple cross-compilation. Still runs a real `cargo build` for the host target (napi-rs has no lighter typegen-only mode), so this is not a no-op — it's meant for regenerating a checked-in declarations fixture, not for toolchain-free environments.",
Expand Down Expand Up @@ -158,6 +163,7 @@ export const buildCommand = new Command("build")
.addOption(concurrencyOption)
.addOption(verboseOption)
.addOption(dtsOnlyOption)
.addOption(skipFeatureCheckOption)
.action(
wrapAction(
async ({
Expand All @@ -173,6 +179,7 @@ export const buildCommand = new Command("build")
concurrency,
verbose,
dtsOnly,
skipFeatureCheck,
}) => {
if (dtsOnly) {
assertFixable(
Expand Down Expand Up @@ -271,6 +278,9 @@ export const buildCommand = new Command("build")
}
ensureCargo();
ensureAvailableTargets(targets);
if (!skipFeatureCheck) {
assertNapiFeatures(process.cwd(), targets);
}

const appleTargets = filterTargetsByPlatform(targets, "apple");
const androidTargets = filterTargetsByPlatform(targets, "android");
Expand Down
65 changes: 65 additions & 0 deletions packages/ferric/src/features.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
getNodeApiVersion,
getResolvedFeatures,
type CargoResolvedMetadata,
} from "./features.js";

const NAPI_SYS_ID =
"registry+https://github.com/rust-lang/crates.io-index#napi-sys@3.4.0";
const NAPI_ID =
"registry+https://github.com/rust-lang/crates.io-index#napi@3.14.2";

function createMetadata(napiSysFeatures: string[]): CargoResolvedMetadata {
return {
packages: [
{ id: NAPI_SYS_ID, name: "napi-sys" },
{ id: NAPI_ID, name: "napi" },
],
resolve: {
nodes: [
{ id: NAPI_SYS_ID, features: napiSysFeatures },
{ id: NAPI_ID, features: ["default", "dyn-symbols"] },
],
},
};
}

describe("getResolvedFeatures", () => {
it("returns the features resolved for the package", () => {
const features = getResolvedFeatures(
createMetadata(["dyn-symbols", "napi1"]),
"napi-sys",
);
assert.deepEqual([...features], ["dyn-symbols", "napi1"]);
});

it("ignores features of other packages", () => {
const features = getResolvedFeatures(createMetadata(["napi1"]), "napi-sys");
assert.deepEqual([...features], ["napi1"]);
});

it("returns no features when the package isn't a dependency", () => {
const metadata = createMetadata(["napi1"]);
assert.equal(getResolvedFeatures(metadata, "missing").size, 0);
assert.equal(
getResolvedFeatures({ ...metadata, resolve: null }, "napi-sys").size,
0,
);
});
});

describe("getNodeApiVersion", () => {
it("returns the highest enabled napiN feature", () => {
assert.equal(
getNodeApiVersion(["napi1", "napi10", "napi3", "dyn-symbols"]),
10,
);
});

it("returns 0 when no napiN feature is enabled", () => {
assert.equal(getNodeApiVersion(["dyn-symbols", "napi"]), 0);
});
});
98 changes: 98 additions & 0 deletions packages/ferric/src/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import cp from "node:child_process";

import { assertFixable, chalk } from "@react-native-node-api/cli-utils";
import * as z from "zod";

import type { TargetName } from "./targets.js";

const CargoResolvedMetadata = z.object({
packages: z.array(z.object({ id: z.string(), name: z.string() })),
resolve: z
.object({
nodes: z.array(
z.object({ id: z.string(), features: z.array(z.string()).optional() }),
),
})
.nullable(),
});

export type CargoResolvedMetadata = z.infer<typeof CargoResolvedMetadata>;

export function getResolvedFeatures(
metadata: CargoResolvedMetadata,
packageName: string,
): Set<string> {
const ids = new Set(
metadata.packages
.filter((pkg) => pkg.name === packageName)
.map((pkg) => pkg.id),
);
const nodes = metadata.resolve?.nodes ?? [];
return new Set(
nodes
.filter((node) => ids.has(node.id))
.flatMap((node) => node.features ?? []),
);
}

function readResolvedMetadata(
cwd: string,
target: TargetName,
): CargoResolvedMetadata {
const output = cp.execFileSync(
"cargo",
["metadata", "--format-version", "1", "--filter-platform", target],
{ cwd, encoding: "utf-8" },
);
return CargoResolvedMetadata.parse(JSON.parse(output));
}

/**
* The highest Node-API version implemented by the host.
*/
export const MAX_NODE_API_VERSION = 10;

export function getNodeApiVersion(features: Iterable<string>): number {
let result = 0;
for (const feature of features) {
const match = /^napi(\d+)$/.exec(feature);
if (match) {
result = Math.max(result, parseInt(match[1], 10));
}
}
return result;
}

export function assertNapiFeatures(cwd: string, targets: Iterable<TargetName>) {
for (const target of targets) {
const features = getResolvedFeatures(
readResolvedMetadata(cwd, target),
"napi-sys",
);
const treeCommand = chalk.blue(
`cargo tree -e features -i napi-sys --target ${target}`,
);
const skipFlag = chalk.blue("--skip-feature-check");
assertFixable(
!features.has("dyn-symbols"),
`The "dyn-symbols" feature of napi-sys is enabled when building for ${target}, which loads Node-API functions at runtime instead of linking them against weak-node-api`,
{
instructions: [
`Set ${chalk.blue("default-features = false")} on the napi dependency in Cargo.toml and enable the "napiN" features you need explicitly.`,
`Run ${treeCommand} to see which dependency enables it, or pass ${skipFlag} to build anyway.`,
].join("\n"),
},
);
const version = getNodeApiVersion(features);
assertFixable(
version <= MAX_NODE_API_VERSION,
`The "napi${version}" feature of napi-sys is enabled when building for ${target}, but React Native Node-API supports Node-API versions up to ${MAX_NODE_API_VERSION}`,
{
instructions: [
`Enable at most the "napi${MAX_NODE_API_VERSION}" feature on the napi dependency in Cargo.toml.`,
`Run ${treeCommand} to see which dependency enables it, or pass ${skipFlag} to build anyway.`,
].join("\n"),
},
);
}
}
14 changes: 6 additions & 8 deletions packages/ferric/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"composite": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
"composite": true
},
"include": ["src/**/*.ts"],
"exclude": ["**.test.ts"]
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.node-tests.json" }
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"exclude": [],
"references": [
{
"path": "./tsconfig.json"
"path": "./tsconfig.node.json"
}
]
}
12 changes: 12 additions & 0 deletions packages/ferric/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"composite": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["**/*.test.ts"]
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading