Sync
This started as a thought experiment that is growing into a viable option for realtime Rails apps without ditching the standard rails stack that we love and are so productive with for a heavy client side MVC framework.
Real-time partials with Rails. Sync lets you render partials for models that, with minimal code, update in realtime in the browser when changes occur on the server.
Watch a screencast to see it in action
In practice, one simply only needs to replace:
<%= render partial: 'user_row', locals: {user: @user} %>
with:
<%= sync partial: 'user_row', resource: @user %>
Then update views realtime automatically with the sync
DSL or with a with a simple sync_update(@user)
in the controller without any extra javascript or
configuration.
In addition to real-time updates, Sync also provides:
- Realtime removal of partials from the DOM when the sync'd model is destroyed in the controller via
sync_destroy(@user)
- Realtime appending of newly created model's on scoped channels
- JavaScript/CoffeeScript hooks to override and extend element updates/appends/removes for partials
- Support for Faye and Pusher
Requirements
- Ruby >= 1.9.2
- Rails 3 >= 3.1 or Rails 4
- jQuery >= 1.9
Installation
1) Add the gem to your Gemfile
Using Faye
gem 'faye'
gem 'thin', require: false
gem 'sync'
Using Pusher
gem 'pusher'
gem 'sync'
Install
$ bundle
$ rails g sync:install
2) Require sync in your asset javascript manifest app/assets/javascripts/application.js
:
//= require sync
3) Add the pubsub adapter's javascript to your application layout app/views/layouts/application.html.erb
<%= javascript_include_tag Sync.adapter_javascript_url %>
4) Configure your pubsub server (Faye or Pusher)
Using Faye (self hosted)
Set your configuration in the generated config/sync.yml
file, using the Faye adapter. Then run Faye alongside your app.
rackup sync.ru -E production
Using Pusher (SaaS)
Set your configuration in the generated config/sync.yml
file, using the Pusher adapter. No extra process/setup.
Current Caveats
The current implementation uses a DOM range query (jQuery's nextUntil
) to match your partial's "element" in
the DOM. The way this selector works requires your sync'd partial to be wrapped in a root level html tag for that partial file.
For example, this parent view/sync partial approach would not work:
Given the sync partial _todo_row.html.erb
:
Title:
<%= link_to todo.title, todo %>
And the parent view:
<table>
<tbody>
<tr>
<%= sync partial: 'todo_row', resource: @todo %>
</tr>
</tbody>
</table>
The markup would need to change to:
sync partial _todo_row.html.erb
:
<tr> <!-- root level container for the partial required here -->
Title:
<%= link_to todo.title, todo %>
</tr>
And the parent view changed to:
<table>
<tbody>
<%= sync partial: 'todo_row', resource: @todo %>
</tbody>
</table>
I'm currently investigating true DOM ranges via the Range object.
'Automatic' syncing through the sync DSL
In addition to calling explicit sync actions within controller methods, a
sync
and enable_sync
DSL has been added to ActionController::Base and ActiveRecord::Base to automate the syncing
approach in a controlled, threadsafe way.
Example Controller/Model
class TodosController < ApplicationController
enable_sync only: [:create, :update, :destroy]
...
end
class Todo < ActiveRecord::Base
belongs_to :project, counter_cache: true
has_many :comments, dependent: :destroy
sync :all, scope: :project
end
Syncing outside of the controller
Sync::Actions
can be included into any object wishing to perform sync
publishes for a given resource. Instead of using the the controller as
context for rendering, a Sync::Renderer instance is used. Since the Renderer
is not part of the request/response/session, it has no knowledge of the
current session (ie. current_user), so syncing from outside the controller
context will require some care that the partial can be rendered within a
sessionless context.
Example Syncing from a background worker or rails console
# Inside some script/worker
Sync::Model.enable do
Todo.first.update title: "This todo will be sync'd on save"
end
Todo.first.update title: "This todo will NOT be sync'd on save"
Sync::Model.enable!
Todo.first.update title: "This todo will be sync'd on save"
Todo.first.update title: "This todo will be sync'd on save"
Todo.first.update title: "This todo will be sync'd on save"
Sync::Model.disable!
Todo.first.update title: "This todo will NOT be sync'd on save"
Custom Sync Views and javascript hooks
Sync allows you to hook into and override or extend all of the actions it performs when updating partials on the client side. When a sync partial is rendered, sync will instantiate a javascript View class based on the following order of lookup:
- The camelized version of the concatenated snake case resource and partial names.
- The camelized version of the snake cased partial name.
Examples
partial name 'list_row', resource name 'todo', order of lookup:
- Sync.TodoListRow
- Sync.ListRow
- Sync.View (Default fallback)
For example, if you wanted to fade in/out a row in a sync'd todo list instead of the Sync.View default of instant insert/remove:
class Sync.TodoListRow extends Sync.View
beforeInsert: ($el) ->
$el.hide()
@insert($el)
afterInsert: -> @$el.fadeIn 'slow'
beforeRemove: -> @$el.fadeOut 'slow', => @remove()
Narrowing sync_new scope
Sometimes, you do not want your page to update with every new record. With the scope
option, you can limit what is being updated on a given page.
One way of using scope
is by supplying a String or a Symbol. This is useful for example when you want to only show new records for a given locale:
View:
<%= sync_new partial: 'todo_list_row', resource: Todo.new, scope: I18n.locale %>
Controller/Model:
sync_new @todo, scope: @todo.locale
Another use of scope
is with a parent resource. This way you can for example update a project page with new todos for this single project:
View:
<%= sync_new partial: 'todo_list_row', resource: Todo.new, scope: @project %>
Controller/Model:
sync_new @todo, scope: @project
Both approaches can be combined. Just supply an Array of Strings/Symbols and/or parent resources to the scope
option. Note that the order of elements matters. Be sure to use the same order in your view and in your controller/model.
Refetching Partials
Refetching allows syncing partials across different users when the partial requires the session's context (ie. current_user).
Ex:
View: Add refetch: true
to sync calls, and place partial file in a 'refetch'
subdirectory in the model's sync view folder:
The partial file would be located in app/views/sync/todos/refetch/_list_row.html.erb
<% @project.todos.ordered.each do |todo| %>
<%= sync partial: 'list_row', resource: todo, refetch: true %>
<% end %>
<%= sync_new partial: 'list_row', resource: Todo.new, scope: @project, refetch: true %>
Notes
While this approach works very well for the cases it's needed, syncing without refetching should be used unless refetching is absolutely necessary for performance reasons. For example,
A sync update request is triggered on the server for a 'regular' sync'd partial with 100 listening clients:
- number of http requests 1
- number of renders 1, pushed out to all 100 clients via pubsub server.
A sync update request is triggered on the server for a 'refetch' sync'd partial with 100 listening clients:
- number of http requests 100
- number of renders 100, rendering each request in clients session context.
Using with cache_digests (Russian doll caching)
Sync has a custom DependencyTracker::ERBTracker
that can handle sync
render calls.
Because the full partial name is not included, it has to guess the location of
your partial based on the name of the resource
or collection
passed to it.
See the tests to see how it works. If it doesn't work for you, you can always
use the explicit "Template Dependency"
markers.
To enable, add to config/initializers/cache_digests.rb
:
Rails 4
require 'action_view/dependency_tracker'
ActionView::DependencyTracker.register_tracker :haml, Sync::ERBTracker
ActionView::DependencyTracker.register_tracker :erb, Sync::ERBTracker
Rails 3 with cache_digests gem
require 'cache_digests/dependency_tracker'
CacheDigests::DependencyTracker.register_tracker :haml, Sync::ERBTracker
CacheDigests::DependencyTracker.register_tracker :erb, Sync::ERBTracker
Note: haml support is limited, but it seems to work in most cases.
Serving Faye over HTTPS (with Thin)
Create a thin configuration file config/sync_thin.yml
similar to the following:
---
port: 4443
ssl: true
ssl_key_file: /path/to/server.pem
ssl_cert_file: /path/to/certificate_chain.pem
environment: production
rackup: sync.ru
The certificate_chain.pem
file should contain your signed certificate, followed by intermediate certificates (if any) and the root certificate of the CA that signed the key.
Next reconfigure the server
and adapter_javascript_url
in config/sync.yml
to look like https://your.hostname.com:4443/faye
and https://your.hostname.com:4443/faye/faye.js
respectively.
Finally start up Thin from the project root.
thin -C config/sync_thin.yml start
Brief Example or checkout an example application
View sync/users/_user_list_row.html.erb
<tr>
<td><%= link_to user.name, user %></td>
<td><%= link_to 'Edit', edit_user_path(user) %></td>
<td><%= link_to 'Destroy', user, method: :delete, remote: true, data: { confirm: 'Are you sure?' } %></td>
</tr>
View users/index.html.erb
<h1>Some Users</h1>
<table>
<tbody>
<%= sync partial: 'user_list_row', collection: @users %>
<%= sync_new partial: 'user_list_row', resource: User.new, direction: :append %>
</tbody>
</table>
Controller
def UsersController < ApplicationController
…
def create
@user = User.new(user_params)
if @user.save
sync_new @user
end
respond_to do |format|
format.html { redirect_to users_url }
format.json { head :no_content }
end
end
def update
@user = User.find(params[:id])
if user.save
…
end
# Sync updates to any partials listening for this user
sync_update @user
redirect_to users_path, notice: "Saved!"
end
def destroy
@user = User.find(params[:id])
@user.destroy
# Sync destroy, telling client to remove all dom elements containing this user
sync_destroy @user
respond_to do |format|
format.html { redirect_to users_url }
format.json { head :no_content }
end
end
end