├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── examples └── echochat.rb ├── lib ├── sinatra-websocket.rb └── sinatra-websocket │ ├── error.rb │ ├── ext │ ├── sinatra │ │ └── request.rb │ └── thin │ │ └── connection.rb │ └── version.rb ├── sinatra-websocket.gemspec └── spec ├── error_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | Gemfile 3 | Gemfile.lock 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.1 2 | - [#10](https://github.com/simulacre/sinatra-websocket/issues/10): Event machine not initialized error on close 3 | - [#12](https://github.com/simulacre/sinatra-websocket/issues/12): Don't use Thin 2.0.0 4 | 5 | ## 0.3.0 6 | - [#6](https://github.com/simulacre/sinatra-websocket/pull/6): check for the existence of async.orig_callback - [@crazed](https://github.com/crazed) 7 | 8 | ## 0.2.1 9 | - [#3](https://github.com/simulacre/sinatra-websocket/pull/3): Lock em-websocket dependency due to latest API changes. - [@pedrocarrico](https://github.com/pedrocarrico) 10 | 11 | ## 0.2.0 12 | - [#2](https://github.com/simulacre/sinatra-websocket/pull/2): Update gem spec to work with latest thin (1.5.0) - [@pedrocarrico](https://github.com/pedrocarrico) 13 | 14 | ## 0.1.2 15 | - [#1](https://github.com/simulacre/sinatra-websocket/pull/1): Sinatra::Request#websocket? will check if HTTP_CONNECTION and HTTP_UPGRADE headers exist before examining them - [@ttencate](https://github.com/ttencate) 16 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Caleb Crane 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | Portion of this software are Copyright (c) Bernard Potocki 23 | Portion of this software are Copyright (c) 2010 Samuel Cochran 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SinatraWebsocket 2 | 3 | Makes it easy to upgrade any request to a websocket connection. 4 | 5 | SinatraWebsocket is a fork of [Skinny](https://github.com/sj26/skinny) merged with [Rack WebSocket](https://github.com/imanel/websocket-rack). It provides helpers methods to detect if a request is a WebSocket request and defer to an [EM::WebSocket::Connection](https://github.com/igrigorik/em-websocket). 6 | 7 | 8 | ## Put this in your pipe ... 9 | 10 | ```ruby 11 | 12 | require 'sinatra' 13 | require 'sinatra-websocket' 14 | 15 | set :server, 'thin' 16 | set :sockets, [] 17 | 18 | get '/' do 19 | if !request.websocket? 20 | erb :index 21 | else 22 | request.websocket do |ws| 23 | ws.onopen do 24 | ws.send("Hello World!") 25 | settings.sockets << ws 26 | end 27 | ws.onmessage do |msg| 28 | EM.next_tick { settings.sockets.each{|s| s.send(msg) } } 29 | end 30 | ws.onclose do 31 | warn("websocket closed") 32 | settings.sockets.delete(ws) 33 | end 34 | end 35 | end 36 | end 37 | 38 | __END__ 39 | @@ index 40 | 41 | 42 |

Simple Echo & Chat Server

43 |
44 | 45 |
46 |
47 | 48 | 49 | 73 | 74 | 75 | ``` 76 | 77 | ## And Smoke It 78 | 79 | ``` 80 | ruby echo.rb 81 | ``` 82 | 83 | 84 | ## Copyright 85 | 86 | Copyright (c) 2012 Caleb Crane. 87 | 88 | Portions of this software are Copyright (c) Bernard Potocki and Samuel Cochran. 89 | 90 | See License.txt for more details. 91 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | 3 | version_file = File.expand_path __FILE__ + '/../VERSION' 4 | version = File.read(version_file).strip 5 | 6 | spec_file = File.expand_path __FILE__ + '/../skinny.gemspec' 7 | spec = Gem::Specification.load spec_file 8 | 9 | require 'rdoc/task' 10 | RDoc::Task.new :rdoc => "rdoc", 11 | :clobber_rdoc => "rdoc:clean", 12 | :rerdoc => "rdoc:force" do |rdoc| 13 | rdoc.title = "Skinny #{version}" 14 | rdoc.rdoc_dir = 'rdoc' 15 | rdoc.main = 'README.md' 16 | rdoc.rdoc_files.include 'lib/**/*.rb' 17 | end 18 | 19 | desc "Package as Gem" 20 | task "package:gem" do 21 | builder = Gem::Builder.new spec 22 | builder.build 23 | end 24 | 25 | task "package" => ["package:gem"] 26 | 27 | desc "Release Gem to RubyGems" 28 | task "release:gem" do 29 | %x[gem push skinny-#{version}.gem] 30 | end 31 | 32 | task "release" => ["package", "release:gem"] 33 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.0 2 | -------------------------------------------------------------------------------- /examples/echochat.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # ruby examples/echochat.rb 3 | 4 | $: << File.expand_path('../../lib/', __FILE__) 5 | require 'sinatra' 6 | require 'sinatra-websocket' 7 | 8 | set :server, 'thin' 9 | set :sockets, [] 10 | 11 | get '/' do 12 | if !request.websocket? 13 | erb :index 14 | else 15 | request.websocket do |ws| 16 | ws.onopen do 17 | ws.send("Hello World!") 18 | settings.sockets << ws 19 | end 20 | ws.onmessage do |msg| 21 | EM.next_tick { settings.sockets.each{|s| s.send(msg) } } 22 | end 23 | ws.onclose do 24 | warn("wetbsocket closed") 25 | settings.sockets.delete(ws) 26 | end 27 | end 28 | end 29 | end 30 | 31 | __END__ 32 | @@ index 33 | 34 | 35 |

Simple Echo & Chat Server

36 |
37 | 38 |
39 |
40 | 41 | 42 | 66 | 67 | -------------------------------------------------------------------------------- /lib/sinatra-websocket.rb: -------------------------------------------------------------------------------- 1 | require 'thin' 2 | require 'em-websocket' 3 | require 'sinatra-websocket/error' 4 | require 'sinatra-websocket/ext/thin/connection' 5 | require 'sinatra-websocket/ext/sinatra/request' 6 | 7 | module SinatraWebsocket 8 | class Connection < ::EventMachine::WebSocket::Connection 9 | class << self 10 | def from_env(env, options = {}) 11 | if env.include?('async.orig_callback') 12 | callback_key = 'async.orig_callback' 13 | elsif env.include?(Thin::Request::ASYNC_CALLBACK) 14 | callback_key = Thin::Request::ASYNC_CALLBACK 15 | else 16 | raise Error::ConfigurationError.new('Could not find an async callback in our environment!') 17 | end 18 | socket = env[callback_key].receiver 19 | request = request_from_env(env) 20 | connection = Connection.new(env, socket, :debug => options[:debug]) 21 | yield(connection) if block_given? 22 | connection.dispatch(request) ? async_response : failure_response 23 | end 24 | 25 | ####### 26 | # Taken from WebSocket Rack 27 | # https://github.com/imanel/websocket-rack 28 | ####### 29 | 30 | # Parse Rack env to em-websocket-compatible format 31 | # this probably should be moved to Base in future 32 | def request_from_env(env) 33 | request = {} 34 | request['path'] = env['REQUEST_URI'].to_s 35 | request['method'] = env['REQUEST_METHOD'] 36 | request['query'] = env['QUERY_STRING'].to_s 37 | request['Body'] = env['rack.input'].read 38 | 39 | env.each do |key, value| 40 | if key.match(/HTTP_(.+)/) 41 | request[$1.downcase.gsub('_','-')] ||= value 42 | end 43 | end 44 | request 45 | end 46 | 47 | # Standard async response 48 | def async_response 49 | [-1, {}, []] 50 | end 51 | 52 | # Standard 400 response 53 | def failure_response 54 | [ 400, {'Content-Type' => 'text/plain'}, [ 'Bad request' ] ] 55 | end 56 | end # class << self 57 | 58 | 59 | ######################### 60 | ### EventMachine part ### 61 | ######################### 62 | 63 | # Overwrite new from EventMachine 64 | # we need to skip standard procedure called 65 | # when socket is created - this is just a stub 66 | def self.new(*args) 67 | instance = allocate 68 | instance.__send__(:initialize, *args) 69 | instance 70 | end 71 | 72 | # Overwrite send_data from EventMachine 73 | # delegate send_data to rack server 74 | def send_data(*args) 75 | EM.next_tick do 76 | @socket.send_data(*args) 77 | end 78 | end 79 | 80 | # Overwrite close_connection from EventMachine 81 | # delegate close_connection to rack server 82 | def close_connection(*args) 83 | EM.next_tick do 84 | @socket.close_connection(*args) 85 | end 86 | end 87 | 88 | ######################### 89 | ### EM-WebSocket part ### 90 | ######################### 91 | 92 | # Overwrite initialize from em-websocket 93 | # set all standard options and disable 94 | # EM connection inactivity timeout 95 | def initialize(app, socket, options = {}) 96 | @app = app 97 | @socket = socket 98 | @options = options 99 | @debug = options[:debug] || false 100 | @ssl = socket.backend.respond_to?(:ssl?) && socket.backend.ssl? 101 | 102 | socket.websocket = self 103 | socket.comm_inactivity_timeout = 0 104 | 105 | debug [:initialize] 106 | end 107 | 108 | def get_peername 109 | @socket.get_peername 110 | end 111 | 112 | # Overwrite dispath from em-websocket 113 | # we already have request headers parsed so 114 | # we can skip it and call build_with_request 115 | def dispatch(data) 116 | return false if data.nil? 117 | debug [:inbound_headers, data] 118 | @handler = EventMachine::WebSocket::HandlerFactory.build_with_request(self, data, data['Body'], @ssl, @debug) 119 | unless @handler 120 | # The whole header has not been received yet. 121 | return false 122 | end 123 | @handler.run 124 | return true 125 | end 126 | end 127 | end # module::SinatraWebSocket 128 | -------------------------------------------------------------------------------- /lib/sinatra-websocket/error.rb: -------------------------------------------------------------------------------- 1 | module SinatraWebsocket 2 | module Error 3 | class StandardError < ::StandardError 4 | include Error 5 | end 6 | class ConfigurationError < StandardError; end 7 | class ConnectionError < StandardError; end 8 | end 9 | end # module::SinatraWebsocket 10 | -------------------------------------------------------------------------------- /lib/sinatra-websocket/ext/sinatra/request.rb: -------------------------------------------------------------------------------- 1 | module SinatraWebsocket 2 | module Ext 3 | module Sinatra 4 | module Request 5 | 6 | # Taken from skinny https://github.com/sj26/skinny and updated to support Firefox 7 | def websocket? 8 | env['HTTP_CONNECTION'] && env['HTTP_UPGRADE'] && 9 | env['HTTP_CONNECTION'].split(',').map(&:strip).map(&:downcase).include?('upgrade') && 10 | env['HTTP_UPGRADE'].downcase == 'websocket' 11 | end 12 | 13 | # Taken from skinny https://github.com/sj26/skinny 14 | def websocket(options={}, &blk) 15 | env['skinny.websocket'] ||= begin 16 | raise Error::ConnectionError.new("Not a WebSocket request") unless websocket? 17 | SinatraWebsocket::Connection.from_env(env, options, &blk) 18 | end 19 | end 20 | end 21 | end # module::Sinatra 22 | end # module::Ext 23 | end # module::SinatraWebsocket 24 | defined?(Sinatra) && Sinatra::Request.send(:include, SinatraWebsocket::Ext::Sinatra::Request) 25 | -------------------------------------------------------------------------------- /lib/sinatra-websocket/ext/thin/connection.rb: -------------------------------------------------------------------------------- 1 | module SinatraWebsocket 2 | module Ext 3 | module Thin 4 | module Connection 5 | def self.included(base) 6 | base.class_eval do 7 | alias :receive_data_without_websocket :receive_data 8 | alias :receive_data :receive_data_with_websocket 9 | 10 | alias :unbind_without_websocket :unbind 11 | alias :unbind :unbind_with_websocket 12 | 13 | alias :receive_data_without_flash_policy_file :receive_data 14 | alias :receive_data :receive_data_with_flash_policy_file 15 | 16 | alias :pre_process_without_websocket :pre_process 17 | alias :pre_process :pre_process_with_websocket 18 | end 19 | end 20 | 21 | attr_accessor :websocket 22 | 23 | # Set 'async.connection' Rack env 24 | def pre_process_with_websocket 25 | @request.env['async.connection'] = self 26 | pre_process_without_websocket 27 | end 28 | 29 | # Is this connection WebSocket? 30 | def websocket? 31 | !self.websocket.nil? 32 | end 33 | 34 | # Skip default receive_data if this is 35 | # WebSocket connection 36 | def receive_data_with_websocket(data) 37 | if self.websocket? 38 | self.websocket.receive_data(data) 39 | else 40 | receive_data_without_websocket(data) 41 | end 42 | end 43 | 44 | # Skip standard unbind it this is 45 | # WebSocket connection 46 | def unbind_with_websocket 47 | self.websocket? && self.websocket.unbind 48 | unbind_without_websocket 49 | end 50 | 51 | # Send flash policy file if requested 52 | def receive_data_with_flash_policy_file(data) 53 | # thin require data to be proper http request - in it's not 54 | # then @request.parse raises exception and data isn't parsed 55 | # by futher methods. Here we only check if it is flash 56 | # policy file request ("\000") and 57 | # if so then flash policy file is returned. if not then 58 | # rest of request is handled. 59 | if (data == "\000") 60 | file = '' 61 | # ignore errors - we will close this anyway 62 | send_data(file) rescue nil 63 | close_connection_after_writing 64 | else 65 | receive_data_without_flash_policy_file(data) 66 | end 67 | end 68 | 69 | end # module::Connection 70 | end # module::Thin 71 | end # module::Ext 72 | end # module::SinatraWebsocket 73 | defined?(Thin) && Thin::Connection.send(:include, SinatraWebsocket::Ext::Thin::Connection) 74 | -------------------------------------------------------------------------------- /lib/sinatra-websocket/version.rb: -------------------------------------------------------------------------------- 1 | module SinatraWebsocket 2 | VERSION = '0.3.1' 3 | end 4 | 5 | -------------------------------------------------------------------------------- /sinatra-websocket.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/sinatra-websocket/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'sinatra-websocket' 5 | s.version = SinatraWebsocket::VERSION 6 | s.summary = "Simple, upgradable WebSockets for Sinatra." 7 | s.description = "Makes it easy to upgrade any request to a websocket connection in Sinatra" 8 | s.homepage = 'http://github.com/simulacre/sinatra-websocket' 9 | s.email = 'sinatra-websocket@simulacre.org' 10 | s.authors = ['Caleb Crane'] 11 | s.files = Dir["lib/**/*.rb", "bin/*", "*.md"] 12 | s.require_paths = ["lib"] 13 | 14 | s.add_dependency 'eventmachine' 15 | s.add_dependency 'thin', '>= 1.3.1', '<2.0.0' 16 | s.add_dependency 'em-websocket', '~>0.3.6' 17 | end 18 | -------------------------------------------------------------------------------- /spec/error_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", __FILE__) 2 | 3 | describe SinatraWebsocket::Error::StandardError do 4 | it "should be tagged with SinatraWebsocket::Error" do 5 | subject.should be_a SinatraWebsocket::Error 6 | end 7 | end 8 | 9 | describe SinatraWebsocket::Error::ConfigurationError do 10 | it "should be tagged with SinatraWebsocket::Error" do 11 | subject.should be_a SinatraWebsocket::Error 12 | end 13 | end 14 | 15 | describe SinatraWebsocket::Error::ConnectionError do 16 | it "should be tagged with SinatraWebsocket::Error" do 17 | subject.should be_a SinatraWebsocket::Error 18 | end 19 | end 20 | 21 | describe "Sinatra::Request" do 22 | context "the request is not a websocket request" do 23 | subject { Sinatra::Request.new({})} 24 | describe "#websocket?" do 25 | it { subject.websocket?.should be_nil } 26 | end 27 | describe "#websocket" do 28 | it do 29 | expect { 30 | subject.websocket 31 | }.to raise_error(SinatraWebsocket::Error::ConnectionError) 32 | end 33 | end 34 | end 35 | end 36 | 37 | describe "Sinatra::Websoket::Connection" do 38 | context "the request does not have an async callback" do 39 | describe ".from_env" do 40 | it "should raise SinatraWebsocket::Error::ConfigurationError" do 41 | expect { 42 | SinatraWebsocket::Connection.from_env({}) 43 | }.to raise_error(SinatraWebsocket::Error::ConfigurationError) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "sinatra" 3 | require "sinatra-websocket" 4 | 5 | module SinatraWebsocket 6 | module Test 7 | 8 | end 9 | end 10 | --------------------------------------------------------------------------------