initial commit

This commit is contained in:
Gered 2014-12-07 17:09:32 -05:00
commit ada511f5d2
6 changed files with 490 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
.DS_Store
/target
/classes
/checkouts
pom.xml
pom.xml.asc
*.jar
*.class
/.lein-*
/.nrepl-port
.settings/
.project
.classpath
.idea/
*.iml
*.ipr
*.iws

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Gered King
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

170
README.md Normal file
View file

@ -0,0 +1,170 @@
# clj-browserchannel-messaging
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.**
## Usage
### Dependencies
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
Copyright © 2014 Gered King
Distributed under the the MIT License (the same as clj-browserchannel). See LICENSE for more details.

13
project.clj Normal file
View file

@ -0,0 +1,13 @@
(defproject clj-browserchannel-messaging "0.0.1"
:description "Tools for quickly using BrowserChannel for bi-directional client-server messaging."
:url "https://github.com/gered/clj-browserchannel-messaging"
:license {:name "MIT License"
:url "http://opensource.org/licenses/MIT"}
: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"]]
:source-paths ["src/clj"]
:resource-paths ["src/cljs"])

View file

@ -0,0 +1,114 @@
(ns clj-browserchannel-messaging.server
(:refer-clojure :exclude [send])
(:require [clojure.edn :as edn]
[clojure.core.async :refer [chan pub sub <! put! go-loop]]
[net.thegeez.browserchannel :as browserchannel]))
(defonce incoming-messages (chan))
(defonce incoming-messages-pub (pub incoming-messages :topic))
(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)]
{"topic" topic
"body" (pr-str body)}))
(defn decode-message
"decodes a message received via browserchannel into a map composed of a
topic and body. returns nil if the message could not be decoded."
[msg]
(let [topic (get msg "topic")
body (get msg "body")]
(if topic
{:topic (keyword topic)
:body (edn/read-string body)})))
(defn send
"sends a browserchannel message to a client identified by the given
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)))
(defn message-handler
"listens for incoming browserchannel messages with the specified topic.
executes the passed handler function when any are received. handler should
be a function which accepts the received decoded message. the decoded
message will contain the browserchannel session id of the client that
sent the message under :browserchannel-session-id.
note that the handler is executed asynchronously."
[topic handler]
(let [incoming-topic-messages (chan)]
(sub incoming-messages-pub topic incoming-topic-messages)
(go-loop []
(when-let [msg (<! incoming-topic-messages)]
(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))
(browserchannel/add-listener
browserchannel-session-id
:close
(fn [request reason]
(if on-close (on-close 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))))))
(defn wrap-browserchannel
"Middleware to handle server-side browserchannel session and message
processing.
You can specify the same set of options that
net.thegeez.browserchannel/wrap-browserchannel accepts, except for
:on-session (which will be overridden even if you do try to pass it).
See net.thegeez.browserchannel/default-options for more info.
Note that if :base is not specified, the default is '/browserchannel'
(this differs from net.thegeez.browserchannel/wrap-browserchannel).
In addition, you can pass event handler functions. Note that the return
value for all of these handlers is not used.
:on-open
Occurs when a new browserchannel session has been established. Receives 2
arguments: the browserchannel session id and the request map (for the
request that resulted in the browserchannel session being established) as
arguments.
: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 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-close
Occurs when the browserchannel session is closed. Receives 3 arguments: the
browserchannel session id, the request map (for the request sent by the
client causing the session to be closed, if any), and a string containing
the reason for the session close. Note that, this may or may not be
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])))))))

View file

@ -0,0 +1,155 @@
(ns clj-browserchannel-messaging.client
(:require-macros [cljs.core.async.macros :refer [go-loop]])
(:require
[cljs.reader :as reader]
[cljs.core.async :refer [pub sub chan <! put!]]
goog.net.BrowserChannel
[goog.events :as events]))
(defonce browser-channel (goog.net.BrowserChannel.))
(defonce incoming-messages (chan))
(defonce incoming-messages-pub (pub incoming-messages :topic))
(defonce outgoing-messages (chan))
(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
anything. returns nil if the message could not be encoded."
[{:keys [topic body] :as msg}]
(if-let [topic (name topic)]
(clj->js {"topic" topic
"body" (pr-str body)})))
(defn decode-message
"decodes a message received via browserchannel into a map composed of a
topic and body. returns nil if the message could not be decoded."
[msg]
(let [msg (js->clj msg)
topic (keyword (get msg "topic"))
body (get msg "body")]
(if topic
{:topic topic
:body (reader/read-string body)})))
(defn send
"sends a browserchannel message to the server asynchronously."
[topic body]
(put! outgoing-messages {:topic topic :body body}))
(defn message-handler
"listens for incoming browserchannel messages with the specified topic.
executes the passed handler function when any are received. handler should
be a function which accepts the received decoded message.
note that the handler is executed asynchronously"
[topic handler]
(let [incoming-topic-messages (chan)]
(sub incoming-messages-pub topic incoming-topic-messages)
(go-loop []
(when-let [msg (<! incoming-topic-messages)]
(handler msg)
(recur)))))
(defn- handle-outgoing [channel on-send]
(go-loop []
(when-let [msg (<! outgoing-messages)]
(when-let [encoded (encode-message msg)]
(if on-send (on-send msg))
(.sendMap channel encoded))
(recur))))
(defn- handle-incoming [channel msg on-receive]
(when-let [decoded (decode-message msg)]
(if on-receive (on-receive decoded))
(put! incoming-messages decoded)))
; see: http://docs.closure-library.googlecode.com/git/local_closure_goog_net_browserchannel.js.source.html#line521
(def bch-error-enum-to-keyword
{0 :ok
2 :request-failed
4 :logged-out
5 :no-data
6 :unknown-session-id
7 :stop
8 :network
9 :blocked
10 :bad-data
11 :bad-response
12 :active-x-blocked})
(defn- bch-error-enum->keyword [error-code]
(or (get bch-error-enum-to-keyword error-code)
:unknown))
(defn- handler [{:keys [on-open on-send on-receive on-close on-error]}]
(let [h (goog.net.BrowserChannel.Handler.)]
(set! (.-channelOpened h)
(fn [channel]
(if on-open (on-open))
(handle-outgoing channel on-send)))
(set! (.-channelHandleArray h)
(fn [channel msg]
(handle-incoming channel msg on-receive)))
(set! (.-channelClosed h)
(fn [channel pending undelivered]
(if on-close (on-close pending undelivered))))
(set! (.-channelError h)
(fn [channel error]
(if on-error (on-error (bch-error-enum->keyword error)))))
h))
(defn- set-debug-logger! [level]
(if-let [logger (-> browser-channel .getChannelDebug .getLogger)]
(.setLevel logger level)))
(defn init!
"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'
callbacks:
:on-open
occurs when a browserchannel session with the server is established
:on-close
occurs when the browserchannel session is closed (e.g. terminated by the
server due to error, timeout, etc).
receives 2 arguments: array of pending messages that may or may not
have been sent to the server, and an array of undelivered messages that
have definitely not been delivered to the server. note that these
arguments will both be javascript arrays containing
goog.net.BrowserChannel.QueuedMap objects.
:on-error
occurs when an error occurred on the browserchannel. receives 1 argument:
a keyword indicating the type of error
: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
:on-receive
occurs whenever a browserchannel message is received from the server.
receives 1 argument: the message that was received. note that this event is
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}]]
(let [base (or base "/browserchannel")]
(events/listen
js/window "unload"
(fn []
(.disconnect browser-channel)
(events/removeAll)))
(set-debug-logger! goog.debug.Logger.Level.OFF)
(.setHandler browser-channel (handler opts))
(.connect browser-channel
(str base "/test")
(str base "/bind"))))