From 0a1ee1df25cefb2da3b30b7d8800ea5a5cc3a72f Mon Sep 17 00:00:00 2001 From: gered Date: Wed, 24 Dec 2014 15:00:48 -0500 Subject: [PATCH] re-work app-specified callbacks into a 'middleware' system --- README.md | 156 +----------------- project.clj | 4 +- .../clj_browserchannel_messaging/server.clj | 117 ++++++++++--- .../clj_browserchannel_messaging/client.cljs | 103 +++++++++--- 4 files changed, 179 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index 2c6dd29..f6eb5c0 100644 --- a/README.md +++ b/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 <> - :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 diff --git a/project.clj b/project.clj index 8af17b8..b407eb3 100644 --- a/project.clj +++ b/project.clj @@ -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"]) diff --git a/src/clj/clj_browserchannel_messaging/server.clj b/src/clj/clj_browserchannel_messaging/server.clj index 7a43e7c..a264ff5 100644 --- a/src/clj/clj_browserchannel_messaging/server.clj +++ b/src/clj/clj_browserchannel_messaging/server.clj @@ -4,15 +4,25 @@ [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)))))) + +(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)})) \ No newline at end of file diff --git a/src/cljs/clj_browserchannel_messaging/client.cljs b/src/cljs/clj_browserchannel_messaging/client.cljs index 8a9bdcd..2b8360d 100644 --- a/src/cljs/clj_browserchannel_messaging/client.cljs +++ b/src/cljs/clj_browserchannel_messaging/client.cljs @@ -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 (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"))))