Skip to content

Commit

Permalink
Welcome to your new skinnies.
Browse files Browse the repository at this point in the history
  • Loading branch information
sj26 committed Oct 27, 2010
0 parents commit f651bf5
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
pkg
*.gemspec
20 changes: 20 additions & 0 deletions LICENSE
@@ -0,0 +1,20 @@
Copyright (c) 2010 Samuel Cochran

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
51 changes: 51 additions & 0 deletions README.md
@@ -0,0 +1,51 @@
# Skinny

Simple, upgradable Thin WebSockets.

I wanted to be able to upgrade a plain old Rack request to a proper
WebSocket. The easiest way seemed to use the oh-so-nice-and-clean
Thin with a new pair of skinnies.

More details coming soon.

## Examples

More comprehensive examples will be coming soon. Here's a really
simple, not-yet-optimised example I'm using at the moment:

module MailCatcher
class Web < Sinatra::Base
get '/messages' do
if request.websocket?
request.websocket! :protocol => "MailCatcher 0.2 Message Push",
:on_start => proc do |websocket|
subscription = MailCatcher::Events::MessageAdded.subscribe { |message| websocket.send_message message.to_json }
websocket.on_close do |websocket|
MailCatcher::Events::MessageAdded.unsubscribe subscription
end
end
else
MailCatcher::Mail.messages.to_json
end
end
end
end

This syntax will probably get cleaned up. I would like to build a
nice Sinatra handler with DSL with unbound handlers so Sinatra
requests can be recycled.

## TODO

* Nicer
* Documentation
* Tests
* Make more generic for alternate server implementations?

## Copyright

Copyright (c) 2010 Samuel Cochran. See LICENSE for details.

## Wear Them

