oceanex-slanger 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
 - data/README.md +231 -0
 - data/bin/slanger +137 -0
 - data/lib/slanger/api.rb +5 -0
 - data/lib/slanger/api/event.rb +16 -0
 - data/lib/slanger/api/event_publisher.rb +21 -0
 - data/lib/slanger/api/request_validation.rb +105 -0
 - data/lib/slanger/api/server.rb +56 -0
 - data/lib/slanger/channel.rb +104 -0
 - data/lib/slanger/config.rb +27 -0
 - data/lib/slanger/connection.rb +46 -0
 - data/lib/slanger/handler.rb +117 -0
 - data/lib/slanger/logger.rb +8 -0
 - data/lib/slanger/presence_channel.rb +140 -0
 - data/lib/slanger/presence_subscription.rb +33 -0
 - data/lib/slanger/private_subscription.rb +9 -0
 - data/lib/slanger/redis.rb +65 -0
 - data/lib/slanger/service.rb +20 -0
 - data/lib/slanger/subscription.rb +53 -0
 - data/lib/slanger/version.rb +3 -0
 - data/lib/slanger/web_socket_server.rb +38 -0
 - data/lib/slanger/webhook.rb +31 -0
 - data/slanger.rb +23 -0
 - data/spec/have_attributes.rb +65 -0
 - data/spec/integration/channel_spec.rb +113 -0
 - data/spec/integration/integration_spec.rb +67 -0
 - data/spec/integration/presence_channel_spec.rb +148 -0
 - data/spec/integration/private_channel_spec.rb +77 -0
 - data/spec/integration/replaced_handler_spec.rb +23 -0
 - data/spec/integration/ssl_spec.rb +18 -0
 - data/spec/server.crt +12 -0
 - data/spec/server.key +15 -0
 - data/spec/slanger_helper_methods.rb +107 -0
 - data/spec/spec_helper.rb +43 -0
 - data/spec/unit/channel_spec.rb +105 -0
 - data/spec/unit/request_validation_spec.rb +71 -0
 - data/spec/unit/webhook_spec.rb +42 -0
 - metadata +392 -0
 
| 
         @@ -0,0 +1,56 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # encoding: utf-8
         
     | 
| 
      
 2 
     | 
    
         
            +
            require "sinatra/base"
         
     | 
| 
      
 3 
     | 
    
         
            +
            require "signature"
         
     | 
| 
      
 4 
     | 
    
         
            +
            require "json"
         
     | 
| 
      
 5 
     | 
    
         
            +
            require "active_support/core_ext/hash"
         
     | 
| 
      
 6 
     | 
    
         
            +
            require "eventmachine"
         
     | 
| 
      
 7 
     | 
    
         
            +
            require "em-hiredis"
         
     | 
| 
      
 8 
     | 
    
         
            +
            require "rack"
         
     | 
| 
      
 9 
     | 
    
         
            +
            require "fiber"
         
     | 
| 
      
 10 
     | 
    
         
            +
            require "rack/fiber_pool"
         
     | 
| 
      
 11 
     | 
    
         
            +
            require "oj"
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
            module Slanger
         
     | 
| 
      
 14 
     | 
    
         
            +
              module Api
         
     | 
| 
      
 15 
     | 
    
         
            +
                class Server < Sinatra::Base
         
     | 
| 
      
 16 
     | 
    
         
            +
                  use Rack::FiberPool
         
     | 
| 
      
 17 
     | 
    
         
            +
                  set :raise_errors, lambda { false }
         
     | 
| 
      
 18 
     | 
    
         
            +
                  set :show_exceptions, false
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                  error(Signature::AuthenticationError) { |e| halt 401, "401 UNAUTHORIZED" }
         
     | 
| 
      
 21 
     | 
    
         
            +
                  error(Slanger::Api::InvalidRequest) { |c| halt 400, "400 Bad Request" }
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                  before do
         
     | 
| 
      
 24 
     | 
    
         
            +
                    valid_request
         
     | 
| 
      
 25 
     | 
    
         
            +
                  end
         
     | 
| 
      
 26 
     | 
    
         
            +
             
     | 
| 
      
 27 
     | 
    
         
            +
                  post "/apps/:app_id/events" do
         
     | 
| 
      
 28 
     | 
    
         
            +
                    socket_id = valid_request.socket_id
         
     | 
| 
      
 29 
     | 
    
         
            +
                    body = valid_request.body
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                    event = Slanger::Api::Event.new(body["name"], body["data"], socket_id)
         
     | 
| 
      
 32 
     | 
    
         
            +
                    EventPublisher.publish(valid_request.channels, event)
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
                    status 202
         
     | 
| 
      
 35 
     | 
    
         
            +
                    return Oj.dump({}, mode: :compat)
         
     | 
| 
      
 36 
     | 
    
         
            +
                  end
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                  post "/apps/:app_id/channels/:channel_id/events" do
         
     | 
| 
      
 39 
     | 
    
         
            +
                    params = valid_request.params
         
     | 
| 
      
 40 
     | 
    
         
            +
             
     | 
| 
      
 41 
     | 
    
         
            +
                    event = Event.new(params["name"], valid_request.body, valid_request.socket_id)
         
     | 
