kalebr-pusher 0.6.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.
@@ -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.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,121 @@
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
+
14
+ attr_accessor :connection
15
+ delegate :error, :send_payload, to: :connection
16
+
17
+ def initialize(socket, handshake)
18
+ @socket = socket
19
+ @handshake = handshake
20
+ @connection = Connection.new(@socket)
21
+ @subscriptions = {}
22
+ authenticate
23
+ end
24
+
25
+ # Dispatches message handling to method with same name as
26
+ # the event name
27
+ def onmessage(msg)
28
+ msg = Oj.load(msg)
29
+
30
+ msg['data'] = Oj.load(msg['data']) if msg['data'].is_a? String
31
+
32
+ event = msg['event'].gsub(/\Apusher:/, 'pusher_')
33
+
34
+ if event =~ /\Aclient-/
35
+ msg['socket_id'] = connection.socket_id
36
+ Channel.send_client_message msg
37
+ elsif respond_to? event, true
38
+ send event, msg
39
+ end
40
+
41
+ rescue JSON::ParserError
42
+ error({ code: 5001, message: "Invalid JSON" })
43
+ rescue Exception => e
44
+ error({ code: 500, message: "#{e.message}\n #{e.backtrace.join "\n"}" })
45
+ end
46
+
47
+ def onclose
48
+
49
+ subscriptions = @subscriptions.select { |k,v| k && v }
50
+
51
+ subscriptions.each_key do |channel_id|
52
+ subscription_id = subscriptions[channel_id]
53
+ Channel.unsubscribe channel_id, subscription_id
54
+ end
55
+
56
+ end
57
+
58
+ def authenticate
59
+ if !valid_app_key? app_key
60
+ error({ code: 4001, message: "Could not find app by key #{app_key}" })
61
+ @socket.close_websocket
62
+ elsif !valid_protocol_version?
63
+ error({ code: 4007, message: "Unsupported protocol version" })
64
+ @socket.close_websocket
65
+ else
66
+ return connection.establish
67
+ end
68
+ end
69
+
70
+ def valid_protocol_version?
71
+ protocol_version.between?(3, 7)
72
+ end
73
+
74
+ def pusher_ping(msg)
75
+ send_payload nil, 'pusher:pong'
76
+ end
77
+
78
+ def pusher_pong msg; end
79
+
80
+ def pusher_subscribe(msg)
81
+ channel_id = msg['data']['channel']
82
+ klass = subscription_klass channel_id
83
+
84
+ if @subscriptions[channel_id]
85
+ error({ code: nil, message: "Existing subscription to #{channel_id}" })
86
+ else
87
+ @subscriptions[channel_id] = klass.new(connection.socket, connection.socket_id, msg).subscribe
88
+ end
89
+ end
90
+
91
+ def pusher_unsubscribe(msg)
92
+ channel_id = msg['data']['channel']
93
+ subscription_id = @subscriptions.delete(channel_id)
94
+
95
+ Channel.unsubscribe channel_id, subscription_id
96
+ end
97
+
98
+ private
99
+
100
+ def app_key
101
+ @handshake.path.split(/\W/)[2]
102
+ end
103
+
104
+ def protocol_version
105
+ @query_string ||= Rack::Utils.parse_nested_query(@handshake.query_string)
106
+ @query_string["protocol"].to_i || -1
107
+ end
108
+
109
+ def valid_app_key? app_key
110
+ Slanger::Config.app_key == app_key
111
+ end
112
+
113
+ def subscription_klass channel_id
114
+ klass = channel_id.match(/\A(private|presence)-/) do |match|
115
+ Slanger.const_get "#{match[1]}_subscription".classify
116
+ end
117
+
118
+ klass || Slanger::Subscription
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,7 @@
1
+ module Slanger
2
+ module Logger
3
+ def log(msg)
4
+ end
5
+ extend self
6
+ end
7
+ 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.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
@@ -0,0 +1,33 @@
1
+ module Slanger
2
+ class PresenceSubscription < Subscription
3
+ def subscribe
4
+ return handle_invalid_signature if invalid_signature?
5
+
6
+ unless channel_data?
7
+ return connection.error({
8
+ message: "presence-channel is a presence channel and subscription must include channel_data"
9
+ })
10
+ end
11
+
12
+ channel.subscribe(@msg, callback) { |m| send_message m }
13
+ end
14
+
15
+ private
16
+
17
+ def channel_data?
18
+ @msg['data']['channel_data']
19
+ end
20
+
21
+ def callback
22
+ Proc.new {
23
+ connection.send_payload(channel_id, 'pusher_internal:subscription_succeeded', {
24
+ presence: {
25
+ count: channel.subscribers.size,
26
+ ids: channel.ids,
27
+ hash: channel.subscribers
28
+ }
29
+ })
30
+ }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,9 @@
1
+ module Slanger
2
+ class PrivateSubscription < Subscription
3
+ def subscribe
4
+ return handle_invalid_signature if auth && invalid_signature?
5
+
6
+ Subscription.new(connection.socket, connection.socket_id, @msg).subscribe
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,41 @@
1
+ # Redis class.
2
+ # Interface with Redis.
3
+
4
+ require 'forwardable'
5
+ require 'oj'
6
+
7
+ module Slanger
8
+ module Redis
9
+ extend Forwardable
10
+
11
+ def_delegator :publisher, :publish
12
+ def_delegators :subscriber, :subscribe
13
+ def_delegators :regular_connection, :hgetall, :hdel, :hset, :hincrby
14
+
15
+ private
16
+
17
+ def regular_connection
18
+ @regular_connection ||= new_connection
19
+ end
20
+
21
+ def publisher
22
+ @publisher ||= new_connection
23
+ end
24
+
25
+ def subscriber
26
+ @subscriber ||= new_connection.pubsub.tap do |c|
27
+ c.on(:message) do |channel, message|
28
+ message = Oj.load(message)
29
+ c = Channel.from message['channel']
30
+ c.dispatch message, channel
31
+ end
32
+ end
33
+ end
34
+
35
+ def new_connection
36
+ EM::Hiredis.connect Slanger::Config.redis_address
37
+ end
38
+
39
+ extend self
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ require 'thin'
2
+ require 'rack'
3
+
4
+ module Slanger
5
+ module Service
6
+ def run
7
+ Slanger::Config[:require].each { |f| require f }
8
+ Thin::Logging.silent = true
9
+ Rack::Handler::Thin.run Slanger::Api::Server, Host: Slanger::Config.api_host, Port: Slanger::Config.api_port
10
+ Slanger::WebSocketServer.run
11
+ end
12
+
13
+ def stop
14
+ EM.stop if EM.reactor_running?
15
+ end
16
+
17
+ extend self
18
+ Signal.trap('HUP') { Slanger::Service.stop }
19
+ end
20
+ end