jolt build: bundle native libs + resources into standalone binaries

A built binary dropped its deps.edn :jolt/native declarations and its
resource roots, so an FFI+resources app (ring-app) failed at runtime:
sockets/sqlite gave 'no entry for socket' and io/resource returned nil.
The buildsmoke fixture is pure compute, so neither path was exercised.

The launcher now loads required + :process native libs before the app's
top-level forms (a library's defcfn resolves its foreign-procedure symbols
at top-level eval during startup, so the libs must be loaded first);
optional libs load in the scheme-start launcher, where a missing lib is
caught rather than aborting the heap build.

deps.edn :jolt/build {:embed [dirs]} bakes those dirs' files into the heap
(register-embedded-resource! at heap build), so io/resource serves them with
no files on disk. Non-embedded resources resolve at runtime against JOLT_PWD,
and io/file reads (e.g. config.edn) stay external.

build-binary now takes the encoded natives, embed dirs, and project paths
from cmd-build; deps/resolve-project surfaces them. Buildsmoke fixture grows
an embedded resource + a :process native to cover both paths.
This commit is contained in:
Yogthos 2026-06-23 13:19:33 -04:00
parent 22c08d30f2
commit 1d345bfd0f
10 changed files with 227 additions and 36 deletions

View file

@ -127,6 +127,14 @@
{dep-roots :roots dep-natives :natives} (resolve-deps all-deps project-dir)]
{:roots (vec (distinct (concat project-roots dep-roots)))
:main-opts main-opts
;; the project's own paths (relative to project-dir) and absolute resource
;; roots, plus its :jolt/build options — `jolt build` uses these to bundle
;; resources into / alongside a standalone binary.
:project-dir project-dir
:project-paths (vec project-paths)
:project-roots (vec project-roots)
:build (:jolt/build edn)
:embed-dirs (mapv #(abspath project-dir %) (:embed (:jolt/build edn)))
:tasks (:tasks edn)
:natives (vec (distinct (concat (:jolt/native edn) dep-natives)))
;; nREPL middleware a library contributes (jolt.nrepl composes them over its

View file

@ -8,6 +8,12 @@
(defn- project-dir [] (or (jolt.host/getenv "JOLT_PWD") "."))
(defn- current-platform []
(let [os (str/lower-case (or (System/getProperty "os.name") ""))]
(cond (str/includes? os "mac") :darwin
(str/includes? os "win") :windows
:else :linux)))
;; Load a library's declared native shared objects (deps.edn :jolt/native) before
;; its Clojure is required, so its foreign-fn bindings resolve. Each entry is a
;; map: {:name "sqlite3" :darwin ["libsqlite3.0.dylib" ...] :linux ["libsqlite3.so.0" ...]}
@ -15,10 +21,7 @@
;; (use the running process's symbols, e.g. libc sockets — no external file).
(defn- load-natives! [natives]
(when (seq natives)
(let [os (str/lower-case (or (System/getProperty "os.name") ""))
plat (cond (str/includes? os "mac") :darwin
(str/includes? os "win") :windows
:else :linux)]
(let [plat (current-platform)]
(doseq [spec natives]
(if (:process spec)
(jolt.ffi/load-library)
@ -116,23 +119,41 @@
;; standalone executable. Resolves deps + roots like `run`, then hands the entry
;; namespace to the host build driver (jolt.host/build-binary, defined by
;; build.ss). Default mode is release; --opt selects optimized, --dev unoptimized.
;; Encode a deps.edn :jolt/native spec for the build launcher, resolving the
;; current platform's candidate list now (the binary runs on this OS). Each entry
;; becomes a vector the launcher (build.ss) reads: ["process"] for the running
;; binary's own symbols, else ["req"|"opt" cand…] to try in turn.
(defn- encode-natives [natives]
(let [plat (current-platform)]
(vec (for [spec natives]
(if (:process spec)
["process"]
(let [c (get spec plat)
cands (if (string? c) [c] (vec c))]
(into [(if (:optional spec) "opt" "req")] cands)))))))
(defn- cmd-build [more]
(apply-project! (deps/resolve-project (project-dir)))
(let [opts (loop [a more, entry nil, out nil]
(cond
(empty? a) {:entry entry :out out}
(= "-m" (first a)) (recur (drop 2 a) (second a) out)
(= "-o" (first a)) (recur (drop 2 a) entry (second a))
(str/starts-with? (first a) "-") (recur (rest a) entry out)
:else (recur (rest a) (or entry (first a)) out)))
entry (:entry opts)
mode (cond (some #{"--opt"} more) "optimized"
(some #{"--dev"} more) "dev"
:else "release")]
(when (nil? entry)
(throw (ex-info "build needs an entry: -m NS" {})))
(let [out (or (:out opts) (first (str/split entry #"\.")))]
(jolt.host/build-binary entry out mode))))
(let [{:keys [project-paths embed-dirs] :as resolved}
(deps/resolve-project (project-dir))]
(apply-project! resolved)
(let [opts (loop [a more, entry nil, out nil]
(cond
(empty? a) {:entry entry :out out}
(= "-m" (first a)) (recur (drop 2 a) (second a) out)
(= "-o" (first a)) (recur (drop 2 a) entry (second a))
(str/starts-with? (first a) "-") (recur (rest a) entry out)
:else (recur (rest a) (or entry (first a)) out)))
entry (:entry opts)
mode (cond (some #{"--opt"} more) "optimized"
(some #{"--dev"} more) "dev"
:else "release")]
(when (nil? entry)
(throw (ex-info "build needs an entry: -m NS" {})))
(let [out (or (:out opts) (first (str/split entry #"\.")))
natives (encode-natives (:natives resolved))]
;; embed-dirs (absolute) are walked + baked into the binary by the driver;
;; project-paths (relative) become runtime io/resource roots (ship-alongside).
(jolt.host/build-binary entry out mode natives embed-dirs project-paths)))))
(defn- nrepl [more]
;; resolve the project (deps on the roots, native libs loaded), then start the