Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

presence channels, private channels, and examples. TODO: tests and us…

…e redis to keep track of presence so it scales across slanger instances
  • Loading branch information...
commit 89b962dcbc8e879223f9de750c3b8b8702f22040 1 parent 50f2227
@stevegraham authored
View
3  .gitignore
@@ -1,2 +1,5 @@
.DS_Store
*.swp
+*.swn
+*.swo
+*.swp
View
40 bin/slanger
@@ -1,8 +1,7 @@
#!/usr/bin/env ruby -Ku
require 'optparse'
-require 'bundler'
-Bundler.require
+require 'bundler/setup'
require 'eventmachine'
options = {
@@ -10,15 +9,19 @@ options = {
websocket_port: '8080', debug: false, redis_address: 'redis://0.0.0.0:6379/0'
}
-optparse = OptionParser.new do |opts|
+OptionParser.new do |opts|
opts.on '-h', '--help', 'Display this screen' do
exit
end
- opts.on '-k', '--key APP_KEY', "Pusher application key" do |k|
+ opts.on '-k', '--app_key APP_KEY', "Pusher application key" do |k|
options[:app_key] = k
end
+ opts.on '-s', '--secret SECRET', "Pusher application secret" do |k|
+ options[:secret] = k
+ end
+
opts.on '-r', '--redis_address URL', "Address to bind to (Default: redis://127.0.0.1:6379/0)" do |h|
options[:redis_address] = h
end
@@ -36,17 +39,30 @@ optparse = OptionParser.new do |opts|
end
end.parse!
-raise RuntimeError.new '--key APP_KEY is a required argument. Use your Pusher key.' unless options[:app_key]
+%w<app_key secret>.each do |parameter|
+ raise RuntimeError.new "--#{parameter} STRING is a required argument. Use your Pusher #{parameter}." unless options[parameter.to_sym]
+end
EM.run do
File.tap { |f| require f.expand_path(f.join(f.dirname(__FILE__),'..', 'slanger.rb')) }
+ Slanger::Config.load options
+ Slanger::Service.run
- Slanger::Service.run options
-
- puts "\n"
- puts '*' * 72
- puts "* Slanger API server listening on port #{options[:api_port]}".ljust(71) + "*"
- puts "* Slanger WebSocket server listening on port #{options[:websocket_port]}".ljust(71) + "*"
- puts '*' * 72
+ puts "\x1b[2J\x1b[H"
puts "\n"
+ puts " .d8888b. 888 "
+ puts " d88P Y88b 888 "
+ puts " Y88b. 888 "
+ puts ' "Y888b. 888 8888b. 88888b. .d88b. .d88b. 888d888 '
+ puts ' "Y88b. 888 "88b 888 "88b d88P"88b d8P Y8b 888P" '
+ puts ' "888 888 .d888888 888 888 888 888 88888888 888 '
+ puts " Y88b d88P 888 888 888 888 888 Y88b 888 Y8b. 888 "
+ puts ' "Y8888P" 888 "Y888888 888 888 "Y88888 "Y8888 888 '
+ puts " 888 "
+ puts " Y8b d88P "
+ puts ' "Y88P" '
+ puts "\n" * 2
+
+ puts "Slanger API server listening on port #{options[:api_port]}"
+ puts "Slanger WebSocket server listening on port #{options[:websocket_port]}"
end
View
56 example/app.rb
@@ -0,0 +1,56 @@
+require 'sinatra'
+require 'haml'
+require 'pusher'
+require 'json'
+require 'digest/md5'
+require 'thin'
+
+set :views, File.dirname(__FILE__) + '/templates'
+set :port, 3000
+
+enable :sessions
+
+Pusher.host = '0.0.0.0'
+Pusher.port = 4567
+Pusher.app_id = 'your-pusher-app-id'
+Pusher.secret = 'your-pusher-secret'
+Pusher.key = '765ec374ae0a69f4ce44'
+
+get '/' do
+ @channel = "MY_CHANNEL"
+ haml :index
+end
+
+get '/chat' do
+ @channel = "presence-channel"
+ if session[:current_user]
+ haml :chat_room
+ else
+ haml :chat_lobby
+ end
+end
+
+post '/chat' do
+ if session[:current_user]
+ Pusher['presence-channel'].trigger_async('chat_message', {
+ sender: session[:current_user], body: params['message']
+ })
+ request.xhr? ? status(201) : redirect('/chat')
+ else
+ status 403
+ end
+end
+
+post '/identify' do
+ session[:current_user] = params['handle']
+ redirect request.referer
+end
+
+post '/pusher/auth' do
+ Pusher[params['channel_name']].authenticate(params['socket_id'], {
+ user_id: Digest::MD5.hexdigest(session[:current_user]),
+ user_info: {
+ name: session[:current_user]
+ }
+ }).to_json
+end
View
62 example/public/screen.css
@@ -0,0 +1,62 @@
+/* @override http://127.0.0.1:3000/screen.css */
+
+* {
+ margin: 0;
+ padding: 0;
+}
+
+body, html {
+ height: 100%;
+ font: 90% "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
+ overflow: hidden;
+}
+
+#presence {
+ width: 300px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ border-left: 1px solid #ddd;
+ z-index: -1;
+}
+
+#presence li {
+ margin: 5px 10px;
+}
+
+#main {
+ height: 100%;
+}
+
+#message {
+ width: 600px;
+}
+
+input {
+
+}
+
+#main form {
+ vertical-align: bottom;
+ position: absolute;
+ bottom: 0;
+ padding: 10px;
+ background-color: #333;
+ width: 100%;
+}
+
+#messages li {
+ margin: 5px 7px;
+ overflow: scroll;
+}
+
+#messages li.info {
+ color: #999;
+}
+
+ol {
+ list-style-type: none;
+ height: 100%;
+}
+
+
View
55 example/ref.rb
@@ -0,0 +1,55 @@
+require 'sinatra'
+require 'haml'
+require 'pusher'
+require 'json'
+require 'digest/md5'
+require 'thin'
+
+set :views, File.dirname(__FILE__) + '/templates'
+set :port, 3001
+
+enable :sessions
+
+Pusher.app_id = '8792'
+Pusher.key = '5ad8640c9b11e84cc60a'
@markburns Collaborator