(Do you?)[http://www.shaunoakes.com/images/skinny-jeans-no.jpg]
30 changes: 30 additions & 0 deletions Rakefile
@@ -0,0 +1,30 @@
require 'rubygems'
require 'rake'

begin
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = "skinny"
gem.summary = %Q{Thin WebSockets}
gem.description = <<-EOD
Simple, upgradable WebSockets for Thin.
EOD
gem.email = "sj26@sj26.com"
gem.homepage = "http://github.com/sj26/skinny"
gem.authors = ["Samuel Cochran"]
end
Jeweler::GemcutterTasks.new
rescue LoadError
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
end

require 'rake/rdoctask'
Rake::RDocTask.new do |rdoc|
version = File.exist?('VERSION') ? File.read('VERSION') : ""

rdoc.rdoc_dir = 'rdoc'
rdoc.title = "skinny #{version}"
rdoc.rdoc_files.include('README*')
rdoc.rdoc_files.include('lib/*.rb')
rdoc.rdoc_files.include('lib/**/*.rb')
end
237 changes: 237 additions & 0 deletions lib/skinny.rb
@@ -0,0 +1,237 @@
require 'eventmachine'
require 'digest/md5'
require 'thin'

module Skinny
module Callbacks
def self.included base
base.class_eval do
extend ClassMethods
include InstanceMethods
end
end

module ClassMethods
def define_callback *names
names.each do |name|
define_method name do |&block|
add_callback name, &block
end
end
end
end

module InstanceMethods
def add_callback name, &block
@callbacks ||= {}
@callbacks[name] ||= []
@callbacks[name] << block
end

def callback name, *args, &block
return [] if @callbacks.nil? || @callbacks[name].nil?
@callbacks[name].collect { |callback| callback.call *args, &block }
end
end
end

class WebSocketError < RuntimeError; end
class WebSocketProtocolError < WebSocketError; end

class Websocket < EventMachine::Connection
include Callbacks

define_callback :on_open, :on_start, :on_handshake, :on_message, :on_error, :on_finish, :on_close

# 4mb is almost too generous, imho.
MAX_BUFFER_LENGTH = 2 ** 32

def self.from_env env, options={}
# Steal the connection
thin_connection = env[Thin::Request::ASYNC_CALLBACK].receiver
# We have all the events now, muahaha
EM.attach(thin_connection.detach, self, env, options)
end

def initialize env, options={}
@env = env.dup
@buffer = ''

self.protocol = options.delete :protocol if options.has_key? :protocol
[:on_open, :on_start, :on_handshake, :on_message, :on_error, :on_finish, :on_close].each do |name|
send name, &options.delete(name) if options.has_key?(name)
end
raise ArgumentError, "Unknown options: #{options.inspect}" unless options.empty?

EM.next_tick { callback :on_open, self }
end

# Return an async response -- stops Thin doing anything with connection.
def response
Thin::Connection::AsyncResponse
end

# Arrayify self into a response tuple
alias :to_a :response

def start!
# Steal any remaining data from rack.input
@buffer = @env[Thin::Request::RACK_INPUT].read + @buffer

# Remove references to Thin connection objects, freeing memory
@env.delete Thin::Request::RACK_INPUT
@env.delete Thin::Request::ASYNC_CALLBACK
@env.delete Thin::Request::ASYNC_CLOSE

EM.next_tick { callback :on_start, self }

# Queue up the actual handshake
EM.next_tick method :handshake!

# Return self so we can be used as a response
self
rescue
error! $!
end

def protocol
@env['HTTP_SEC_WEBSOCKET_PROTOCOL']
end

def protocol= value
@env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = value
end

[1, 2].each do |i|
define_method "key#{i}" do
key = @env["HTTP_SEC_WEBSOCKET_KEY#{i}"]
key.scan(/[0-9]/).join.to_i / key.count(' ')
end
end

def key3
@key3 ||= @buffer.slice!(0...8)
end

def challenge?
@env.has_key? 'HTTP_SEC_WEBSOCKET_KEY1'
end

def challenge
[key1, key2].pack("N*") + key3
end

def challenge_response
Digest::MD5.digest(challenge)
end

def handshake
"HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
"Connection: Upgrade\r\n" +
"Upgrade: WebSocket\r\n" +
"Sec-WebSocket-Location: ws#{@env['rack.url_scheme'] == 'https' ? 's' : ''}://#{@env['HTTP_HOST']}#{@env['REQUEST_PATH']}\r\n" +
"Sec-WebSocket-Origin: #{@env['HTTP_ORIGIN']}\r\n" +
("Sec-WebSocket-Protocol: #{@env['HTTP_SEC_WEBSOCKET_PROTOCOL']}\r\n" if @env['HTTP_SEC_WEBSOCKET_PROTOCOL']) +
"\r\n" +
"#{challenge_response}"
end

def handshake!
[key1, key2].each { |key| raise WebSocketProtocolError, "Invalid key: #{key}" if key >= 2**32 }
# XXX: Should we wait for 8 bytes?
raise WebSocketProtocolError, "Invalid challenge: #{key3}" if key3.length < 8

send_data handshake
@handshook = true

EM.next_tick { callback :on_handshake, self }
rescue
error! $!
end

def receive_data data
@buffer += data

EM.next_tick { process_frame } if @handshook
rescue
error! $!
end

def process_frame
if @buffer.length >= 1
if @buffer[0] == "\x00"
if ending = @buffer.index("\xff")
frame = @buffer.slice! 0..ending
message = frame[1..-2]

EM.next_tick { receive_message message }
elsif @buffer.length > MAX_BUFFER_LENGTH
error! "Maximum buffer length (#{MAX_BUFFER_LENGTH}) exceeded: #{@buffer.length}"
end
elsif @buffer[0] == "\xff"
if @buffer.length > 1
if @buffer[1] == "\x00"
@buffer.slice! 0..1

EM.next_tick { finish! }
else
error! "Incorrect finish frame length: #{@buffer[1].inspect}"
end
end
else
error! "Unknown frame type: #{@buffer[0].inspect}"
end
end
end

def receive_message message
EM.next_tick { callback :on_message, self, message }
end

def frame_message message
"\x00#{message}\xff"
end

def send_message message
send_data frame_message(message)
end

def error! message=nil
EM.next_tick { callback :on_error, self }
EM.next_tick { finish! } unless @finished
# XXX: Log or something
puts "Websocket Error: #{$!}"
end

def finish!
send_data "\xff\x00"
close_connection_after_writing
@finished = true

EM.next_tick { callback :on_finish, self }
rescue
error! $!
end

def unbind
EM.next_tick { callback :on_close, self }
end
end

module RequestHelpers
def websocket?
@env['HTTP_CONNECTION'] == 'Upgrade' && @env['HTTP_UPGRADE'] == 'WebSocket'
end

def websocket(options={})
@env['skinny.websocket'] ||= begin
raise RuntimerError, "Not a WebSocket request" unless websocket?
Websocket.from_env(@env, options)
end
end

def websocket!(options={})
websocket(options).start!
end
end
end

0 comments on commit f651bf5

Please sign in to comment.