| 
      
 42 
     | 
    
         
            +
                    EventPublisher.publish(valid_request.channels, event)
         
     | 
| 
      
 43 
     | 
    
         
            +
             
     | 
| 
      
 44 
     | 
    
         
            +
                    status 202
         
     | 
| 
      
 45 
     | 
    
         
            +
                    return Oj.dump({}, mode: :compat)
         
     | 
| 
      
 46 
     | 
    
         
            +
                  end
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                  def valid_request
         
     | 
| 
      
 49 
     | 
    
         
            +
                    @valid_request ||= begin
         
     | 
| 
      
 50 
     | 
    
         
            +
                        request_body ||= request.body.read.tap { |s| s.force_encoding("utf-8") }
         
     | 
| 
      
 51 
     | 
    
         
            +
                        RequestValidation.new(request_body, params, env["PATH_INFO"])
         
     | 
| 
      
 52 
     | 
    
         
            +
                      end
         
     | 
| 
      
 53 
     | 
    
         
            +
                  end
         
     | 
| 
      
 54 
     | 
    
         
            +
                end
         
     | 
| 
      
 55 
     | 
    
         
            +
              end
         
     | 
| 
      
 56 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,104 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # Channel class.
         
     | 
| 
      
 2 
     | 
    
         
            +
            #
         
     | 
| 
      
 3 
     | 
    
         
            +
            # Uses an EventMachine channel to let clients interact with the
         
     | 
| 
      
 4 
     | 
    
         
            +
            # Pusher channel. Relay events received from Redis into the
         
     | 
| 
      
 5 
     | 
    
         
            +
            # EM channel.
         
     | 
| 
      
 6 
     | 
    
         
            +
            #
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
            require "eventmachine"
         
     | 
| 
      
 9 
     | 
    
         
            +
            require "forwardable"
         
     | 
| 
      
 10 
     | 
    
         
            +
            require "oj"
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
            module Slanger
         
     | 
| 
      
 13 
     | 
    
         
            +
              class Channel
         
     | 
| 
      
 14 
     | 
    
         
            +
                extend Forwardable
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                def_delegators :channel, :push
         
     | 
| 
      
 17 
     | 
    
         
            +
                attr_reader :channel_id
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
                class << self
         
     | 
| 
      
 20 
     | 
    
         
            +
                  def from(channel_id)
         
     | 
| 
      
 21 
     | 
    
         
            +
                    klass = channel_id[/\Apresence-/] ? PresenceChannel : Channel
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                    klass.lookup(channel_id) || klass.create(channel_id: channel_id)
         
     | 
| 
      
 24 
     | 
    
         
            +
                  end
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                  def lookup(channel_id)
         
     | 
| 
      
 27 
     | 
    
         
            +
                    all.detect { |o| o.channel_id == channel_id }
         
     | 
| 
      
 28 
     | 
    
         
            +
                  end
         
     | 
| 
      
 29 
     | 
    
         
            +
             
     | 
| 
      
 30 
     | 
    
         
            +
                  def create(params = {})
         
     | 
| 
      
 31 
     | 
    
         
            +
                    new(params).tap { |r| all << r }
         
     | 
| 
      
 32 
     | 
    
         
            +
                  end
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
                  def all
         
     | 
| 
      
 35 
     | 
    
         
            +
                    @all ||= []
         
     | 
| 
      
 36 
     | 
    
         
            +
                  end
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                  def unsubscribe(channel_id, subscription_id)
         
     | 
| 
      
 39 
     | 
    
         
            +
                    from(channel_id).try :unsubscribe, subscription_id
         
     | 
| 
      
 40 
     | 
    
         
            +
                  end
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
                  def send_client_message(msg)
         
     | 
| 
      
 43 
     | 
    
         
            +
                    from(msg["channel"]).try :send_client_message, msg
         
     | 
| 
      
 44 
     | 
    
         
            +
                  end
         
     | 
| 
      
 45 
     | 
    
         
            +
                end
         
     | 
| 
      
 46 
     | 
    
         
            +
             
     | 
| 
      
 47 
     | 
    
         
            +
                def initialize(attrs)
         
     | 
| 
      
 48 
     | 
    
         
            +
                  @channel_id = attrs.with_indifferent_access[:channel_id]
         
     | 
| 
      
 49 
     | 
    
         
            +
                  Slanger::Redis.subscribe channel_id
         
     | 
| 
      
 50 
     | 
    
         
            +
                end
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                def channel
         
     | 
| 
      
 53 
     | 
    
         
            +
                  @channel ||= EM::Channel.new
         
     | 
| 
      
 54 
     | 
    
         
            +
                end
         
     | 
| 
      
 55 
     | 
    
         
            +
             
     | 
| 
      
 56 
     | 
    
         
            +
                def subscribe(*a, &blk)
         
     | 
| 
      
 57 
     | 
    
         
            +
                  Slanger::Redis.hincrby("channel_subscriber_count", channel_id, 1).
         
     | 
| 
      
 58 
     | 
    
         
            +
                    callback do |value|
         
     | 
| 
      
 59 
     | 
    
         
            +
                    Slanger::Webhook.post name: "channel_occupied", channel: channel_id if value == 1
         
     | 
| 
      
 60 
     | 
    
         
            +
                  end
         
     | 
