Skip to content

Commit 17c4c7c

Browse files
authored
Add programmatic api section for cli (#420)
1 parent e217615 commit 17c4c7c

File tree

4 files changed

+347
-3
lines changed

4 files changed

+347
-3
lines changed

.husky/pre-commit

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
#!/bin/sh
2-
. "$(dirname "$0")/_/husky.sh"
3-
41
yarn lint-staged

next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
/// <reference path="./.next/types/routes.d.ts" />
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

pages/docs/cli/_meta.en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"programmatic-api": "Programmatic API",
23
"build": "Build",
34
"artifacts": "Artifacts",
45
"pre-publish": "Prepublish",
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
---
2+
description: Use @napi-rs/cli programmatic APIs to customize your build workflow.
3+
---
4+
5+
# Programmatic API
6+
7+
import { Green, Rust, Warning } from '../../../components/chalk'
8+
import { Callout } from 'nextra-theme-docs'
9+
10+
The `@napi-rs/cli` package exports programmatic APIs that allow you to customize your build workflow beyond what the CLI commands offer. This is useful when you need to:
11+
12+
- Post-process build outputs (format, transform, or validate generated files)
13+
- Integrate with custom build systems like Bazel
14+
- Generate TypeScript definitions separately from the Rust compilation
15+
- Build automation scripts with full control over the build process
16+
17+
## Post-Processing Build Outputs
18+
19+
The most common use case is running custom post-processing on the generated JavaScript and TypeScript files. Here's an example using <Green>oxfmt</Green> to format the output files:
20+
21+
```ts filename="build.ts"
22+
import { readFile, writeFile } from 'node:fs/promises'
23+
24+
import { NapiCli, createBuildCommand } from '@napi-rs/cli'
25+
import { format, type FormatOptions } from 'oxfmt'
26+
27+
import oxfmtConfig from './.oxfmtrc.json' with { type: 'json' }
28+
29+
const buildCommand = createBuildCommand(process.argv.slice(2))
30+
const cli = new NapiCli()
31+
const buildOptions = buildCommand.getOptions()
32+
const { task } = await cli.build(buildOptions)
33+
const outputs = await task
34+
35+
for (const output of outputs) {
36+
if (output.kind !== 'node') {
37+
const { code } = await format(output.path, await readFile(output.path, 'utf-8'), oxfmtConfig as FormatOptions)
38+
await writeFile(output.path, code)
39+
}
40+
}
41+
```
42+
43+
Run this script with the same arguments you would pass to <Green>napi build</Green>:
44+
45+
```sh
46+
node ./build.ts --release --platform
47+
```
48+
49+
### How It Works
50+
51+
1. `createBuildCommand(args)` parses CLI arguments and returns a `BuildCommand` instance
52+
2. `buildCommand.getOptions()` extracts the parsed options as a plain object
53+
3. `cli.build(options)` starts the build and returns `{ task, abort }`
54+
4. `await task` waits for completion and returns an array of `Output` objects
55+
56+
### Output Types
57+
58+
Each item in the outputs array has this structure:
59+
60+
```ts
61+
type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
62+
63+
type Output = {
64+
kind: OutputKind
65+
path: string // Absolute path to the output file
66+
}
67+
```
68+
69+
| Kind | Description |
70+
|------|-------------|
71+
| <Green>node</Green> | Native Node.js addon (<Green>.node</Green> file) |
72+
| <Green>js</Green> | JavaScript binding file |
73+
| <Green>dts</Green> | TypeScript definition file |
74+
| <Green>exe</Green> | Executable binary |
75+
| <Green>wasm</Green> | WebAssembly module |
76+
77+
## Standalone Types/JS Generation
78+
79+
<Callout type="info">
80+
This is useful for build systems like Bazel that handle Rust compilation separately and only need the TypeScript type generation step.
81+
</Callout>
82+
83+
If you compile Rust code outside of `@napi-rs/cli` (e.g., using Bazel's `rust_shared_library`), you can still generate TypeScript definitions using the `generateTypeDef` and `writeJsBinding` APIs:
84+
85+
```ts filename="generate-types.ts"
86+
import { spawn } from 'node:child_process'
87+
import { mkdir, writeFile, copyFile, rm } from 'node:fs/promises'
88+
import { join, dirname } from 'node:path'
89+
import { fileURLToPath } from 'node:url'
90+
91+
import { generateTypeDef, writeJsBinding, parseTriple } from '@napi-rs/cli'
92+
93+
import pkg from './package.json' with { type: 'json' }
94+
95+
const currentTarget = 'x86_64-unknown-linux-gnu'
96+
97+
const currentDir = dirname(fileURLToPath(import.meta.url))
98+
const typeDefDir = join(currentDir, 'target', 'napi-rs', 'YOUR_PKG_NAME')
99+
const triple = parseTriple(currentTarget)
100+
const bindingName = `customized.${triple.platformArchABI}.node`
101+
102+
await mkdir(typeDefDir, { recursive: true })
103+
104+
const childProcess = spawn('cargo', ['build', '--release'], {
105+
stdio: 'pipe',
106+
env: {
107+
NAPI_TYPE_DEF_TMP_FOLDER: typeDefDir,
108+
...process.env,
109+
},
110+
})
111+
112+
// Remove old binding file, this is necessary on some platforms like macOS
113+
// copy the new binding file without removing the old one will cause weird segmentation fault
114+
await rm(join(currentDir, bindingName)).catch(() => {
115+
// ignore error
116+
})
117+
118+
await copyFile(join(currentDir, 'target', currentTarget, 'release', 'libfoo.so'), join(currentDir, bindingName))
119+
120+
childProcess.stdout.on('data', (data) => {
121+
console.log(data.toString())
122+
})
123+
124+
childProcess.stderr.on('data', (data) => {
125+
console.error(data.toString())
126+
})
127+
128+
await new Promise((resolve, reject) => {
129+
childProcess.on('error', (error) => {
130+
reject(error)
131+
})
132+
childProcess.on('close', (code) => {
133+
if (code === 0) {
134+
resolve(true)
135+
} else {
136+
reject(new Error(`cargo build --release failed with code ${code}`))
137+
}
138+
})
139+
})
140+
141+
const { dts, exports } = await generateTypeDef({
142+
typeDefDir,
143+
cwd: process.cwd(),
144+
})
145+
146+
await writeFile(join(currentDir, 'customized.d.ts'), dts)
147+
148+
await writeJsBinding({
149+
jsBinding: 'customized.js',
150+
platform: true,
151+
binaryName: pkg.napi.binaryName,
152+
packageName: pkg.name,
153+
version: pkg.version,
154+
outputDir: currentDir,
155+
idents: exports
156+
})
157+
```
158+
159+
<Callout type="warning">
160+
The `typeDefDir` must contain the intermediate type definition files generated by the `napi-derive` proc macro when the `type-def` feature is enabled. These files are normally created in a temporary directory during `napi build`.
161+
</Callout>
162+
163+
### Control Flow
164+
165+
```
166+
┌─────────────────────────────────────────────────────────────────────────┐
167+
│ SETUP PHASE │
168+
├─────────────────────────────────────────────────────────────────────────┤
169+
│ 1. Read package.json for napi config │
170+
│ 2. Get target triple (from cli flag) (e.g.,'x86_64-unknown-linux-gnu') │
171+
│ 3. parseTriple() → get platformArchABI for binding filename │
172+
│ 4. mkdir(typeDefDir) → create directory for type definitions │
173+
└─────────────────────────────────────────────────────────────────────────┘
174+
175+
176+
┌─────────────────────────────────────────────────────────────────────────┐
177+
│ BUILD PHASE │
178+
├─────────────────────────────────────────────────────────────────────────┤
179+
│ 5. spawn('cargo', ['build', '--release']) │
180+
│ └─ env: { NAPI_TYPE_DEF_TMP_FOLDER: typeDefDir } │
181+
│ ▲ │
182+
│ └─ This env var tells napi-derive where to write type defs │
183+
│ │
184+
│ 6. rm(old binding) → Remove old .node file (prevents macOS segfault) │
185+
│ 7. copyFile(libfoo.so → customized.{platform}.node) │
186+
│ 8. Stream stdout/stderr from cargo │
187+
│ 9. await cargo completion │
188+
└─────────────────────────────────────────────────────────────────────────┘
189+
190+
191+
┌─────────────────────────────────────────────────────────────────────────┐
192+
│ TYPE GENERATION PHASE │
193+
├─────────────────────────────────────────────────────────────────────────┤
194+
│ 10. generateTypeDef({ typeDefDir, cwd }) │
195+
│ └─ Reads intermediate .json files from typeDefDir │
196+
│ └─ Returns { dts: string, exports: string[] } │
197+
│ │
198+
│ 11. writeFile('customized.d.ts', dts) │
199+
│ │
200+
│ 12. writeJsBinding({ platform, binaryName, idents: exports, ... }) │
201+
│ └─ Generates JS loader that imports the .node file │
202+
└─────────────────────────────────────────────────────────────────────────┘
203+
204+
205+
┌───────────┐
206+
│ DONE │
207+
│ │
208+
│ Output: │
209+
│ • .node │
210+
│ • .d.ts │
211+
│ • .js │
212+
└───────────┘
213+
```
214+
215+
### Key Concepts
216+
217+
#### The `NAPI_TYPE_DEF_TMP_FOLDER` Environment Variable
218+
219+
When you run `cargo build` with `NAPI_TYPE_DEF_TMP_FOLDER` set, the `napi-derive` proc macro writes intermediate type definition files (JSON format) to that directory. This is how type information flows from Rust to TypeScript:
220+
221+
```
222+
Rust Code → napi-derive macro → JSON files → generateTypeDef() → .d.ts
223+
```
224+
225+
#### Platform-Specific Binding Names
226+
227+
The `parseTriple()` function extracts platform information from a target triple:
228+
229+
```ts
230+
const triple = parseTriple('x86_64-unknown-linux-gnu')
231+
// Returns: { platform: 'linux', arch: 'x64', abi: 'gnu', platformArchABI: 'linux-x64-gnu', ... }
232+
233+
const bindingName = `mylib.${triple.platformArchABI}.node`
234+
// Result: 'mylib.linux-x64-gnu.node'
235+
```
236+
237+
#### Removing Old Binding Files
238+
239+
On macOS/Linux, copying a new `.node` file over an existing one without first removing it can cause segmentation faults. Always remove the old file first:
240+
241+
```ts
242+
await rm(join(currentDir, bindingName)).catch(() => {
243+
// ignore error if file doesn't exist
244+
})
245+
await copyFile(sourceLib, join(currentDir, bindingName))
246+
```
247+
248+
### GenerateTypeDefOptions
249+
250+
| Option | Type | Required | Default | Description |
251+
|--------|------|----------|---------|-------------|
252+
| typeDefDir | string | Yes | | Directory containing intermediate type def files |
253+
| cwd | string | Yes | | Working directory for resolving relative paths |
254+
| noDtsHeader | boolean | No | false | Skip the default file header |
255+
| dtsHeader | string | No | | Custom header string for the <Green>.d.ts</Green> file |
256+
| dtsHeaderFile | string | No | | Path to a file containing the header content |
257+
| configDtsHeader | string | No | | Header from config (lower priority than dtsHeader) |
258+
| configDtsHeaderFile | string | No | | Header file from config (highest priority) |
259+
| constEnum | boolean | No | true | Generate <Green>const enum</Green> instead of regular enum |
260+
261+
### WriteJsBindingOptions
262+
263+
| Option | Type | Required | Default | Description |
264+
|--------|------|----------|---------|-------------|
265+
| platform | boolean | No | false | Required to generate JS binding; adds platform triple |
266+
| noJsBinding | boolean | No | false | Skip JS binding generation |
267+
| idents | string[] | Yes | | Exported identifiers from <Green>generateTypeDef</Green> |
268+
| jsBinding | string | No | 'index.js' | Custom filename for the JS binding |
269+
| esm | boolean | No | false | Generate ESM format instead of CommonJS |
270+
| binaryName | string | Yes | | Name of the native binary |
271+
| packageName | string | Yes | | Package name for require/import statements |
272+
| version | string | Yes | | Package version |
273+
| outputDir | string | Yes | | Directory to write the JS binding file |
274+
275+
## Other Exported APIs
276+
277+
### NapiCli Class
278+
279+
The main class for programmatic access to all CLI commands:
280+
281+
```ts
282+
import { NapiCli } from '@napi-rs/cli'
283+
284+
const cli = new NapiCli()
285+
286+
// Available methods:
287+
cli.build(options) // Build the project
288+
cli.artifacts(options) // Collect artifacts from CI
289+
cli.new(options) // Create new project
290+
cli.createNpmDirs(options)// Create npm package directories
291+
cli.prePublish(options) // Prepare for publishing
292+
cli.rename(options) // Rename project
293+
cli.universalize(options) // Create universal binaries
294+
cli.version(options) // Update versions
295+
```
296+
297+
### Command Creators
298+
299+
Parse CLI arguments into command option objects:
300+
301+
```ts
302+
import {
303+
createBuildCommand,
304+
createArtifactsCommand,
305+
createCreateNpmDirsCommand,
306+
createPrePublishCommand,
307+
createRenameCommand,
308+
createUniversalizeCommand,
309+
createVersionCommand,
310+
createNewCommand,
311+
} from '@napi-rs/cli'
312+
313+
// Parse arguments as if running `napi build --release --platform`
314+
const buildCmd = createBuildCommand(['--release', '--platform'])
315+
const options = buildCmd.getOptions()
316+
```
317+
318+
### Utility Functions
319+
320+
```ts
321+
import { parseTriple, readNapiConfig } from '@napi-rs/cli'
322+
323+
// Parse target triple string
324+
const triple = parseTriple('x86_64-unknown-linux-gnu')
325+
// { platform: 'linux', arch: 'x64', abi: 'gnu', ... }
326+
327+
// Read napi config from package.json or napi.json
328+
const config = await readNapiConfig('/path/to/project')
329+
```
330+
331+
## Aborting a Build
332+
333+
The `build()` method returns an `abort` function to cancel the build:
334+
335+
```ts
336+
const { task, abort } = await cli.build(options)
337+
338+
// Handle SIGINT to abort cleanly
339+
process.on('SIGINT', () => {
340+
abort()
341+
process.exit(1)
342+
})
343+
344+
const outputs = await task
345+
```

0 commit comments

Comments
 (0)