react-nitro-ffigen 0.0.0-stage → 0.0.1-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Margelo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,237 @@
1
- # Temporary Holding Version
1
+ # Static Hermes FFI generator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```mermaid
4
+ flowchart TD
5
+ A["Yoga C headers"] -->|ffigen| B["native.ts — FFI wrappers"]
6
+ B -->|"shermes -emit-c"| C["yoga.c — wrappers translated into C"]
7
+ C -->|"C compiler"| D["Compiled bridge"]
8
+
9
+ E["Yoga C++ implementation"] -->|"C++ compiler"| F["Compiled Yoga"]
10
+
11
+ D --> G["Link into the app with the Hermes runtime"]
12
+ F --> G
13
+ ```
14
+
15
+ A small Bun/TypeScript generator for selected functions in public C headers.
16
+ Clang parses the header with the library's include paths, defines, and target
17
+ flags. The generator emits TypeScript native bindings and public declarations.
18
+ No Python, CastXML, or runtime FFI dependency is needed by the generator.
19
+
20
+ V1 supports C scalar values, typedefs, named enums whose constants fit `int32_t`,
21
+ and opaque pointers to structs/unions. Struct values, buffers, callbacks,
22
+ variadics, and other unsupported selections fail generation with the function
23
+ and type that need support. Only explicitly selected functions are generated.
24
+
25
+ ## Generate bindings
26
+
27
+ Call `generateBindings` from Bun. Paths in `clangArgs` are relative to the working
28
+ directory passed as its second argument:
29
+
30
+ ```ts
31
+ import { generateBindings } from "react-nitro-ffigen";
32
+ import type { BindingOptions } from "react-nitro-ffigen";
33
+
34
+ const options = {
35
+ name: "layout",
36
+ header: "yoga/Yoga.h",
37
+ clangArgs: ["-I", "/absolute/path/to/yoga"],
38
+ functions: [
39
+ "YGNodeNew",
40
+ "YGNodeStyleSetWidth",
41
+ "YGNodeLayoutGetWidth",
42
+ "YGNodeFreeRecursive",
43
+ ],
44
+ } satisfies BindingOptions;
45
+
46
+ const files = await generateBindings(options, import.meta.dir);
47
+ ```
48
+
49
+ The returned file map contains:
50
+
51
+ | File | Purpose |
52
+ | -------------------------- | -------------------------------------------------------------- |
53
+ | `native.ts` | Compile with Static Hermes; installs `globalThis.layout` |
54
+ | `native.d.ts` | Target-specific global declaration for checking `native.ts` |
55
+ | `api.d.ts` | Exported application types, including distinct pointer types |
56
+ | `ffigen_layout_adapters.h` | C adapters that normalize enum parameters/results to `int32_t` |
57
+
58
+ Shared Hermes declarations (`c_ptr`, the C scalar types, and `$SHBuiltin`) live
59
+ once in `react-nitro-ffigen/hermes`. Generated `native.d.ts` files import those
60
+ types automatically. The project containing generated bindings must have
61
+ `react-nitro-ffigen` installed; targets do not copy or maintain Hermes types.
62
+
63
+ Scalar and pointer calls use the original C prototypes through their public
64
+ header. Enum adapters let the C compiler handle the actual enum ABI, including
65
+ short enums, without assuming that all enums are C `int`. Constants come from
66
+ Clang's evaluated AST, including declarations produced by macros. Typedef chains
67
+ are resolved; mutable pointers can be used as const pointers, but not vice versa.
68
+ Different struct/union handles are incompatible in the public TypeScript API.
69
+ Boolean wrappers convert between JS booleans and Hermes' numeric `c_bool`.
70
+
71
+ Generation completes before any output files are written. A missing function,
72
+ unsupported selected type, or Clang failure does not silently omit a binding.
73
+ `generateBindings(options, cwd)` returns the
74
+ four files as a `Record<string, string>` without writing them. The required `cwd`
75
+ sets Clang's working directory for relative paths; the generator leaves the
76
+ parent process's working directory unchanged.
77
+
78
+ Import public types from the generated `api.d.ts` module:
79
+
80
+ ```ts
81
+ import type { YGNodeRef } from "./generated/api";
82
+
83
+ const node: YGNodeRef = globalThis.layout.YGNodeNew();
84
+ globalThis.layout.YGNodeFreeRecursive(node);
85
+ ```
86
+
87
+ The type import also supplies the declaration for the runtime global. It does not
88
+ load the compiled library; native initialization must evaluate the Hermes unit
89
+ before application code runs. Public C types are module exports and require no
90
+ global type namespace or triple-slash reference.
91
+
92
+ ## Native C adapters
93
+
94
+ An adapter is a generated C wrapper that exposes a signature Hermes can call
95
+ and invokes the original library function using its declared C types. Adapter
96
+ implementations live in `src/adapters/`; they use the resolved binding descriptions
97
+ in `src/binding-types.ts`, while `src/clang-ast-types.ts` describes Clang's input AST.
98
+
99
+ The [enum adapter](src/adapters/enum.ts) handles functions with enum parameters
100
+ or return values. Hermes can represent the numeric values, but mapping every C
101
+ enum directly to `c_int` would assume its underlying integer type. Compiler flags
102
+ such as `-fshort-enums` can change that type. Instead, the generated wrapper uses
103
+ `int32_t` at the Hermes boundary (`c_i32` in the binding) and casts to/from the
104
+ original enum. For example:
105
+
106
+ ```c
107
+ static inline void ffigen_yoga_YGNodeStyleSetFlexDirection(
108
+ YGNodeRef node, int32_t direction) {
109
+ YGNodeStyleSetFlexDirection(node, (YGFlexDirection)direction);
110
+ }
111
+ ```
112
+
113
+ The C compiler handles the enum's actual calling convention. Compile the adapter
114
+ with the same target and enum-related flags as the library. Generation rejects
115
+ enum constants outside the signed 32-bit range so the adapter can preserve them.
116
+
117
+ Future APIs may need other adapters, such as for structs passed or returned by
118
+ value. Those require a concrete design for how JS supplies or reads the fields
119
+ and how native storage is managed. Struct values currently remain unsupported;
120
+ opaque struct pointers already work. Add a separate adapter when an API requires
121
+ it, using the enum module as the first concrete implementation.
122
+
123
+ ## Compile and consume
124
+
125
+ Check the native source separately from the application declarations:
126
+
127
+ ```sh
128
+ bun packages/react-nitro-ffigen/src/tsc.ts generated/native.ts
129
+ ```
130
+
131
+ The check extends `config/tsconfig.json`, selects the supplied files, and disables
132
+ emission and composite builds. The build pipeline and generator tests use this
133
+ same check.
134
+
135
+ Compile it using the pinned Hermes compiler:
136
+
137
+ ```sh
138
+ packages/react-nitro-hermes/.build/hermes-cmake/bin/shermes \
139
+ -typed -parse-flow -O -emit-c -exported-unit=layout \
140
+ -o generated/layout.c generated/native.ts
141
+ ```
142
+
143
+ Compile that C with the generated directory, library headers, and matching Hermes
144
+ headers on the include path. Link it with the native library and Hermes, then
145
+ load it with `evaluateSHUnit(sh_export_layout)`, as the Yoga integration does.
146
+
147
+ Import public types from `api.d.ts` in application code. The loaded unit supplies
148
+ the runtime object:
149
+
150
+ ```ts
151
+ import type { YGNodeRef } from "./generated/api";
152
+ const node: YGNodeRef = globalThis.layout.YGNodeNew();
153
+ globalThis.layout.YGNodeStyleSetWidth(node, 50);
154
+ globalThis.layout.YGNodeFreeRecursive(node);
155
+ ```
156
+
157
+ Do not include `native.ts` or `native.d.ts` in that app project, or send the native
158
+ source through a bundler that erases its annotations. The generated source uses
159
+ the shared TypeScript/Flow syntax supported by the pinned compiler. Ambient
160
+ declarations are separate because `shermes` does not execute them.
161
+
162
+ Use the same library version, target, SDK, and preprocessor flags for generation
163
+ and native compilation. Generate separately for different target ABIs; typedefs
164
+ such as `size_t` are resolved for the target passed to Clang. `clang` can be
165
+ overridden in the config.
166
+
167
+ This is a low-level API: ownership and freeing remain explicit. Handles are opaque
168
+ native values, not JS objects with fields or numeric addresses to manipulate.
169
+ No nullability, error-code, lifetime, or garbage-collection policy is inferred.
170
+ Integer values use JS numbers and Hermes' conversions, so full-range 64-bit
171
+ integers are not supported. Enums accept their declared values; arbitrary bitmask
172
+ combinations need a future API-specific policy. No struct layout offsets or
173
+ general macro constants are generated.
174
+
175
+ ## Yoga package (macOS)
176
+
177
+ The shared builder in `src/build.ts` generates and checks bindings, builds a
178
+ native CMake target, and emits an exported Static Hermes C unit. A target's
179
+ `ffi.config.ts` uses the `TargetConfig` type from `react-nitro-ffigen/build` and
180
+ extends the binding config with its native build settings:
181
+
182
+ ```ts
183
+ native: {
184
+ sourceDir: 'yoga/yoga',
185
+ target: 'yogacore',
186
+ },
187
+ ```
188
+
189
+ Paths are relative to the config directory. Invoke the builder with any config:
190
+
191
+ ```sh
192
+ bun packages/react-nitro-ffigen/src/build.ts packages/react-nitro-yoga/ffi.config.ts
193
+ ```
194
+
195
+ The [React Nitro Yoga package](../react-nitro-yoga/README.md) owns its pinned
196
+ `yoga` submodule, `ffi.config.ts`, Swift package, and generated bindings. New
197
+ libraries can use this generator from their own packages.
198
+
199
+ Initialize the submodules and build from the repository root:
200
+
201
+ ```sh
202
+ git submodule update --init --recursive
203
+ bun install
204
+ ```
205
+
206
+ The root postinstall runs workspace builds in dependency order, building the
207
+ pinned compiler in `react-nitro-hermes` before generating and compiling Yoga
208
+ in `react-nitro-yoga`. To rebuild only Yoga, run
209
+ `bun run --cwd packages/react-nitro-yoga build`. Native builds are incremental;
210
+ standalone target builds require an existing compiler.
211
+ Yoga outputs are under `packages/react-nitro-yoga/Generated/`, with the host
212
+ native library at `packages/react-nitro-yoga/.build/yoga/libyogacore.a`.
213
+
214
+ Import Yoga's public types from `react-nitro-yoga`:
215
+
216
+ ```ts
217
+ import type { YGNodeRef } from "react-nitro-yoga";
218
+
219
+ const node: YGNodeRef = globalThis.yoga.YGNodeNew();
220
+ globalThis.yoga.YGNodeFreeRecursive(node);
221
+ ```
222
+
223
+ Declare `react-nitro-yoga` as a dependency of consumers. Its Swift package
224
+ exposes native `Yoga` separately from `ReactNitroYoga`, which initializes the
225
+ compiled unit in each runtime. The renderer consumes native Yoga and the public
226
+ types; runtime creation calls the initializer in `react-nitro-yoga`.
227
+
228
+ ## Checks
229
+
230
+ ```sh
231
+ bun run --cwd packages/react-nitro-ffigen typecheck
232
+ bun run --cwd packages/react-nitro-ffigen test
233
+ ```
234
+
235
+ Tests check public pointer/const/enum types, compile multiple targets using the
236
+ shared Hermes declarations, execute C enum adapters under `-fshort-enums`, and
237
+ verify that unsupported APIs and Clang errors are reported.
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "react-nitro-ffigen",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1-alpha.7",
4
+ "dependencies": {
5
+ "react-nitro-hermes": "0.0.1-alpha.7",
6
+ "typescript": "7.0.2"
7
+ },
8
+ "publishConfig": {
9
+ "tag": "latest",
10
+ "access": "public"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/generate.ts",
15
+ "./hermes": {
16
+ "types": "./src/hermes.d.ts"
17
+ },
18
+ "./build": "./src/build.ts",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/margelo/react-nitro.git",
25
+ "directory": "packages/react-nitro-ffigen"
26
+ },
27
+ "files": [
28
+ "src"
29
+ ]
30
+ }
@@ -0,0 +1,48 @@
1
+ import type { FunctionBinding } from '../binding-types';
2
+
3
+ interface EnumAdapter {
4
+ symbol: string;
5
+ lines: string[];
6
+ }
7
+
8
+ /**
9
+ * Generate a C wrapper when a function takes or returns an enum.
10
+ *
11
+ * Hermes represents our enum values as c_i32, but a C enum's underlying integer
12
+ * type depends on the target and compiler flags (for example, -fshort-enums).
13
+ * The wrapper exposes int32_t to Hermes and casts to/from the declared C types.
14
+ * Compiling it with the library's headers and flags lets the C compiler handle
15
+ * the actual enum ABI. Type resolution rejects constants outside int32_t range.
16
+ * Functions without enum parameters or results need no enum adapter.
17
+ */
18
+ export function generateEnumAdapter(
19
+ binding: FunctionBinding,
20
+ targetName: string,
21
+ ): EnumAdapter | undefined {
22
+ const hasEnumParameter = binding.parameters.some(parameter => parameter.type.kind === 'enum');
23
+ const needsAdapter = binding.result.kind === 'enum' || hasEnumParameter;
24
+ if (needsAdapter === false) return undefined;
25
+
26
+ const symbol = `ffigen_${targetName}_${binding.name}`;
27
+ const cParameters: string[] = [];
28
+ const cArguments: string[] = [];
29
+ for (const { name, type } of binding.parameters) {
30
+ const cType = type.kind === 'enum' ? 'int32_t' : type.c;
31
+ cParameters.push(`${cType} ${name}`);
32
+ cArguments.push(`(${type.c})${name}`);
33
+ }
34
+
35
+ const { result } = binding;
36
+ const cResult = result.kind === 'enum' ? 'int32_t' : result.c;
37
+ const cParams = cParameters.length === 0 ? 'void' : cParameters.join(', ');
38
+ const cArgs = cArguments.join(', ');
39
+ const cCall = `${binding.name}(${cArgs})`;
40
+ const statement = result.kind === 'void' ? `${cCall};` : `return (${cResult})${cCall};`;
41
+ const lines = [
42
+ `static inline ${cResult} ${symbol}(${cParams}) {`,
43
+ ` ${statement}`,
44
+ '}',
45
+ '',
46
+ ];
47
+ return { symbol, lines };
48
+ }
@@ -0,0 +1,18 @@
1
+ /** Resolved binding descriptions shared by the generator and C adapters. */
2
+ export interface Type {
3
+ kind: 'scalar' | 'boolean' | 'pointer' | 'enum' | 'void';
4
+ native: string;
5
+ public: string;
6
+ c: string;
7
+ }
8
+
9
+ export interface Parameter {
10
+ name: string;
11
+ type: Type;
12
+ }
13
+
14
+ export interface FunctionBinding {
15
+ name: string;
16
+ parameters: Parameter[];
17
+ result: Type;
18
+ }
package/src/build.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { hermesBuildDirectory as hermesBuild, shermesPath as compiler } from 'react-nitro-hermes';
5
+ import { generateBindings } from './generate';
6
+ import type { BindingConfig } from './generate';
7
+ import { tscPath as tsc, typecheck } from './tsc';
8
+
9
+ /**
10
+ * This build script takes a target's ffigen configuration and builds the corresponding native library and TypeScript bindings.
11
+ * It's the build step for "turning a native library expsed by a C header into TypeScript bindings, using shermes ffi".
12
+ */
13
+
14
+ export interface TargetConfig extends BindingConfig {
15
+ native: {
16
+ /** CMake source directory, relative to the config file. */
17
+ sourceDir: string;
18
+ /** CMake target that builds the native library. */
19
+ target: string;
20
+ };
21
+ }
22
+
23
+ export async function run(command: string[], cwd?: string): Promise<void> {
24
+ const child = Bun.spawn(command, { cwd, stdout: 'inherit', stderr: 'inherit' });
25
+ const code = await child.exited;
26
+ if (code !== 0) throw new Error(`${command[0]} exited with status ${code}`);
27
+ }
28
+
29
+ export async function buildTarget(configPath: string) {
30
+ // Prepare all paths:
31
+ const path = resolve(configPath);
32
+ const url = pathToFileURL(path);
33
+ const module = await import(url.href);
34
+ const config: TargetConfig = module.default;
35
+ const targetDirectory = dirname(path);
36
+ const nativeSource = resolve(targetDirectory, config.native.sourceDir);
37
+ const cmakeFile = join(nativeSource, 'CMakeLists.txt');
38
+ try {
39
+ await access(cmakeFile);
40
+ } catch {
41
+ throw new Error(`Cannot read ${cmakeFile}. Initialize target submodules with git submodule update --init --recursive`);
42
+ }
43
+ try {
44
+ await access(compiler);
45
+ } catch {
46
+ throw new Error(`Shermes compiler not found at ${compiler}. Run bun run --cwd packages/react-nitro-hermes build from the repository root`);
47
+ }
48
+ const directory = resolve(targetDirectory, '.build');
49
+ const generated = resolve(targetDirectory, config.outDir);
50
+ const nativeBuild = join(directory, config.name);
51
+
52
+ // Step 1: compile the actual native library (for example yoga):
53
+ // Configure a Release build of the target's native library using Ninja.
54
+ await run(['cmake', '-S', nativeSource, '-B', nativeBuild, '-G', 'Ninja', '-DCMAKE_BUILD_TYPE=Release']);
55
+ // Compile the configured native library
56
+ await run(['cmake', '--build', nativeBuild, '--target', config.native.target, '--parallel', '8']);
57
+
58
+ // Step 2: Generate the TypeScript bindings from the C header:
59
+ const files = await generateBindings(config, targetDirectory);
60
+ await mkdir(generated, { recursive: true });
61
+ const entries = Object.entries(files);
62
+ for (const [name, contents] of entries) {
63
+ const path = join(generated, name);
64
+ await writeFile(path, contents);
65
+ }
66
+ const nativePath = join(generated, 'native.ts');
67
+ // Type-check the generated FFI wrappers without emitting JavaScript.
68
+ await typecheck([nativePath]);
69
+
70
+ // Step 3: Turn typescript bindings into shermes C code:
71
+ const nativeC_OutputPath = join(generated, `${config.name}.c`);
72
+ const relativeNativeC = relative(targetDirectory, nativeC_OutputPath);
73
+ const relativeNativePath = relative(targetDirectory, nativePath);
74
+ // Emit the optimized FFI wrappers as an exported C unit for native compilation.
75
+ await run([
76
+ compiler, '-typed', '-parse-flow', '-O', '-g0', '-emit-c', `-exported-unit=${config.name}`,
77
+ '-o', relativeNativeC, relativeNativePath,
78
+ ], targetDirectory);
79
+ console.log(`${config.name} target built in ${directory}`);
80
+
81
+ // The platform build pipeline will integrate the shermes c code into the final build
82
+ return { compiler, directory, generated, hermesBuild, nativePath, tsc, nativeBuild };
83
+ }
84
+
85
+ if (import.meta.main) {
86
+ const argument = process.argv[2];
87
+ if (argument === undefined || process.argv.length !== 3) {
88
+ console.error('Usage: bun packages/react-nitro-ffigen/src/build.ts path/to/ffi.config.ts');
89
+ process.exit(1);
90
+ }
91
+ try {
92
+ await buildTarget(argument);
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ console.error(message);
96
+ process.exitCode = 1;
97
+ }
98
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The subset of Clang's `-ast-dump=json` output consumed by the generator.
3
+ * These types describe the parsed C declarations.
4
+ */
5
+ export interface AstType {
6
+ qualType: string;
7
+ }
8
+
9
+ export interface AstNode {
10
+ kind: string;
11
+ name?: string;
12
+ type?: AstType;
13
+ inner?: AstNode[];
14
+ value?: string;
15
+ variadic?: boolean;
16
+ }
@@ -0,0 +1,346 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { generateEnumAdapter } from './adapters/enum';
5
+ import type { FunctionBinding, Parameter, Type } from './binding-types';
6
+ import type { AstNode } from './clang-ast-types';
7
+
8
+ /**
9
+ * The main generator that turns a c header into TypeScript bindings.
10
+ */
11
+
12
+ export interface BindingOptions {
13
+ /** C include spelling, for example yoga/Yoga.h. */
14
+ header: string;
15
+ /** Global object installed by the compiled Hermes unit. */
16
+ name: string;
17
+ /** Explicit public function allowlist. Unsupported selections are errors. */
18
+ functions: string[];
19
+ /** Include paths, defines, target and SDK flags for the actual library build. */
20
+ clangArgs?: string[];
21
+ clang?: string;
22
+ }
23
+
24
+ export interface BindingConfig extends BindingOptions {
25
+ outDir: string;
26
+ }
27
+
28
+ // Map of c-scalar types to shermes ffi type names
29
+ const nativeTypes = new Map([
30
+ ['char', 'c_char'],
31
+ ['signed char', 'c_schar'],
32
+ ['unsigned char', 'c_uchar'],
33
+ ['short', 'c_short'],
34
+ ['unsigned short', 'c_ushort'],
35
+ ['int', 'c_int'],
36
+ ['unsigned int', 'c_uint'],
37
+ ['long', 'c_long'],
38
+ ['unsigned long', 'c_ulong'],
39
+ ['long long', 'c_longlong'],
40
+ ['unsigned long long', 'c_ulonglong'],
41
+ ['float', 'c_float'],
42
+ ['double', 'c_double'],
43
+ ]);
44
+
45
+ function identifier(value: string): void {
46
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) return;
47
+ throw new Error(`Invalid identifier: ${value}`);
48
+ }
49
+
50
+ function constantValue(node: AstNode): string | undefined {
51
+ if (node.kind === 'ConstantExpr' && node.value !== undefined) {
52
+ return node.value;
53
+ }
54
+ for (const child of node.inner ?? []) {
55
+ const value = constantValue(child);
56
+ if (value !== undefined) return value;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function wrapperType(type: Type): string {
62
+ switch (type.kind) {
63
+ case 'pointer': return 'c_ptr';
64
+ case 'boolean': return 'boolean';
65
+ case 'void': return 'void';
66
+ default: return 'number';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * To parse a library's C header we use clang, which gives us a structured JSON representation.
72
+ */
73
+ async function parseHeader(options: BindingOptions, cwd: string): Promise<AstNode> {
74
+ if (/^[A-Za-z0-9_./+-]+$/.test(options.header) === false) {
75
+ throw new Error('header must be a C include path without quotes or newlines');
76
+ }
77
+ const temporaryRoot = tmpdir();
78
+ const prefix = join(temporaryRoot, 'shermes-ffigen-');
79
+ const directory = await mkdtemp(prefix);
80
+ try {
81
+ // We create a temporary c file that include's the library's header
82
+ const input = join(directory, 'input.c');
83
+ await writeFile(input, `#include <${options.header}>\n`);
84
+ // We use clang to get an AST dump of the library's header file
85
+ const command = [
86
+ options.clang ?? 'clang', '-x', 'c', '-std=c11',
87
+ ...options.clangArgs ?? [],
88
+ '-Xclang', '-ast-dump=json', '-fsyntax-only', input,
89
+ ];
90
+ const process = Bun.spawn(command, { cwd, stdout: 'pipe', stderr: 'pipe' });
91
+ const output = new Response(process.stdout);
92
+ const errors = new Response(process.stderr);
93
+ const [stdout, stderr, exitCode] = await Promise.all([
94
+ output.text(), errors.text(), process.exited,
95
+ ]);
96
+ if (exitCode !== 0) {
97
+ throw new Error(`Clang could not parse ${options.header}:\n${stderr}`);
98
+ }
99
+ return JSON.parse(stdout) as AstNode;
100
+ } finally {
101
+ await rm(directory, { recursive: true, force: true });
102
+ }
103
+ }
104
+
105
+ export async function generateBindings(
106
+ options: BindingOptions,
107
+ cwd: string,
108
+ ): Promise<Record<string, string>> {
109
+ identifier(options.name);
110
+ if (options.functions.length === 0) {
111
+ throw new Error('Select at least one function');
112
+ }
113
+ const selected = new Set(options.functions);
114
+ if (selected.size !== options.functions.length) {
115
+ throw new Error('The function allowlist contains duplicates');
116
+ }
117
+ const ast = await parseHeader(options, cwd);
118
+ const declarations = new Map<string, AstNode>();
119
+ const enums = new Map<string, AstNode>();
120
+ const typedefs = new Map<string, AstNode>();
121
+ for (const node of ast.inner ?? []) {
122
+ if (node.name === undefined) continue;
123
+ if (node.kind === 'FunctionDecl') declarations.set(node.name, node);
124
+ if (node.kind === 'TypedefDecl') typedefs.set(node.name, node);
125
+ if (node.kind === 'EnumDecl') enums.set(node.name, node);
126
+ }
127
+
128
+ const publicTypes = new Map<string, string>();
129
+ const constants = new Map<string, string>();
130
+
131
+ function resolveType(spelling: string, visiting = new Set<string>()): Type {
132
+ const trimmed = spelling.trim();
133
+ // Top-level const has no effect for scalar values passed by value.
134
+ const name = trimmed.endsWith('*') ? trimmed : trimmed.replace(/^const /, '');
135
+ const native = nativeTypes.get(name);
136
+ if (native !== undefined) {
137
+ return { kind: 'scalar', native, public: 'number', c: spelling };
138
+ }
139
+ if (name === '_Bool' || name === 'bool') {
140
+ return { kind: 'boolean', native: 'c_bool', public: 'boolean', c: spelling };
141
+ }
142
+ if (name === 'void') {
143
+ return { kind: 'void', native: 'void', public: 'void', c: spelling };
144
+ }
145
+ const typedef = typedefs.get(name);
146
+ if (typedef?.type !== undefined) {
147
+ if (visiting.has(name)) throw new Error(`Recursive typedef: ${name}`);
148
+ visiting.add(name);
149
+ const resolved = resolveType(typedef.type.qualType, visiting);
150
+ if (resolved.public !== name) publicTypes.set(name, resolved.public);
151
+ return { ...resolved, public: name, c: spelling };
152
+ }
153
+ const pointer = /^(const )?(struct|union) ([A-Za-z_][A-Za-z0-9_]*) \*$/.exec(name);
154
+ if (pointer !== null) {
155
+ const record = `${pointer[2]} ${pointer[3]}`;
156
+ const constant = pointer[1] === undefined ? 'false' : 'boolean';
157
+ const publicType = `Pointer<"${record}", ${constant}>`;
158
+ return { kind: 'pointer', native: 'c_ptr', public: publicType, c: spelling };
159
+ }
160
+ if (name.startsWith('enum ')) {
161
+ const enumName = name.slice(5);
162
+ const enumeration = enums.get(enumName);
163
+ if (enumeration === undefined) throw new Error(`Incomplete enum: ${name}`);
164
+ const values: string[] = [];
165
+ let next = 0n;
166
+ for (const member of enumeration.inner ?? []) {
167
+ if (member.kind !== 'EnumConstantDecl' || member.name === undefined) continue;
168
+ const explicit = constantValue(member);
169
+ const value = explicit === undefined ? next : BigInt(explicit);
170
+ // The enum adapter exposes int32_t to Hermes; every constant must fit.
171
+ if (value < -2147483648n || value > 2147483647n) {
172
+ throw new Error(`Enum ${enumName} exceeds the supported signed 32-bit range`);
173
+ }
174
+ const text = value.toString();
175
+ constants.set(member.name, text);
176
+ values.push(text);
177
+ next = value + 1n;
178
+ }
179
+ if (values.length === 0) throw new Error(`Empty enum: ${enumName}`);
180
+ const unique = new Set(values);
181
+ const literals = [...unique];
182
+ const publicType = literals.join(' | ');
183
+ publicTypes.set(enumName, publicType);
184
+ return { kind: 'enum', native: 'c_i32', public: enumName, c: spelling };
185
+ }
186
+ throw new Error(`Unsupported C type "${spelling}"; v1 supports scalars, enums and opaque struct/union pointers`);
187
+ }
188
+
189
+ const functions: FunctionBinding[] = [];
190
+ for (const name of options.functions) {
191
+ identifier(name);
192
+ const declaration = declarations.get(name);
193
+ if (declaration?.type === undefined) throw new Error(`Function not found: ${name}`);
194
+ const signature = declaration.type.qualType;
195
+ if (declaration.variadic || signature.includes('...')) {
196
+ throw new Error(`${name}: variadic functions are not supported`);
197
+ }
198
+ if (signature.endsWith('()')) {
199
+ throw new Error(`${name}: a full C function prototype is required`);
200
+ }
201
+ if (signature.includes('__attribute__')) {
202
+ throw new Error(`${name}: attributed calling conventions are not supported`);
203
+ }
204
+ const opening = signature.indexOf('(');
205
+ if (opening < 0 || signature.includes('(*')) {
206
+ throw new Error(`${name}: unsupported function signature ${signature}`);
207
+ }
208
+ const resultSpelling = signature.slice(0, opening);
209
+ try {
210
+ const result = resolveType(resultSpelling);
211
+ const parameters: Parameter[] = [];
212
+ for (const parameter of declaration.inner ?? []) {
213
+ if (parameter.kind !== 'ParmVarDecl' || parameter.type === undefined) continue;
214
+ const parameterName = parameter.name ?? `arg${parameters.length}`;
215
+ const type = resolveType(parameter.type.qualType);
216
+ parameters.push({ name: `_${parameterName}`, type });
217
+ }
218
+ functions.push({ name, parameters, result });
219
+ } catch (error) {
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ throw new Error(`${name}: ${message}`);
222
+ }
223
+ }
224
+
225
+ const source: string[] = [
226
+ '// Generated by react-nitro-ffigen. Do not edit.',
227
+ '/// <reference path="./native.d.ts" />',
228
+ '"use strict";',
229
+ '',
230
+ ];
231
+ const nativeApi: string[] = [];
232
+ const publicApi: string[] = [];
233
+ const adapterName = `ffigen_${options.name}_adapters.h`;
234
+ const adapters = [
235
+ '// Generated by react-nitro-ffigen. Do not edit.',
236
+ '#pragma once',
237
+ '#include <stdint.h>',
238
+ `#include <${options.header}>`,
239
+ '',
240
+ ];
241
+ const body = ['(function () {'];
242
+ const exported: string[] = [];
243
+
244
+ for (const binding of functions) {
245
+ const adapter = generateEnumAdapter(binding, options.name);
246
+ let symbol = binding.name;
247
+ let include = options.header;
248
+ if (adapter !== undefined) {
249
+ symbol = adapter.symbol;
250
+ include = adapterName;
251
+ adapters.push(...adapter.lines);
252
+ }
253
+ const index = exported.length;
254
+ const nativeParameters: string[] = [];
255
+ const wrapperParameters: string[] = [];
256
+ const publicParameters: string[] = [];
257
+ const argumentsList: string[] = [];
258
+ const conversions: string[] = [];
259
+ for (const parameter of binding.parameters) {
260
+ const { name, type } = parameter;
261
+ nativeParameters.push(`${name}: ${type.native}`);
262
+ const parameterType = wrapperType(type);
263
+ wrapperParameters.push(`${name}: ${parameterType}`);
264
+ publicParameters.push(`${name}: ${type.public}`);
265
+ if (type.kind === 'boolean') {
266
+ const converted = `nativeArg${argumentsList.length}`;
267
+ conversions.push(` const ${converted} = ${name} ? 1 : 0;`);
268
+ argumentsList.push(converted);
269
+ } else {
270
+ argumentsList.push(name);
271
+ }
272
+ }
273
+ const nativeParams = nativeParameters.join(', ');
274
+ const wrapperParams = wrapperParameters.join(', ');
275
+ const publicParams = publicParameters.join(', ');
276
+ const argumentsText = argumentsList.join(', ');
277
+ const { result } = binding;
278
+ const wrapperResult = wrapperType(result);
279
+ nativeApi.push(` ${binding.name}: (${wrapperParams}) => ${wrapperResult};`);
280
+ publicApi.push(` ${binding.name}(${publicParams}): ${result.public};`);
281
+ body.push(
282
+ ` const native_${index} = $SHBuiltin.extern_c(`,
283
+ ` { include: "${include}" },`,
284
+ ` function ${symbol}(${nativeParams}): ${result.native} { throw 0; },`,
285
+ ' );',
286
+ ` function call_${index}(${wrapperParams}): ${wrapperResult} {`,
287
+ ...conversions,
288
+ );
289
+ const call = `native_${index}(${argumentsText})`;
290
+ if (result.kind === 'boolean') {
291
+ body.push(` const result = ${call};`, ' return result !== 0;');
292
+ } else if (result.kind === 'void') {
293
+ body.push(` ${call};`);
294
+ } else {
295
+ body.push(` return ${call};`);
296
+ }
297
+ body.push(' }', '');
298
+ exported.push(`${binding.name}: call_${index}`);
299
+ }
300
+ for (const [name, value] of constants) {
301
+ if (selected.has(name)) throw new Error(`Enum constant conflicts with function: ${name}`);
302
+ nativeApi.push(` ${name}: number;`);
303
+ publicApi.push(` readonly ${name}: ${value};`);
304
+ exported.push(`${name}: ${value}`);
305
+ }
306
+ const exportList = exported.join(', ');
307
+ source.push(...body);
308
+ source.push(` globalThis.${options.name} = { ${exportList} };`, '})();', '');
309
+ const types = [
310
+ '// Generated public types. Import in the app, not the native compilation.',
311
+ 'declare const pointer: unique symbol;',
312
+ 'type Pointer<Record extends string, Const extends boolean> = {',
313
+ ' readonly [pointer]: readonly [Record, Const];',
314
+ '};',
315
+ '',
316
+ ];
317
+ for (const [name, type] of publicTypes) types.push(`export type ${name} = ${type};`);
318
+ types.push(
319
+ '',
320
+ 'export interface API {',
321
+ ...publicApi,
322
+ '}',
323
+ '',
324
+ 'declare global {',
325
+ ` var ${options.name}: API;`,
326
+ '}',
327
+ '',
328
+ );
329
+ const nativeDeclaration = [
330
+ '// Generated native API types. Include only when checking native.ts.',
331
+ 'import type {} from "react-nitro-ffigen/hermes";',
332
+ '',
333
+ 'declare global {',
334
+ ` var ${options.name}: {`,
335
+ ...nativeApi,
336
+ ' };',
337
+ '}',
338
+ '',
339
+ ];
340
+ return {
341
+ 'native.ts': source.join('\n'),
342
+ 'api.d.ts': types.join('\n'),
343
+ 'native.d.ts': nativeDeclaration.join('\n'),
344
+ [adapterName]: adapters.join('\n'),
345
+ };
346
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * shermes only works properly with flow typed files, however, we want to consume the binding JS code
3
+ * as properly typed TypeScript definitions.
4
+ * This provides definitions currently missing in shermes.
5
+ */
6
+ export {};
7
+
8
+ declare global {
9
+ type c_char = number;
10
+ type c_schar = number;
11
+ type c_uchar = number;
12
+ type c_short = number;
13
+ type c_ushort = number;
14
+ type c_int = number;
15
+ type c_uint = number;
16
+ type c_long = number;
17
+ type c_ulong = number;
18
+ type c_longlong = number;
19
+ type c_ulonglong = number;
20
+ type c_float = number;
21
+ type c_double = number;
22
+ type c_bool = number;
23
+ type c_i32 = number;
24
+ const nativePointer: unique symbol;
25
+ type c_ptr = { readonly [nativePointer]: true };
26
+
27
+ const $SHBuiltin: {
28
+ extern_c<T extends (...args: any[]) => any>(
29
+ options: { include: string },
30
+ signature: T,
31
+ ): T;
32
+ };
33
+ }
package/src/tsc.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import typescript from 'typescript/package.json';
6
+
7
+ const packageURL = import.meta.resolve('typescript/package.json');
8
+ const executableURL = new URL(typescript.bin.tsc, packageURL);
9
+ export const tscPath = fileURLToPath(executableURL);
10
+
11
+ export async function typecheck(sources: string[]): Promise<void> {
12
+ const files: string[] = [];
13
+ for (const source of sources) {
14
+ const path = resolve(source);
15
+ files.push(path);
16
+ }
17
+ const baseConfig = resolve(import.meta.dir, '../../../config/tsconfig.json');
18
+ const root = tmpdir();
19
+ const prefix = join(root, 'ffigen-tsconfig-');
20
+ const directory = await mkdtemp(prefix);
21
+ try {
22
+ const config = {
23
+ extends: baseConfig,
24
+ compilerOptions: { composite: false, noEmit: true },
25
+ files,
26
+ include: [],
27
+ };
28
+ const contents = JSON.stringify(config, null, 2);
29
+ const project = join(directory, 'tsconfig.json');
30
+ await writeFile(project, contents);
31
+ const child = Bun.spawnSync([process.execPath, tscPath, '--project', project], {
32
+ stdout: 'pipe',
33
+ stderr: 'pipe',
34
+ });
35
+ if (child.exitCode !== 0) {
36
+ const output = child.stdout.toString();
37
+ const errors = child.stderr.toString();
38
+ throw new Error(`TypeScript checking failed:\n${output}${errors}`);
39
+ }
40
+ } finally {
41
+ await rm(directory, { recursive: true, force: true });
42
+ }
43
+ }
44
+
45
+ if (import.meta.main) {
46
+ const sources = process.argv.slice(2);
47
+ if (sources.length === 0) {
48
+ console.error('Usage: bun packages/react-nitro-ffigen/src/tsc.ts <source.ts> [...]');
49
+ process.exit(1);
50
+ }
51
+ await typecheck(sources);
52
+ }