slangerq 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.
- checksums.yaml +7 -0
- data/README.md +224 -0
- data/bin/slanger +137 -0
- data/lib/slanger/api.rb +5 -0
- data/lib/slanger/api/event.rb +17 -0
- data/lib/slanger/api/event_publisher.rb +24 -0
- data/lib/slanger/api/request_validation.rb +105 -0
- data/lib/slanger/api/server.rb +57 -0
- data/lib/slanger/channel.rb +106 -0
- data/lib/slanger/config.rb +27 -0
- data/lib/slanger/connection.rb +46 -0
- data/lib/slanger/handler.rb +121 -0
- data/lib/slanger/logger.rb +7 -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 +41 -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 +37 -0
- data/lib/slanger/webhook.rb +31 -0
- data/slanger.rb +23 -0
- data/spec/have_attributes.rb +64 -0
- data/spec/integration/channel_spec.rb +114 -0
- data/spec/integration/integration_spec.rb +68 -0
- data/spec/integration/presence_channel_spec.rb +157 -0
- data/spec/integration/private_channel_spec.rb +79 -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 +109 -0
- data/spec/spec_helper.rb +43 -0
- data/spec/unit/channel_spec.rb +105 -0
- data/spec/unit/request_validation_spec.rb +72 -0
- data/spec/unit/webhook_spec.rb +43 -0
- metadata +392 -0
@@ -0,0 +1,57 @@
|
|
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 ||=
|
50
|
+
begin
|
51
|
+
request_body ||= request.body.read.tap{|s| s.force_encoding("utf-8")}
|
52
|
+
RequestValidation.new(request_body, params, env["PATH_INFO"])
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,106 @@
|
|
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
|
+
|
75
|
+
# Send a client event to the EventMachine channel.
|
76
|
+
# Only events to channels requiring authentication (private or presence)
|
77
|
+
# are accepted. Public channels only get events from the API.
|
78
|
+
def send_client_message(message)
|
79
|
+
Slanger::Redis.publish(message['channel'], Oj.dump(message, mode: :compat)) if authenticated?
|
80
|
+
end
|
81
|
+
|
82
|
+
# Send an event received from Redis to the EventMachine channel
|
83
|
+
# which will send it to subscribed clients.
|
84
|
+
def dispatch(message, channel)
|
85
|
+
push(Oj.dump(message, mode: :compat)) unless channel =~ /\Aslanger:/
|
86
|
+
|
87
|
+
perform_client_webhook!(message)
|
88
|
+
end
|
89
|
+
|
90
|
+
def authenticated?
|
91
|
+
channel_id =~ /\Aprivate-/ || channel_id =~ /\Apresence-/
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def perform_client_webhook!(message)
|
97
|
+
if (message['event'].start_with?('client-')) then
|
98
|
+
|
99
|
+
event = message.merge({'name' => 'client_event'})
|
100
|
+
event['data'] = Oj.dump(event['data'])
|
101
|
+
|
102
|
+
Slanger::Webhook.post(event)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
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.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,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
|