CHANGELOG | API | current Break Version:
[com.taoensso/sente "1.17.0"] ; See CHANGELOG for details
See here if you're interested in helping support my open-source work, thanks! - Peter Taoussanis
Or: We don't need no Socket.IO
Or: core.async + Ajax + WebSockets = The Shiznizzle
Sente is a small client+server library that makes it easy to build reliable, high-performance realtime web applications with Clojure + ClojureScript.
Sen-te (先手) is a Japanese Go term used to describe a play with such an overwhelming follow-up that it demands an immediate response, leaving its player with the initiative.
- Bidirectional a/sync comms over both WebSockets and Ajax (auto-fallback)
- It just works: auto keep-alives, buffering, protocol selection, reconnects
- Efficient design incl. transparent event batching for low-bandwidth use, even over Ajax
- Send arbitrary Clojure vals over edn or Transit (JSON, MessagePack, etc.)
- Tiny API:
make-channel-socket!
and you're good to go - Automatic, sensible support for users connected with multiple clients and/or devices simultaneously
- Realtime info on which users are connected over which protocols (v0.10.0+)
- Flexible model: use it anywhere you'd use WebSockets/Ajax/Socket.IO, etc.
- Standard Ring security model: auth as you like, HTTPS when available, CSRF support, etc.
- Fully documented, with examples
- Small codebase: ~1.5k lines for the entire client+server implementation
- Supported servers: http-kit, Immutant v2+, nginx-clojure, node.js, Aleph, ring-jetty9-adapter
Protocol | client>server | client>server + ack/reply | server>user push |
---|---|---|---|
WebSockets | ✓ (native) | ✓ (emulated) | ✓ (native) |
Ajax | ✓ (emulated) | ✓ (native) | ✓ (emulated) |
So you can ignore the underlying protocol and deal directly with Sente's unified API. It's simple, and exposes the best of both WebSockets (bidirectionality + performance) and Ajax (optional evented ack/reply model).
Note that there's also a variety of full example projects available
Add the necessary dependency to your project:
Leiningen: [com.taoensso/sente "1.17.0"] ; or
deps.edn: com.taoensso/sente {:mvn/version "1.17.0"}
First make sure that you're using one of the supported web servers (PRs for additional server adapters welcome!).
Somewhere in your web app's code you'll already have a routing mechanism in place for handling Ring requests by request URL. If you're using Compojure for example, you'll have something that looks like this:
(defroutes my-app
(GET "/" req (my-landing-pg-handler req))
(POST "/submit-form" req (my-form-submit-handler req)))
For Sente, we're going to add 2 new URLs and setup their handlers:
(ns my-server-side-routing-ns ; .clj
(:require
;; <other stuff>
[taoensso.sente :as sente] ; <--- Add this
[ring.middleware.anti-forgery :refer [wrap-anti-forgery]] ; <--- Recommended
;; Uncomment a web-server adapter --->
;; [taoensso.sente.server-adapters.http-kit :refer (get-sch-adapter)]
;; [taoensso.sente.server-adapters.immutant :refer (get-sch-adapter)]
;; [taoensso.sente.server-adapters.nginx-clojure :refer (get-sch-adapter)]
;; [taoensso.sente.server-adapters.aleph :refer (get-sch-adapter)]
))
;;; Add this: --->
(let [{:keys [ch-recv send-fn connected-uids
ajax-post-fn ajax-get-or-ws-handshake-fn]}
(sente/make-channel-socket! (get-sch-adapter) {})]
(def ring-ajax-post ajax-post-fn)
(def ring-ajax-get-or-ws-handshake ajax-get-or-ws-handshake-fn)
(def ch-chsk ch-recv) ; ChannelSocket's receive channel
(def chsk-send! send-fn) ; ChannelSocket's send API fn
(def connected-uids connected-uids) ; Watchable, read-only atom
)
(defroutes my-app-routes
;; <other stuff>
;;; Add these 2 entries: --->
(GET "/chsk" req (ring-ajax-get-or-ws-handshake req))
(POST "/chsk" req (ring-ajax-post req))
)
(def my-app
(-> my-app-routes
;; Add necessary Ring middleware:
ring.middleware.keyword-params/wrap-keyword-params
ring.middleware.params/wrap-params
ring.middleware.anti-forgery/wrap-anti-forgery
ring.middleware.session/wrap-session))
The
ring-ajax-post
andring-ajax-get-or-ws-handshake
fns will automatically handle Ring GET and POST requests to our channel socket URL ("/chsk"
). Together these take care of the messy details of establishing + maintaining WebSocket or long-polling requests.
Add a CSRF token somewhere in your HTML:
(let [csrf-token (force ring.middleware.anti-forgery/*anti-forgery-token*)]
[:div#sente-csrf-token {:data-csrf-token csrf-token}])
You'll setup something similar on the client side:
(ns my-client-side-ns ; .cljs
(:require-macros
[cljs.core.async.macros :as asyncm :refer (go go-loop)])
(:require
;; <other stuff>
[cljs.core.async :as async :refer (<! >! put! chan)]
[taoensso.sente :as sente :refer (cb-success?)] ; <--- Add this
))
;;; Add this: --->
(def ?csrf-token
(when-let [el (.getElementById js/document "sente-csrf-token")]
(.getAttribute el "data-csrf-token")))
(let [{:keys [chsk ch-recv send-fn state]}
(sente/make-channel-socket-client!
"/chsk" ; Note the same path as before
?csrf-token
{:type :auto ; e/o #{:auto :ajax :ws}
})]
(def chsk chsk)
(def ch-chsk ch-recv) ; ChannelSocket's receive channel
(def chsk-send! send-fn) ; ChannelSocket's send API fn
(def chsk-state state) ; Watchable, read-only atom
)
The client will automatically initiate a WebSocket or repeating long-polling connection to your server. Client<->server events are now ready to transmit over the ch-chsk
channel.
Last step: you'll want to hook your own event handlers up to this channel. Please see one of the example projects for details.
ch-recv
is a core.async channel that'll receiveevent-msg
schsk-send!
is a(fn [event & [?timeout-ms ?cb-fn]])
for standard client>server req>resp calls
ch-recv
is a core.async channel that'll receiveevent-msg
schsk-send!
is a(fn [user-id event])
for async server>user PUSH calls
===============
Term | Form |
---|---|
event | [<ev-id> <?ev-data>] , e.g. [:my-app/some-req {:data "data"}] |
server event-msg | {:keys [event id ?data send-fn ?reply-fn uid ring-req client-id]} |
client event-msg | {:keys [event id ?data send-fn]} |
<ev-id> |
A namespaced keyword like :my-app/some-req |
<?ev-data> |
An optional arbitrary edn value like {:data "data"} |
:ring-req |
Ring map for Ajax request or WebSocket's initial handshake request |
:?reply-fn |
Present only when client requested a reply |
- So clients can use
chsk-send!
to sendevent
s to the server and optionally request a reply with timeout - The server can likewise use
chsk-send!
to sendevent
s to all the clients (browser tabs, devices, etc.) of a particular connected user by his/heruser-id
- The server can also use an
event-msg
's?reply-fn
to reply to a particular clientevent
using an arbitrary edn value
It's worth noting that the server>user push
(chsk-send! <user-id> <event>)
takes a mandatory user-id argument. See the FAQ later for more info.
(jayq/ajax ; Using the jayq wrapper around jQuery
{:type :post :url "/some-url-on-server/"
:data {:name "Rich Hickey"
:type "Awesome"}
:timeout 8000
:success (fn [content text-status xhr]
(do-something! content))
:error (fn [xhr text-status] (error-handler!))})
(chsk-send! ; Using Sente
[:some/request-id {:name "Rich Hickey" :type "Awesome"}] ; Event
8000 ; Timeout
;; Optional callback:
(fn [reply] ; Reply is arbitrary Clojure data
(if (sente/cb-success? reply) ; Checks for :chsk/closed, :chsk/timeout, :chsk/error
(do-something! reply)
(error-handler!))))
Some important differences to note:
- The Ajax request is slow to initialize, and bulky (HTTP overhead)
- The Sente request is pre-initialized (usu. WebSocket), and lean (edn/Transit protocol)
- Ajax would require clumsy long-polling setup, and wouldn't easily support users connected with multiple clients simultaneously
- Sente:
(chsk-send! "destination-user-id" [:some/alert-id <arb-clj-data-payload>])
Each time the channel socket client's state changes, a client-side :chsk/state
event will fire that you can watch for and handle like any other event.
The event form is [:chsk/state [<old-state-map> <new-state-map>]]
with the following possible state map keys:
Key | Value |
---|---|
:type | e/o #{:auto :ws :ajax} |
:open? | Truthy iff chsk appears to be open (connected) now |
:ever-opened? | Truthy iff chsk handshake has ever completed successfully |
:first-open? | Truthy iff chsk just completed first successful handshake |
:uid | User id provided by server on handshake, or nil |
:csrf-token | CSRF token provided by server on handshake, or nil |
:handshake-data | Arb user data provided by server on handshake |
:last-ws-error | ?{:udt _ :ev <WebSocket-on-error-event>} |
:last-ws-close | ?{:udt _ :ev <WebSocket-on-close-event> :clean? _ :code _ :reason _} |
:last-close | ?{:udt _ :reason _} , with reason e/o #{nil :requested-disconnect :requested-reconnect :downgrading-ws-to-ajax :unexpected} |
Please note that unofficial examples are provided by the community and may contain out-of-date or inaccurate information. If you spot issues with any of the community examples, please contact the relevant authors to let them know!
Link | Description |
---|---|
Official example | Official Sente reference example, always up-to-date |
@fiv0/spa-ws-template | Example Single Page App with ReFrame, http-kit, shadow-cljs |
@dharrigan/websockets | Example using Reitit, Jetty 9/10 and @dharrigan/websockets-js (JS not Cljs!) |
@laforge49/sente-boot | Example using Sente v1.11.0, Boot (also works with Windows) |
@laforge49/sente-boot-reagent | Example using Sente v1.11.0, Boot, and Reagent |
@tiensonqin/lymchat | Example chat app using React Native |
@danielsz/system-websockets | Client-side UI, login and wiring of components |
@timothypratley/snakelake | Multiplayer snake game with screencast walkthrough |
@theasp/sente-nodejs-example | Ref. example adapted for Node.js servers (Express, Dog Fort), as well as a node.js client |
@ebellani/carpet | Web+mobile interface for a remmitance application |
@danielsz/sente-system | Ref example adapted for @danielsz/system |
@danielsz/sente-boot | Ref example adapted for boot |
@seancorfield/om-sente | ?? |
@tfoldi/data15-blackjack | Multiplayer blackjack game with documented source code |
@davidvujic/sente-with-reagent-and-re-frame | Example code that combines Sente with Reagent and re-frame in a single page application |
Your link here? | PR's welcome! |
There's now also a full
user-id
,client-id
summary up here
For the server to push events, we need a destination. Traditionally we might push to a client (e.g. browser tab). But with modern rich web applications and the increasing use of multiple simultaneous devices (tablets, mobiles, etc.) - the value of a client push is diminishing. You'll often see applications (even by Google) struggling to deal with these cases.
Sente offers an out-the-box solution by pulling the concept of identity one level higher and dealing with unique users rather than clients. What constitutes a user is entirely at the discretion of each application:
- Each user-id may have zero or more connected clients at any given time
- Each user-id may survive across clients (browser tabs, devices), and sessions
To give a user an identity, either set the user's :uid
Ring session key OR supply a :user-id-fn
(takes request, returns an identity string) to the make-channel-socket!
constructor.
If you want a simple per-session identity, generate a random uuid. If you want an identity that persists across sessions, try use something with semantic meaning that you may already have like a database-generated user-id, a login email address, a secure URL fragment, etc.
Note that user-ids are used only for server>user push. client>server requests don't take a user-id.
As of Sente v0.13.0+ it's also possible to send events to :sente/all-users-without-uid
.
This is trivially easy as of Sente v0.13.0+. Please see one of the example projects for details.
Sure! Sente's just a client<->server comms mechanism so it'll work with any view/rendering approach you'd like.
I have a strong preference for Reagent myself, so would recommend checking that out first if you're still evaluating options.
As of v1, Sente uses an extensible client<->server serialization mechanism. It uses edn by default since this usu. gives good performance and doesn't require any external dependencies. The reference example project shows how you can plug in an alternative de/serializer. In particular, note that Sente ships with a Transit de/serializer that allows manual or smart (automatic) per-payload format selection.
To add custom handlers to the TransitPacker, pass them in as writer-opts
and reader-opts
when creating a TransitPacker
. These arguments are the same as the opts
map you would pass directly to transit/writer
. The code sample below shows how you would do this to add a write handler to convert Joda-Time DateTime
objects to Transit time
objects.
(ns my-ns.app
(:require [cognitect.transit :as transit]
[taoensso.sente.packers.transit :as sente-transit])
(:import [org.joda.time DateTime ReadableInstant]))
;; From https://increasinglyfunctional.com/2014/09/02/custom-transit-writers-clojure-joda-time.html
(def joda-time-writer
(transit/write-handler
(constantly "m")
(fn [v] (-> ^ReadableInstant v .getMillis))
(fn [v] (-> ^ReadableInstant v .getMillis .toString))))
(def packer (sente-transit/->TransitPacker :json {:handlers {DateTime joda-time-writer}} {}))
However you like! If you don't have many events, a simple cond
will probably do. Otherwise a multimethod dispatching against event ids works well (this is the approach taken in the reference example project).
Yup, it's automatic for both Ajax and WebSockets. If the page serving your JavaScript (ClojureScript) is running HTTPS, your Sente channel sockets will run over HTTPS and/or the WebSocket equivalent (WSS).
This is important. Sente has support, and use is strongly recommended. You'll need to use middleware like ring-anti-forgery or ring-defaults to generate and check CSRF codes. The ring-ajax-post
handler should be covered (i.e. protected).
Please see one of the example projects for a fully-baked example.
You'll want to listen on the receive channel for a [:chsk/state [_ {:first-open? true}]]
event. That's the signal that the socket's been established.
Update: @danielsz has kindly provided a detailed example here.
Recall that server-side event-msg
s are of the form {:ring-req _ :event _ :?reply-fn _}
, so each server-side event is accompanied by the relevant[*] Ring request.
For WebSocket events this is the initial Ring HTTP handshake request, for Ajax events it's just the Ring HTTP Ajax request.
The Ring request's :session
key is an immutable value, so how do you modify a session in response to an event? You won't be doing this often, but it can be handy (e.g. for login/logout forms).
You've got two choices:
-
Write any changes directly to your Ring SessionStore (i.e. the mutable state that's actually backing your sessions). You'll need the relevant user's session key, which you can find under your Ring request's
:cookies
key. This is flexible, but requires that you know how+where your session data is being stored. -
Just use regular HTTP Ajax requests for stuff that needs to modify sessions (like login/logout), since these will automatically go through the usual Ring session middleware and let you modify a session with a simple
{:status 200 :session <new-session>}
response. This is the strategy the reference example takes.
Using something like @stuartsierra/component or @palletops/leaven?
Most of Sente's state is held internally to each channel socket (the map returned from client/server calls to make-channel-socket!
). The absence of global state makes things like testing, and running multiple concurrent connections easy. It also makes integration with your component management easy.
The only thing you may[1] want to do on component shutdown is stop any router loops that you've created to dispatch events to handlers. The client/server side start-chsk-router!
fns both return a (fn stop [])
that you can call to do this.
[1] The cost of not doing this is actually negligible (a single parked go thread).
There's also a couple lifecycle libraries that include Sente components:
- @danielsz/system for use with @stuartsierra/component
- @palletops/bakery for use with @palletops/leaven
@arichiardi has kindly provided notes on some of Sente's current implementation details here.
If I've missed something here, feel free to open a GitHub issue or pop me an email!
Please use the project's GitHub issues page for all questions, ideas, etc. Pull requests welcome. See the project's GitHub contributors page for a list of contributors.
Otherwise, you can reach me at Taoensso.com. Happy hacking!
Distributed under the EPL v1.0 (same as Clojure).
Copyright © 2014-2020 Peter Taoussanis.