Client-Side Routing with Pedestal

It’s fairly common for rich web applications to use some kind of client-side routing: Load the application once, then navigate with special URLs like http://example.com/#/subpage. Browser doesn’t perform a round trip to server for them, but instead the right chunk of JavaScript can rebuild part of the page locally, hide and show elements etc.

JavaScript tools

There is a number of JavaScript tools to do it. I find two examples particularly inspiring.

Flatiron Director lets you do something like:

var author = function () { console.log("author"); };
var books = function () { console.log("books"); };
 var routes = {
  '/author': author,
  '/books': books
};</p>

<p>var router = Router(routes);
router.init();

Then when you visit page ending with #/books, it will call the books function. Very flexible, but then you need to write a lot of code yourself.

Another good example with completely different philosophy is the Angular JS Router. In your page you can bless a special section as the ngView, and configure router with:

$routeProvider
    .when('/author', {templateUrl: '/t_author.html', controller: "authorController"})
    .when('/books', {templateUrl: '/t_books.html', controller: "booksController"})
    .otherwise({redirectTo: '/books'});

When you go to #/author, Angular will replace the contents of ngView with the template and install the selected controller on it. It fits well in the Angular philosophy and has some benefits.

Pedestal

Anyway, how can we use routing with Pedestal? Turns out there is no built-in or even recommended way to do it, but you can plug in whatever you want. After some research I decided to use goog.History from Closure which comes free of charge with ClojureScript.

All code for these examples is in my GitHub repository. Note that there are 3 tags corresponding to each of the solutions.

Attempt 1: Simple, hardcoded

The first thing I did was a rather ugly, hardcoded solution using the entire Pedestal data flow.

First I installed a listener on goog.History that extracts the location token and pushes it to the :route topic in Pedestal input queue:

(defn ^:private set-route [input-queue route]
  (p/put-message input-queue {msg/topic :route msg/type :set-route :value route}))

(defn configure-router 
  ([input-queue] (configure-router input-queue ""))
  ([input-queue default-route]
    (doto (goog.History.)
      (goog.events/listen (goog.object/getValues goog.history/EventType) 
                          (fn [e]
                            (let [token (.-token e)]
                              (if (= "" token)
                                (set-route input-queue default-route)
                                (set-route input-queue token)))))
    (.setEnabled true))))

I plugged in the default transform and emitter that basically push that message up to the application model:

(def count-app {:transform {:route {:init nil :fn #(:value %2)}}
                :emit {:router {:fn app/default-emitter-fn :input #{:route}}}})

(defn ^:private set-route [input-queue route]
  (p/put-message input-queue {msg/topic :route msg/type :set-route :value route}))

The last step on the pipeline is renderer. This one here is fairly uninteresting, it basically replaces contents of some specific div with different text:

(defn route-changed [_ [_ _ old-value new-value] input-queue]
  (.log js/console "Routing from" old-value "to" new-value)
  (let [container (dom/by-id "view-container")]
    (dom/destroy-children! container)
    (dom/append! container (str "<p>" new-value "</p>"))))

The main function:

(defn ^:export main []
  (let [app (app/build count-app)
        render-fn (push/renderer "content" [[:value [:route] route-changed]])]
    (render/consume-app-model app render-fn)
    (configure-router (:input app) "first")
    (app/begin app)))

This solution is hardly reusable, but it’s also very flexible. At any point we can decide to do something specific when switching the location – change something in data or application model, make a server request, etc.

I wouldn’t recommend it, it’s just the very first attempt at the problem.

Attempt 2: Generic

It’s quite obvious that the above solution can be generalized. One way to do it is to generalize the renderer. We can initialize it with some configuration telling it what action to execute for each path. For instance, something like this:

(defn render-route [msg]
  (let [container (dom/by-id "view-container")]
    (dom/destroy-children! container)
    (dom/append! container (str "<p>" msg "</p>"))))

(defn route-first []
  (render-route "This is the first route"))

(defn route-second []
  (render-route "This is the second route"))

(def router-config
  {:routes {"first" route-first
            "second" route-second}
   :default-route "first"
   :listener (fn [old-value new-value]
               (.log js/console "Routing from" old-value "to" new-value))})

There isn’t too much interesting stuff happening here, but it clearly is more reusable.

The renderer is now initialized with this config like:

(defn route-renderer [cfg]
  (fn [_ [_ _ old-value new-value] input-queue]
    (when-let [listener (:listener cfg)]
      (listener old-value new-value))
    (if-let [dispatcher (get-in cfg [:routes new-value])]
      (dispatcher)
      (.log js/console "Unknown route:" new-value))))

I’ll omit the history listener and main for clarity, but I hope the point is clear. Again, code is at Github under the generic tag.

All this solution does is bind a function call to each path. I could easily extract it to a tiny generic library. I could also make it more powerful – for instance, use higher order “constructor functions” that let each action access state or push to the input queue. I could use templating. And so on.

Attempt 3: Generic – light

As I was wrapping that up, I figured out one more way to do it. Remember, both solutions above use the entire Pedestal stack – the history listener pushes a message to input queue, and we need a transform and emitter to pass it up to renderer. Maybe I don’t need to involve the “lower layers” with navigation and rendering?

I realized I can just plug the rendering in the history listener itself:

(defn route-first [input-queue]
  (render-route "This is the first route"))

(defn route-second [input-queue]
  (render-route "This is the second route"))

(def router-config
  {:routes {"first" route-first
            "second" route-second}
   :default-route "first"
   :listener (fn [new-value]
               (.log js/console "Routing to" new-value))})

(defn route-changed [{:keys [input]} route-config]
  (fn [e]
    (let [token (.-token e)
          token (if (= "" token) (:default-route route-config) token)]
      (when-let [listener (:listener cfg)]
        (listener token))
      (if-let [dispatcher (get-in route-config [:routes token])]
        (dispatcher input)
        (.log js/console "Unknown route:" token)))))

(defn configure-router 
  ([app route-config]
    (doto (goog.History.)
      (goog.events/listen 
        (goog.object/getValues goog.history/EventType) 
        (route-changed app route-config))
      (.setEnabled true))))

(defn ^:export main []
  (let [app (app/build)]
    (configure-router app router-config)
    (app/begin app)))

That’s it. It doesn’t even need Pedestal. The router is a generic little thing based only on Google Closure. However, in this case each action also has access to the input queue and it’s pretty obvious how you could expose any other things from Pedestal. You can still push messages to Pedestal, install renderers etc. – but that’s no longer required for routing itself.

Wrapping Up

In the end, I’m fairly satisfied with the last solution. Well, in a way – it still requires me to do a ton of manual work! Not for the routing itself, but for DOM rendering. It feels a lot like jQuery, with tons and tons of tedious, manual DOM manipulation.

I guess I became a little spoilt by Angular, and I’m already experimenting with marrying the two.

All the time I’m facing friction though – if I let Angular do too much, I don’t really have any use for Pedestal. On the other hand, there is quite some impedance mismatch between the Angular “pull-oriented model” and Pedestal’s differential pushes. If I ever come to any sane conclusions on this front I’ll write that up. But that’s another story.

One thought on “Client-Side Routing with Pedestal

Leave a Reply

Your email address will not be published. Required fields are marked *

Spam protection by WP Captcha-Free