| 
      
 61 
     | 
    
         
            +
             
     | 
| 
      
 62 
     | 
    
         
            +
                  channel.subscribe *a, &blk
         
     | 
| 
      
 63 
     | 
    
         
            +
                end
         
     | 
| 
      
 64 
     | 
    
         
            +
             
     | 
| 
      
 65 
     | 
    
         
            +
                def unsubscribe(*a, &blk)
         
     | 
| 
      
 66 
     | 
    
         
            +
                  Slanger::Redis.hincrby("channel_subscriber_count", channel_id, -1).
         
     | 
| 
      
 67 
     | 
    
         
            +
                    callback do |value|
         
     | 
| 
      
 68 
     | 
    
         
            +
                    Slanger::Webhook.post name: "channel_vacated", channel: channel_id if value == 0
         
     | 
| 
      
 69 
     | 
    
         
            +
                  end
         
     | 
| 
      
 70 
     | 
    
         
            +
             
     | 
| 
      
 71 
     | 
    
         
            +
                  channel.unsubscribe *a, &blk
         
     | 
| 
      
 72 
     | 
    
         
            +
                end
         
     | 
| 
      
 73 
     | 
    
         
            +
             
     | 
| 
      
 74 
     | 
    
         
            +
                # Send a client event to the EventMachine channel.
         
     | 
| 
      
 75 
     | 
    
         
            +
                # Only events to channels requiring authentication (private or presence)
         
     | 
| 
      
 76 
     | 
    
         
            +
                # are accepted. Public channels only get events from the API.
         
     | 
| 
      
 77 
     | 
    
         
            +
                def send_client_message(message)
         
     | 
| 
      
 78 
     | 
    
         
            +
                  Slanger::Redis.publish(message["channel"], Oj.dump(message, mode: :compat)) if authenticated?
         
     | 
| 
      
 79 
     | 
    
         
            +
                end
         
     | 
| 
      
 80 
     | 
    
         
            +
             
     | 
| 
      
 81 
     | 
    
         
            +
                # Send an event received from Redis to the EventMachine channel
         
     | 
| 
      
 82 
     | 
    
         
            +
                # which will send it to subscribed clients.
         
     | 
| 
      
 83 
     | 
    
         
            +
                def dispatch(message, channel)
         
     | 
| 
      
 84 
     | 
    
         
            +
                  push(Oj.dump(message, mode: :compat)) unless channel =~ /\Aslanger:/
         
     | 
| 
      
 85 
     | 
    
         
            +
             
     | 
| 
      
 86 
     | 
    
         
            +
                  perform_client_webhook!(message)
         
     | 
| 
      
 87 
     | 
    
         
            +
                end
         
     | 
| 
      
 88 
     | 
    
         
            +
             
     | 
| 
      
 89 
     | 
    
         
            +
                def authenticated?
         
     | 
| 
      
 90 
     | 
    
         
            +
                  channel_id =~ /\Aprivate-/ || channel_id =~ /\Apresence-/
         
     | 
| 
      
 91 
     | 
    
         
            +
                end
         
     | 
| 
      
 92 
     | 
    
         
            +
             
     | 
| 
      
 93 
     | 
    
         
            +
                private
         
     | 
| 
      
 94 
     | 
    
         
            +
             
     | 
| 
      
 95 
     | 
    
         
            +
                def perform_client_webhook!(message)
         
     | 
| 
      
 96 
     | 
    
         
            +
                  if (message["event"].start_with?("client-"))
         
     | 
| 
      
 97 
     | 
    
         
            +
                    event = message.merge({ "name" => "client_event" })
         
     | 
| 
      
 98 
     | 
    
         
            +
                    event["data"] = Oj.dump(event["data"])
         
     | 
| 
      
 99 
     | 
    
         
            +
             
     | 
| 
      
 100 
     | 
    
         
            +
                    Slanger::Webhook.post(event)
         
     | 
| 
      
 101 
     | 
    
         
            +
                  end
         
     | 
| 
      
 102 
     | 
    
         
            +
                end
         
     | 
| 
      
 103 
     | 
    
         
            +
              end
         
     | 
| 
      
 104 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,27 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # Config singleton holding the configuration.
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Slanger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Config
         
     | 
| 
      
 5 
     | 
    
         
            +
                def load(opts = {})
         
     | 
| 
      
 6 
     | 
    
         
            +
                  options.update opts
         
     | 
| 
      
 7 
     | 
    
         
            +
                end
         
     | 
| 
      
 8 
     | 
    
         
            +
             
     | 
| 
      
 9 
     | 
    
         
            +
                def [](key)
         
     | 
| 
      
 10 
     | 
    
         
            +
                  options[key]
         
     | 
| 
      
 11 
     | 
    
         
            +
                end
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
                def options
         
     | 
| 
      
 14 
     | 
    
         
            +
                  @options ||= {
         
     | 
| 
      
 15 
     | 
    
         
            +
                    api_host: "0.0.0.0", api_port: "4567", websocket_host: "0.0.0.0",
         
     | 
| 
      
 16 
     | 
    
         
            +
                    websocket_port: "8080", debug: false, redis_address: "redis://0.0.0.0:6379/0",
         
     | 
| 
      
 17 
     | 
    
         
            +
                    socket_handler: Slanger::Handler, require: [], activity_timeout: 120,
         
     | 
| 
      
 18 
     | 
    
         
            +
                  }
         
     | 
