From 1a453e098ded26fc6cd4cda5f6587bfb39941752 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 09:10:10 +0000 Subject: [PATCH 1/3] feat(ferric): check resolved napi-sys features before building Ask cargo metadata for the crate's resolved features for each target and fail with a fixable error if napi-sys' dyn-symbols feature is enabled, since it resolves Node-API functions at runtime instead of linking them against weak-node-api (#331). Pass --skip-feature-check to bypass. Also adds a unit test setup to ferric, mirroring cmake-rn's tsconfig layout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE3sZkTrATdVrV2xqbZ --- .changeset/ferric-napi-feature-check.md | 5 ++ package.json | 2 +- packages/ferric/README.md | 13 ++++ packages/ferric/package.json | 6 +- packages/ferric/src/build.ts | 10 +++ packages/ferric/src/features.test.ts | 48 +++++++++++++ packages/ferric/src/features.ts | 67 +++++++++++++++++++ packages/ferric/tsconfig.json | 14 ++-- ...ig.tests.json => tsconfig.node-tests.json} | 2 +- packages/ferric/tsconfig.node.json | 12 ++++ pnpm-lock.yaml | 3 + 11 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 .changeset/ferric-napi-feature-check.md create mode 100644 packages/ferric/src/features.test.ts create mode 100644 packages/ferric/src/features.ts rename packages/ferric/{tsconfig.tests.json => tsconfig.node-tests.json} (85%) create mode 100644 packages/ferric/tsconfig.node.json diff --git a/.changeset/ferric-napi-feature-check.md b/.changeset/ferric-napi-feature-check.md new file mode 100644 index 00000000..0a9423f9 --- /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, as it prevents linking against `weak-node-api`. 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..4ab4d8b5 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -1,3 +1,16 @@ # `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`'s `dyn-symbols` feature is enabled. That feature makes napi-rs look up Node-API functions in the host process at runtime instead of linking them against `weak-node-api`, which doesn't work in React Native ([#331](https://github.com/callstackincubator/react-native-node-api/issues/331)). `napi` enables it 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..c0b399e2 --- /dev/null +++ b/packages/ferric/src/features.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { 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, + ); + }); +}); diff --git a/packages/ferric/src/features.ts b/packages/ferric/src/features.ts new file mode 100644 index 00000000..3465bd78 --- /dev/null +++ b/packages/ferric/src/features.ts @@ -0,0 +1,67 @@ +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)); +} + +export function assertNapiFeatures(cwd: string, targets: Iterable) { + for (const target of targets) { + const features = getResolvedFeatures( + readResolvedMetadata(cwd, target), + "napi-sys", + ); + 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 ${chalk.blue(`cargo tree -e features -i napi-sys --target ${target}`)} to see which dependency enables it, or pass ${chalk.blue("--skip-feature-check")} 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: From 3714b26e9b9b6a00fce2a4b47023e7f7aeb86656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 09:31:36 +0000 Subject: [PATCH 2/3] feat(ferric): fail on napiN features above Node-API version 10 The host implements Node-API up to version 10 (NAPI_VERSION in packages/host/cpp/Versions.hpp), so a higher napiN feature on napi-sys would call functions the runtime doesn't provide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE3sZkTrATdVrV2xqbZ --- .changeset/ferric-napi-feature-check.md | 2 +- packages/ferric/README.md | 7 +++++- packages/ferric/src/features.test.ts | 19 +++++++++++++- packages/ferric/src/features.ts | 33 ++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.changeset/ferric-napi-feature-check.md b/.changeset/ferric-napi-feature-check.md index 0a9423f9..cad88430 100644 --- a/.changeset/ferric-napi-feature-check.md +++ b/.changeset/ferric-napi-feature-check.md @@ -2,4 +2,4 @@ "ferric-cli": minor --- -Check the crate's resolved `napi-sys` features before building and fail if `dyn-symbols` is enabled, as it prevents linking against `weak-node-api`. Pass `--skip-feature-check` to bypass. +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/packages/ferric/README.md b/packages/ferric/README.md index 4ab4d8b5..5e9e0ad1 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -4,7 +4,12 @@ A wrapper around Cargo making it easier to produce prebuilt binaries targeting i ## Feature check -Before building, `ferric build` asks Cargo for the crate's resolved features for each target and fails if `napi-sys`'s `dyn-symbols` feature is enabled. That feature makes napi-rs look up Node-API functions in the host process at runtime instead of linking them against `weak-node-api`, which doesn't work in React Native ([#331](https://github.com/callstackincubator/react-native-node-api/issues/331)). `napi` enables it through its default features, so disable those and pick the Node-API version explicitly: +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] diff --git a/packages/ferric/src/features.test.ts b/packages/ferric/src/features.test.ts index c0b399e2..aa71da29 100644 --- a/packages/ferric/src/features.test.ts +++ b/packages/ferric/src/features.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { getResolvedFeatures, type CargoResolvedMetadata } from "./features.js"; +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"; @@ -46,3 +50,16 @@ describe("getResolvedFeatures", () => { ); }); }); + +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 index 3465bd78..8efcf1a5 100644 --- a/packages/ferric/src/features.ts +++ b/packages/ferric/src/features.ts @@ -47,19 +47,50 @@ function readResolvedMetadata( 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 ${chalk.blue(`cargo tree -e features -i napi-sys --target ${target}`)} to see which dependency enables it, or pass ${chalk.blue("--skip-feature-check")} to build anyway.`, + `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"), }, ); From 34e263b1d7e34f7cd0280a0727d37f57b3ccb869 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 09:32:01 +0000 Subject: [PATCH 3/3] chore: trigger CI with Ferric label Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE3sZkTrATdVrV2xqbZ