diff --git a/.changeset/ferric-napi-feature-check.md b/.changeset/ferric-napi-feature-check.md new file mode 100644 index 00000000..cad88430 --- /dev/null +++ b/.changeset/ferric-napi-feature-check.md @@ -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. diff --git a/package.json b/package.json index 5caf4ae8..92b1136d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ferric/README.md b/packages/ferric/README.md index 0568eb37..5e9e0ad1 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -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. diff --git a/packages/ferric/package.json b/packages/ferric/package.json index b8950ba9..ec83168a 100644 --- a/packages/ferric/package.json +++ b/packages/ferric/package.json @@ -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" } } diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts index 5a72ec03..5cd83bee 100644 --- a/packages/ferric/src/build.ts +++ b/packages/ferric/src/build.ts @@ -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; @@ -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.", @@ -158,6 +163,7 @@ export const buildCommand = new Command("build") .addOption(concurrencyOption) .addOption(verboseOption) .addOption(dtsOnlyOption) + .addOption(skipFeatureCheckOption) .action( wrapAction( async ({ @@ -173,6 +179,7 @@ export const buildCommand = new Command("build") concurrency, verbose, dtsOnly, + skipFeatureCheck, }) => { if (dtsOnly) { assertFixable( @@ -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"); diff --git a/packages/ferric/src/features.test.ts b/packages/ferric/src/features.test.ts new file mode 100644 index 00000000..aa71da29 --- /dev/null +++ b/packages/ferric/src/features.test.ts @@ -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); + }); +}); diff --git a/packages/ferric/src/features.ts b/packages/ferric/src/features.ts new file mode 100644 index 00000000..8efcf1a5 --- /dev/null +++ b/packages/ferric/src/features.ts @@ -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; + +export function getResolvedFeatures( + metadata: CargoResolvedMetadata, + packageName: string, +): Set { + 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): 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) { + 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"), + }, + ); + } +} diff --git a/packages/ferric/tsconfig.json b/packages/ferric/tsconfig.json index a678ba53..95abca17 100644 --- a/packages/ferric/tsconfig.json +++ b/packages/ferric/tsconfig.json @@ -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" } + ] } diff --git a/packages/ferric/tsconfig.tests.json b/packages/ferric/tsconfig.node-tests.json similarity index 85% rename from packages/ferric/tsconfig.tests.json rename to packages/ferric/tsconfig.node-tests.json index 17ee7c84..cabcdca7 100644 --- a/packages/ferric/tsconfig.tests.json +++ b/packages/ferric/tsconfig.node-tests.json @@ -8,7 +8,7 @@ "exclude": [], "references": [ { - "path": "./tsconfig.json" + "path": "./tsconfig.node.json" } ] } diff --git a/packages/ferric/tsconfig.node.json b/packages/ferric/tsconfig.node.json new file mode 100644 index 00000000..d6472ce0 --- /dev/null +++ b/packages/ferric/tsconfig.node.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fab0626..ca51f945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: weak-node-api: specifier: workspace:* version: link:../weak-node-api + zod: + specifier: ^4.1.11 + version: 4.4.3 packages/ferric-example: dependencies: