commit ada511f5d2919c3aa4758a35d41ab72694ffdf38 Author: gered Date: Sun Dec 7 17:09:32 2014 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e91d482 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8d9476f --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c6dd29 --- /dev/null +++ b/README.md @@ -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 <> + :body <>} +``` + +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. diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..8af17b8 --- /dev/null +++ b/project.clj @@ -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"]) diff --git a/src/clj/clj_browserchannel_messaging/server.clj b/src/clj/clj_browserchannel_messaging/server.clj new file mode 100644 index 0000000..7a43e7c --- /dev/null +++ b/src/clj/clj_browserchannel_messaging/server.clj @@ -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 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]))))))) diff --git a/src/cljs/clj_browserchannel_messaging/client.cljs b/src/cljs/clj_browserchannel_messaging/client.cljs new file mode 100644 index 0000000..8a9bdcd --- /dev/null +++ b/src/cljs/clj_browserchannel_messaging/client.cljs @@ -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 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 (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"))))