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/wet-carrots-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ferric-cli": patch
---

Add `--dts-only` flag to `ferric build`, generating just the TypeScript declaration file and JS entrypoint without cross-compiling any Android/Apple binaries. It still runs a real host `cargo build` (napi-rs has no lighter typegen-only mode), so it's meant for regenerating a checked-in declarations fixture rather than for environments without a Rust toolchain.
32 changes: 16 additions & 16 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,24 +59,18 @@ jobs:
uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: ${{ github.job }}-${{ runner.os }}
# Set up JDK and Android SDK only because we need weak-node-api, to build ferric-example and to run the linting
# TODO: Remove this once we have a way to run linting without building the native code
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v4
with:
packages: tools platform-tools ndk;${{ env.NDK_VERSION }}
- run: rustup target add x86_64-linux-android
- run: pnpm install
- run: pnpm run build
# Bootstrap weak-node-api and ferric-example to get types
# TODO: Solve this by adding an option to ferric to build only types or by committing the types into the repo as a fixture for an "init" command
- run: pnpm --filter weak-node-api run bootstrap
- run: pnpm --filter @react-native-node-api/ferric-example run bootstrap
# Generate the TypeScript/C++ declarations that other packages' type-checking
# depends on, without building any native binaries: weak-node-api's
# "prebuild:prepare" only copies headers and runs codegen (needs clang-format,
# set up above, but no JDK/Android SDK/NDK). ferric-example's declarations are
# committed as a fixture instead (see packages/ferric-example/.gitignore) —
# napi-rs's dts generation has no way to run without a real `cargo build`
# (confirmed: it leaves a populated target/ directory), so unlike
# weak-node-api's codegen it can't be reproduced here without reintroducing a
# native build into the fastest-feedback job. See #414.
- run: pnpm --filter weak-node-api run prebuild:prepare
- run: pnpm run lint
env:
DEBUG: eslint:eslint
Expand Down Expand Up @@ -350,6 +344,9 @@ jobs:
- name: Build ferric-example for all architectures
run: pnpm run build --android
working-directory: packages/ferric-example
- name: Verify committed ferric-example TypeScript declarations are up to date
run: git diff --exit-code -- ferric_example.d.ts ferric_example.js
working-directory: packages/ferric-example
- name: Run tests (Android)
timeout-minutes: 75
uses: reactivecircus/android-emulator-runner@v2
Expand Down Expand Up @@ -417,6 +414,9 @@ jobs:
# Build Ferric example for all Apple architectures
- run: pnpm exec ferric --apple
working-directory: packages/ferric-example
- name: Verify committed ferric-example TypeScript declarations are up to date
run: git diff --exit-code -- ferric_example.d.ts ferric_example.js
working-directory: packages/ferric-example
- name: Inspect the structure of the prebuilt binary
run: |
lipo -info ferric_example.apple.node/*/libferric_example.framework/libferric_example > lipo-output.txt
Expand Down
7 changes: 7 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ packages/node-addon-examples/examples
packages/node-tests/node
packages/node-tests/tests
packages/node-tests/*.generated.js

# Committed napi-rs codegen fixture (see packages/ferric-example/.gitignore) — left
# in napi-rs's own output formatting so `pnpm run build:types` reproduces it exactly
# and the CI drift check (see .github/workflows/check.yml) doesn't false-positive on
# formatting alone.
packages/ferric-example/ferric_example.d.ts
packages/ferric-example/ferric_example.js
4 changes: 0 additions & 4 deletions packages/ferric-example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,3 @@
/*.xcframework/
/*.apple.node/
/*.android.node/

# Generated files
/ferric_example.d.ts
/ferric_example.js
11 changes: 11 additions & 0 deletions packages/ferric-example/ferric_example.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* This file was generated by
* ╭─────────────────────────╮
* │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│
* │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│
* │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│
* ╰─────────────────────────╯
* Powered by napi.rs
*/
/* eslint-disable */
export declare function sum(a: number, b: number): number
13 changes: 13 additions & 0 deletions packages/ferric-example/ferric_example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* eslint-disable */

/**
* This file was generated by
* ╭─────────────────────────╮
* │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│
* │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│
* │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│
* ╰─────────────────────────╯
* Powered by napi.rs
*/

