re-work app-specified callbacks into a 'middleware' system

This commit is contained in:
Gered 2014-12-24 15:00:48 -05:00
parent ada511f5d2
commit 0a1ee1df25
4 changed files with 179 additions and 201 deletions

156
README.md
View file

@ -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

View file

@ -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"])

View file

@ -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)]
(browserchannel/send-map browserchannel-session-id encoded)))
(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)}))

View file

@ -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"))))