| 
      
 19 
     | 
    
         
            +
                end
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                def method_missing(meth, *args, &blk)
         
     | 
| 
      
 22 
     | 
    
         
            +
                  options[meth]
         
     | 
| 
      
 23 
     | 
    
         
            +
                end
         
     | 
| 
      
 24 
     | 
    
         
            +
             
     | 
| 
      
 25 
     | 
    
         
            +
                extend self
         
     | 
| 
      
 26 
     | 
    
         
            +
              end
         
     | 
| 
      
 27 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,46 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            require "oj"
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Slanger
         
     | 
| 
      
 4 
     | 
    
         
            +
              class Connection
         
     | 
| 
      
 5 
     | 
    
         
            +
                attr_accessor :socket, :socket_id
         
     | 
| 
      
 6 
     | 
    
         
            +
             
     | 
| 
      
 7 
     | 
    
         
            +
                def initialize(socket, socket_id = nil)
         
     | 
| 
      
 8 
     | 
    
         
            +
                  @socket, @socket_id = socket, socket_id
         
     | 
| 
      
 9 
     | 
    
         
            +
                end
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
                def send_message(m)
         
     | 
| 
      
 12 
     | 
    
         
            +
                  msg = Oj.strict_load m
         
     | 
| 
      
 13 
     | 
    
         
            +
                  s = msg.delete "socket_id"
         
     | 
| 
      
 14 
     | 
    
         
            +
                  socket.send Oj.dump(msg, mode: :compat) unless s == socket_id
         
     | 
| 
      
 15 
     | 
    
         
            +
                end
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                def send_payload(*args)
         
     | 
| 
      
 18 
     | 
    
         
            +
                  socket.send format(*args)
         
     | 
| 
      
 19 
     | 
    
         
            +
                end
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                def error(e)
         
     | 
| 
      
 22 
     | 
    
         
            +
                  begin
         
     | 
| 
      
 23 
     | 
    
         
            +
                    send_payload nil, "pusher:error", e
         
     | 
| 
      
 24 
     | 
    
         
            +
                  rescue EventMachine::WebSocket::WebSocketError
         
     | 
| 
      
 25 
     | 
    
         
            +
                    # Raised if connecection already closed. Only seen with Thor load testing tool
         
     | 
| 
      
 26 
     | 
    
         
            +
                  end
         
     | 
| 
      
 27 
     | 
    
         
            +
                end
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
                def establish
         
     | 
| 
      
 30 
     | 
    
         
            +
                  @socket_id = "%d.%d" % [Process.pid, SecureRandom.random_number(10 ** 6)]
         
     | 
| 
      
 31 
     | 
    
         
            +
             
     | 
| 
      
 32 
     | 
    
         
            +
                  send_payload nil, "pusher:connection_established", {
         
     | 
| 
      
 33 
     | 
    
         
            +
                    socket_id: @socket_id,
         
     | 
| 
      
 34 
     | 
    
         
            +
                    activity_timeout: Slanger::Config.activity_timeout,
         
     | 
| 
      
 35 
     | 
    
         
            +
                  }
         
     | 
| 
      
 36 
     | 
    
         
            +
                end
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                private
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
                def format(channel_id, event_name, payload = {})
         
     | 
| 
      
 41 
     | 
    
         
            +
                  body = { event: event_name, data: Oj.dump(payload, mode: :compat) }
         
     | 
| 
      
 42 
     | 
    
         
            +
                  body[:channel] = channel_id if channel_id
         
     | 
| 
      
 43 
     | 
    
         
            +
                  Oj.dump(body, mode: :compat)
         
     | 
| 
      
 44 
     | 
    
         
            +
                end
         
     | 
| 
      
 45 
     | 
    
         
            +
              end
         
     | 
| 
      
 46 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,117 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # Handler class.
         
     | 
| 
      
 2 
     | 
    
         
            +
            # Handles a client connected via a websocket connection.
         
     | 
| 
      
 3 
     | 
    
         
            +
             
     | 
| 
      
 4 
     | 
    
         
            +
            require "active_support/core_ext/hash"
         
     | 
| 
      
 5 
     | 
    
         
            +
            require "securerandom"
         
     | 
| 
      
 6 
     | 
    
         
            +
            require "signature"
         
     | 
| 
      
 7 
     | 
    
         
            +
            require "fiber"
         
     | 
| 
      
 8 
     | 
    
         
            +
            require "rack"
         
     | 
| 
      
 9 
     | 
    
         
            +
            require "oj"
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
            module Slanger
         
     | 
| 
      
 12 
     | 
    
         
            +
              class Handler
         
     | 
| 
      
 13 
     | 
    
         
            +
                attr_accessor :connection
         
     | 
| 
      
 14 
     | 
    
         
            +
                delegate :error, :send_payload, to: :connection
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                def initialize(socket, handshake)
         
     | 
| 
      
 17 
     | 
    
         
            +
                  @socket = socket
         
     | 
| 
      
 18 
     | 
    
         
            +
                  @handshake = handshake
         
     | 
