faye.jcoglan.com


Simple publish-subscribe messaging system


example based on kanbanery.com


past days

pusher.com


  • hosted API

  • client/server libs (pusher gem)

  • based on WebSocket

  • 200$/month for 5,000 max connections



Pusher is great for...

Chat
Activity Streams
Notifications
Collaboration
Multiplayer games
Realtime data
Dashboards
2nd Screen experiences

nowadays

Faye


  • provides message servers for Node.js and Ruby 
    (Faye is just like any other Rack app)

  • clients for use on the server and in all major web browsers

  • storage layer: memory or redis  

architecture

Faye


  • based on the Bayeux protocol  
    (
    operations: handshake, connect, disconnect, subscribe,  unsubscribe and publish )

  • Server-side/Client-side extensions


Clustering

Redis
Faye

Adapter

Faye


Faye

Ruby Server

# file: faye.ru - rackup config file
require File.expand_path('../config/environment',  __FILE__)
bayeux = Faye::RackAdapter.new(:mount => '/faye', :timeout => 25)

bayeux.add_extension(PushOnlyServer.new)
bayeux.add_extension(ServerAuth.new)

bayeux.bind(:subscribe) do |client_id, channel|
  Rails.logger.info "[#{Time.now.to_s(:db)} Faye #{client_id}] Subscribed channel #{channel}"
end

run bayeux

Faye

Start Ruby Server


$ thin --rackup faye.ru -e development -p 8090 start

http://127.0.0.1:8090/faye
http://127.0.0.1:8090/faye/client.js


tip for circleci.yml
nohup bash -c "bundle exec thin --rackup faye.ru -e test 
-p 8090 start &"

Extensions

Push Only Server

incoming message
# lib/faye/push_only_server.rb 
class PushOnlyServer
  def incoming(message, callback)
    unless message['channel'] =~ /^\/meta\//
      password = message['ext'] && message['ext']['password']

      if password != AppConfig.faye.push_secret
        message['error'] = '403::Password required'
        Rails.logger.warn "[#{Time.now.to_s(:db)} Faye #{message['clientId']}] Failed to authenticate as message sender: invalid password"
      end
    end

    callback.call(message)
  end
  # ...
end
    

Extensions

Push Only Server

outgoing message
# lib/faye/push_only_server.rb
class PushOnlyServer
  # ...
  def outgoing(message, callback)
    unless message['ext'].nil?
      message['ext'].delete('password')
    end

    callback.call(message)
  end
end

publish message on server-side

 class LiveUpdate
  def self.send_to_faye(projects, project_parts, ...)
    # ...
      channel = FayeUtils.js_app_channel_name(project_part, project.id)
      message = {
        :channel => channel,
        :data => {
          :event => msg,
          :faye_socket_id => socket_id
        },
        :ext => {
          :password => AppConfig.faye.push_secret
        }
      }
      uri = URI.parse(AppConfig.faye.internal_url)
      Net::HTTP.post_form(uri, :message => message.to_json) # POST message
   # ...
  end
end

Extensions

Server Auth

 class ServerAuth
  def incoming(message, callback)
    # Let non-subscribe messages through
    return callback.call(message) unless message['channel'] == '/meta/subscribe'

    if message['ext'].nil?
      message['error'] = 'Missing ext key in the message'
    else
      user_id = message['ext']['user_id']
      # ... check if user can subscribe the channel
    end

    # Call the server back now we're done
    callback.call(message)
  end
end
# ...
      user_id = message['ext']['user_id']
      faye_token = message['ext']['faye_token']
      public_board_keys = message['ext']['public_board_keys']
      project_id = message['ext']['project_id']
      project = Project.get(project_id)
      if project.present?
          # ... check if user has access to project etc
          user = User.get(user_id)
          if user && user.valid_faye_token?(faye_token)
              client_id = message['clientId'] # faye client id
              project_membership = project.project_memberships.first(user_id: user_id)

              $semaphore.synchronize do
                $user_activities[client_id] = project_membership.id
              end
            end
          elsif user
            message['error'] = "Invalid subscription faye token"
          else
            message['error'] = "User doesn't exist"
          end
        end
      else
        message['error'] = "Project doesn't exist"
      end
    end
# ...

faye.ru - Ruby Server with Mutex

require File.expand_path('../config/environment',  __FILE__)

# key: faye client id
# value: list of project memberships ids
$user_activities = {}

$semaphore = Mutex.new
$update_thread = Thread.new do
  loop do
    sleep 60
    $semaphore.synchronize do
      ids = $user_activities.values.uniq
      Rails.logger.info "[#{Time.now.to_s(:db)} Faye] Updating user activity timestamps for #{ids.size} membership(s): #{ids.join(", ")}"
      ProjectMembership.touch_timestamps_and_last_activity($user_activities.values.uniq)
    end
  end
end

bayeux = Faye::RackAdapter.new(:mount => '/faye', :timeout => 25)
# ...

Browser client

Subscribing to channels


The * wildcard matches any channel segment. 
So /foo/* matches /foo/bar and /foo/thing but not /foo/bar/thing.


The ** wildcard matches any channel name recursively. So /foo/** matches /foo/bar, /foo/thing and /foo/bar/thing.

Browser client

Client-side extension

class K2.FayeConnection
  # ...
  connectAndBindEvents: ->
    # ...
    @faye_client = new Faye.Client '<%= AppConfig.faye.public_url %>',
      timeout: timeout

    # client-side extension
    @faye_client.addExtension outgoing: (message, callback) ->
      message.ext = message.ext or {}
      message.ext.user_id = K2.faye.currentUserId
      message.ext.faye_token = K2.faye.fayeToken
      message.ext.public_board_keys = K2.faye.publicBoardKeys
      message.ext.project_id = K2.faye.projectId
      callback(message)
  # ...
      

Browser client

Bind events

class K2.FayeConnection
  # ...  
    @faye_client.bind 'transport:up', ->
      # the client is online
      console.log "TRANSPORT_UP"
      console.log "New socket ID: " + faye_connection.faye_client._0      
      faye_connection.polling.stop()
      faye_connection.polling.fullRefresh()

    @faye_client.bind 'transport:down', ->
      # the client is offline
      # update data with interval (kanbanery stuff - not related to faye)
      faye_connection.polling.start() 
      faye_connected = false

Browser client

Subscribe to channel

class K2.FayeConnection
  subscribeToChannelAndBindEvents: ->
    # ...
    subscription = @faye_client.subscribe @fayeChannelName(), (message) ->
      # handle message
      console.log "GOT MESSAGE", message
      # ignore message sent by client
      if message.faye_socket_id != faye_connection.faye_client._0
        faye_connection.store.refresh message.event

    # subscription has been set up and acknowledged by the server
    subscription.callback ->
      # uniq faye hash for connection
      console.log "SUBSCRIBED"
      console.log "New socket ID: " + faye_connection.faye_client._0
      faye_connection.polling.stop()

    # error while creating subscription
    subscription.errback (error) ->
      console.log(error.message)
      faye_connection.polling.start()

Check 2nd screen experiences

on

Thanks

Links

Alternatives for Faye
https://github.com/maccman/juggernaut (deprecated)
http://socket.io
Alternative hosted API for pusher.com
http://www.pubnub.com
Other:
http://railscasts.com/episodes/260-messaging-with-faye
http://net.tutsplus.com/tutorials/ruby/how-to-use-faye-as-a-real-time-push-server-in-rails/
https://blog.jcoglan.com/2012/06/09/why-you-should-never-use-hash-functions-for-message-authentication/





Artur Trzop

lunarlogic.io

XII 2013

Faye

By ArturT

Faye

  • 3,590