diff --git a/.changeset/wet-carrots-relax.md b/.changeset/wet-carrots-relax.md new file mode 100644 index 00000000..6050c6cb --- /dev/null +++ b/.changeset/wet-carrots-relax.md @@ -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. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3b6500f5..cb093ba7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 @@ -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 @@ -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 diff --git a/.prettierignore b/.prettierignore index 23bef2b3..c8aaf613 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/packages/ferric-example/.gitignore b/packages/ferric-example/.gitignore index f1d36c32..a225cf72 100644 --- a/packages/ferric-example/.gitignore +++ b/packages/ferric-example/.gitignore @@ -3,7 +3,3 @@ /*.xcframework/ /*.apple.node/ /*.android.node/ - -# Generated files -/ferric_example.d.ts -/ferric_example.js diff --git a/packages/ferric-example/ferric_example.d.ts b/packages/ferric-example/ferric_example.d.ts new file mode 100644 index 00000000..3b38df96 --- /dev/null +++ b/packages/ferric-example/ferric_example.d.ts @@ -0,0 +1,11 @@ +/** + * This file was generated by + * ╭─────────────────────────╮ + * │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│ + * │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│ + * │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│ + * ╰─────────────────────────╯ + * Powered by napi.rs + */ +/* eslint-disable */ +export declare function sum(a: number, b: number): number diff --git a/packages/ferric-example/ferric_example.js b/packages/ferric-example/ferric_example.js new file mode 100644 index 00000000..69fff2e3 --- /dev/null +++ b/packages/ferric-example/ferric_example.js @@ -0,0 +1,13 @@ +/* eslint-disable */ + +/** + * This file was generated by + * ╭─────────────────────────╮ + * │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│ + * │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│ + * │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│ + * ╰─────────────────────────╯ + * Powered by napi.rs + */ + +module.exports = require('./ferric_example.node'); diff --git a/packages/ferric-example/package.json b/packages/ferric-example/package.json index 28735907..3e9fd232 100644 --- a/packages/ferric-example/package.json +++ b/packages/ferric-example/package.json @@ -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:*" diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts index 3e5236ac..50e32b94 100644 --- a/packages/ferric/src/build.ts +++ b/packages/ferric/src/build.ts @@ -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, @@ -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 ", @@ -153,6 +157,7 @@ export const buildCommand = new Command("build") .addOption(appleBundleIdentifierOption) .addOption(concurrencyOption) .addOption(verboseOption) + .addOption(dtsOnlyOption) .action( wrapAction( async ({ @@ -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" }), diff --git a/packages/ferric/src/cargo.ts b/packages/ferric/src/cargo.ts index fc4fb2ac..31c8eaa3 100644 --- a/packages/ferric/src/cargo.ts +++ b/packages/ferric/src/cargo.ts @@ -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;