| 
      
 19 
     | 
    
         
            +
                  @connection = Connection.new(@socket)
         
     | 
| 
      
 20 
     | 
    
         
            +
                  @subscriptions = {}
         
     | 
| 
      
 21 
     | 
    
         
            +
                  authenticate
         
     | 
| 
      
 22 
     | 
    
         
            +
                end
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                # Dispatches message handling to method with same name as
         
     | 
| 
      
 25 
     | 
    
         
            +
                # the event name
         
     | 
| 
      
 26 
     | 
    
         
            +
                def onmessage(msg)
         
     | 
| 
      
 27 
     | 
    
         
            +
                  msg = Oj.strict_load(msg)
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
                  msg["data"] = Oj.strict_load(msg["data"]) if msg["data"].is_a? String
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                  event = msg["event"].gsub(/\Apusher:/, "pusher_")
         
     | 
| 
      
 32 
     | 
    
         
            +
             
     | 
| 
      
 33 
     | 
    
         
            +
                  if event =~ /\Aclient-/
         
     | 
| 
      
 34 
     | 
    
         
            +
                    msg["socket_id"] = connection.socket_id
         
     | 
| 
      
 35 
     | 
    
         
            +
                    Channel.send_client_message msg
         
     | 
| 
      
 36 
     | 
    
         
            +
                  elsif respond_to? event, true
         
     | 
| 
      
 37 
     | 
    
         
            +
                    send event, msg
         
     | 
| 
      
 38 
     | 
    
         
            +
                  end
         
     | 
| 
      
 39 
     | 
    
         
            +
                rescue JSON::ParserError
         
     | 
| 
      
 40 
     | 
    
         
            +
                  error({ code: 5001, message: "Invalid JSON" })
         
     | 
| 
      
 41 
     | 
    
         
            +
                rescue Exception => e
         
     | 
| 
      
 42 
     | 
    
         
            +
                  error({ code: 500, message: "#{e.message}\n #{e.backtrace.join "\n"}" })
         
     | 
| 
      
 43 
     | 
    
         
            +
                end
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                def onclose
         
     | 
| 
      
 46 
     | 
    
         
            +
                  subscriptions = @subscriptions.select { |k, v| k && v }
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                  subscriptions.each_key do |channel_id|
         
     | 
| 
      
 49 
     | 
    
         
            +
                    subscription_id = subscriptions[channel_id]
         
     | 
| 
      
 50 
     | 
    
         
            +
                    Channel.unsubscribe channel_id, subscription_id
         
     | 
| 
      
 51 
     | 
    
         
            +
                  end
         
     | 
| 
      
 52 
     | 
    
         
            +
                end
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
                def authenticate
         
     | 
| 
      
 55 
     | 
    
         
            +
                  if !valid_app_key? app_key
         
     | 
| 
      
 56 
     | 
    
         
            +
                    error({ code: 4001, message: "Could not find app by key #{app_key}" })
         
     | 
| 
      
 57 
     | 
    
         
            +
                    @socket.close_websocket
         
     | 
| 
      
 58 
     | 
    
         
            +
                  elsif !valid_protocol_version?
         
     | 
| 
      
 59 
     | 
    
         
            +
                    error({ code: 4007, message: "Unsupported protocol version" })
         
     | 
| 
      
 60 
     | 
    
         
            +
                    @socket.close_websocket
         
     | 
| 
      
 61 
     | 
    
         
            +
                  else
         
     | 
| 
      
 62 
     | 
    
         
            +
                    return connection.establish
         
     | 
| 
      
 63 
     | 
    
         
            +
                  end
         
     | 
| 
      
 64 
     | 
    
         
            +
                end
         
     | 
| 
      
 65 
     | 
    
         
            +
             
     | 
| 
      
 66 
     | 
    
         
            +
                def valid_protocol_version?
         
     | 
| 
      
 67 
     | 
    
         
            +
                  protocol_version.between?(3, 7)
         
     | 
| 
      
 68 
     | 
    
         
            +
                end
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
      
 70 
     | 
    
         
            +
                def pusher_ping(msg)
         
     | 
| 
      
 71 
     | 
    
         
            +
                  send_payload nil, "pusher:pong"
         
     | 
| 
      
 72 
     | 
    
         
            +
                end
         
     | 
| 
      
 73 
     | 
    
         
            +
             
     | 
| 
      
 74 
     | 
    
         
            +
                def pusher_pong(msg); end
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
                def pusher_subscribe(msg)
         
     | 
| 
      
 77 
     | 
    
         
            +
                  channel_id = msg["data"]["channel"]
         
     | 
| 
      
 78 
     | 
    
         
            +
                  klass = subscription_klass channel_id
         
     | 
| 
      
 79 
     | 
    
         
            +
             
     | 
| 
      
 80 
     | 
    
         
            +
                  if @subscriptions[channel_id]
         
     | 
| 
      
 81 
     | 
    
         
            +
                    error({ code: nil, message: "Existing subscription to #{channel_id}" })
         
     | 
| 
      
 82 
     | 
    
         
            +
                  else
         
     | 
| 
      
 83 
     | 
    
         
            +
                    @subscriptions[channel_id] = klass.new(connection.socket, connection.socket_id, msg).subscribe
         
     | 
