A Fulcro Review + Websockets!
Fulcro?
A breath of fresh air
Fulcro has gotten way better, it's probably the closest thing I've seen (other than Luminus) to providing a cohesive batteries-included clojure web offering.
That isn't a slight against Luminus in the slightest, it's a solid offering providing exactly what you need to serve webpages or an api, however if you've been enviously staring Phoenix's web offering wanting simple access to websockets and not knowing where to start Luminus isn't going to go out of its way to help you.
Beginners have very much had to walk their own path, not that the path is too challenging, but every extra hurdle you add to people getting started can seem an insurmountable barrier.
Fulcro isn't perfect, there are some things I'm not happy with, their stance against hiccup for one, but honestly, at this point, it's a quibble.
And the most significant thing? It has a Guide! Tony Kay has clearly spent an enormous amount of time creating cohesive and easy to understand docs, there's even a great getting started section that shows you how an application is built from the ground up, I'm impressed.
The best part about all of this? Both Fulcro and Luminus are strongly plugged into clojure's philosophy about things being libraries, so as your application grows and you want to adjust its functionality, you can, in fact, mix and match the bits you like from both, they're pretty cross-compatible.
The Websockets bit!
So, what's this about websockets? I did mention that as a pretty big gain versus Luminus, but turning them on isn't quite documented as well as I'd like, specifically the examples are currently for immutant, but the fulcro template gives you http-kit. I expect this is a temporary oversight, but for the moment here's what you need do to.
Starting at the template:
git clone --depth 1 -o fulcro-template https://github.com/fulcrologic/fulcro-template.git fulcro-app
cd fulcro-app
npm install
So in deps.edn
:
com.fulcrologic/fulcro-websockets {:mvn/version "3.1.1"}
Create an app.server-components.websockets
ns:
(ns app.server-components.websockets
(:require [mount.core :refer [defstate]]
[com.fulcrologic.fulcro.networking.websockets :as fws]
[taoensso.sente.server-adapters.http-kit :refer [get-sch-adapter]]
[app.server-components.pathom :refer [parser]]))
(defstate websockets
:start
(fws/start! (fws/make-websockets
parser
{:http-server-adapter (get-sch-adapter)
:parser-accepts-env? true
;; I'm not going to cover how to handle CSRF well, if this is a toy project
;; this bit doesn't matter as much, if it isn't please sit down and read about it.
;; I've added some notes at the end.
:sente-options {:csrf-token-fn nil}}))
:stop
(fws/stop! websockets))
(defn wrap-websockets [handler]
(fws/wrap-api handler websockets))
Edit app.server-components.middleware
and add an entry in middleware for wrap-websockets
:
(ns app.server-components.middleware
(:require [mount.core :refer [defstate]]
[com.fulcrologic.fulcro.server.api-middleware :refer [handle-api-request
wrap-transit-params
wrap-transit-response]]
[ring.middleware.defaults :refer [wrap-defaults]]
[ring.middleware.gzip :refer [wrap-gzip]]
[ring.util.response :refer [response file-response resource-response]]
[ring.util.response :as resp]
[hiccup.page :refer [html5]]
[taoensso.timbre :as log]
[app.server-components.config :refer [config]]
[app.server-components.pathom :refer [parser]]
[app.server-components.websockets :refer [wrap-websockets]])) ; <--- ADDED AT THE END
...
(defstate middleware
:start
(let [defaults-config (:ring.middleware/defaults-config config)
legal-origins (get config :legal-origins #{"localhost"})]
(-> not-found-handler
(wrap-api "/api")
(wrap-websockets) ; <--- STICK THIS IN
wrap-transit-params
wrap-transit-response
(wrap-html-routes)
;; If you want to set something like session store, you'd do it against
;; the defaults-config here (which comes from an EDN file, so it can't have
;; code initialized).
;; E.g. (wrap-defaults (assoc-in defaults-config [:session :store] (my-store)))
(wrap-defaults defaults-config)
wrap-gzip)))
Edit app.application
's definition of SPA
:
(defonce SPA (app/fulcro-app
{;; This ensures your client can talk to a CSRF-protected server.
;; See middleware.clj to see how the token is embedded into the HTML
:remotes {:remote (net/fulcro-http-remote
{:url "/api"
:request-middleware secured-request-middleware})
:ws-remote (fws/fulcro-websocket-remote {})}}))
That's the basics! Now, how do we use them?
Let's take a look at signup!
in app.model.session
:
(defmutation signup! [_]
(action [{:keys [state]}]
(log/info "Marking complete")
(swap! state fs/mark-complete* signup-ident))
(ok-action [{:keys [app state]}]
(dr/change-route app ["signup-success"]))
(remote [{:keys [state] :as env}]
(let [{:account/keys [email password password-again]} (get-in @state signup-ident)]
(boolean (and (valid-email? email) (valid-password? password)
(= password password-again))))))
That remote
call is basically using the remote referenced in [:remotes :remote]
so sending the request to the /api
http endpoint, so if we want to send it over the websocket instead?
(defmutation signup! [_]
(action [{:keys [state]}]
(log/info "Marking complete")
(swap! state fs/mark-complete* signup-ident))
(ok-action [{:keys [app state]}]
(dr/change-route app ["signup-success"]))
(ws-remote [{:keys [state] :as env}] ; <-- Just change it to match the keyword
(let [{:account/keys [email password password-again]} (get-in @state signup-ident)]
(boolean (and (valid-email? email) (valid-password? password)
(= password password-again))))))
Now I sign up with test@example.com
/testtest
and check out the chrome Network panel:
↑+[["~:fulcro.client/API",[["~#list",["~$app.model.session/signup!",["^ ","~:email","test@example.com","~:password","testtest"]]]]],"575c78"]
↓+[["^ ","~:status",200,"~:body",["^ ","~$app.model.session/signup!",["^ ","~:signup/result","OK"],"~:com.wsscode.pathom/trace",["^ ","~:start",5141,"~:path",[],"~:duration",129,"~...
We can see the websocket messages travelling, if you have Fulcro Inspect
installed, under it's Network tab we can see:
Request
[(app.model.session/signup! {:email "test@example.com", :password "testtest"})]
Response
{app.model.session/signup! {:signup/result "OK"}}
Similarly login can be sent over websockets by changing this:
(defn login [{::uism/keys [event-data] :as env}]
(-> env
(clear)
(uism/trigger-remote-mutation :actor/login-form 'app.model.session/login
{:username (:username event-data)
:password (:password event-data)
::m/returning (uism/actor-class env :actor/current-session)
::uism/ok-event :event/complete
::uism/error-event :event/failed})
(uism/activate :state/checking-session)))
to this:
(defn login [{::uism/keys [event-data] :as env}]
(-> env
(clear)
(uism/trigger-remote-mutation :actor/login-form 'app.model.session/login
{:username (:username event-data)
:password (:password event-data)
::m/returning (uism/actor-class env :actor/current-session)
::uism/ok-event :event/complete
::uism/error-event :event/failed
::uism/mutation-remote :ws-remote}) ; <-- Add the keyword
(uism/activate :state/checking-session)))
And there we go!
PS: CSRF NOTES
Security is hard to get right, so please spend some time reading about it, and if you make something serious, have someone (preferably more than one), look over it =)...
Some starting points:
PPS: An Example!
If you're having issues, I've deployed a currently up-to-date version of the code here. If problems crop up, pay attention to the deps.edn
versions, make sure they're up-to-date.