2025-09-17 14:34:56 +02:00
# nim-ffi
2026-05-11 23:28:17 +02:00
2026-07-06 12:31:49 -03:00
Expose a Nim library to C, C++ and Rust by annotating ordinary Nim procs.
2026-05-11 23:28:17 +02:00
2026-07-06 12:31:49 -03:00
You write async Nim; `nim-ffi` provides the whole FFI runtime — a dedicated
worker thread, a request channel, CBOR (de)serialization, an event queue and a
context/handle registry — and generates the foreign-language bindings for you.
No hand-written `.h` files, no manual request enums, no shared-memory plumbing.
2026-07-10 16:27:23 -03:00
## Install
Add nim-ffi to your library's `.nimble` , then `import ffi` :
```nim
requires "https://github.com/logos-messaging/nim-ffi >= 0.2.0"
```
2026-07-06 12:31:49 -03:00
## Mental model
- You **declare a library** once with `declareLibrary(name, LibType)` .
- You **annotate procs and types** with pragmas (`{.ffi.}` , `{.ffiCtor.}` ,
`{.ffiDtor.}` , `{.ffiEvent.}` ).
- You **call `genBindings()` last** , which emits the foreign bindings.
By default, every request/response crosses the boundary as a single CBOR blob
(the wire format is configurable per library or per annotation — see
[ABI format ](#abi-format )); the ctx handle returned by the constructor is the
only pointer that crosses. Each `{.ffi.}` proc runs on the library's own chronos
event loop, so bodies can `await` freely.
## Minimal example
```nim
import ffi, chronos
# 1. The library's main state. The FFI context owns one instance.
type Counter = object
value: int
declareLibrary("counter", Counter)
# 2. Request/response shapes. Any {.ffi.} object type becomes a first-class
# struct/class in the generated bindings and rides the wire in the library's
# ABI format (CBOR by default).
type BumpRequest {.ffi.} = object
by: int
type BumpResponse {.ffi.} = object
newValue: int
# 3. Constructor: returns Future[Result[LibType, string]].
proc counterNew*(): Future[Result[Counter, string]] {.ffiCtor.} =
return ok(Counter(value: 0))
# 4. A method: first param is the library value, then any typed params.
# Return type is always Future[Result[T, string]].
proc counterBump*(
c: Counter, req: BumpRequest
): Future[Result[BumpResponse, string]] {.ffi.} =
await sleepAsync(1.milliseconds)
return ok(BumpResponse(newValue: c.value + req.by))
# 5. Destructor: exactly one param (the library value).
proc counterDestroy*(c: Counter) {.ffiDtor.} =
discard
# 6. genBindings() must be the LAST FFI call in the file (see below).
genBindings()
```
The generated C export names are the snake_case form of the proc names, e.g.
`counterBump` → `counter_bump` .
## Pragma reference
| Pragma | Applies to | Purpose |
| --- | --- | --- |
| `declareLibrary(name, LibType[, defaultABIFormat])` | call | Registers the library, its state type, and the default wire format. Must run before any annotation. |
| `{.ffi.}` on a `type` | `object` | Registers the type for binding generation; it serializes via the library's ABI format (CBOR by default). |
| `{.ffi.}` on a `proc` | proc | Exposes a method. First param is the library value, then typed params; returns `Future[Result[T, string]]` . |
| `{.ffiCtor.}` | proc | The constructor. Returns `Future[Result[LibType, string]]` ; creates the FFI context. |
| `{.ffiDtor.}` | proc | The destructor. Exactly one param `(x: LibType)` ; tears the context down. |
| `{.ffiEvent[: "wire_name"].}` | proc (empty body) | A library-initiated callback. Call the proc from any `{.ffi.}` handler to fire it. The wire name is optional — see below. |
| `{.ffiHandle.}` | `ref object` | Marks a type as an opaque handle: it stays server-side and crosses the wire as a `uint64` id. |
| `genBindings()` | call | Emits the bindings. Must be the **last** FFI call in the compilation root. |
### The return-type contract
Every `{.ffi.}` / `{.ffiCtor.}` proc must have an explicit
`Future[Result[T, string]]` return type — even for synchronous logic (just
`return ok(...)` without awaiting). The `Result` 's error string is delivered to
the foreign caller as the failure message.
2026-07-14 17:07:33 +02:00
### The result callback contract
Each request carries a result callback. It receives one of these status codes
(`ret` / `err_code` ):
| Code | Value | Terminal? | Meaning |
| --- | --- | --- | --- |
| `RET_OK` | 0 | yes | Success; the payload carries the encoded result. |
| `RET_ERR` | 1 | yes | Failure; the payload carries the UTF-8 error string. |
| `RET_MISSING_CALLBACK` | 2 | — | No callback was passed; the request path reports this itself. |
| `RET_STALE_WARN` | 3 | **no** | Progress ping — the handler is still running. |
**nim-ffi never times a handler out.** A slow request runs to its natural
`RET_OK` / `RET_ERR` ; it is never cancelled (a hard-cancel mid-call into the
underlying library can leave it half-applied). Instead, while a handler is still
in flight the callback receives a **non-terminal** `RET_STALE_WARN` every 5s
(Android's ANR interval; override at build time with
`-d:ffiStaleWarnIntervalMs=<ms>` ), with the payload carrying the elapsed
milliseconds as a decimal string. The dev decides what to do with a slow request
— keep waiting, surface a spinner, tear the context down — nim-ffi does not
decide for them.
`RET_STALE_WARN` may fire any number of times and is **always** followed by
exactly one terminal `RET_OK` / `RET_ERR` . A caller that only wants the final
answer must ignore it (do not treat a non-zero code as an error without checking
for `RET_STALE_WARN` first). The generated higher-level typed wrappers currently
ignore it; the progress signal is delivered at the raw result-callback boundary.
2026-07-10 16:27:23 -03:00
2026-07-06 12:31:49 -03:00
### Events
An event is a proc with an empty body annotated `{.ffiEvent.}` . You fire it by
calling it with a typed payload from inside any `{.ffi.}` handler; the foreign
side receives it through a registered callback.
```nim
type PeerConnected {.ffi.} = object
id: string
proc onPeerConnected*(peer: PeerConnected) {.ffiEvent.} # wire name: "on_peer_connected"
proc counterBump*(c: Counter, req: BumpRequest): Future[Result[BumpResponse, string]] {.ffi.} =
onPeerConnected(PeerConnected(id: "p-1"))
return ok(BumpResponse(newValue: c.value + req.by))
```
The wire name is **optional** : when omitted it is derived from the proc name
(`onPeerConnected` → `on_peer_connected` ), matching how `{.ffi.}` derives its C
export symbol. Pass a string literal (`{.ffiEvent: "custom_name".}` ) only when
you need a name that differs from the proc.
### ABI format
2026-07-10 16:27:23 -03:00
The wire format is chosen **in code** , never by a compile flag. Override the
library default with `declareLibrary("lib", Lib, defaultABIFormat = "c")` , or
per annotation with an `"abi = ..."` spec, e.g. `{.ffi: "abi = c".}` . The
`-d:targetLang` flag (below) picks which *language* the bindings are emitted
for; it does not change the wire.
`cbor` is the default and fully-supported format: every proc, ctor, dtor and
event serializes through the generic CBOR path, and all binding generators emit
working callers for it.
2026-07-15 09:54:19 -03:00
`abi = c` is a newer, native C-struct wire (no CBOR round-trip). The single `c`
generator (`-d:targetLang=c` ) emits its callers, choosing the `abi = c` or CBOR
header shape from the library's ABI format. It carries two honest limits today:
2026-07-10 16:27:23 -03:00
- **Events are CBOR-only.** Applying `abi = c` to an `{.ffiEvent.}` proc is a
hard compile error; declare events with `abi = cbor` (they ride CBOR
internally regardless of the library default).
2026-07-16 10:58:45 -03:00
- **All-scalar `abi = c` procs bind only in the `abi = c` C header.** A
`{.ffi: "abi = c".}` method whose params and return are all scalars — ints,
floats, bools; a `string` return is fine, a `string` param is not — takes a
CBOR-free fast path, and its C wrapper passes the args inline instead of
packing a request struct. Only the `abi = c` C header emits that shape. The
CBOR C header and the `cpp` , `rust` and `cddl` targets have no scalar codegen
and would silently omit the proc, so `genBindings()` fails and names it. Fix
it by generating C bindings from an `abi = c` library, switching the proc to
`abi = cbor` , giving it a non-scalar param, or passing
`-d:ffiAllowScalarSkip` to accept the omission — the proc still works over the
fast path, it's just absent from the bindings.
2026-07-09 16:43:21 -03:00
2026-07-06 12:31:49 -03:00
## Placement of `genBindings()`
`genBindings()` reads the compile-time registries that the pragmas populate as
the compiler expands them, so **it must come after every annotation** . Since
Nim resolves `import` s before running the importing module's body, a multi-file
library keeps its annotations in imported sub-modules and calls `genBindings()`
once at the bottom of the top-level root file.
An annotation that expands *after* `genBindings()` is now a **compile error**
(previously it was silently dropped from the bindings).
2026-07-10 16:27:23 -03:00
## Building — the two-compile model
2026-07-06 12:31:49 -03:00
2026-07-10 16:27:23 -03:00
A nim-ffi library ships from **two separate compiles of the same source** ,
because binding emission is gated behind `-d:ffiGenBindings` : without that
define `genBindings()` is a no-op, so the normal build just produces the shared
library and nothing else.
**1. Build the shared library** (the artifact your host loads):
2026-07-06 12:31:49 -03:00
```sh
2026-07-10 16:27:23 -03:00
nim c --app:lib --noMain --nimMainPrefix:libmylib mylib.nim
2026-07-06 12:31:49 -03:00
```
2026-07-10 17:13:06 -03:00
**2. Emit the foreign bindings** — add the binding defines and `--compileOnly` ,
which stops after codegen: the binding files are written during macro expansion,
so there's no library to link (no `--app:lib` /`-o:/dev/null` needed). The
2026-07-15 09:54:19 -03:00
generated files (for `targetLang=c` : the `<name>.h` header your host includes,
plus a `CMakeLists.txt` ) land in `-d:ffiOutputDir` :
2026-07-10 16:27:23 -03:00
```sh
2026-07-10 17:13:06 -03:00
nim c -d:ffiGenBindings -d:targetLang=rust,cpp,c --compileOnly mylib.nim
2026-07-10 16:27:23 -03:00
```
2026-07-10 17:13:06 -03:00
- `-d:targetLang` — which generator(s) run; pass a comma-separated list to emit
2026-07-15 09:54:19 -03:00
several from one compile:
- **Language bindings:** `rust` (default), `cpp` , `c` . The `c` target follows
the library's ABI format — an `abi = c` C-struct header for `abi = c` , a CBOR
header otherwise; `rust` /`cpp` speak CBOR.
- **`cddl` ** — a CDDL schema of the CBOR wire, not a language binding at all.
2026-07-10 17:13:06 -03:00
- `-d:ffiOutputDir` — override where the generated files land. Defaults to
`<lang>_bindings/` next to the compiled source.
- `-d:ffiSrcPath` — override the Nim source path embedded in the generated build
files. Defaults to the compiled source made relative to the output dir.
2026-07-06 12:31:49 -03:00
2026-07-10 16:27:23 -03:00
### The `--nimMainPrefix:lib<name>` rule
`--app:lib` builds a shared library; `--noMain` hands program entry to the
foreign host rather than Nim's own `main` . To initialize the Nim runtime,
`declareLibrary("<name>", …)` emits an `initializeLibrary()` export that calls
`lib<name>NimMain()` — the symbol Nim's `NimMain` is renamed to by
`--nimMainPrefix` . So the prefix **must be exactly `lib` + the `declareLibrary`
name**, on *both* compiles above, or the library fails to link. For
`declareLibrary("my_timer", …)` that is `--nimMainPrefix:libmy_timer` .
**Library-naming collisions.** When several nim-ffi libraries are loaded into
one process (as the C++ end-to-end test does with `timer` + `echo` ), each must
use a **distinct** library name — and therefore a distinct `--nimMainPrefix` —
so their exported `NimMain` , `initializeLibrary` and per-symbol names don't
clash. The example libraries deliberately differ: `libmy_timer` vs `libecho` .
2026-07-06 12:31:49 -03:00
## Examples
- `examples/timer` — a self-contained Nimble project covering the ctor, sync
and async methods, multi-param methods, events, and C / C++ / Rust / CDDL
bindings with runnable clients. Start here:
```sh
cd examples/timer & & nimble install -y ../.. & & nimble build
```
- `examples/echo` — a second minimal library, loaded alongside `timer` in the
C++ end-to-end test to prove two libraries coexist in one process.