| 
      
 84 
     | 
    
         
            +
                  end
         
     | 
| 
      
 85 
     | 
    
         
            +
                end
         
     | 
| 
      
 86 
     | 
    
         
            +
             
     | 
| 
      
 87 
     | 
    
         
            +
                def pusher_unsubscribe(msg)
         
     | 
| 
      
 88 
     | 
    
         
            +
                  channel_id = msg["data"]["channel"]
         
     | 
| 
      
 89 
     | 
    
         
            +
                  subscription_id = @subscriptions.delete(channel_id)
         
     | 
| 
      
 90 
     | 
    
         
            +
             
     | 
| 
      
 91 
     | 
    
         
            +
                  Channel.unsubscribe channel_id, subscription_id
         
     | 
| 
      
 92 
     | 
    
         
            +
                end
         
     | 
| 
      
 93 
     | 
    
         
            +
             
     | 
| 
      
 94 
     | 
    
         
            +
                private
         
     | 
| 
      
 95 
     | 
    
         
            +
             
     | 
| 
      
 96 
     | 
    
         
            +
                def app_key
         
     | 
| 
      
 97 
     | 
    
         
            +
                  @handshake.path.split(/\W/)[2]
         
     | 
| 
      
 98 
     | 
    
         
            +
                end
         
     | 
| 
      
 99 
     | 
    
         
            +
             
     | 
| 
      
 100 
     | 
    
         
            +
                def protocol_version
         
     | 
| 
      
 101 
     | 
    
         
            +
                  @query_string ||= Rack::Utils.parse_nested_query(@handshake.query_string)
         
     | 
| 
      
 102 
     | 
    
         
            +
                  @query_string["protocol"].to_i || -1
         
     | 
| 
      
 103 
     | 
    
         
            +
                end
         
     | 
| 
      
 104 
     | 
    
         
            +
             
     | 
| 
      
 105 
     | 
    
         
            +
                def valid_app_key?(app_key)
         
     | 
| 
      
 106 
     | 
    
         
            +
                  Slanger::Config.app_key == app_key
         
     | 
| 
      
 107 
     | 
    
         
            +
                end
         
     | 
| 
      
 108 
     | 
    
         
            +
             
     | 
| 
      
 109 
     | 
    
         
            +
                def subscription_klass(channel_id)
         
     | 
| 
      
 110 
     | 
    
         
            +
                  klass = channel_id.match(/\A(private|presence)-/) do |match|
         
     | 
| 
      
 111 
     | 
    
         
            +
                    Slanger.const_get "#{match[1]}_subscription".classify
         
     | 
| 
      
 112 
     | 
    
         
            +
                  end
         
     | 
| 
      
 113 
     | 
    
         
            +
             
     | 
| 
      
 114 
     | 
    
         
            +
                  klass || Slanger::Subscription
         
     | 
| 
      
 115 
     | 
    
         
            +
                end
         
     | 
| 
      
 116 
     | 
    
         
            +
              end
         
     | 
| 
      
 117 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,140 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # PresenceChannel class.
         
     | 
| 
      
 2 
     | 
    
         
            +
            #
         
     | 
| 
      
 3 
     | 
    
         
            +
            # Uses an EventMachine channel to let handlers interact with the
         
     | 
| 
      
 4 
     | 
    
         
            +
            # Pusher channel. Relay events received from Redis into the
         
     | 
| 
      
 5 
     | 
    
         
            +
            # EM channel. Keeps data on the subscribers to send it to clients.
         
     | 
| 
      
 6 
     | 
    
         
            +
            #
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
            require "eventmachine"
         
     | 
| 
      
 9 
     | 
    
         
            +
            require "forwardable"
         
     | 
| 
      
 10 
     | 
    
         
            +
            require "fiber"
         
     | 
| 
      
 11 
     | 
    
         
            +
            require "oj"
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
            module Slanger
         
     | 
| 
      
 14 
     | 
    
         
            +
              class PresenceChannel < Channel
         
     | 
| 
      
 15 
     | 
    
         
            +
                def_delegators :channel, :push
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                # Send an event received from Redis to the EventMachine channel
         
     | 
| 
      
 18 
     | 
    
         
            +
                def dispatch(message, channel)
         
     | 
| 
      
 19 
     | 
    
         
            +
                  if channel =~ /\Aslanger:/
         
     | 
| 
      
 20 
     | 
    
         
            +
                    # Messages received from the Redis channel slanger:*  carry info on
         
     | 
| 
      
 21 
     | 
    
         
            +
                    # subscriptions. Update our subscribers accordingly.
         
     | 
| 
      
 22 
     | 
    
         
            +
                    update_subscribers message
         
     | 
| 
      
 23 
     | 
    
         
            +
                  else
         
     | 
| 
      
 24 
     | 
    
         
            +
                    push Oj.dump(message, mode: :compat)
         
     | 
| 
      
 25 
     | 
    
         
            +
                  end
         
     | 
| 
      
 26 
     | 
    
         
            +
                end
         
     | 
| 
      
 27 
     | 
    
         
            +
             
     | 
| 
      
 28 
     | 
    
         
            +
                def initialize(attrs)
         
     | 
| 
      
 29 
     | 
    
         
            +
                  super
         
     | 
