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,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,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
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Slanger
|
2
|
+
class Subscription
|
3
|
+
attr_accessor :connection, :socket
|
4
|
+
delegate :send_payload, :send_message, :error, :socket_id, to: :connection
|
5
|
+
|
6
|
+
def initialize socket, socket_id, msg
|
7
|
+
@connection = Connection.new socket, socket_id
|
8
|
+
@msg = msg
|
9
|
+
end
|
10
|
+
|
11
|
+
def subscribe
|
12
|
+
send_payload channel_id, 'pusher_internal:subscription_succeeded'
|
13
|
+
|
14
|
+
channel.subscribe { |m| send_message m }
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def channel
|
20
|
+
Channel.from channel_id
|
21
|
+
end
|
22
|
+
|
23
|
+
def channel_id
|
24
|
+
@msg['data']['channel']
|
25
|
+
end
|
26
|
+
|
27
|
+
def token(channel_id, params=nil)
|
28
|
+
to_sign = [socket_id, channel_id, params].compact.join ':'
|
29
|
+
|
30
|
+
digest = OpenSSL::Digest::SHA256.new
|
31
|
+
OpenSSL::HMAC.hexdigest digest, Slanger::Config.secret, to_sign
|
32
|
+
end
|
33
|
+
|
34
|
+
def invalid_signature?
|
35
|
+
token(channel_id, data) != auth.split(':')[1]
|
36
|
+
end
|
37
|
+
|
38
|
+
def auth
|
39
|
+
@msg['data']['auth']
|
40
|
+
end
|
41
|
+
|
42
|
+
def data
|
43
|
+
@msg['data']['channel_data']
|
44
|
+
end
|
45
|
+
|
46
|
+
def handle_invalid_signature
|
47
|
+
message = "Invalid signature: Expected HMAC SHA256 hex digest of "
|
48
|
+
message << "#{socket_id}:#{channel_id}, but got #{auth}"
|
49
|
+
|
50
|
+
error({ message: message})
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'em-websocket'
|
3
|
+
|
4
|
+
module Slanger
|
5
|
+
module WebSocketServer
|
6
|
+
def run
|
7
|
+
case
|
8
|
+
when EM.epoll? then EM.epoll
|
9
|
+
when EM.kqueue? then EM.kqueue
|
10
|
+
end
|
11
|
+
|
12
|
+
EM.run do
|
13
|
+
options = {
|
14
|
+
host: Slanger::Config[:websocket_host],
|
15
|
+
port: Slanger::Config[:websocket_port],
|
16
|
+
debug: Slanger::Config[:debug],
|
17
|
+
app_key: Slanger::Config[:app_key]
|
18
|
+
}
|
19
|
+
|
20
|
+
if Slanger::Config[:tls_options]
|
21
|
+
options.merge! secure: true,
|
22
|
+
tls_options: Slanger::Config[:tls_options]
|
23
|
+
end
|
24
|
+
|
25
|
+
EM::WebSocket.start options do |ws|
|
26
|
+
# Keep track of handler instance in instance of EM::Connection to ensure a unique handler instance is used per connection.
|
27
|
+
ws.class_eval { attr_accessor :connection_handler }
|
28
|
+
# Delegate connection management to handler instance.
|
29
|
+
ws.onopen { |handshake| ws.connection_handler = Slanger::Config.socket_handler.new ws, handshake }
|
30
|
+
ws.onmessage { |msg| ws.connection_handler.onmessage msg }
|
31
|
+
ws.onclose { ws.connection_handler.onclose }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
extend self
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'fiber'
|
2
|
+
require 'em-http-request'
|
3
|
+
require 'oj'
|
4
|
+
|
5
|
+
module Slanger
|
6
|
+
module Webhook
|
7
|
+
def post payload
|
8
|
+
return unless Slanger::Config.webhook_url
|
9
|
+
|
10
|
+
payload ={
|
11
|
+
time_ms: Time.now.strftime('%s%L'), events: [payload]
|
12
|
+
}
|
13
|
+
|
14
|
+
payload = Oj.dump(payload, mode: :compat)
|
15
|
+
|
16
|
+
digest = OpenSSL::Digest::SHA256.new
|
17
|
+
hmac = OpenSSL::HMAC.hexdigest(digest, Slanger::Config.secret, payload)
|
18
|
+
content_type = 'application/json'
|
19
|
+
|
20
|
+
EM::HttpRequest.new(Slanger::Config.webhook_url).
|
21
|
+
post(body: payload, head: {
|
22
|
+
"X-Pusher-Key" => Slanger::Config.app_key,
|
23
|
+
"X-Pusher-Signature" => hmac,
|
24
|
+
"Content-Type" => content_type
|
25
|
+
})
|
26
|
+
# TODO: Exponentially backed off retries for errors
|
27
|
+
end
|
28
|
+
|
29
|
+
extend self
|
30
|
+
end
|
31
|
+
end
|
data/slanger.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'eventmachine'
|
3
|
+
require 'em-hiredis'
|
4
|
+
require 'rack'
|
5
|
+
require 'active_support/core_ext/string'
|
6
|
+
require File.join(File.dirname(__FILE__), 'lib', 'slanger', 'version')
|
7
|
+
|
8
|
+
module Slanger; end
|
9
|
+
|
10
|
+
case
|
11
|
+
when EM.epoll? then EM.epoll
|
12
|
+
when EM.kqueue? then EM.kqueue
|
13
|
+
end
|
14
|
+
|
15
|
+
File.tap do |f|
|
16
|
+
Dir[f.expand_path(f.join(f.dirname(__FILE__),'lib', 'slanger', '*.rb'))].each do |file|
|
17
|
+
Slanger.autoload File.basename(file, '.rb').camelize, file
|
18
|
+
end
|
19
|
+
|
20
|
+
Dir[f.expand_path(f.join(f.dirname(__FILE__),'lib', 'slanger', 'api', '*.rb'))].each do |file|
|
21
|
+
Slanger::Api.autoload File.basename(file, '.rb').camelize, file
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module SlangerHelperMethods
|
2
|
+
class HaveAttributes
|
3
|
+
attr_reader :messages, :attributes
|
4
|
+
def initialize attributes
|
5
|
+
@attributes = attributes
|
6
|
+
end
|
7
|
+
|
8
|
+
CHECKS = %w(first_event last_event last_data )
|
9
|
+
|
10
|
+
def matches?(messages)
|
11
|
+
@messages = messages
|
12
|
+
@failures = []
|
13
|
+
|
14
|
+
check_connection_established if attributes[:connection_established]
|
15
|
+
check_id_present if attributes[:id_present]
|
16
|
+
|
17
|
+
CHECKS.each { |a| attributes[a.to_sym] ? check(a) : true }
|
18
|
+
|
19
|
+
@failures.empty?
|
20
|
+
end
|
21
|
+
|
22
|
+
def check message
|
23
|
+
send(message) == attributes[message.to_sym] or @failures << message
|
24
|
+
end
|
25
|
+
|
26
|
+
def failure_message
|
27
|
+
@failures.map {|f| "expected #{f}: to equal #{attributes[f]} but got #{send(f)}"}.join "\n"
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def check_connection_established
|
33
|
+
if first_event != 'pusher:connection_established'
|
34
|
+
@failures << :connection_established
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def check_id_present
|
39
|
+
if messages.first['data']['socket_id'] == nil
|
40
|
+
@failures << :id_present
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def first_event
|
45
|
+
messages.first['event']
|
46
|
+
end
|
47
|
+
|
48
|
+
def last_event
|
49
|
+
messages.last['event']
|
50
|
+
end
|
51
|
+
|
52
|
+
def last_data
|
53
|
+
messages.last['data']
|
54
|
+
end
|
55
|
+
|
56
|
+
def count
|
57
|
+
messages.length
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def have_attributes attributes
|
62
|
+
HaveAttributes.new attributes
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
#encoding: utf-8
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe 'Integration:' do
|
5
|
+
|
6
|
+
before(:each) { start_slanger }
|
7
|
+
|
8
|
+
describe 'channel' do
|
9
|
+
it 'pushes messages to interested websocket connections' do
|
10
|
+
messages = em_stream do |websocket, messages|
|
11
|
+
case messages.length
|
12
|
+
when 1
|
13
|
+
websocket.callback { websocket.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json) }
|
14
|
+
when 2
|
15
|
+
Pusher['MY_CHANNEL'].trigger 'an_event', some: "Mit Raben Und Wölfen"
|
16
|
+
when 3
|
17
|
+
EM.stop
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
expect(messages).to have_attributes connection_established: true, id_present: true,
|
22
|
+
last_event: 'an_event', last_data: { some: "Mit Raben Und Wölfen" }.to_json
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'does not send message to excluded sockets' do
|
26
|
+
messages = em_stream do |websocket, messages|
|
27
|
+
case messages.length
|
28
|
+
when 1
|
29
|
+
websocket.callback { websocket.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json) }
|
30
|
+
when 2
|
31
|
+
socket_id = JSON.parse(messages.first["data"])["socket_id"]
|
32
|
+
Pusher['MY_CHANNEL'].trigger 'not_excluded_socket_event', { some: "Mit Raben Und Wölfen" }
|
33
|
+
Pusher['MY_CHANNEL'].trigger 'excluded_socket_event', { some: "Mit Raben Und Wölfen" }, socket_id
|
34
|
+
when 3
|
35
|
+
EM.stop
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
expect(messages).to have_attributes connection_established: true, id_present: true,
|
40
|
+
last_event: 'not_excluded_socket_event', last_data: { some: "Mit Raben Und Wölfen" }.to_json
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'enforces one subcription per channel, per socket' do
|
44
|
+
messages = em_stream do |websocket, messages|
|
45
|
+
case messages.length
|
46
|
+
when 1
|
47
|
+
websocket.callback { websocket.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json) }
|
48
|
+
when 2
|
49
|
+
websocket.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json)
|
50
|
+
when 3
|
51
|
+
EM.stop
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
expect(messages.last).to eq({"event"=>"pusher:error", "data"=>"{\"code\":null,\"message\":\"Existing subscription to MY_CHANNEL\"}"})
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'supports unsubscribing to channels without closing the socket' do
|
59
|
+
client2_messages = nil
|
60
|
+
|
61
|
+
messages = em_stream do |client, messages|
|
62
|
+
case messages.length
|
63
|
+
when 1
|
64
|
+
client.callback { client.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json) }
|
65
|
+
when 2
|
66
|
+
client.send({ event: 'pusher:unsubscribe', data: { channel: 'MY_CHANNEL'} }.to_json)
|
67
|
+
|
68
|
+
client2_messages = em_stream do |client2, client2_messages|
|
69
|
+
case client2_messages.length
|
70
|
+
when 1
|
71
|
+
client2.callback { client2.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json) }
|
72
|
+
when 2
|
73
|
+
Pusher['MY_CHANNEL'].trigger 'an_event', { some: 'data' }
|
74
|
+
EM.next_tick { EM.stop }
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
expect(messages).to have_attributes connection_established: true, id_present: true,
|
81
|
+
last_event: 'pusher_internal:subscription_succeeded', count: 2
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'avoids sending duplicate events' do
|
85
|
+
client2_messages = nil
|
86
|
+
|
87
|
+
client1_messages = em_stream do |client1, client1_messages|
|
88
|
+
# if this is the first message to client 1 set up another connection from the same client
|
89
|
+
if client1_messages.one?
|
90
|
+
client1.callback do
|
91
|
+
client1.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json)
|
92
|
+
end
|
93
|
+
|
94
|
+
client2_messages = em_stream do |client2, client2_messages|
|
95
|
+
case client2_messages.length
|
96
|
+
when 1
|
97
|
+
client2.callback { client2.send({ event: 'pusher:subscribe', data: { channel: 'MY_CHANNEL'} }.to_json) }
|
98
|
+
when 2
|
99
|
+
socket_id = JSON.parse(client1_messages.first['data'])['socket_id']
|
100
|
+
Pusher['MY_CHANNEL'].trigger 'an_event', { some: 'data' }, socket_id
|
101
|
+
when 3
|
102
|
+
EM.stop
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
expect(client1_messages).to have_attributes count: 2
|
109
|
+
|
110
|
+
expect(client2_messages).to have_attributes last_event: 'an_event',
|
111
|
+
last_data: { some: 'data' }.to_json
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|