reel 0.2.0 → 0.3.0.pre
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of reel might be problematic. Click here for more details.
- data/.travis.yml +2 -3
- data/CHANGES.md +12 -2
- data/README.md +105 -19
- data/bin/reel +2 -1
- data/examples/chunked.rb +25 -0
- data/examples/hello_world.rb +4 -1
- data/examples/roundtrip.rb +157 -0
- data/examples/server-sent-events.rb +48 -0
- data/examples/stream.rb +26 -0
- data/examples/websocket-wall.rb +53 -0
- data/examples/websocket.ru +92 -0
- data/examples/websocket_rack.sh +1 -0
- data/examples/websockets.rb +1 -3
- data/lib/rack/handler/reel.rb +7 -5
- data/lib/reel.rb +6 -0
- data/lib/reel/connection.rb +43 -34
- data/lib/reel/mixins.rb +57 -0
- data/lib/reel/rack_worker.rb +103 -59
- data/lib/reel/request.rb +19 -34
- data/lib/reel/request_parser.rb +12 -2
- data/lib/reel/response.rb +36 -30
- data/lib/reel/server.rb +11 -7
- data/lib/reel/stream.rb +128 -0
- data/lib/reel/version.rb +1 -1
- data/lib/reel/websocket.rb +44 -8
- data/spec/reel/connection_spec.rb +89 -0
- data/spec/reel/rack_worker_spec.rb +28 -16
- data/spec/reel/response_spec.rb +2 -2
- data/spec/reel/server_spec.rb +2 -2
- data/spec/reel/websocket_spec.rb +2 -17
- data/spec/spec_helper.rb +26 -2
- metadata +74 -22
data/examples/stream.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'reel'
|
4
|
+
|
5
|
+
app = Rack::Builder.new do
|
6
|
+
map '/' do
|
7
|
+
run lambda { |env|
|
8
|
+
body = Reel::Stream.new do |body|
|
9
|
+
# sending a payload to make sure browsers will render chunks as received
|
10
|
+
body << "<html>#{' '*1024}\n"
|
11
|
+
('A'..'Z').each do |l|
|
12
|
+
body << "<div>#{l}</div>\n"
|
13
|
+
sleep 0.5
|
14
|
+
end
|
15
|
+
body << "</html>\n"
|
16
|
+
body.finish
|
17
|
+
end
|
18
|
+
[200, {
|
19
|
+
'Transfer-Encoding' => 'identity',
|
20
|
+
'Content-Type' => 'text/html'
|
21
|
+
}, body]
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end.to_app
|
25
|
+
|
26
|
+
Rack::Handler::Reel.run app, Port: 9292
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'reel'
|
4
|
+
|
5
|
+
Connections = []
|
6
|
+
Body = DATA.read
|
7
|
+
app = Rack::Builder.new do
|
8
|
+
map '/' do
|
9
|
+
run lambda { |env|
|
10
|
+
[200, {'Content-Type' => 'text/html'}, [Body]]
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
map '/subscribe' do
|
15
|
+
run lambda { |env|
|
16
|
+
if socket = env['rack.websocket']
|
17
|
+
socket.on_message do |m|
|
18
|
+
socket << "Server got \"#{m}\" message"
|
19
|
+
end
|
20
|
+
socket.on_error { Connections.delete socket }
|
21
|
+
Connections << socket
|
22
|
+
socket.read_every 1
|
23
|
+
end
|
24
|
+
[200, {}, []]
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
map '/wall' do
|
29
|
+
run lambda { |env|
|
30
|
+
msg = env['PATH_INFO'].gsub(/\/+/, '').strip
|
31
|
+
msg = Time.now if msg.empty?
|
32
|
+
Connections.each { |s| s << msg }
|
33
|
+
[200, {'Content-Type' => 'text/html'}, ["Sent \"#{msg}\" to #{Connections.size} clients"]]
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end.to_app
|
37
|
+
|
38
|
+
Rack::Handler::Reel.run app, Port: 9292
|
39
|
+
|
40
|
+
__END__
|
41
|
+
<!doctype html>
|
42
|
+
<html lang="en">
|
43
|
+
<body>
|
44
|
+
<input type="button" onClick="ws.send(Math.random());" value="Send a message to server">
|
45
|
+
<div id="content"></div>
|
46
|
+
</body>
|
47
|
+
<script type="text/javascript">
|
48
|
+
ws = new WebSocket('ws://' + window.location.host + '/subscribe');
|
49
|
+
ws.onmessage = function(e) {
|
50
|
+
document.getElementById('content').innerHTML += e.data + '<br>';
|
51
|
+
}
|
52
|
+
</script>
|
53
|
+
</html>
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'reel'
|
4
|
+
|
5
|
+
class TimeServer
|
6
|
+
include Celluloid
|
7
|
+
include Celluloid::Notifications
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
run!
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
now = Time.now.to_f
|
15
|
+
sleep now.ceil - now + 0.001
|
16
|
+
|
17
|
+
every(1) { publish 'time_change', Time.now }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class TimeClient
|
22
|
+
include Celluloid
|
23
|
+
include Celluloid::Notifications
|
24
|
+
include Celluloid::Logger
|
25
|
+
|
26
|
+
def initialize(websocket)
|
27
|
+
info "Streaming time changes to client"
|
28
|
+
@socket = websocket
|
29
|
+
subscribe('time_change', :notify_time_change)
|
30
|
+
end
|
31
|
+
|
32
|
+
def notify_time_change(topic, new_time)
|
33
|
+
@socket << new_time.inspect
|
34
|
+
rescue Reel::SocketError
|
35
|
+
info "Time client disconnected"
|
36
|
+
terminate
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class Web
|
41
|
+
include Celluloid::Logger
|
42
|
+
|
43
|
+
def render_index
|
44
|
+
info "200 OK: /"
|
45
|
+
<<-HTML
|
46
|
+
<!doctype html>
|
47
|
+
<html lang="en">
|
48
|
+
<head>
|
49
|
+
<meta charset="utf-8">
|
50
|
+
<title>Reel WebSockets time server example</title>
|
51
|
+
<style>
|
52
|
+
body {
|
53
|
+
font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
|
54
|
+
font-weight: 300;
|
55
|
+
text-align: center;
|
56
|
+
}
|
57
|
+
|
58
|
+
#content {
|
59
|
+
width: 800px;
|
60
|
+
margin: 0 auto;
|
61
|
+
background: #EEEEEE;
|
62
|
+
padding: 1em;
|
63
|
+
}
|
64
|
+
</style>
|
65
|
+
</head>
|
66
|
+
<script>
|
67
|
+
var SocketKlass = "MozWebSocket" in window ? MozWebSocket : WebSocket;
|
68
|
+
var ws = new SocketKlass('ws://' + window.location.host + '/timeinfo');
|
69
|
+
ws.onmessage = function(msg){
|
70
|
+
document.getElementById('current-time').innerHTML = msg.data;
|
71
|
+
}
|
72
|
+
</script>
|
73
|
+
<body>
|
74
|
+
<div id="content">
|
75
|
+
<h1>Time Server Example</h1>
|
76
|
+
<div>The time is now: <span id="current-time">...</span></div>
|
77
|
+
</div>
|
78
|
+
</body>
|
79
|
+
</html>
|
80
|
+
HTML
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
TimeServer.supervise_as :time_server
|
85
|
+
|
86
|
+
run Rack::URLMap.new(
|
87
|
+
"/" => Proc.new{ [200, {"Content-Type" => "text/html"}, [Web.new.render_index]]},
|
88
|
+
"/timeinfo" => Proc.new{ |env|
|
89
|
+
TimeClient.new(env["websocket.rack"])
|
90
|
+
[200, {}, []] # Fake response for middleware.
|
91
|
+
}
|
92
|
+
)
|
@@ -0,0 +1 @@
|
|
1
|
+
rackup -s reel websocket.ru -Enone
|
data/examples/websockets.rb
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
# FIXME: not quite complete yet, but it should give you an idea
|
2
|
-
|
3
1
|
require 'rubygems'
|
4
2
|
require 'bundler/setup'
|
5
3
|
require 'reel'
|
@@ -9,7 +7,7 @@ class TimeServer
|
|
9
7
|
include Celluloid::Notifications
|
10
8
|
|
11
9
|
def initialize
|
12
|
-
run
|
10
|
+
async.run
|
13
11
|
end
|
14
12
|
|
15
13
|
def run
|
data/lib/rack/handler/reel.rb
CHANGED
@@ -18,12 +18,13 @@ module Rack
|
|
18
18
|
|
19
19
|
def self.run(app, options = {})
|
20
20
|
|
21
|
-
handler = Reel.new(options)
|
21
|
+
@handler = Reel.new(options.merge :app => app)
|
22
22
|
|
23
23
|
::Reel::Logger.info "A Reel good HTTP server!"
|
24
|
-
::Reel::Logger.info "Listening on #{handler[:host]}:#{handler[:port]}"
|
24
|
+
::Reel::Logger.info "Listening on #{@handler[:host]}:#{@handler[:port]}"
|
25
25
|
|
26
|
-
handler
|
26
|
+
yield @handler if block_given?
|
27
|
+
@handler.start
|
27
28
|
end
|
28
29
|
|
29
30
|
def initialize(opts = {})
|
@@ -73,12 +74,13 @@ module Rack
|
|
73
74
|
|
74
75
|
# Transform the options that rails s reel passes
|
75
76
|
def normalize_options(options)
|
76
|
-
options.inject({}) { |h, (k,v)|
|
77
|
+
options = options.inject({}) { |h, (k,v)| h[k.downcase] = v ; h }
|
77
78
|
options[:rackup] = options[:config] if options[:config]
|
79
|
+
options[:port] = options[:port].to_i if options[:port]
|
78
80
|
options
|
79
81
|
end
|
80
82
|
end
|
81
83
|
|
82
84
|
register :reel, Reel
|
83
85
|
end
|
84
|
-
end
|
86
|
+
end
|
data/lib/reel.rb
CHANGED
@@ -1,9 +1,13 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
1
3
|
require 'http/parser'
|
2
4
|
require 'http'
|
3
5
|
require 'celluloid/io'
|
4
6
|
|
5
7
|
require 'reel/version'
|
6
8
|
|
9
|
+
require 'reel/mixins'
|
10
|
+
|
7
11
|
require 'reel/connection'
|
8
12
|
require 'reel/logger'
|
9
13
|
require 'reel/request'
|
@@ -11,6 +15,7 @@ require 'reel/request_parser'
|
|
11
15
|
require 'reel/response'
|
12
16
|
require 'reel/server'
|
13
17
|
require 'reel/websocket'
|
18
|
+
require 'reel/stream'
|
14
19
|
|
15
20
|
require 'rack'
|
16
21
|
require 'rack/handler'
|
@@ -19,6 +24,7 @@ require 'reel/rack_worker'
|
|
19
24
|
|
20
25
|
# A Reel good HTTP server
|
21
26
|
module Reel
|
27
|
+
|
22
28
|
# Error reading a request
|
23
29
|
class RequestError < StandardError; end
|
24
30
|
|
data/lib/reel/connection.rb
CHANGED
@@ -1,8 +1,17 @@
|
|
1
1
|
module Reel
|
2
2
|
# A connection to the HTTP server
|
3
3
|
class Connection
|
4
|
+
include HTTPVersionsMixin
|
5
|
+
include ConnectionMixin
|
6
|
+
|
4
7
|
class StateError < RuntimeError; end # wrong state for a given operation
|
5
8
|
|
9
|
+
CONNECTION = 'Connection'.freeze
|
10
|
+
TRANSFER_ENCODING = 'Transfer-Encoding'.freeze
|
11
|
+
KEEP_ALIVE = 'Keep-Alive'.freeze
|
12
|
+
CLOSE = 'close'.freeze
|
13
|
+
CHUNKED = 'chunked'.freeze
|
14
|
+
|
6
15
|
attr_reader :socket, :parser
|
7
16
|
|
8
17
|
# Attempt to read this much data
|
@@ -16,7 +25,6 @@ module Reel
|
|
16
25
|
reset_request
|
17
26
|
|
18
27
|
@response_state = :header
|
19
|
-
@body_remaining = nil
|
20
28
|
end
|
21
29
|
|
22
30
|
# Is the connection still active?
|
@@ -31,18 +39,6 @@ module Reel
|
|
31
39
|
self
|
32
40
|
end
|
33
41
|
|
34
|
-
# Obtain the IP address of the remote connection
|
35
|
-
def remote_ip
|
36
|
-
@socket.peeraddr(false)[3]
|
37
|
-
end
|
38
|
-
alias_method :remote_addr, :remote_ip
|
39
|
-
|
40
|
-
# Obtain the hostname of the remote connection
|
41
|
-
def remote_host
|
42
|
-
# NOTE: Celluloid::IO does not yet support non-blocking reverse DNS
|
43
|
-
@socket.peeraddr(true)[2]
|
44
|
-
end
|
45
|
-
|
46
42
|
# Reset the current request state
|
47
43
|
def reset_request(state = :header)
|
48
44
|
@request_state = state
|
@@ -58,11 +54,9 @@ module Reel
|
|
58
54
|
case req
|
59
55
|
when Request
|
60
56
|
@request_state = :body
|
61
|
-
@keepalive = false if req[
|
62
|
-
@body_remaining = Integer(req['Content-Length']) if req['Content-Length']
|
57
|
+
@keepalive = false if req[CONNECTION] == CLOSE || req.version == HTTP_VERSION_1_0
|
63
58
|
when WebSocket
|
64
59
|
@request_state = @response_state = :websocket
|
65
|
-
@body_remaining = nil
|
66
60
|
@socket = nil
|
67
61
|
else raise "unexpected request type: #{req.class}"
|
68
62
|
end
|
@@ -79,19 +73,35 @@ module Reel
|
|
79
73
|
def readpartial(size = BUFFER_SIZE)
|
80
74
|
raise StateError, "can't read in the `#{@request_state}' state" unless @request_state == :body
|
81
75
|
|
82
|
-
|
76
|
+
chunk = @parser.chunk
|
77
|
+
unless chunk || @parser.finished?
|
78
|
+
@parser << @socket.readpartial(size)
|
83
79
|
chunk = @parser.chunk
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
80
|
+
end
|
81
|
+
|
82
|
+
chunk
|
83
|
+
end
|
84
|
+
|
85
|
+
# read length bytes from request body
|
86
|
+
def read(length = nil, buffer = nil)
|
87
|
+
raise ArgumentError, "negative length #{length} given" if length && length < 0
|
89
88
|
|
90
|
-
|
91
|
-
@body_remaining = nil if @body_remaining < 1
|
89
|
+
return '' if length == 0
|
92
90
|
|
93
|
-
|
91
|
+
res = buffer.nil? ? '' : buffer.clear
|
92
|
+
|
93
|
+
chunk_size = length.nil? ? BUFFER_SIZE : length
|
94
|
+
begin
|
95
|
+
while chunk_size > 0
|
96
|
+
chunk = readpartial(chunk_size)
|
97
|
+
break unless chunk
|
98
|
+
res << chunk
|
99
|
+
chunk_size = length - res.length unless length.nil?
|
100
|
+
end
|
101
|
+
rescue EOFError
|
94
102
|
end
|
103
|
+
|
104
|
+
return length && res.length == 0 ? nil : res
|
95
105
|
end
|
96
106
|
|
97
107
|
# Send a response back to the client
|
@@ -107,9 +117,9 @@ module Reel
|
|
107
117
|
end
|
108
118
|
|
109
119
|
if @keepalive
|
110
|
-
headers[
|
120
|
+
headers[CONNECTION] = KEEP_ALIVE
|
111
121
|
else
|
112
|
-
headers[
|
122
|
+
headers[CONNECTION] = CLOSE
|
113
123
|
end
|
114
124
|
|
115
125
|
case response
|
@@ -122,7 +132,7 @@ module Reel
|
|
122
132
|
response.render(@socket)
|
123
133
|
|
124
134
|
# Enable streaming mode
|
125
|
-
if response.headers[
|
135
|
+
if response.headers[TRANSFER_ENCODING] == CHUNKED and response.body.nil?
|
126
136
|
@response_state = :chunked_body
|
127
137
|
end
|
128
138
|
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
@@ -140,24 +150,23 @@ module Reel
|
|
140
150
|
# Write body chunks directly to the connection
|
141
151
|
def write(chunk)
|
142
152
|
raise StateError, "not in chunked body mode" unless @response_state == :chunked_body
|
143
|
-
chunk_header = chunk.bytesize.to_s(16)
|
144
|
-
@socket << chunk_header
|
145
|
-
@socket << chunk
|
146
|
-
@socket << Response::CRLF
|
153
|
+
chunk_header = chunk.bytesize.to_s(16)
|
154
|
+
@socket << chunk_header + Response::CRLF
|
155
|
+
@socket << chunk + Response::CRLF
|
147
156
|
end
|
148
157
|
alias_method :<<, :write
|
149
158
|
|
150
159
|
# Finish the response and reset the response state to header
|
151
160
|
def finish_response
|
152
161
|
raise StateError, "not in body state" if @response_state != :chunked_body
|
153
|
-
@socket << "0
|
162
|
+
@socket << "0#{Response::CRLF * 2}"
|
154
163
|
@response_state = :header
|
155
164
|
end
|
156
165
|
|
157
166
|
# Close the connection
|
158
167
|
def close
|
159
168
|
@keepalive = false
|
160
|
-
@socket.close
|
169
|
+
@socket.close unless @socket.closed?
|
161
170
|
end
|
162
171
|
end
|
163
172
|
end
|
data/lib/reel/mixins.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
module HTTPVersionsMixin
|
2
|
+
|
3
|
+
HTTP_VERSION_1_0 = '1.0'.freeze
|
4
|
+
HTTP_VERSION_1_1 = '1.1'.freeze
|
5
|
+
DEFAULT_HTTP_VERSION = HTTP_VERSION_1_1
|
6
|
+
end
|
7
|
+
|
8
|
+
module ConnectionMixin
|
9
|
+
|
10
|
+
# Obtain the IP address of the remote connection
|
11
|
+
def remote_ip
|
12
|
+
@socket.peeraddr(false)[3]
|
13
|
+
end
|
14
|
+
alias_method :remote_addr, :remote_ip
|
15
|
+
|
16
|
+
# Obtain the hostname of the remote connection
|
17
|
+
def remote_host
|
18
|
+
# NOTE: Celluloid::IO does not yet support non-blocking reverse DNS
|
19
|
+
@socket.peeraddr(true)[2]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module RequestMixin
|
24
|
+
|
25
|
+
def method
|
26
|
+
@http_parser.http_method
|
27
|
+
end
|
28
|
+
|
29
|
+
def headers
|
30
|
+
@http_parser.headers
|
31
|
+
end
|
32
|
+
|
33
|
+
def [] header
|
34
|
+
headers[header]
|
35
|
+
end
|
36
|
+
|
37
|
+
def version
|
38
|
+
@http_parser.http_version || HTTPVersionsMixin::DEFAULT_HTTP_VERSION
|
39
|
+
end
|
40
|
+
|
41
|
+
def url
|
42
|
+
@http_parser.url
|
43
|
+
end
|
44
|
+
|
45
|
+
def uri
|
46
|
+
@uri ||= URI(url)
|
47
|
+
end
|
48
|
+
|
49
|
+
def path
|
50
|
+
@http_parser.request_path
|
51
|
+
end
|
52
|
+
|
53
|
+
def query_string
|
54
|
+
@http_parser.query_string
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|