Find, cache and bind a prebuilt native library from Clojure — the boring half
of every FFM
wrapper, factored out of clj-ruff,
clj-fff and
clj-imaging, which all shipped the
same ~100 lines.
com.blockether/nativeresolver {:mvn/version "0.1.0"}
Zero dependencies. No tools.deps, no logging, no core.async — this is the
first thing your library loads and it has to be native-image clean. Requires JDK
22+ (final FFM API); tested on 25.
You wrote a Rust cdylib, published it as mylib-native-linux-x64,
mylib-native-darwin-arm64, … Now the wrapper has to answer which file do I
dlopen in three unrelated situations:
| situation | what should happen |
|---|---|
a dev with a fresh cargo build --release | use that path, verbatim, no download |
| an uberjar / GraalVM native image | use the library bundled on the classpath |
| a user who only declared the pure-Clojure jar | fetch the per-platform artifact from Maven and cache it |
nativeresolver is that decision, plus the extraction, the cache and the
downcall binding.
(ns my.lib
(:require [com.blockether.nativeresolver :as nr]
[com.blockether.nativeresolver.ffm :as ffm])
(:import [java.lang.foreign Arena]))
(def ^:private lib (nr/spec {:lib "mylib"}))
(defonce ^:private fns
(delay (nr/bind! lib {:version [:ptr "mylib_version"]
:greet [:ptr "mylib_greet" :ptr :i64]
:free [nil "mylib_free_string" :ptr]})))
(defn- free! [seg] (nr/invoke @fns :free seg))
(defn version []
(ffm/c-string (nr/invoke @fns :version)))
(defn greet [name]
(with-open [arena (Arena/ofConfined)]
(let [s (ffm/->c-string arena name)]
(ffm/take-string! free! (nr/invoke @fns :greet s (alength (.getBytes ^String name "UTF-8")))))))
That single {:lib "mylib"} derives everything:
| default | |
|---|---|
| explicit path | MYLIB_NATIVE_PATH env · com.blockether.mylib.native.path property |
| library file | libmylib_c.dylib · libmylib_c.so · mylib_c.dll |
| classpath resource | prebuilds/<platform>/<file> |
| Maven artifact | com.blockether/mylib-native-<platform> |
| version | classpath resource mylib/VERSION |
| cache | ~/.cache/clj-mylib/<version>/<platform>/ — MYLIB_CACHE_DIR to move it |
| download off switch | MYLIB_DISABLE_DOWNLOAD=1 |
<platform> ∈ linux-x64 linux-arm64 darwin-arm64 darwin-x64 windows-x64.
Every one of those is an override key on spec: :group :lib-base :lib-files :artifact-prefix :env-prefix :prop-prefix :version :version-resource :resource-prefix :cache-name :cache-dir :platform :download? :resolve-jar.
<LIB>_NATIVE_PATH / <group>.<lib>.native.path. Used
verbatim: no existence check, no download. This is the cargo build loop and
the escape hatch for distro packagers.prebuilds/<platform>/<file>, shipped by
com.blockether/<lib>-native-<platform>. A resource inside a jar is copied to
a temp file (dlopen needs a real path); a directory resource is used in
place. This is the uberjar / native-image path.clojure.tools.deps, so your Maven repositories, mirrors and
~/.m2/settings.xml are honoured, then extracted into the cache. tools.deps
is reached with requiring-resolve, so it is never a hard dependency and
never reachable in a native image. Missing tools.deps on this path throws a
message that tells the user exactly which artifact to add.resolve-library returns which one won:
(nr/resolve-library lib)
;; => {:path #object[sun.nio.fs.UnixPath "…/libmylib_c.dylib"]
;; :source :bundled ; or :configured / :downloaded
;; :platform "darwin-arm64"
;; :resource "prebuilds/darwin-arm64/libmylib_c.dylib"}
com.blockether.nativeresolver
platform → {:os "darwin" :arch "arm64" :id "darwin-arm64"}, platform-id,
library-file-namespec, artifact, version, resource-name, cache-rootresolve-library, library-path, extract-native!layout, fd, lookup, downcall, bind!, invokeptr i8 i16 i32 i64 f32 f64 bool chr; layout keywords
:ptr :i8 :i16 :i32 :i64 :f32 :f64 :bool :char (+ :addr :int :long :size :u32 :u64 :float :double :byte :short aliases)com.blockether.nativeresolver.ffm
NULL, null?, c-string, take-string!->c-string, ->bytes, ->strings, bytes<-, confinedArena/ofAuto, never Arena/ofShared. A shared arena is incompatible with
Truffle runtime compilation, so a GraalVM native image that also embeds GraalPy
fails to build with "Arena.ofShared is not supported with runtime
compilations". ofAuto is GC-managed, process-lifetime, and needs no flag.
A namespaced version resource. The default is <lib>/VERSION, not a root
VERSION: an unqualified VERSION collides with every other library that ships
one, whichever lands first on the classpath wins, and your library then resolves
a foreign version and 404s a native artifact that never existed. This actually
happened to us.
Nothing to configure — no reflection, no resources, no JNI. Give the JVM
--enable-native-access=ALL-UNNAMED so the foreign linker loads the library
without a restricted-method warning, and prefer resolution step 2 (bundle the
library) so the image never needs tools.deps.
clojure -X:test # 12 tests, incl. a real dlopen + downcall
clojure -T:build jar
clojure -T:build install
Release: push a vX.Y.Z tag — CI deploys to Clojars.
MIT © Blockether
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 |