feat(hiccup): add live reload via file watcher + browser polling

The hiccup dev server now auto-reloads when source files change:
- Background thread polls src/ and dev/hiccup/src/ every 500ms
- On change, reloads dev.hiccup namespace and all transitive deps
- Injects a polling script that hits /dev/changes and reloads the
  browser when the version counter bumps
- Server uses #'handler (var ref) so httpkit picks up redefined fns

No more manual server restarts needed for hiccup development.
This commit is contained in:
Florian Schroedl
2026-03-05 19:46:29 +01:00
parent 93edebf144
commit d4f21f80a5

View File

@@ -2,6 +2,8 @@
(:require [org.httpkit.server :as http] (:require [org.httpkit.server :as http]
[hiccup2.core :as h] [hiccup2.core :as h]
[clojure.string :as str] [clojure.string :as str]
[clojure.java.io :as io]
[babashka.fs :as fs]
[ui.button :as button] [ui.button :as button]
[ui.alert :as alert] [ui.alert :as alert]
[ui.badge :as badge] [ui.badge :as badge]
@@ -507,6 +509,18 @@
(sidebar/sidebar-footer {} (sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Dev Mode" :email (str "hiccup · port " own-port) :avatar "bb"})))) (sidebar/sidebar-user {:user-name "Dev Mode" :email (str "hiccup · port " own-port) :avatar "bb"}))))
(def live-reload-script
"/* Live reload: poll /dev/changes, reload on version bump */
(function() {
var lastV = null;
setInterval(function() {
fetch('/dev/changes').then(function(r) { return r.text(); }).then(function(v) {
if (lastV !== null && v !== lastV) location.reload();
lastV = v;
}).catch(function() {});
}, 500);
})();")
(defn render-page [uri port] (defn render-page [uri port]
(let [params (parse-query-params uri) (let [params (parse-query-params uri)
theme (get params "theme") theme (get params "theme")
@@ -521,7 +535,8 @@
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:link {:rel "stylesheet" :href "/theme.css"}] [:link {:rel "stylesheet" :href "/theme.css"}]
[:style (h/raw "html, body { margin: 0; padding: 0; }")] [:style (h/raw "html, body { margin: 0; padding: 0; }")]
[:script (h/raw theme-persistence-script)]] [:script (h/raw theme-persistence-script)]
[:script (h/raw live-reload-script)]]
[:body [:body
[:script {:src "/theme-adapter.js" :defer true}] [:script {:src "/theme-adapter.js" :defer true}]
[:script {:src "/css-live-reload.js" :defer true}] [:script {:src "/css-live-reload.js" :defer true}]
@@ -538,6 +553,58 @@
:sidebar (sidebar-page) :sidebar (sidebar-page)
[:div (page-header "Not Found" "This page doesn't exist.")])]))]])))) [:div (page-header "Not Found" "This page doesn't exist.")])]))]]))))
;; ── Live Reload ─────────────────────────────────────────────────────
(defonce !version (atom 0))
(defonce !last-mtimes (atom {}))
(def watch-dirs ["src" "dev/hiccup/src"])
(def watch-exts #{".clj" ".cljc" ".css" ".edn"})
(defn source-mtimes
"Collect last-modified timestamps for all source files."
[]
(into {}
(for [dir watch-dirs
:let [root (io/file dir)]
:when (.isDirectory root)
f (file-seq root)
:when (and (.isFile f)
(some #(str/ends-with? (.getName f) %) watch-exts))]
[(.getPath f) (.lastModified f)])))
(defn reload-namespaces!
"Reload dev.hiccup and all transitive deps (all ui.* namespaces)."
[]
(try
(require 'dev.hiccup :reload-all)
(catch Exception e
(println "⚠ Reload error:" (.getMessage e)))))
(defn start-watcher!
"Start a background thread that polls source files for changes."
[]
(reset! !last-mtimes (source-mtimes))
(future
(loop []
(Thread/sleep 500)
(try
(let [current (source-mtimes)]
(when (not= current @!last-mtimes)
(let [changed (into []
(filter #(not= (get current %) (get @!last-mtimes %)))
(keys current))
new-files (into []
(filter #(not (contains? @!last-mtimes %)))
(keys current))]
(reset! !last-mtimes current)
(println (str "♻ Reloading (" (count (concat changed new-files)) " file(s) changed)"))
(reload-namespaces!)
(swap! !version inc))))
(catch Exception e
(println "⚠ Watcher error:" (.getMessage e))))
(recur))))
;; ── Server ────────────────────────────────────────────────────────── ;; ── Server ──────────────────────────────────────────────────────────
(defonce !port (atom 3003)) (defonce !port (atom 3003))
@@ -546,6 +613,12 @@
(let [port @!port (let [port @!port
path (first (str/split uri #"\?" 2))] path (first (str/split uri #"\?" 2))]
(cond (cond
(= path "/dev/changes")
{:status 200
:headers {"Content-Type" "text/plain"
"Cache-Control" "no-cache"}
:body (str @!version)}
(= path "/theme.css") (= path "/theme.css")
{:status 200 {:status 200
:headers {"Content-Type" "text/css"} :headers {"Content-Type" "text/css"}
@@ -573,5 +646,6 @@
(defn start! [{:keys [port] :or {port 3003}}] (defn start! [{:keys [port] :or {port 3003}}]
(reset! !port port) (reset! !port port)
(println (str "Hiccup server running at http://localhost:" port)) (start-watcher!)
(println (str "Hiccup server running at http://localhost:" port " (live reload enabled)"))
(http/run-server #'handler {:port port})) (http/run-server #'handler {:port port}))