Should this or even this file even be here?

@stevegraham Owner

not really, but i also don't care. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+Pusher.secret = '11a872de453861e042a9'
+
+get '/' do
+ @channel = "MY_CHANNEL"
+ haml :index
+end
+
+get '/chat' do
+ @use_pusher = true
+ @channel = "presence-channel"
+ if session[:current_user]
+ haml :chat_room
+ else
+ haml :chat_lobby
+ end
+end
+
+post '/chat' do
+ if session[:current_user]
+ Pusher['presence-channel'].trigger_async('chat_message', {
+ sender: session[:current_user], body: params['message']
+ })
+ request.xhr? ? status(201) : redirect('/chat')
+ else
+ status 403
+ end
+end
+
+post '/identify' do
+ session[:current_user] = params['handle']
+ redirect request.referer
+end
+
+post '/pusher/auth' do
+ Pusher[params['channel_name']].authenticate(params['socket_id'], {
+ user_id: Digest::MD5.hexdigest(session[:current_user]),
+ user_info: {
+ name: session[:current_user]
+ }
+ }).to_json
+end
View
15 example/templates/chat_lobby.haml
@@ -0,0 +1,15 @@
+!!!
+%html
+ %head
+ %title Chat Lobby
+ %body
+ %h1 Slanger Chat
+ %p
+ Share those special slanger moments that always bring a smile to your face.
+
+ %form{ action: '/identify', method: 'POST'}
+ %label{ for: 'handle' }
+ Handle
+ %input{ type: 'text', id: 'handle', name: 'handle' }
+ %input{ type: 'submit' }
+
View
51 example/templates/chat_room.haml
@@ -0,0 +1,51 @@
+!!!
+%html
+ %head
+ %title Chat Room
+ %meta{ 'http-equiv' => 'Content-Type', content: 'text/html', charset: 'utf-8' }
+ %link{ rel: 'stylesheet', href: 'screen.css', type: 'text/css' }
+
+ %body
+ %div{ id: 'main'}
+ %ol{ id: 'messages' }
+ %form{ action: '/chat', method: 'POST' }
+ %input{ type: 'text', id: 'message', name: 'message' }
+ %input{ type: 'submit', value: 'Send' }
+ %div{ id: 'sidebar' }
+ %ol{ id: 'presence' }
+ %script{src: "http://js.pusherapp.com/1.8/pusher.min.js"}
+ %script{src: "http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"}
+ :javascript
+ Pusher.host = "#{@use_pusher ? 'ws.pusherapp.com' : '0.0.0.0' }"
+ Pusher.ws_port = "#{@use_pusher ? 80 : 8080}"
+ Pusher.log = function(data) {
+ console.log('\t\t', data);
+ };
+ var pusher = new Pusher('#{Pusher.key}');
+ var myChannel = pusher.subscribe('presence-channel');
+ myChannel.bind('chat_message', function(msg) {
+ $('#messages').append("<li><strong>" + msg['sender'] + "</strong>: " + msg['body'] + "</li>")
+ })
+
+ myChannel.bind('pusher:subscription_succeeded', function(members) {
+ members.each(function(member) {
+ $('#presence').append($('<li>' + member.info.name + '</li>').attr('id', member.id))
+ })
+ })
+
+ myChannel.bind('pusher:member_added', function(member) {
+ $('#messages').append($('<li class="info">**** ' + member.info.name + ' entered the chat ****</li>'))
+ $('#presence').append($('<li>' + member.info.name + '</li>').attr('id', member.id))
+ })
+
+ myChannel.bind('pusher:member_removed', function(member) {
+ $('#messages').append($('<li class="info">**** ' + member.info.name + ' left the chat ****</li>'))
+ $('#' + member.id).remove()
+ })
+
+
+ $('#main form').submit(function(ev) {
+ $.post('/chat', { message: $('#message').val() })
+ $('#message').val(null)
+ return false
+ })
View
16 example/templates/index.haml
@@ -0,0 +1,16 @@
+!!!
+%html
+ %head
+ %title Websocket Test
+ %body
+ %script{src: "http://js.pusherapp.com/1.8/pusher.min.js"}
+ :javascript
+ //Pusher.host = "0.0.0.0"
+ //Pusher.ws_port = "8080"
+ Pusher.log = function(data) {
+ console.log('\t\t', data);
+ };
+ var pusher = new Pusher('5ad8640c9b11e84cc60a');
+ pusher.bind('pusher:error', function(data) { alert(data['message']) })
+ var myChannel = pusher.subscribe('#{@channel}');
+ myChannel.bind('an_event', function(data) { alert('WOWEEWOWEEWOWEEE!') })
View
2  index.html
@@ -17,4 +17,4 @@
myChannel.bind('an_event', function(data) { alert('WOWEEWOWEEWOWEEE!') })
</script>
</body>
-</html>
+</html>
View
6 lib/slanger/api_server.rb
@@ -21,7 +21,7 @@ class ApiServer < Sinatra::Base
# authenticate request. exclude 'channel_id' and 'app_id', these are added the the params
# by the pusher client lib after computing HMAC
Signature::Request.new('POST', env['PATH_INFO'], params.except('channel_id', 'app_id')).
- authenticate { |key| Signature::Token.new key, lookup_secret[key] }
+ authenticate { |key| Signature::Token.new key, Slanger::Config.secret }
f = Fiber.current
Slanger::Redis.publish(params[:channel_id], payload).tap do |r|
@@ -37,10 +37,6 @@ def payload
}
Hash[payload.reject { |k,v| v.nil? }].to_json
end
-
- def lookup_secret
- Hash.new "your-pusher-secret"
- end
end
end
View
24 lib/slanger/config.rb
@@ -0,0 +1,24 @@
+module Slanger
+ module Config
+ def load(opts={})
+ options.update opts
+ end
+
+ def [](key)
+ options[key]
+ end
+
+ def options
+ @options ||= {
+ api_host: '0.0.0.0', api_port: '4567', websocket_host: '0.0.0.0',
+ websocket_port: '8080', debug: false, redis_address: 'redis://0.0.0.0:6379/0'
+ }
+ end
+
+ def method_missing(meth, *args, &blk)
+ @options[meth]
+ end
+
+ extend self
+ end
+end
View
58 lib/slanger/handler.rb
@@ -1,6 +1,7 @@
require 'active_support/json'
require 'active_support/core_ext/hash'
require 'securerandom'
+require 'signature'
module Slanger
class Handler
@@ -11,12 +12,12 @@ def initialize(socket, app_key)
def onmessage(msg)
msg = JSON.parse msg
- send msg['event'].gsub(':', '_'), msg
+ send msg['event'].gsub('pusher:', 'pusher_'), msg
end
def onclose
- channel = Slanger::Channel.find_by_channel_id(@channel_id)
- channel.unsubcribe(@subscription_id)
+ channel = Slanger::Channel.find_by_channel_id(@channel_id) || Slanger::PresenceChannel.find_by_channel_id(@channel_id)
+ channel.try :unsubscribe, @subscription_id
end
private
@@ -33,6 +34,14 @@ def authenticate
def pusher_subscribe(msg)
@channel_id = msg['data']['channel']
+ if match = @channel_id.match(/^((private)|(presence))-/)
+ send "handle_#{match.captures[0]}_subscription", msg
+ else
+ subscribe_channel
+ end
+ end
+
+ def subscribe_channel
channel = Slanger::Channel.find_or_create_by_channel_id(@channel_id)
@subscription_id = channel.subscribe do |msg|
msg = JSON.parse(msg)
@@ -41,8 +50,49 @@ def pusher_subscribe(msg)
end
end
+ def handle_private_subscription(msg)
+ unless token == msg['data']['auth'].split(':')[1]
+ @socket.send(payload 'pusher:error', {
+ message: "Invalid signature: Expected HMAC SHA256 hex digest of #{@socket_id}:#{msg['data']['channel']}, but got #{msg['data']['auth']}"
+ })
+ else
+ subscribe_channel
+ end
+ end
+
+ def handle_presence_subscription(msg)
+ if token(msg['data']['channel_data']) != msg['data']['auth'].split(':')[1]
+ @socket.send(payload 'pusher:error', {
+ message: "Invalid signature: Expected HMAC SHA256 hex digest of #{@socket_id}:#{msg['data']['channel']}, but got #{msg['data']['auth']}"
+ })
+ elsif !msg['data']['channel_data']
+ @socket.send(payload 'pusher:error', {
+ message: "presence-channel is a presence channel and subscription must include channel_data"
+ })
+ else
+ channel = Slanger::PresenceChannel.find_or_create_by_channel_id(@channel_id)
+ @subscription_id = channel.subscribe(msg) do |msg|
+ msg = JSON.parse(msg)
+ socket_id = msg.delete 'socket_id'
+ @socket.send msg.to_json unless socket_id == @socket_id
+ end
+ @socket.send(payload 'pusher_internal:subscription_succeeded', {
+ presence: {
+ count: channel.subscribers.size,
+ ids: channel.ids,
+ hash: channel.subscribers
+ }
+ })
+ end
+ end
+
def payload(event_name, payload = {})
- { event: event_name, data: payload }.to_json
+ { channel: @channel_id, event: event_name, data: payload }.to_json
+ end
+
+ def token(params=nil)
+ string_to_sign = [@socket_id, @channel_id, params].compact.join ':'
+ HMAC::SHA256.hexdigest(Slanger::Config.secret, string_to_sign)
end
end
end
View
53 lib/slanger/presence_channel.rb
@@ -0,0 +1,53 @@
+require 'glamazon'
+require 'eventmachine'
+require 'forwardable'
+
+module Slanger
+ class PresenceChannel < Channel
+ def_delegators :channel, :push
+
+ Slanger::Redis.on(:message) { |channel, message| find_or_create_by_channel_id(channel).push message }
+
+ def subscribe(msg, &blk)
+ data = JSON.parse msg['data']['channel_data']
+ # Don't tell the channel subscriptions a new member has been added if the subscriber data
+ # is already present in the subscriptions hash, i.e. multiple browser windows open.
+ unless subscriptions.has_value? data
+ push payload('pusher_internal:member_added', data)
+ end
+ subscription = channel.subscribe &blk
+ subscriptions[subscription] = data
+ subscription
+ end
+
+ def ids
+ subscriptions.map { |k,v| v['user_id'] }
+ end
+
+ def subscribers
+ Hash[subscriptions.map { |k,v| [v['user_id'], v['user_info']] }]
+ end
+
+ def unsubscribe(id)
+ channel.unsubscribe(id)
+ subscriber = subscriptions.delete(id)
+ # Don't tell the channel subscriptions the member has been removed if the subscriber data
+ # still remains in the subscriptions hash, i.e. multiple browser windows open.
+ unless subscriptions.has_value? subscriber
+ push payload('pusher_internal:member_removed', {
+ user_id: subscriber['user_id']
+ })
+ end
+ end
+
+ private
+
+ def subscriptions
+ @subscriptions ||= {}
+ end
+
+ def payload(event_name, payload = {}, channel_name=nil)
+ { channel: channel_id, event: event_name, data: payload }.to_json
+ end
+ end
+end
View
1  lib/slanger/redis.rb
@@ -15,6 +15,7 @@ def publisher
def subscriber
@subscriber ||= EM::Hiredis.connect
end
+
extend self
end
end
View
15 lib/slanger/service.rb
@@ -3,17 +3,10 @@
module Slanger
module Service
- def run(opts={})
- defaults = {
- api_host: '0.0.0.0', api_port: '4567', websocket_host: '0.0.0.0',
- websocket_port: '8080', debug: false, redis_address: 'redis://0.0.0.0:6379/0'
- }
-
- opts = defaults.merge opts
-
- Rack::Handler::Thin.run Slanger::ApiServer, Host: opts[:api_host], Port: opts[:api_port]
- Slanger::WebSocketServer.run host: opts[:websocket_host], port: opts[:websocket_port],
- debug: opts[:debug], app_key: opts[:app_key]
+ def run
+ Thin::Logging.silent = true
+ Rack::Handler::Thin.run Slanger::ApiServer, Host: Slanger::Config.api_host, Port: Slanger::Config.api_port
+ Slanger::WebSocketServer.run
end
def stop
View
11 lib/slanger/web_socket_server.rb
@@ -3,12 +3,13 @@
module Slanger
module WebSocketServer
- def run(opts)
+ def run
EM.run do
- EM::WebSocket.start opts do |ws|
- ws.onopen { @handler = Slanger::Handler.new ws, opts[:app_key] }
- ws.onmessage { |msg| @handler.onmessage msg }
- ws.onclose { @handler.onclose }
+ EM::WebSocket.start host: Slanger::Config[:websocket_host], port: Slanger::Config[:websocket_port], debug: Slanger::Config[:debug], app_key: Slanger::Config[:app_key] do |ws|
+ ws.class_eval { attr_accessor :connection_handler }
+ ws.onopen { ws.connection_handler = Slanger::Handler.new ws, Slanger::Config.app_key }
+ ws.onmessage { |msg| ws.connection_handler.onmessage msg }
+ ws.onclose { ws.connection_handler.onclose }
end
end
end
View
1  slanger.rb
@@ -3,6 +3,7 @@
require 'eventmachine'
require 'em-hiredis'
require 'rack'
+require 'active_support/core_ext/string'
module Slanger; end
View
3  spec/integration/integration_spec.rb
@@ -14,7 +14,8 @@
@server_pid = EM.fork_reactor do
require File.expand_path(File.dirname(__FILE__) + '/../../slanger.rb')
Thin::Logging.silent = true
- Slanger::Service.run host: '0.0.0.0', api_port: '4567', websocket_port: '8080', app_key: '765ec374ae0a69f4ce44'
+ Slanger::Config.load host: '0.0.0.0', api_port: '4567', websocket_port: '8080', app_key: '765ec374ae0a69f4ce44', secret: 'your-pusher-secret'
+ Slanger::Service.run
end
# Give Slanger a chance to start
sleep 2
@markburns
Collaborator

Should this or even this file even be here?

@stevegraham

not really, but i also don't care. :)

Please sign in to comment.
Something went wrong with that request. Please try again.