Skip to content

Instantly share code, notes, and snippets.

@bhauman
Last active July 4, 2019 14:14
Show Gist options
  • Save bhauman/d731eb4cb54fa187c341aec75f62dd83 to your computer and use it in GitHub Desktop.
Save bhauman/d731eb4cb54fa187c341aec75f62dd83 to your computer and use it in GitHub Desktop.
simple figwheel
(ns figwheel.simple
(:require
[clojure.string :as string]
#?@(:cljs [[goog.object :as gobj]])
#?@(:clj [[clojure.walk :as walk]
[cljs.repl.browser :as brow]
[cljs.repl :as repl]
[cljs.env :as env]
[cljs.analyzer :as ana]
[cljs.build.api :as bapi]
[clojure.data :as data]
[clojure.java.io :as io]
[clojure.set :as st]
[clojure.java.shell :as shell]])))
;; This is a Proof of concept minimal implementation of Figwheel
;; I have thought about this architecture for the last year or so
;; but the advent of Clojure CLI tools and cljs.main makes this work more relevant
;; Major points
;; - a single file that simply needs to be required in the CLJS REPL
;; (require '[figwheel.simple :include-macros true])
;; - adds a watch to the env/*compiler* atom to know when a compile has occured
;; - watching the compile env var de-couples figwheel from the method of file watching
;; anything that compiles the source will be sending a compile signal to figwheel
;; - simply uses the repls connection to send messages via eval to the client
;; This pattern is interesting and it can be reused by different tools.
;; This pattern can be used in a pREPL as well.
;; The drive of this experiment is to see discover what CLJS
;; needs to provide to support this tooling pattern
;; Three main asks have come out of this experiment:
;; 1. Add a cljs.repl/*repl-env* and bind it when you start a repl
;; this would enable tools to eval on the client, very general and powerful
;; 2. Add a binding for cljs.analyzer/*cljs-warning-handlers* so that
;; tools can listen for compiler warnings without having to alter-var-root
;; 3. Add a more concrete signal to notify when a compile finishes or throws an
;; exception.
;; The env/*compiler* is already bound in the environment so
;; it's a decent candidate to append some compile finished information to
;; For example:
;; At the end of a compile, or when an exception is thrown
;; (swap! en/*compiler*
;; assoc :cljs.closure/finished-compile {:time currentTimeMillis :exception (on exception)}
#?(:cljs (enable-console-print!))
#?(:cljs (defn name->path [ns]
(gobj/get js/goog.dependencies_.nameToPath ns)))
#?(:cljs (defn provided? [ns]
(gobj/get js/goog.dependencies_.written (name->path ns))))
#?(:cljs (defn ns-exists? [namespace]
(some? (reduce (fnil gobj/get #js{})
js/goog.global (string/split (name namespace) ".")))))
#?(:cljs (defn reload-ns? [namespace]
(or (provided? namespace) (ns-exists? namespace))))
#?(:cljs
(defn ^:export reload-namespaces [namespaces]
(doseq [n namespaces]
(try
(if (reload-ns? n)
(do
(js/goog.require n true)
(println "[Figwheel] Reloaded: " n))
(println "[Figwheel] Did not reload un-required namespace:" n))
(catch js/Error e
;; TODO console.error is not cross platform
(js/console.error e)
nil)))))
#?(:cljs
(defn ^:export reload [[roots changed-namespaces :as payload]]
(doseq [[n deps] roots]
(when (and (reload-ns? n) (string? deps))
;; TODO console.error is not cross platform
(try (js/eval deps) (catch js/Error e (js/console.error e)))))
(reload-namespaces changed-namespaces)))
#?(:clj
(do
(def ^:dynamic *repl-env*)
;; don't really need to pull in a json lib for this
(defn- jsonify-string-vector [v]
(clojure.walk/postwalk (fn [x]
(cond
(vector? x)
(str "[" (string/join "," x) "]")
(and (string? x)
(not (.startsWith x "[")))
(pr-str x)
:else x))
v))
(defrecord FakeReplEnv []
cljs.repl/IJavaScriptEnv
(-setup [this opts])
(-evaluate [_ _ _ js] js)
(-load [this ns url])
(-tear-down [_] true))
;; this is a hack for now, easy enough to write this without the hack
(let [noop-repl-env (FakeReplEnv.)]
(defn add-dependiencies-js [ns-sym output-dir]
(cljs.repl/load-namespace noop-repl-env ns-sym {:output-dir (or output-dir "out")})))
(defn root-namespaces [env]
(st/difference (->> env :sources (mapv :ns) (into #{}))
(->> env :sources (map :requires) (reduce into #{}))))
(defn output-dir []
(-> @env/*compiler* :options :output-dir (or "out")))
(defn reload-payload [env changed-namespaces]
(let [roots (root-namespaces env)
output-dir' (output-dir)]
[(mapv
(juxt (comp str cljs.compiler/munge)
#(add-dependiencies-js % output-dir'))
roots)
(vec (reverse (map (comp str cljs.compiler/munge) changed-namespaces)))]))
;; TODO probably need to check that topo order is respected
;; TODO also need to expand dependents when :recompile-dependents is false
(defn reload-namespaces [env changed-namespaces]
(when (not-empty changed-namespaces)
(repl/-evaluate
*repl-env*
"<cljs repl>" 1
(format
"figwheel.simple.reload(%s);"
(jsonify-string-vector (reload-payload env changed-namespaces))))))
;; this is super naive but works for now,
;; there are several equally valid ways to detect which namespaces have changed
(defn changed-namespaces? [compiler-env]
(->> compiler-env :cljs.closure/compiled-cljs vals (filter :source-map)))
(defn clean-slate [env]
(update-in env [:cljs.closure/compiled-cljs]
#(into {}
(mapv (fn [[k v]]
[k (if (map? v) (dissoc v :source-map) v)])
%))))
;; Handle warnings
(defn warning-handler [warning-type env extra]
(when (warning-type cljs.analyzer/*cljs-warnings*)
(when-let [s (cljs.analyzer/error-message warning-type extra)]
s)))
(defn create-warning-handler [repl-env compiler-env]
(vary-meta
(fn [warning-type env extra]
(let [message (warning-handler warning-type env extra)]
(binding [env/*compiler* compiler-env
*repl-env* repl-env]
(repl/-evaluate
*repl-env*
"<cljs repl>" 1
(format
"console.warn(%s);"
(pr-str (str "[Figwheel Warning]: " message)))))))
assoc ::warning-handler true))
(defn remove-figwheel-warning-handlers [warning-handlers]
(vec (filter
(complement #(some-> % meta ::warning-handler))
warning-handlers)))
(defmacro fig-start []
;; todo add warning handler
(swap! env/*compiler* clean-slate)
;; add only one warning handler
(set! cljs.analyzer/*cljs-warning-handlers*
(conj (remove-figwheel-warning-handlers cljs.analyzer/*cljs-warning-handlers*)
(create-warning-handler *repl-env* env/*compiler*)))
(add-watch env/*compiler*
::figwheel
(let [state (atom {})]
(fn [_ _ _ compiler-env]
(when-let [changed-ns (not-empty (changed-namespaces? compiler-env))]
(if-not (:start-time @state)
(do
(swap! state assoc :start-time (System/currentTimeMillis))
;; this complexity wouldn't be needed if there was a clear signal that
;; compile has completed
(.start (Thread.
(bound-fn []
(Thread/sleep 300)
(reload-namespaces compiler-env (:changed-namespaces @state))
(swap! env/*compiler* clean-slate) ;; nasty :)
(Thread/sleep 500)
(reset! state {})))))
(swap! state assoc :changed-namespaces (mapv :ns changed-ns)))))))
"Figwheel Starting!")
(defmacro fig-stop []
;; TODO remove the warning handler as well
(set! cljs.analyzer/*cljs-warning-handlers*
(remove-figwheel-warning-handlers cljs.analyzer/*cljs-warning-handlers*))
(remove-watch env/*compiler* ::figwheel))
(defn -main []
(binding [*repl-env* (brow/repl-env)
cljs.analyzer/*cljs-warning-handlers* cljs.analyzer/*cljs-warning-handlers*]
(repl/repl
*repl-env*
:watch "src")))))
(comment
(shell/sh "touch" "src/example/core.cljs")
(shell/sh "touch" "src/example/some.cljs")
(changed-namespaces (swap! env clean-slate))
(def env (cljs.env/default-compiler-env))
(:cljs.closure/compiled-cljs @env)
(:sources @env)
(keys @env)
(keys (:cljs.analyzer/namespaces @env))
(bapi/build "src" nil env)
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment