re-work app-specified callbacks into a 'middleware' system
This commit is contained in:
parent
ada511f5d2
commit
0a1ee1df25
156
README.md
156
README.md
|
@ -3,165 +3,13 @@
|
|||
Helper utilities and Ring middleware for using [BrowserChannel](http://thegeez.net/2012/04/03/why_browserchannel.html)
|
||||
as a real-time client-server messaging protocol in your Clojure/ClojureScript web apps.
|
||||
|
||||
**Note: This library is currently "beta status." As such some of this setup may be a bit overly complex
|
||||
and the documentation a bit rough.**
|
||||
**Currently in early development. Documentation and examples to come!**
|
||||
|
||||
## Usage
|
||||
|
||||
### Dependencies
|
||||
TODO: write better and more complete usage instructions + examples
|
||||
|
||||
None of the current versions of Clojure BrowserChannel libraries we need (including this library at the moment)
|
||||
are available on Clojars.
|
||||
|
||||
You will need to install **clj-browserchannel-server** and **clj-browserchannel-jetty-adapter** manually via
|
||||
`lein install`. This library depends on the versions of these libraries
|
||||
[located here](https://github.com/gered/clj-browserchannel) currently.
|
||||
|
||||
Then you will need to locally install this library via `lein install` as well.
|
||||
|
||||
### `project.clj`
|
||||
|
||||
Add these to your dependencies.
|
||||
|
||||
```clojure
|
||||
[net.thegeez/clj-browserchannel-jetty-adapter "0.0.5"]
|
||||
[clj-browserchannel-messaging "0.0.1"]
|
||||
```
|
||||
|
||||
## Message Format
|
||||
|
||||
This library wraps messages sent/received by client and server in a lightly structured format:
|
||||
|
||||
```clojure
|
||||
{:topic <<keyword describing the contents of the message>>
|
||||
:body <<any clojure data structure or value>>}
|
||||
```
|
||||
|
||||
The topic is kind of like a message type or category. Similar types of messages communicating the same types of
|
||||
information should share the same message topic.
|
||||
|
||||
## Server-side Setup
|
||||
|
||||
### Jetty Async
|
||||
|
||||
Both clj-browserchannel and this library _require_ use of an async HTTP server. The
|
||||
**clj-browserchannel-jetty-adapter** library you installed previously contains the Jetty Async adapter that can be
|
||||
used by **clj-browserchannel-server**.
|
||||
|
||||
```clojure
|
||||
(ns yourapp
|
||||
(:gen-class)
|
||||
(:require [net.thegeez.jetty-async-adapter :refer [run-jetty-async]]))
|
||||
|
||||
(defn -main [& args]
|
||||
(run-jetty-async handler {:port 8080 :join? false}))
|
||||
```
|
||||
|
||||
### Ring Middleware
|
||||
|
||||
You need to add the `clj-browserchannel-messaging.server/wrap-browserchannel` middleware to your Ring app handler.
|
||||
|
||||
Note that currently, this library does not play nice with Ring's `wrap-anti-forgery` middleware. If you are using
|
||||
lib-noir or ring-defaults, then this middleware is enabled by default when using the `site-defaults` settings
|
||||
from ring-defaults. You will need to disable this by setting, e.g.:
|
||||
|
||||
```clojure
|
||||
(assoc-in site-defaults [:security :anti-forgery] false)
|
||||
```
|
||||
|
||||
Otherwise you will get 403 access denied responses when sending BrowserChannel messages from client to server. This
|
||||
will be addressed properly in this library in a future release.
|
||||
|
||||
See the doc comments for `clj-browserchannel-messaging.server/wrap-browserchannel` for more detailed descriptions
|
||||
of the options you can pass to this middleware. Example usage:
|
||||
|
||||
```clojure
|
||||
(wrap-browserchannel
|
||||
{:on-open (fn [browserchannel-session-id request]
|
||||
(println "browserchannel session opened with client:" browserchannel-session-id))
|
||||
:on-close (fn [browserchannel-session-id request reason]
|
||||
(println "browserchannel session closed for client:" browserchannel-session-id ", reason:" reason))
|
||||
:on-receive (fn [browserchannel-session-id request message]
|
||||
(println "received message from client" browserchannel-session-id ":" message))})
|
||||
```
|
||||
|
||||
### Sending and Receiving Messages
|
||||
|
||||
The `:on-receive` handler passed to `wrap-browserchannel` will be invoked when any message is received from any
|
||||
BrowserChannel client. It's basically your main event handler.
|
||||
|
||||
However, you can also use `clj-browserchannel-messaging.server/message-handler` anywhere in your application code
|
||||
to listen for specific types of messages and provide a separate handler function to run when they are received.
|
||||
|
||||
```clojure
|
||||
(message-handler
|
||||
:foobar
|
||||
(fn [msg]
|
||||
(println "received :foobar message:" msg)))
|
||||
```
|
||||
|
||||
To send a message to a client, you must have the "BrowserChannel session id" associated with that client. This is
|
||||
first generated and passed to the `:on-open` event and becomes invalid after `:on-close`. All messages received
|
||||
from the client will automatically have the BrowserChannel session id of the sending client included in the message
|
||||
under the `:browserchannel-session-id` key.
|
||||
|
||||
Use the `clj-browserchannel-messaging.server/send` function to send a message to a client. If the message could not
|
||||
be sent for any reason, a nil value is returned.
|
||||
|
||||
#### BrowserChannel Sessions
|
||||
|
||||
Just a quick note about BrowserChannel Sessions. They are essentially tied to the length of time that a user has
|
||||
a single page of the web app open in their browser, so obviously it goes without saying that BrowserChannel should be
|
||||
used by Single Page Apps or other applications that keep the user on a single page for a lengthy time and have heavy
|
||||
client-side scripting driving the UI.
|
||||
|
||||
When the page is first loaded, the BrowserChannel setup occurs and a session id is generated (`:on-open`). The user
|
||||
then continues using the app in their browser and if they leave the page or close their browser, the BrowserChannel
|
||||
connection and session is closed (`:on-close`). If the user refreshes the page with their browser, the existing
|
||||
session is closed and a new one is created when the page reloads.
|
||||
|
||||
Also, BrowserChannel sessions can expire after a period of inactivity (the `:on-close` reason will be "Timed out").
|
||||
|
||||
## Client-side Setup
|
||||
|
||||
### Page Load
|
||||
|
||||
When the page loads, in your ClojureScript code you should call `clj-browserchannel-messaging.client/init!`. This
|
||||
function takes some options, the most important of which are callbacks (similar in idea to the callbacks you specify
|
||||
to the `wrap-browserchannel` middleware on the server-side).
|
||||
|
||||
See the doc comments for `clj-browserchannel-messaging.client/init!` for more detailed descriptions of the options
|
||||
available. Example usage:
|
||||
|
||||
```clojure
|
||||
(init!
|
||||
{:on-open (fn []
|
||||
(println "on-open"))
|
||||
:on-send (fn [msg]
|
||||
(println "sending" msg))
|
||||
:on-receive (fn [msg]
|
||||
(println "receiving" msg))
|
||||
:on-close (fn [pending undelivered]
|
||||
(println "closed" pending undelivered))
|
||||
:on-error (fn [error]
|
||||
(println "error:" error))})
|
||||
```
|
||||
|
||||
On the client-side, you'll probably care most about `:on-receive` and possibly `:on-close` and `:on-error` to help
|
||||
gracefully deal with connection loss / server timeouts.
|
||||
|
||||
### Sending and Receiving Messages
|
||||
|
||||
Note that, unlike on the server, the client does not deal with any "BrowserChannel session ids." That is because
|
||||
it only sends and receives messages to/from the server, not directly to other clients.
|
||||
|
||||
Like on the server, the `:on-receive` handler will be invoked when any message is received from the server.
|
||||
|
||||
You can also use `clj-browserchannel-messaging.client/message-handler` which works in exactly the same manner as the
|
||||
server-side version mentioned above.
|
||||
|
||||
To send a message to the server, use the `clj-browserchannel-messaging.client/send` function. If the message could
|
||||
not be sent for any reason, a nil value is returned.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
(defproject clj-browserchannel-messaging "0.0.1"
|
||||
(defproject clj-browserchannel-messaging "0.0.2"
|
||||
:description "Tools for quickly using BrowserChannel for bi-directional client-server messaging."
|
||||
:url "https://github.com/gered/clj-browserchannel-messaging"
|
||||
:license {:name "MIT License"
|
||||
|
@ -7,7 +7,7 @@
|
|||
:dependencies [[org.clojure/clojure "1.6.0"]
|
||||
[org.clojure/clojurescript "0.0-2371" :scope "provided"]
|
||||
[org.clojure/core.async "0.1.346.0-17112a-alpha"]
|
||||
[net.thegeez/clj-browserchannel-server "0.0.9"]]
|
||||
[net.thegeez/clj-browserchannel-server "0.1.0"]]
|
||||
|
||||
:source-paths ["src/clj"]
|
||||
:resource-paths ["src/cljs"])
|
||||
|
|
|
@ -4,15 +4,25 @@
|
|||
[clojure.core.async :refer [chan pub sub <! put! go-loop]]
|
||||
[net.thegeez.browserchannel :as browserchannel]))
|
||||
|
||||
(defonce ^:private handler-middleware (atom nil))
|
||||
|
||||
(defonce incoming-messages (chan))
|
||||
(defonce incoming-messages-pub (pub incoming-messages :topic))
|
||||
|
||||
(defn- run-middleware [middleware final-handler & args]
|
||||
(let [wrap (fn [handler [f & more]]
|
||||
(if f
|
||||
(recur (f handler) more)
|
||||
handler))
|
||||
handler (wrap final-handler middleware)]
|
||||
(apply handler args)))
|
||||
|
||||
(defn encode-message
|
||||
"encodes a message made up of a topic and body into a format that can be sent
|
||||
via browserchannel to a client. topic should be a keyword, while body can be
|
||||
anything. returns nil if the message could not be encoded."
|
||||
[topic body]
|
||||
(if-let [topic (name topic)]
|
||||
any Clojure data structure. returns nil if the message could not be encoded."
|
||||
[{:keys [topic body] :as msg}]
|
||||
(if-let [topic (if topic (name topic))]
|
||||
{"topic" topic
|
||||
"body" (pr-str body)}))
|
||||
|
||||
|
@ -31,8 +41,14 @@
|
|||
browserchannel session id. topic should be a keyword, while body can be
|
||||
anything. returns nil if the message was not sent."
|
||||
[browserchannel-session-id topic body]
|
||||
(if-let [encoded (encode-message topic body)]
|
||||
(let [msg {:topic topic
|
||||
:body body}]
|
||||
(run-middleware
|
||||
(:on-send @handler-middleware)
|
||||
(fn [browserchannel-session-id msg]
|
||||
(if-let [encoded (encode-message msg)]
|
||||
(browserchannel/send-map browserchannel-session-id encoded)))
|
||||
browserchannel-session-id msg)))
|
||||
|
||||
(defn message-handler
|
||||
"listens for incoming browserchannel messages with the specified topic.
|
||||
|
@ -49,21 +65,37 @@
|
|||
(handler msg)
|
||||
(recur)))))
|
||||
|
||||
(defn- handle-session [browserchannel-session-id req {:keys [on-open on-close on-receive]}]
|
||||
(if on-open (on-open browserchannel-session-id req))
|
||||
(defn- handle-session [browserchannel-session-id req]
|
||||
(run-middleware
|
||||
(:on-open @handler-middleware)
|
||||
(fn [browserchannel-session-id request]
|
||||
; no-op
|
||||
)
|
||||
browserchannel-session-id req)
|
||||
|
||||
(browserchannel/add-listener
|
||||
browserchannel-session-id
|
||||
:close
|
||||
(fn [request reason]
|
||||
(if on-close (on-close browserchannel-session-id request reason))))
|
||||
(run-middleware
|
||||
(:on-close @handler-middleware)
|
||||
(fn [browserchannel-session-id request reason]
|
||||
; no-op
|
||||
)
|
||||
browserchannel-session-id request reason)))
|
||||
|
||||
(browserchannel/add-listener
|
||||
browserchannel-session-id
|
||||
:map
|
||||
(fn [request m]
|
||||
(if-let [decoded (decode-message m)]
|
||||
(let [msg (assoc decoded :browserchannel-session-id browserchannel-session-id)]
|
||||
(if on-receive (on-receive browserchannel-session-id request msg))
|
||||
(put! incoming-messages msg))))))
|
||||
(run-middleware
|
||||
(:on-receive @handler-middleware)
|
||||
(fn [browserchannel-session-id request msg]
|
||||
(if msg
|
||||
(put! incoming-messages msg)))
|
||||
browserchannel-session-id request msg))))))
|
||||
|
||||
(defn wrap-browserchannel
|
||||
"Middleware to handle server-side browserchannel session and message
|
||||
|
@ -80,6 +112,47 @@
|
|||
In addition, you can pass event handler functions. Note that the return
|
||||
value for all of these handlers is not used.
|
||||
|
||||
"
|
||||
[handler & [opts]]
|
||||
(-> handler
|
||||
(browserchannel/wrap-browserchannel
|
||||
(assoc
|
||||
opts
|
||||
:base (or (:base opts) "/browserchannel")
|
||||
:on-session
|
||||
(fn [browserchannel-session-id request]
|
||||
(handle-session browserchannel-session-id request))))))
|
||||
|
||||
(defn- get-handlers [middleware k]
|
||||
(->> middleware (map k) (remove nil?) (doall)))
|
||||
|
||||
(defn init!
|
||||
"Sets up browserchannel for server-side use. This function should be called
|
||||
once during application startup.
|
||||
|
||||
:middleware - a vector of middleware maps.
|
||||
|
||||
Middleware is optional. If specificed, each middleware is provided as a
|
||||
'middleware map'. This is a map where functions are specified for one
|
||||
or more of :on-open, :on-close, :on-send, :on-receive. A middleware map
|
||||
need not provide a function for any events it is not doing any processing
|
||||
for.
|
||||
|
||||
Each middleware function looks like a Ring middleware function. They
|
||||
are passed a handler and should return a function which performs the
|
||||
actual middleware processing and calls handler to continue on down
|
||||
the chain of middleware. e.g.
|
||||
|
||||
{:on-send (fn [handler]
|
||||
(fn [session-id request {:keys [topic body] :as msg]
|
||||
; do something here with the message to be sent
|
||||
(handler session-id request msg)))}
|
||||
|
||||
Remember that middleware is run in the reverse order that they appear
|
||||
in the vector you pass in.
|
||||
|
||||
Middleware function descriptions:
|
||||
|
||||
:on-open
|
||||
Occurs when a new browserchannel session has been established. Receives 2
|
||||
arguments: the browserchannel session id and the request map (for the
|
||||
|
@ -88,11 +161,15 @@
|
|||
|
||||
:on-receive
|
||||
Occurs when a new message is received from a client. Receives 3 arguments:
|
||||
the browsercannel session id, the request map (for the client request that
|
||||
the browserchannel session id, the request map (for the client request that
|
||||
the message was sent with), and the actual decoded message as arguments.
|
||||
the browserchannel session id of the client that sent the message is
|
||||
automatically added to the message under :browserchannel-session-id.
|
||||
|
||||
:on-send
|
||||
Occurs when a message is to be sent to a client. Receives 2 arguments:
|
||||
the browserchannel session id and the actual message to be sent.
|
||||
|
||||
:on-close
|
||||
Occurs when the browserchannel session is closed. Receives 3 arguments: the
|
||||
browserchannel session id, the request map (for the request sent by the
|
||||
|
@ -101,14 +178,10 @@
|
|||
initiated directly by the client. The request argument will be nil if the
|
||||
session is being closed as part of some server-side operation (e.g.
|
||||
browserchannel session timeout)."
|
||||
[handler & [opts]]
|
||||
(-> handler
|
||||
(browserchannel/wrap-browserchannel
|
||||
(assoc
|
||||
opts
|
||||
:base (or (:base opts) "/browserchannel")
|
||||
:on-session
|
||||
(fn [browserchannel-session-id request]
|
||||
(handle-session
|
||||
browserchannel-session-id request
|
||||
(select-keys opts [:on-open :on-close :on-receive])))))))
|
||||
[& {:keys [middleware]}]
|
||||
(reset!
|
||||
handler-middleware
|
||||
{:on-open (get-handlers middleware :on-open)
|
||||
:on-close (get-handlers middleware :on-close)
|
||||
:on-receive (get-handlers middleware :on-receive)
|
||||
:on-send (get-handlers middleware :on-send)}))
|
|
@ -6,12 +6,22 @@
|
|||
goog.net.BrowserChannel
|
||||
[goog.events :as events]))
|
||||
|
||||
(defonce ^:private handler-middleware (atom nil))
|
||||
|
||||
(defonce browser-channel (goog.net.BrowserChannel.))
|
||||
|
||||
(defonce incoming-messages (chan))
|
||||
(defonce incoming-messages-pub (pub incoming-messages :topic))
|
||||
(defonce outgoing-messages (chan))
|
||||
|
||||
(defn- run-middleware [middleware final-handler & args]
|
||||
(let [wrap (fn [handler [f & more]]
|
||||
(if f
|
||||
(recur (f handler) more)
|
||||
handler))
|
||||
handler (wrap final-handler middleware)]
|
||||
(apply handler args)))
|
||||
|
||||
(defn encode-message
|
||||
"encodes a message composed of a topic and body into a format that can be
|
||||
sent via browserchannel. topic should be a keyword while body can be
|
||||
|
@ -50,18 +60,25 @@
|
|||
(handler msg)
|
||||
(recur)))))
|
||||
|
||||
(defn- handle-outgoing [channel on-send]
|
||||
(defn- handle-outgoing [channel]
|
||||
(go-loop []
|
||||
(when-let [msg (<! outgoing-messages)]
|
||||
(when-let [encoded (encode-message msg)]
|
||||
(if on-send (on-send msg))
|
||||
(.sendMap channel encoded))
|
||||
(run-middleware
|
||||
(:on-send @handler-middleware)
|
||||
(fn [msg]
|
||||
(if-let [encoded (encode-message msg)]
|
||||
(.sendMap channel encoded)))
|
||||
msg)
|
||||
(recur))))
|
||||
|
||||
(defn- handle-incoming [channel msg on-receive]
|
||||
(defn- handle-incoming [channel msg]
|
||||
(when-let [decoded (decode-message msg)]
|
||||
(if on-receive (on-receive decoded))
|
||||
(put! incoming-messages decoded)))
|
||||
(run-middleware
|
||||
(:on-receive @handler-middleware)
|
||||
(fn [msg]
|
||||
(if msg
|
||||
(put! incoming-messages msg)))
|
||||
decoded)))
|
||||
|
||||
; see: http://docs.closure-library.googlecode.com/git/local_closure_goog_net_browserchannel.js.source.html#line521
|
||||
(def bch-error-enum-to-keyword
|
||||
|
@ -81,37 +98,78 @@
|
|||
(or (get bch-error-enum-to-keyword error-code)
|
||||
:unknown))
|
||||
|
||||
(defn- handler [{:keys [on-open on-send on-receive on-close on-error]}]
|
||||
(defn- ->handler []
|
||||
(let [h (goog.net.BrowserChannel.Handler.)]
|
||||
(set! (.-channelOpened h)
|
||||
(fn [channel]
|
||||
(if on-open (on-open))
|
||||
(handle-outgoing channel on-send)))
|
||||
(run-middleware (:on-open @handler-middleware) (fn []))
|
||||
(handle-outgoing channel)))
|
||||
(set! (.-channelHandleArray h)
|
||||
(fn [channel msg]
|
||||
(handle-incoming channel msg on-receive)))
|
||||
(handle-incoming channel msg)))
|
||||
(set! (.-channelClosed h)
|
||||
(fn [channel pending undelivered]
|
||||
(if on-close (on-close pending undelivered))))
|
||||
(run-middleware
|
||||
(:on-close @handler-middleware)
|
||||
(fn [pending undelivered]
|
||||
; no-op
|
||||
)
|
||||
pending undelivered)))
|
||||
(set! (.-channelError h)
|
||||
(fn [channel error]
|
||||
(if on-error (on-error (bch-error-enum->keyword error)))))
|
||||
(run-middleware
|
||||
(:on-error @handler-middleware)
|
||||
(fn [error]
|
||||
; no-op
|
||||
)
|
||||
(bch-error-enum->keyword error))))
|
||||
h))
|
||||
|
||||
(defn- set-debug-logger! [level]
|
||||
(if-let [logger (-> browser-channel .getChannelDebug .getLogger)]
|
||||
(.setLevel logger level)))
|
||||
|
||||
(defn- get-handlers [middleware k]
|
||||
(->> middleware (map k) (remove nil?) (doall)))
|
||||
|
||||
(defn- register-middleware! [middleware]
|
||||
(reset!
|
||||
handler-middleware
|
||||
{:on-open (get-handlers middleware :on-open)
|
||||
:on-close (get-handlers middleware :on-close)
|
||||
:on-error (get-handlers middleware :on-error)
|
||||
:on-receive (get-handlers middleware :on-receive)
|
||||
:on-send (get-handlers middleware :on-send)}))
|
||||
|
||||
(defn init!
|
||||
"sets up browserchannel for use, creating a handler with the specified
|
||||
"Sets up browserchannel for use, creating a handler with the specified
|
||||
properties. this function should be called once on page load.
|
||||
|
||||
properties:
|
||||
|
||||
:base - the base URL on which the server's browserchannel routes are
|
||||
located at. default is '/browserchannel'
|
||||
located at. default if not specified is '/browserchannel'
|
||||
|
||||
callbacks:
|
||||
:middleware - a vector of middleware maps.
|
||||
|
||||
Middleware is optional. If specificed, each middleware is provided as a
|
||||
'middleware map'. This is a map where functions are specified for one
|
||||
or more of :on-open, :on-close, :on-error, :on-send, :on-receive. A
|
||||
middleware map need not provide a function for any events it is
|
||||
not doing any processing for.
|
||||
|
||||
Each middleware function looks like a Ring middleware function. They
|
||||
are passed a handler and should return a function which performs the
|
||||
actual middleware processing and calls handler to continue on down
|
||||
the chain of middleware. e.g.
|
||||
|
||||
{:on-send (fn [handler]
|
||||
(fn [{:keys [topic body] :as msg]
|
||||
; do something here with the message to be sent
|
||||
(handler msg)))}
|
||||
|
||||
Remember that middleware is run in the reverse order that they appear
|
||||
in the vector you pass in.
|
||||
|
||||
Middleware function descriptions:
|
||||
|
||||
:on-open
|
||||
occurs when a browserchannel session with the server is established
|
||||
|
@ -131,9 +189,7 @@
|
|||
|
||||
:on-send
|
||||
raised whenever a message is sent via the send function. receives 1
|
||||
argument: the message that is to be sent. this is probably only useful for
|
||||
debugging/logging purposes. note that this event is only raised for messages
|
||||
which can be encoded by encode-message
|
||||
argument: the message that is to be sent.
|
||||
|
||||
:on-receive
|
||||
occurs whenever a browserchannel message is received from the server.
|
||||
|
@ -141,15 +197,16 @@
|
|||
only raised for messages which can be decoded by decode-message. also note
|
||||
that this event is raised for all messages received, regardless of any
|
||||
listeners created via message-handler."
|
||||
[& [{:keys [base] :as opts}]]
|
||||
[& {:keys [base middleware]}]
|
||||
(let [base (or base "/browserchannel")]
|
||||
(register-middleware! middleware)
|
||||
(events/listen
|
||||
js/window "unload"
|
||||
(fn []
|
||||
(.disconnect browser-channel)
|
||||
(events/removeAll)))
|
||||
(set-debug-logger! goog.debug.Logger.Level.OFF)
|
||||
(.setHandler browser-channel (handler opts))
|
||||
(.setHandler browser-channel (->handler))
|
||||
(.connect browser-channel
|
||||
(str base "/test")
|
||||
(str base "/bind"))))
|
||||
|
|
Reference in a new issue