| 
      
 30 
     | 
    
         
            +
                  # Also subscribe the slanger daemon to a Redis channel used for events concerning subscriptions.
         
     | 
| 
      
 31 
     | 
    
         
            +
                  Slanger::Redis.subscribe "slanger:connection_notification"
         
     | 
| 
      
 32 
     | 
    
         
            +
                end
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
                def subscribe(msg, callback, &blk)
         
     | 
| 
      
 35 
     | 
    
         
            +
                  channel_data = Oj.strict_load msg["data"]["channel_data"]
         
     | 
| 
      
 36 
     | 
    
         
            +
                  public_subscription_id = SecureRandom.uuid
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                  # Send event about the new subscription to the Redis slanger:connection_notification Channel.
         
     | 
| 
      
 39 
     | 
    
         
            +
                  publisher = publish_connection_notification subscription_id: public_subscription_id, online: true,
         
     | 
| 
      
 40 
     | 
    
         
            +
                                                              channel_data: channel_data, channel: channel_id
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
                  # Associate the subscription data to the public id in Redis.
         
     | 
| 
      
 43 
     | 
    
         
            +
                  roster_add public_subscription_id, channel_data
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                  # fuuuuuuuuuccccccck!
         
     | 
| 
      
 46 
     | 
    
         
            +
                  publisher.callback do
         
     | 
| 
      
 47 
     | 
    
         
            +
                    EM.next_tick do
         
     | 
| 
      
 48 
     | 
    
         
            +
                      # The Subscription event has been sent to Redis successfully.
         
     | 
| 
      
 49 
     | 
    
         
            +
                      # Call the provided callback.
         
     | 
| 
      
 50 
     | 
    
         
            +
                      callback.call
         
     | 
| 
      
 51 
     | 
    
         
            +
                      # Add the subscription to our table.
         
     | 
| 
      
 52 
     | 
    
         
            +
                      internal_subscription_table[public_subscription_id] = channel.subscribe &blk
         
     | 
| 
      
 53 
     | 
    
         
            +
                    end
         
     | 
| 
      
 54 
     | 
    
         
            +
                  end
         
     | 
| 
      
 55 
     | 
    
         
            +
             
     | 
| 
      
 56 
     | 
    
         
            +
                  public_subscription_id
         
     | 
| 
      
 57 
     | 
    
         
            +
                end
         
     | 
| 
      
 58 
     | 
    
         
            +
             
     | 
| 
      
 59 
     | 
    
         
            +
                def ids
         
     | 
| 
      
 60 
     | 
    
         
            +
                  subscriptions.map { |_, v| v["user_id"] }
         
     | 
| 
      
 61 
     | 
    
         
            +
                end
         
     | 
| 
      
 62 
     | 
    
         
            +
             
     | 
| 
      
 63 
     | 
    
         
            +
                def subscribers
         
     | 
| 
      
 64 
     | 
    
         
            +
                  Hash[subscriptions.map { |_, v| [v["user_id"], v["user_info"]] }]
         
     | 
| 
      
 65 
     | 
    
         
            +
                end
         
     | 
| 
      
 66 
     | 
    
         
            +
             
     | 
| 
      
 67 
     | 
    
         
            +
                def unsubscribe(public_subscription_id)
         
     | 
| 
      
 68 
     | 
    
         
            +
                  # Unsubcribe from EM::Channel
         
     | 
| 
      
 69 
     | 
    
         
            +
                  channel.unsubscribe(internal_subscription_table.delete(public_subscription_id)) # if internal_subscription_table[public_subscription_id]
         
     | 
| 
      
 70 
     | 
    
         
            +
                  # Remove subscription data from Redis
         
     | 
| 
      
 71 
     | 
    
         
            +
                  roster_remove public_subscription_id
         
     | 
| 
      
 72 
     | 
    
         
            +
                  # Notify all instances
         
     | 
| 
      
 73 
     | 
    
         
            +
                  publish_connection_notification subscription_id: public_subscription_id, online: false, channel: channel_id
         
     | 
| 
      
 74 
     | 
    
         
            +
                end
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
                private
         
     | 
| 
      
 77 
     | 
    
         
            +
             
     | 
| 
      
 78 
     | 
    
         
            +
                def get_roster
         
     | 
| 
      
 79 
     | 
    
         
            +
                  # Read subscription infos from Redis.
         
     | 
| 
      
 80 
     | 
    
         
            +
                  Fiber.new do
         
     | 
| 
      
 81 
     | 
    
         
            +
                    f = Fiber.current
         
     | 
| 
      
 82 
     | 
    
         
            +
                    Slanger::Redis.hgetall(channel_id).
         
     | 
| 
      
 83 
     | 
    
         
            +
                      callback { |res| f.resume res }
         
     | 
| 
      
 84 
     | 
    
         
            +
                    Fiber.yield
         
     | 
| 
      
 85 
     | 
    
         
            +
                  end.resume
         
     | 
| 
      
 86 
     | 
    
         
            +
                end
         
     | 
| 
      
 87 
     | 
    
         
            +
             
     | 
| 
      
 88 
     | 
    
         
            +
                def roster_add(key, value)
         
     | 
| 
      
 89 
     | 
    
         
            +
                  # Add subscription info to Redis.
         
     | 
