Proposed 2026-09-17. Experimental. node:ffi has stability 1 and requires Node.js 26.1 or newer.
Node.js 26.1 added node:ffi: DynamicLibrary, getFunction with a
signature of type names, typed get and set on a raw address, and
registerCallback. That is enough for the scalar, pointer, layout and
callback part of babashka.ffi, so a script written for babashka can run
under nbb.
Probed on Node.js 26.9.0, macOS arm64:
-0, a fraction, an integer outside the
width, and a number for a 64-bit type all throw.getFunction takes a symbol name only. There is no call through an
address. The Node.js binary does not export libffi for use by
examples/libffi.clj.DynamicLibrary(null) opens the process. The docs say Windows does not
support it.with-open, and its deftype takes only toString under
Object."node:ffi" in
an ns require fails with No such namespace: node:ffi, and the module
has no name without the prefix.src/babashka/ffi.cljs implements babashka.ffi for Node.js using node:ffi.
ffi.clj stays one file that depends only on the JDK, because babashka
embeds it. The layout code is duplicated, not shared through a .cljc.
(deftype Pointer [addr size scope keep]). scope is the
arena, and a closed arena makes every access throw, as a closed FFM arena
does. keep holds the Buffer or the callback function for the garbage
collector.process.getBuiltinModule, one
path for nbb, ClojureScript, shadow-cljs, CommonJS and ESM.ffi.cljs requires its macros from babashka.ffi, as babashka.fs does.
The ClojureScript compiler resolves that to ffi.clj, so ffi.clj has
with-open too: clojure.core's on the JVM, a try and finally around
.close when it expands for ClojureScript. One script closes arenas the
same way on every host. The compiler's JVM loads ffi.clj and needs JDK
25 or newer. nbb uses the defmacros in ffi.cljs when it interprets the
file. A compiled build has no defmacro of a .cljs file, the compiler
emits nothing for one, so each macro body is a public :no-doc function,
defcfn-form and with-open-form. nbb compiles babashka.ffi into a
built-in module and makes its two SCI macros from those.(deftype Arena [kind closed bufs cleanups close]). close
is a field that holds a function, because nbb's deftype takes no methods. An allocation is
a zeroed Buffer, over-allocated for alignment, and its address comes
from getRawPointer. The arena holds its buffers until it closes.unrefCallback with
the pointer as the strong reference, the global arena never releases it.cfn throws when the binding is made for a struct by value, a :&, or
a pointer as the symbol.ucrtbase.dll.A change to a layout rule, a type keyword or an error message goes in both files. test-node/babashka/ffi_test.cljs follows ffi_test.clj case by case to catch drift.
Libraries that bind a function pointer, such as a vtable entry or a callback round trip, require changes to run on Node.js.
Measured under nbb 1.5.212: a scalar call costs about 150 ns, against
about 12 ns from plain JavaScript and 25 to 100 ns for the raw node:ffi
function called from SCI. A multi-arity function costs SCI about 250 ns
per call, so a binding with up to 4 arguments is a single-arity function
with a sentinel parameter, which cost 530 ns as a multi-arity one. read
and write are multi-arity and cost 550 to 900 ns.
The same 14 tests pass under nbb, ClojureScript :none and :advanced,
and shadow-cljs plain and :advanced. A ClojureScript :advanced build
needs :infer-externs true. Every Pointer, Arena and node:ffi access
carries a type hint, so shadow-cljs compiles without infer warnings.
The multi-arity cost is SCI's. 2 million calls each, ns per call:
fn shape planck nbb 1.5.212 compiled, node 26
one arity 40 11 4.3
multi fixed 75 273 4.3
multi + variadic 109 267 4.3
variadic only 225 16 15
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |