├── .gitignore ├── README.md ├── example ├── index.html ├── main.cljs └── style.css ├── josh.cljs ├── josh.mjs └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | node_modules 3 | workspace 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Scittle](https://github.com/babashka/scittle/) cljs live-reloading server. 2 | 3 | [A YouTube video about `cljs-josh`](https://youtu.be/4tbjE0_W-58). 4 | 5 | Start the `josh` watch server: 6 | 7 | ```shell 8 | npm install cljs-josh 9 | npx josh 10 | ``` 11 | 12 | Then visit your [Scittle-enabled index.html](./example/index.html) at . 13 | 14 | When you save your .cljs files they will be hot-loaded into the browser running Scittle. 15 | 16 | You can also install the `josh` command globally: `npm i -g cljs-josh`. 17 | 18 | Bootstrap a basic Scittle project with `josh --init`. 19 | 20 | ## Example project 21 | 22 | See [the example](./example) for a basic project to start with. 23 | 24 | Start the server to try it out: 25 | 26 | ```shell 27 | cd example 28 | npx josh 29 | ``` 30 | 31 | ## Tips 32 | 33 | - Install `josh` globally with `npm i -g cljs-josh` and then you can just use `josh` to run it. 34 | - Use `josh --init` to download and install the example template into the current folder. 35 | 36 | ## Features 37 | 38 | I wanted a Scittle dev experience with these features: 39 | 40 | - No build step (the Scittle default). 41 | - Zero configuration required. 42 | - Live reloading on file change, like shadow-cljs. 43 | - Both cljs and CSS files live-reloaded. 44 | - Installable with `npm install`. 45 | - Pure JavaScript, no Java/binary dependency. 46 | - Minimal library deps. 47 | 48 | Josh is built on [`nbb`](https://github.com/babashka/nbb/). 49 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Scittle Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /example/main.cljs: -------------------------------------------------------------------------------- 1 | (ns main 2 | (:require 3 | [reagent.core :as r] 4 | [reagent.dom :as rdom])) 5 | 6 | (defonce state (r/atom nil)) 7 | 8 | (defn app [] 9 | [:main 10 | [:h1 "Hello"] 11 | [:pre "State: " (pr-str @state)] 12 | [:button 13 | {:on-click #(swap! state update :counter inc)} 14 | "Increment"]]) 15 | 16 | (rdom/render [app] (.getElementById js/document "app")) 17 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | min-height: 100vh; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | 10 | main { 11 | text-align: center; 12 | } 13 | -------------------------------------------------------------------------------- /josh.cljs: -------------------------------------------------------------------------------- 1 | (ns josh 2 | {:clj-kondo/config '{:lint-as {promesa.core/let clojure.core/let}}} 3 | (:require 4 | [clojure.tools.cli :as cli] 5 | ["os" :as os] 6 | ["path" :as path] 7 | ["fs/promises" :as fs] 8 | ["fs" :as fs-sync] 9 | [applied-science.js-interop :as j] 10 | [promesa.core :as p] 11 | ["node-watch$default" :as watch] 12 | ["express$default" :as express] 13 | [nbb.core :refer [load-file *file*]])) 14 | 15 | ; tests 16 | ; /blah/blah/index.html 17 | ; /blah/blah.html 18 | ; /blah/blah (implicit index) 19 | ; test and 20 | 21 | (def default-port 8000) 22 | 23 | (defonce connections (atom #{})) 24 | 25 | (defn get-local-ip-addresses [] 26 | (let [interfaces (os/networkInterfaces)] 27 | (for [[_ infos] (js/Object.entries interfaces) 28 | info infos 29 | :when (= (.-family info) "IPv4")] 30 | (.-address info)))) 31 | 32 | (defn try-file [html-path] 33 | (p/catch 34 | (p/let [file-content (fs/readFile html-path)] 35 | (when file-content 36 | (.toString file-content))) 37 | (fn [_err] nil))) ; couldn't load HTML at this path 38 | 39 | (defn find-html [req dir] 40 | (let [base-path (path/join dir (j/get req :path)) 41 | extension (.toLowerCase (path/extname base-path))] 42 | (when (or (= extension "") 43 | (= extension ".htm") 44 | (= extension ".html")) 45 | (p/let [html (try-file base-path) 46 | html (or html (try-file (str base-path ".html"))) 47 | html (or html (try-file (path/join base-path "index.html")))] 48 | html)))) 49 | 50 | (defn sse-handler 51 | [req res] 52 | (js/console.log "SSE connection established") 53 | (j/call res :setHeader "Content-Type" "text/event-stream") 54 | (j/call res :setHeader "Cache-Control" "no-cache") 55 | (j/call res :setHeader "Connection" "keep-alive") 56 | (j/call res :flushHeaders) 57 | (j/call req :on "close" 58 | (fn [] 59 | (js/console.log "SSE connection closed") 60 | (swap! connections disj res) 61 | (j/call res :end))) 62 | (swap! connections conj res) 63 | (j/call res :write (str "data: " 64 | (js/JSON.stringify 65 | (clj->js {:hello 42})) 66 | "\n\n"))) 67 | 68 | (defn send-to-all [msg] 69 | (doseq [res @connections] 70 | (j/call res :write (str "data: " 71 | (js/JSON.stringify 72 | (clj->js msg)) 73 | "\n\n")))) 74 | 75 | (def loader 76 | '(defonce _josh-reloader 77 | (do 78 | (js/console.log "Josh Scittle re-loader installed") 79 | 80 | (defn- match-tags [tags source-attribute file-path] 81 | (.filter (js/Array.from tags) 82 | #(let [src (aget % source-attribute) 83 | url (when (seq src) (js/URL. src)) 84 | path (when url (aget url "pathname"))] 85 | (= file-path path)))) 86 | 87 | (defn- reload-scittle-tags [file-path] 88 | (let [scittle-tags 89 | (.querySelectorAll 90 | js/document 91 | "script[type='application/x-scittle']") 92 | matching-scittle-tags 93 | (match-tags scittle-tags "src" file-path)] 94 | (doseq [tag matching-scittle-tags] 95 | (js/console.log "Reloading" (aget tag "src")) 96 | (-> js/scittle 97 | .-core 98 | (.eval_script_tags tag))))) 99 | 100 | (defn- reload-css-tags [file-path] 101 | (let [css-tags 102 | (.querySelectorAll 103 | js/document 104 | "link[rel='stylesheet']") 105 | matching-css-tags 106 | (match-tags css-tags "href" file-path)] 107 | (doseq [tag matching-css-tags] 108 | (js/console.log "Reloading" (aget tag "href")) 109 | (aset tag "href" 110 | (-> (aget tag "href") 111 | (.split "?") 112 | first 113 | (str "?" (.getTime (js/Date.)))))))) 114 | 115 | (defn- setup-sse-connection [] 116 | (let [conn (js/EventSource. "/_cljs-josh")] 117 | (aset conn "onerror" 118 | (fn [ev] 119 | (js/console.error "SSE connection closed.") 120 | (when (= (aget conn "readyState") 121 | (aget js/EventSource "CLOSED")) 122 | (js/console.error "Creating new SSE connection.") 123 | (js/setTimeout 124 | #(setup-sse-connection) 125 | 2000)))) 126 | (aset conn "onmessage" 127 | (fn [data] 128 | (let [packet (-> data 129 | (aget "data") 130 | js/JSON.parse 131 | (js->clj :keywordize-keys true))] 132 | ; (js/console.log "packet" (pr-str packet)) 133 | (when-let [file-path (:reload packet)] 134 | (cond (.endsWith file-path ".cljs") 135 | (reload-scittle-tags file-path) 136 | (.endsWith file-path ".css") 137 | (reload-css-tags file-path)))))))) 138 | 139 | (setup-sse-connection)))) 140 | 141 | (defn html-injector [req res done dir] 142 | ; intercept static requests to html and inject the loader script 143 | (p/let [html (find-html req dir)] 144 | (if html 145 | (let [injected-html (.replace html #"(?i)" 146 | (str 147 | ""))] 150 | ;(js/console.log "Intercepted" (j/get req :path)) 151 | (.send res injected-html)) 152 | (done)))) 153 | 154 | (defn frontend-file-changed 155 | [_event-type filename] 156 | (js/console.log "Frontend reloading:" filename) 157 | (send-to-all {:reload (str "/" filename)})) 158 | 159 | (def cli-options 160 | [["-d" "--dir DIR" "Path to dir to serve." 161 | :default "./" 162 | :validate [#(fs-sync/existsSync %) "Must be a directory that exists."]] 163 | ["-p" "--port PORT" "Webserver port number." 164 | :default default-port 165 | :parse-fn js/Number 166 | :validate [#(< 1024 % 0x10000) "Must be a number between 1024 and 65536"]] 167 | ["-i" "--init" (str "Set up a basic Scittle project. Copies an html," 168 | "cljs, and css file into the current folder.")] 169 | ["-h" "--help"]]) 170 | 171 | (defonce handle-error 172 | (.on js/process "uncaughtException" 173 | (fn [error] 174 | (js/console.error error)))) 175 | 176 | (defn print-usage [summary] 177 | (print "Program options:") 178 | (print summary)) 179 | 180 | (defn install-examples [] 181 | (let [josh-dir (path/dirname *file*) 182 | example-dir (path/join josh-dir "example") 183 | files ["index.html" "main.cljs" "style.css"]] 184 | (js/console.log "Copying example files here.") 185 | (p/do! 186 | (->> files 187 | (map #(p/catch 188 | (p/do! 189 | (fs/access %) 190 | (js/console.log % "exists already, skipping.")) 191 | (fn [_err] 192 | (js/console.log "Copying" %) 193 | (fs/cp (path/join example-dir %) %)))) 194 | (p/all)) 195 | (js/console.log "Now run josh to serve this folder.")))) 196 | 197 | (defn spath->posix 198 | "Converts SPATH to a POSIX-style path with '/' separators and returns it." 199 | [spath] 200 | (if (= os/path "/") 201 | spath 202 | (.join (.split spath path/sep) "/"))) 203 | 204 | (defn main 205 | [& args] 206 | (let [{:keys [errors options summary]} (cli/parse-opts args cli-options)] 207 | (cond errors 208 | (doseq [e errors] 209 | (print e)) 210 | (:help options) 211 | (print-usage summary) 212 | (:init options) 213 | (install-examples) 214 | :else 215 | (let [port (:port options) 216 | dir (:dir options)] 217 | ; watch this server itself 218 | (watch #js [*file*] 219 | (fn [_event-type filename] 220 | (js/console.log "Reloading" filename) 221 | (load-file filename))) 222 | ; watch served frontend filem 223 | (watch dir 224 | #js {:filter 225 | (fn [f] 226 | (or (.endsWith f ".css") 227 | (and 228 | (.endsWith f ".cljs") 229 | (try 230 | (fs-sync/accessSync 231 | f fs-sync/constants.R_OK) 232 | true 233 | (catch :default _e))))) 234 | :recursive true} 235 | (fn [event-type filepath] 236 | (let [filepath-rel (path/relative dir filepath) 237 | filepath-posix (spath->posix filepath-rel)] 238 | (frontend-file-changed event-type filepath-posix)))) 239 | ; launch the webserver 240 | (let [app (express)] 241 | (.get app "/*" #(html-injector %1 %2 %3 dir)) 242 | (.use app (.static express dir)) 243 | (.use app "/_cljs-josh" #(sse-handler %1 %2)) 244 | (.listen app port 245 | (fn [] 246 | (js/console.log (str "Serving " dir 247 | " on port " port ":")) 248 | (doseq [ip (reverse 249 | (sort-by count 250 | (get-local-ip-addresses)))] 251 | (js/console.log (str "- http://" ip ":" port)))))))))) 252 | 253 | (defonce started 254 | (apply main (not-empty (js->clj (.slice js/process.argv 2))))) 255 | -------------------------------------------------------------------------------- /josh.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { addClassPath, loadFile } from 'nbb'; 4 | import { fileURLToPath } from 'url'; 5 | import { dirname, resolve } from 'path'; 6 | 7 | const __dirname = fileURLToPath(dirname(import.meta.url)); 8 | 9 | addClassPath(resolve(__dirname)); 10 | const josh = await loadFile(resolve(__dirname, 'josh.cljs')); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cljs-josh", 3 | "version": "0.0.8", 4 | "description": "Scittle cljs live-reloading server.", 5 | "author": "Chris McCormick ", 6 | "homepage": "https://github.com/chr15m/cljs-josh", 7 | "bin": { 8 | "josh": "josh.mjs" 9 | }, 10 | "dependencies": { 11 | "express": "^4.21.1", 12 | "nbb": "^1.3.195", 13 | "node-watch": "^0.7.4" 14 | }, 15 | "scripts": { 16 | "prepublishOnly": "jq --argjson files \"$(git ls-files | jq -R . | jq -s .)\" '.files = $files' package.json > .package-tmp.json && mv .package-tmp.json package.json" 17 | }, 18 | "files": [ 19 | ".gitignore", 20 | "README.md", 21 | "example/index.html", 22 | "example/main.cljs", 23 | "example/style.css", 24 | "josh.cljs", 25 | "josh.mjs", 26 | "package.json" 27 | ] 28 | } 29 | --------------------------------------------------------------------------------