Liking cljdoc? Tell your friends :D

clj-nativeresolver

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.

The problem

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:

situationwhat should happen
a dev with a fresh cargo build --releaseuse that path, verbatim, no download
an uberjar / GraalVM native imageuse the library bundled on the classpath
a user who only declared the pure-Clojure jarfetch the per-platform artifact from Maven and cache it

nativeresolver is that decision, plus the extraction, the cache and the downcall binding.

Use it

(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 pathMYLIB_NATIVE_PATH env · com.blockether.mylib.native.path property
library filelibmylib_c.dylib · libmylib_c.so · mylib_c.dll
classpath resourceprebuilds/<platform>/<file>
Maven artifactcom.blockether/mylib-native-<platform>
versionclasspath resource mylib/VERSION
cache~/.cache/clj-mylib/<version>/<platform>/MYLIB_CACHE_DIR to move it
download off switchMYLIB_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.

Resolution order

  1. Explicit path<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.
  2. Bundled classpath resourceprebuilds/<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.
  3. Runtime download — the native artifact resolved through 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"}

API

com.blockether.nativeresolver

  • platform{:os "darwin" :arch "arm64" :id "darwin-arm64"}, platform-id, library-file-name
  • spec, artifact, version, resource-name, cache-root
  • resolve-library, library-path, extract-native!
  • layout, fd, lookup, downcall, bind!, invoke
  • layout constants ptr 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<-, confined

Two things it gets right on purpose

Arena/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.

Native image

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.

Development

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.

License

MIT © Blockether

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close