Hot-reload registry built on clj-reload.
Extends tonsky/clj-reload with:
Design: Composition over reimplementation.
Hot-reload registry built on clj-reload. Extends tonsky/clj-reload with: - Named component registry - Event-driven listeners (for hive-events integration) - Status tracking and introspection - Cascade control - Watcher integration with coordinated debouncing - Scoped reload: load the changes under a set of source roots, drag in their dependents, DECLINE every other change — with a per-file baseline so a declined change stays pending for the reload that owns it (clj-reload keeps one scalar :since for the whole image) Design: Composition over reimplementation.
(add-listener! listener-id listener-fn)Add a reload event listener.
Events:
Add a reload event listener.
Events:
- {:type :reload-start}
- {:type :reload-success :unloaded [...] :loaded [...] :ms elapsed}
- {:type :reload-error :failed ns :error ex}
- {:type :component-callback :component id :callback :on-reload|:on-error}(ensure-init! opts)init! when not yet initialized, else extend-init! — the idempotent way
for a host to declare the dirs it needs without resetting a baseline another
caller established. Returns {:initialized? true :fresh? bool :dirs :added}.
`init!` when not yet initialized, else `extend-init!` — the idempotent way
for a host to declare the dirs it needs without resetting a baseline another
caller established. Returns {:initialized? true :fresh? bool :dirs :added}.(extend-init! {:keys [dirs no-reload no-unload owner]})Extend an initialized registry: union dirs, no-reload and no-unload
into clj-reload's config WITHOUT resetting the change baseline, the
per-file view, or the work a pass left pending — a change declined before
this call is still pending after it, a namespace an earlier pass unloaded
but never loaded is still queued, and a keep entry still stands. No-op when
nothing is new. Restarts the file watcher, when one is running, over the
union. The inverse is remove-dirs!.
:owner (any value, e.g. an addon id) records who claims dirs; a dir
stays tracked while an owner other than the one releasing it still claims
it. Callers that pass none share one anonymous claim.
Dirs are compared by canonical path: a root already tracked under another spelling is not added twice.
Returns hive-hot.schema/ExtendInitReport {:dirs [...] :added [...]}.
Extend an initialized registry: union `dirs`, `no-reload` and `no-unload`
into clj-reload's config WITHOUT resetting the change baseline, the
per-file view, or the work a pass left pending — a change declined before
this call is still pending after it, a namespace an earlier pass unloaded
but never loaded is still queued, and a keep entry still stands. No-op when
nothing is new. Restarts the file watcher, when one is running, over the
union. The inverse is `remove-dirs!`.
`:owner` (any value, e.g. an addon id) records who claims `dirs`; a dir
stays tracked while an owner other than the one releasing it still claims
it. Callers that pass none share one anonymous claim.
Dirs are compared by canonical path: a root already tracked under another
spelling is not added twice.
Returns hive-hot.schema/ExtendInitReport {:dirs [...] :added [...]}.(find-namespaces pattern)Find namespaces matching a pattern. Delegates to clj-reload/find-namespaces.
Find namespaces matching a pattern. Delegates to clj-reload/find-namespaces.
(get-component component-id)Get component registration by ID.
Get component registration by ID.
(init!)(init! opts)Initialize hive-hot with source directories.
Options (passed to clj-reload/init):
Resets the per-file baseline. The dirs this call declares are the CORE dirs:
remove-dirs! never releases them. Use ensure-init! to extend an
initialized registry without resetting it.
Example:
(init! {:dirs ["src" "dev"]
:no-reload '#{user}})
Initialize hive-hot with source directories.
Options (passed to clj-reload/init):
- :dirs - Source directories (default: ["src"])
- :no-reload - Namespaces to never reload
- :no-unload - Namespaces to reload but not unload
- :since - Epoch ms the change baseline starts from (default: now). A
file modified after it counts as changed on the first reload;
pass the JVM start time so edits made before this init are
not silently taken as the baseline.
Resets the per-file baseline. The dirs this call declares are the CORE dirs:
`remove-dirs!` never releases them. Use `ensure-init!` to extend an
initialized registry without resetting it.
Example:
```clojure
(init! {:dirs ["src" "dev"]
:no-reload '#{user}})
```(init-with-watcher!)(init-with-watcher!
{:keys [dirs claim-checker debounce-ms no-reload no-unload]
:or {dirs ["src"] claim-checker (constantly #{}) debounce-ms 100}
:as opts})Initialize hive-hot with file watcher and coordinating debouncer.
This wires together:
Options:
clj-reload is initialized through ensure-init!: an already-initialized
registry is EXTENDED with the dirs, never reset, so a change made between
init and watch is not silently taken as the baseline.
The claim-checker is typically created via:
(events/make-claim-checker logic/get-all-claims)
When a file changes:
Example:
;; Basic usage (no coordination)
(init-with-watcher! {:dirs ["src" "dev"]})
;; With claim-aware coordination
(init-with-watcher!
{:dirs ["src"]
:claim-checker (events/make-claim-checker logic/get-all-claims)})
Returns :watching on success.
Initialize hive-hot with file watcher and coordinating debouncer.
This wires together:
- FileWatcher (from hive-hot.watcher)
- CoordinatingDebouncer (from hive-hot.debounce)
- reload! for actual reloading
Options:
- :dirs - Source directories to watch (default: ["src"])
- :claim-checker - Function returning set of claimed files
(default: (constantly #{}))
- :debounce-ms - Debounce window in ms (default: 100)
- :no-reload - Namespaces to never reload
- :no-unload - Namespaces to reload but not unload
clj-reload is initialized through `ensure-init!`: an already-initialized
registry is EXTENDED with the dirs, never reset, so a change made between
init and watch is not silently taken as the baseline.
The claim-checker is typically created via:
```clojure
(events/make-claim-checker logic/get-all-claims)
```
When a file changes:
1. FileWatcher detects change
2. Debouncer checks claim-checker
- If file is claimed: buffer until released
- If unclaimed: apply debounce-ms window
3. After debounce: emit :file/changed, call reload!
Example:
```clojure
;; Basic usage (no coordination)
(init-with-watcher! {:dirs ["src" "dev"]})
;; With claim-aware coordination
(init-with-watcher!
{:dirs ["src"]
:claim-checker (events/make-claim-checker logic/get-all-claims)})
```
Returns :watching on success.(list-components)List all registered component IDs.
List all registered component IDs.
(reg-hot component-id {:keys [ns on-reload on-error] :as opts})Register a component for hot-reload callbacks.
Options:
Note: clj-reload handles dependency tracking automatically. Use this for application-level callbacks (restart server, etc).
Example:
(reg-hot :http-server
{:ns 'my.server
:on-reload #(println "Server code reloaded!")})
Register a component for hot-reload callbacks.
Options:
- :ns - Namespace symbol (required)
- :on-reload - Callback after successful reload (fn [])
- :on-error - Callback on reload failure (fn [exception])
Note: clj-reload handles dependency tracking automatically.
Use this for application-level callbacks (restart server, etc).
Example:
```clojure
(reg-hot :http-server
{:ns 'my.server
:on-reload #(println "Server code reloaded!")})
```(reload!)(reload! opts)Reload changed namespaces and their dependents.
Without :only this is (reload-scoped! nil opts): every change the
registry's per-file baseline has not seen, under every tracked dir -- which
includes the changes an earlier SCOPED reload declined.
Options:
Emits events via hive-events:
Returns:
{:success bool
:unloaded [ns ...]
:loaded [ns ...]
:failed ns-or-nil
:error message-or-nil
:exception throwable-or-nil
:stale-registrations [{:registry :fx :id k :owner ns/name} ...] -- present
only when the pass left a hive-events registry entry holding a function
from code it just replaced, which happens when the registration is
guarded so it runs once (see stale-registrations)
:ms elapsed}
Example:
(reload!) ; Reload changed
(reload! {:only :all}) ; Reload everything
(reload! {:only #".*-test"}) ; Reload matching
Reload changed namespaces and their dependents.
Without :only this is `(reload-scoped! nil opts)`: every change the
registry's per-file baseline has not seen, under every tracked dir -- which
includes the changes an earlier SCOPED reload declined.
Options:
- :only - :loaded | :all | #"pattern" -- clj-reload's explicit selection,
passed straight through (bypasses the baseline)
- :throw - Throw on error (default: false, returns result map)
Emits events via hive-events:
- :hot/reload-start before reload
- :hot/reload-success or :hot/reload-error after
Returns:
{:success bool
:unloaded [ns ...]
:loaded [ns ...]
:failed ns-or-nil
:error message-or-nil
:exception throwable-or-nil
:stale-registrations [{:registry :fx :id k :owner ns/name} ...] -- present
only when the pass left a hive-events registry entry holding a function
from code it just replaced, which happens when the registration is
guarded so it runs once (see `stale-registrations`)
:ms elapsed}
Example:
```clojure
(reload!) ; Reload changed
(reload! {:only :all}) ; Reload everything
(reload! {:only #".*-test"}) ; Reload matching
```(reload-all!)Force reload of all namespaces.
Use sparingly - prefer reload! for incremental reloads.
Force reload of all namespaces. Use sparingly - prefer reload! for incremental reloads.
(reload-scoped! roots)(reload-scoped! roots opts)Reload the changes under roots — and only those.
clj-reload holds ONE changed-set across every tracked dir, so a plain reload
loads whatever any co-tenant has saved anywhere. This pass admits the
changed files under roots, drags in the namespaces that depend on them
(the cascade has to recompile those against the new vars, wherever they
live), and DECLINES every other change: those files keep their baseline and
stay pending for the reload that owns their root. roots nil or empty means
every tracked dir.
A pass also runs when nothing under the roots changed but clj-reload holds work an earlier pass left queued (a namespace unloaded and never loaded, or one a caller queued): that work would otherwise wait for an unrelated edit.
Queued work is attributed by file like a change is. A namespace an earlier pass left queued — typically one that FAILED to compile in an unscoped or another root's reload, which clj-reload replays on every later pass — whose files lie outside the roots and the cascade is WITHHELD: it sits this pass out and goes back in the queue afterwards, so one root's broken work in progress cannot fail every other root's reload, and its own root's reload still finds it.
Returns clj-reload's result plus: :success bool :ms elapsed :scoped? true when roots were given :roots the canonical roots :skipped [ns-string ...] changed outside the roots, NOT loaded :dragged [ns-string ...] changed outside the roots, loaded as dependents :unchanged? true when nothing under the roots had changed :pending? true when the pass ran for queued work alone :withheld [ns-string ...] queued work outside the roots, NOT run and left queued (present only when non-empty) :multi-file {ns [path ...]} loaded namespaces found in more than one file
Reload the changes under `roots` — and only those.
clj-reload holds ONE changed-set across every tracked dir, so a plain reload
loads whatever any co-tenant has saved anywhere. This pass admits the
changed files under `roots`, drags in the namespaces that depend on them
(the cascade has to recompile those against the new vars, wherever they
live), and DECLINES every other change: those files keep their baseline and
stay pending for the reload that owns their root. `roots` nil or empty means
every tracked dir.
A pass also runs when nothing under the roots changed but clj-reload holds
work an earlier pass left queued (a namespace unloaded and never loaded, or
one a caller queued): that work would otherwise wait for an unrelated edit.
Queued work is attributed by file like a change is. A namespace an earlier
pass left queued — typically one that FAILED to compile in an unscoped or
another root's reload, which clj-reload replays on every later pass — whose
files lie outside the roots and the cascade is WITHHELD: it sits this pass
out and goes back in the queue afterwards, so one root's broken work in
progress cannot fail every other root's reload, and its own root's reload
still finds it.
Returns clj-reload's result plus:
:success bool
:ms elapsed
:scoped? true when roots were given
:roots the canonical roots
:skipped [ns-string ...] changed outside the roots, NOT loaded
:dragged [ns-string ...] changed outside the roots, loaded as dependents
:unchanged? true when nothing under the roots had changed
:pending? true when the pass ran for queued work alone
:withheld [ns-string ...] queued work outside the roots, NOT run and
left queued (present only when non-empty)
:multi-file {ns [path ...]} loaded namespaces found in more than one file(remove-dirs! {:keys [dirs owner]})Stop tracking and watching dirs — the inverse of extend-init!, for a
source root being plugged OUT. Drops them from clj-reload's :dirs WITHOUT
resetting the change baseline, the per-file view or pending work, and
restarts a running file watcher without them.
A dir the INITIAL init declared (see init!; an uninitialized registry first
adopts clj-reload's dirs as that initial set) is never removed: it is the
host's own source, and is answered under :kept. A dir another owner still
claims (see extend-init! :owner) is released for :owner only, stays
tracked, and is answered under :kept and :shared. A dir that is not tracked
is answered under :absent. Dirs are compared by canonical path and answered
in the caller's spelling. Idempotent: a second call removes nothing.
Returns hive-hot.schema/RemoveDirsReport: {:removed [...] :kept [...] :absent [...] :dirs [...] :shared {dir [owner]}} :dirs are the tracked dirs after the call.
Stop tracking and watching `dirs` — the inverse of `extend-init!`, for a
source root being plugged OUT. Drops them from clj-reload's :dirs WITHOUT
resetting the change baseline, the per-file view or pending work, and
restarts a running file watcher without them.
A dir the INITIAL init declared (see `init!`; an uninitialized registry first
adopts clj-reload's dirs as that initial set) is never removed: it is the
host's own source, and is answered under :kept. A dir another owner still
claims (see `extend-init!` :owner) is released for `:owner` only, stays
tracked, and is answered under :kept and :shared. A dir that is not tracked
is answered under :absent. Dirs are compared by canonical path and answered
in the caller's spelling. Idempotent: a second call removes nothing.
Returns hive-hot.schema/RemoveDirsReport:
{:removed [...] :kept [...] :absent [...] :dirs [...] :shared {dir [owner]}}
:dirs are the tracked dirs after the call.(remove-listener! listener-id)Remove a reload event listener.
Remove a reload event listener.
(reset-all!)Reset all registrations, the per-file baseline and the core dirs. Use in tests.
Reset all registrations, the per-file baseline and the core dirs. Use in tests.
(scope-plan roots)What a scoped reload of roots WOULD do — no effects.
Compares every tracked file's mtime against this registry's per-file
baseline (falling back to clj-reload's :since), splits the changed files by
root, and closes the wanted namespaces forward over the current require
graph. roots nil or empty means every tracked dir.
Returns {:roots [canonical root ...] nil when unscoped :want [File ...] changed under the roots — loaded :dragged [File ...] changed outside, but a dependent of a wanted namespace — loaded, since the cascade must recompile it anyway :skipped [File ...] changed outside, unrelated — DECLINED; stays pending for its own root :cascade [ns ...] tracked require-graph closure, INCLUDING unchanged dependents; potential namespace reloads, before live/no-reload filtering. Untracked namespaces are not covered. :mask #{ns ...} namespaces pinned :no-reload for the run :withheld #{ns ...} work clj-reload holds QUEUED (from an earlier, failed or unscoped pass) for namespaces outside the roots and the cascade — taken out of the pass and put back after it :queued [ns ...] queued work the pass does run :since long | nil the :since window the run needs, nil when there is nothing to load :old-since long}
What a scoped reload of `roots` WOULD do — no effects.
Compares every tracked file's mtime against this registry's per-file
baseline (falling back to clj-reload's :since), splits the changed files by
root, and closes the wanted namespaces forward over the current require
graph. `roots` nil or empty means every tracked dir.
Returns
{:roots [canonical root ...] nil when unscoped
:want [File ...] changed under the roots — loaded
:dragged [File ...] changed outside, but a dependent of a
wanted namespace — loaded, since the
cascade must recompile it anyway
:skipped [File ...] changed outside, unrelated — DECLINED;
stays pending for its own root
:cascade [ns ...] tracked require-graph closure, INCLUDING
unchanged dependents; potential namespace
reloads, before live/no-reload filtering.
Untracked namespaces are not covered.
:mask #{ns ...} namespaces pinned :no-reload for the run
:withheld #{ns ...} work clj-reload holds QUEUED (from an
earlier, failed or unscoped pass) for
namespaces outside the roots and the
cascade — taken out of the pass and
put back after it
:queued [ns ...] queued work the pass does run
:since long | nil the :since window the run needs, nil
when there is nothing to load
:old-since long}(status)Get current hot-reload status.
Every value here is DATA: the report crosses process and serialization boundaries (an MCP tool surface renders it as JSON), so live objects are projected rather than handed over. Callers that need the actual callbacks read the registry, not the report.
Returns: {:initialized? bool :components {component-id {:ns str :status kw :last-reload ms :on-reload? bool :on-error? bool}} :listener-count n :dirs [str ...] tracked source dirs (when initialized) :since ms clj-reload's change baseline (when initialized) :pending [ns ...] namespaces changed on disk that no reload has loaded yet (when initialized)}
Get current hot-reload status.
Every value here is DATA: the report crosses process and serialization
boundaries (an MCP tool surface renders it as JSON), so live objects are
projected rather than handed over. Callers that need the actual callbacks
read the registry, not the report.
Returns:
{:initialized? bool
:components {component-id {:ns str :status kw :last-reload ms
:on-reload? bool :on-error? bool}}
:listener-count n
:dirs [str ...] tracked source dirs (when initialized)
:since ms clj-reload's change baseline (when initialized)
:pending [ns ...] namespaces changed on disk that no reload has loaded
yet (when initialized)}(stop-watcher!)Stop the file watcher if running.
Stop the file watcher if running.
(unreg-hot component-id)Unregister a component.
Unregister a component.
(watcher-status)Get watcher status.
Returns nil if not watching, or map with:
Get watcher status. Returns nil if not watching, or map with: - :watching? true - :dirs watched directories
(watching-paths)Get list of directories being watched. Returns empty vector if not watching.
Get list of directories being watched. Returns empty vector if not watching.
(with-reload & body)Execute body, then reload.
Useful for REPL development:
(with-reload
(spit "src/my/service.clj" new-code))
Execute body, then reload. Useful for REPL development: ```clojure (with-reload (spit "src/my/service.clj" new-code)) ```
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 |