| 
      
 90 
     | 
    
         
            +
                  Slanger::Redis.hset(channel_id, key, value)
         
     | 
| 
      
 91 
     | 
    
         
            +
                end
         
     | 
| 
      
 92 
     | 
    
         
            +
             
     | 
| 
      
 93 
     | 
    
         
            +
                def roster_remove(key)
         
     | 
| 
      
 94 
     | 
    
         
            +
                  # Remove subscription info from Redis.
         
     | 
| 
      
 95 
     | 
    
         
            +
                  Slanger::Redis.hdel(channel_id, key)
         
     | 
| 
      
 96 
     | 
    
         
            +
                end
         
     | 
| 
      
 97 
     | 
    
         
            +
             
     | 
| 
      
 98 
     | 
    
         
            +
                def publish_connection_notification(payload, retry_count = 0)
         
     | 
| 
      
 99 
     | 
    
         
            +
                  # Send a subscription notification to the global slanger:connection_notification
         
     | 
| 
      
 100 
     | 
    
         
            +
                  # channel.
         
     | 
| 
      
 101 
     | 
    
         
            +
                  Slanger::Redis.publish("slanger:connection_notification", Oj.dump(payload, mode: :compat)).
         
     | 
| 
      
 102 
     | 
    
         
            +
                    tap { |r| r.errback { publish_connection_notification payload, retry_count.succ unless retry_count == 5 } }
         
     | 
| 
      
 103 
     | 
    
         
            +
                end
         
     | 
| 
      
 104 
     | 
    
         
            +
             
     | 
| 
      
 105 
     | 
    
         
            +
                # This is the state of the presence channel across the system. kept in sync
         
     | 
| 
      
 106 
     | 
    
         
            +
                # with redis pubsub
         
     | 
| 
      
 107 
     | 
    
         
            +
                def subscriptions
         
     | 
| 
      
 108 
     | 
    
         
            +
                  @subscriptions ||= get_roster || {}
         
     | 
| 
      
 109 
     | 
    
         
            +
                end
         
     | 
| 
      
 110 
     | 
    
         
            +
             
     | 
| 
      
 111 
     | 
    
         
            +
                # This is used map public subscription ids to em channel subscription ids.
         
     | 
| 
      
 112 
     | 
    
         
            +
                # em channel subscription ids are incremented integers, so they cannot
         
     | 
| 
      
 113 
     | 
    
         
            +
                # be used as keys in distributed system because they will not be unique
         
     | 
| 
      
 114 
     | 
    
         
            +
                def internal_subscription_table
         
     | 
| 
      
 115 
     | 
    
         
            +
                  @internal_subscription_table ||= {}
         
     | 
| 
      
 116 
     | 
    
         
            +
                end
         
     | 
| 
      
 117 
     | 
    
         
            +
             
     | 
| 
      
 118 
     | 
    
         
            +
                def update_subscribers(message)
         
     | 
| 
      
 119 
     | 
    
         
            +
                  if message["online"]
         
     | 
| 
      
 120 
     | 
    
         
            +
                    # Don't tell the channel subscriptions a new member has been added if the subscriber data
         
     | 
| 
      
 121 
     | 
    
         
            +
                    # is already present in the subscriptions hash, i.e. multiple browser windows open.
         
     | 
| 
      
 122 
     | 
    
         
            +
                    unless subscriptions.has_value? message["channel_data"]
         
     | 
| 
      
 123 
     | 
    
         
            +
                      push payload("pusher_internal:member_added", message["channel_data"])
         
     | 
| 
      
 124 
     | 
    
         
            +
                    end
         
     | 
| 
      
 125 
     | 
    
         
            +
                    subscriptions[message["subscription_id"]] = message["channel_data"]
         
     | 
| 
      
 126 
     | 
    
         
            +
                  else
         
     | 
| 
      
 127 
     | 
    
         
            +
                    # Don't tell the channel subscriptions the member has been removed if the subscriber data
         
     | 
| 
      
 128 
     | 
    
         
            +
                    # still remains in the subscriptions hash, i.e. multiple browser windows open.
         
     | 
| 
      
 129 
     | 
    
         
            +
                    subscriber = subscriptions.delete message["subscription_id"]
         
     | 
| 
      
 130 
     | 
    
         
            +
                    if subscriber && !subscriptions.has_value?(subscriber)
         
     | 
| 
      
 131 
     | 
    
         
            +
                      push payload("pusher_internal:member_removed", { user_id: subscriber["user_id"] })
         
     | 
| 
      
 132 
     | 
    
         
            +
                    end
         
     | 
| 
      
 133 
     | 
    
         
            +
                  end
         
     | 
| 
      
 134 
     | 
    
         
            +
                end
         
     | 
| 
      
 135 
     | 
    
         
            +
             
     | 
| 
      
 136 
     | 
    
         
            +
                def payload(event_name, payload = {})
         
     | 
| 
      
 137 
     | 
    
         
            +
                  Oj.dump({ channel: channel_id, event: event_name, data: payload }, mode: :compat)
         
     | 
| 
      
 138 
     | 
    
         
            +
                end
         
     | 
| 
      
 139 
     | 
    
         
            +
              end
         
     | 
| 
      
 140 
     | 
    
         
            +
            end
         
     |