module.exports = require('./ferric_example.node');
3 changes: 2 additions & 1 deletion packages/ferric-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
],
"scripts": {
"build": "ferric build",
"bootstrap": "node --run build"
"bootstrap": "node --run build",
"build:types": "ferric build --dts-only"
},
"dependencies": {
"react-native-node-api": "workspace:*"
Expand Down
53 changes: 52 additions & 1 deletion packages/ferric/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
determineLibraryBasename,
} from "react-native-node-api";

import { ensureCargo, build } from "./cargo.js";
import { ensureCargo, build, determineCargoLibraryName } from "./cargo.js";
import {
ALL_TARGETS,
ANDROID_TARGETS,
Expand Down Expand Up @@ -104,6 +104,10 @@ const xcframeworkExtensionOption = new Option(
"--xcframework-extension",
"Don't rename the xcframework to .apple.node",
).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.",
).default(false);

const outputPathOption = new Option(
"--output <path>",
Expand Down Expand Up @@ -153,6 +157,7 @@ export const buildCommand = new Command("build")
.addOption(appleBundleIdentifierOption)
.addOption(concurrencyOption)
.addOption(verboseOption)
.addOption(dtsOnlyOption)
.action(
wrapAction(
async ({
Expand All @@ -167,7 +172,53 @@ export const buildCommand = new Command("build")
appleBundleIdentifier,
concurrency,
verbose,
dtsOnly,
}) => {
if (dtsOnly) {
assertFixable(
targetArg.length === 0 && !apple && !android && !clean,
"The --dts-only flag cannot be combined with --target, --apple, --android or --clean",
{
instructions:
"Drop --dts-only to build native binaries, or remove the other flags to only generate TypeScript declarations",
},
);
ensureCargo();
const libraryName = determineCargoLibraryName(process.cwd());
const declarationsFilename = `${libraryName}.d.ts`;
const declarationsPath = path.join(outputPath, declarationsFilename);
await oraPromise(
generateTypeScriptDeclarations({
outputFilename: declarationsFilename,
createPath: process.cwd(),
outputPath,
}),
{
text: "Generating TypeScript declarations",
successText: `Generated TypeScript declarations ${prettyPath(
declarationsPath,
)}`,
failText: (error) =>
`Failed to generate TypeScript declarations: ${error.message}`,
},
);
const entrypointPath = path.join(outputPath, `${libraryName}.js`);
await oraPromise(
generateEntrypoint({
libraryName,
outputPath: entrypointPath,
}),
{
text: `Generating entrypoint`,
successText: `Generated entrypoint into ${prettyPath(
entrypointPath,
)}`,
failText: (error) =>
`Failed to generate entrypoint: ${error.message}`,
},
);
return;
}
if (clean) {
await oraPromise(
() => spawn("cargo", ["clean"], { outputMode: "buffered" }),
Expand Down
30 changes: 30 additions & 0 deletions packages/ferric/src/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ export function ensureCargo() {
}
}

type CargoMetadata = {
packages: { targets: { name: string; kind: string[] }[] }[];
};

/**
* Determine the name of the crate's "cdylib" target, without building anything,
* by asking cargo for its metadata. This matches the basename a full build would
* produce (e.g. "ferric_example" for a crate named "ferric-example"), since cargo
* normalizes the crate name (dashes to underscores) for the compiled artifact.
*/
export function determineCargoLibraryName(cwd: string): string {
const output = cp.execFileSync(
"cargo",
["metadata", "--no-deps", "--format-version", "1"],
{ cwd, encoding: "utf-8" },
);
const { packages } = JSON.parse(output) as CargoMetadata;
const cdylibNames = packages
.flatMap((pkg) => pkg.targets)
.filter((target) => target.kind.includes("cdylib"))
.map((target) => target.name);
const candidates = new Set(cdylibNames);
assert(
candidates.size === 1,
`Expected exactly one cdylib target, got: ${[...candidates].join(", ")}`,
);
const [name] = candidates;
return name;
}

type BuildOptions = {
configuration: "debug" | "release";
verbose: boolean;
Expand Down
Loading