rack-stream 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ .yardoc
2
+ doc
3
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format=progress
data/.travis.yml ADDED
@@ -0,0 +1,20 @@
1
+ bundler_args:
2
+ --without development debug darwin
3
+
4
+ before_script:
5
+ - "export DISPLAY=:99.0"
6
+ - "sh -e /etc/init.d/xvfb start"
7
+ - "sleep 3"
8
+ - "nohup bundle exec thin start -R spec/integration/sinatra.ru -p 8888 &"
9
+ - "sleep 2"
10
+
11
+ env:
12
+ - SERVER=http://localhost:8888
13
+
14
+ language: ruby
15
+
16
+ script: "bundle exec rake spec:integration && bundle exec rake spec"
17
+
18
+ rvm:
19
+ - 1.9.2
20
+ - 1.9.3
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --no-private --protected - LICENSE
data/Gemfile ADDED
@@ -0,0 +1,37 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :development do
6
+ gem 'yard'
7
+ gem 'guard'
8
+ gem 'guard-rspec'
9
+ gem 'guard-bundler'
10
+ end
11
+
12
+ group :development, :test do
13
+ gem 'rake'
14
+ gem 'rack-test'
15
+ gem 'rspec', '~> 2.9'
16
+ gem 'bundler'
17
+ gem 'pry'
18
+ gem 'faraday'
19
+ gem 'thin'
20
+
21
+ # integration
22
+ gem 'capybara-webkit'
23
+ gem 'em-eventsource'
24
+ gem 'em-http-request'
25
+ gem 'sinatra'
26
+ end
27
+
28
+ # debugger for 1.9 only
29
+ group :debug do
30
+ gem 'debugger'
31
+ end
32
+
33
+ # Mac specific
34
+ group :darwin do
35
+ gem 'rb-fsevent'
36
+ gem 'growl'
37
+ end
data/Guardfile ADDED
@@ -0,0 +1,11 @@
1
+ guard 'bundler' do
2
+ watch 'Gemfile'
3
+ watch 'rack-stream.gemspec'
4
+ end
5
+
6
+ guard 'rspec', :version => 2 do
7
+ watch(%r{^spec/.+_spec\.rb$})
8
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
9
+ watch('spec/spec_helper.rb') { "spec" }
10
+ end
11
+
data/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2012, Jerry Cheung
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
+
6
+ Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
+ Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,282 @@
1
+ # rack-stream [![Build Status](https://secure.travis-ci.org/jch/rack-stream.png)](http://travis-ci.org/jch/rack-stream)
2
+
3
+ ## Overview
4
+
5
+ rack-stream is middleware for building multi-protocol streaming rack endpoints.
6
+
7
+ ## Example
8
+
9
+ ```ruby
10
+ # config.ru
11
+ require 'rack/stream'
12
+
13
+ class App
14
+ include Rack::Stream::DSL
15
+
16
+ def call(env)
17
+ after_open do
18
+ count = 0
19
+ EM.add_periodic_timer(1) do
20
+ if count != 3
21
+ chunk "chunky #{count}\n"
22
+ count += 1
23
+ else
24
+ # Connection isn't closed until #close is called.
25
+ # Useful if you're building a firehose API
26
+ close
27
+ end
28
+ end
29
+ end
30
+
31
+ before_close do
32
+ chunk "monkey!\n"
33
+ end
34
+
35
+ [200, {'Content-Type' => 'application/json'}, []]
36
+ end
37
+ end
38
+
39
+ Rack::Builder.app do
40
+ use Rack::Stream
41
+ run App
42
+ end
43
+ ```
44
+
45
+ To run the example:
46
+
47
+ ```
48
+ > thin start -R config.ru -p 3000
49
+ > curl -i -N http://localhost:3000/
50
+ >> HTTP/1.1 200 OK
51
+ >> Content-Type: text/plain
52
+ >> Transfer-Encoding: chunked
53
+ >>
54
+ >> chunky 0
55
+ >> chunky 1
56
+ >> chunky 2
57
+ >> monkey
58
+ ```
59
+
60
+ This same endpoint can be accessed via WebSockets or EventSource, see
61
+ 'Multi-Protocol Support' below.
62
+
63
+ ## Connection Lifecycle
64
+
65
+ When using rack-stream, downstream apps can access the
66
+ `Rack::Stream::App` instance via `env['rack.stream']`. This object is
67
+ used to control when the connection is closed, and what is streamed.
68
+ `Rack::Stream::DSL` delegates access methods to `env['rack.stream']`
69
+ on the downstream rack app.
70
+
71
+ `Rack::Stream::App` instances are in one of the follow states:
72
+
73
+ * new
74
+ * open
75
+ * closed
76
+ * errored
77
+
78
+ Each state is described below.
79
+
80
+ ### new
81
+
82
+ When a request first comes in, rack-stream processes any downstream
83
+ rack apps and uses their status and headers for its response. Any
84
+ downstream response bodies are queued for streaming once the headers
85
+ and status have been sent.
86
+
87
+ ```ruby
88
+ use Rack::Stream
89
+
90
+ # once Rack::Stream instance is :open, 'Chunky Monkey' will be streamed out
91
+ run lambda {|env| [200, {'Content-Type' => 'text/plain'}, ['Chunky Monkey']]}
92
+ ```
93
+
94
+ ### open
95
+
96
+ Before the status and headers are sent in the response, they are
97
+ frozen and cannot be further modified. Attempting to modify these
98
+ fields will put the instance into an `:errored` state.
99
+
100
+ After the status and headers are sent, registered `:after_open`
101
+ callbacks will be called. If no `:after_open` callbacks are defined,
102
+ the instance will close the connection after flushing any queued
103
+ chunks.
104
+
105
+ If any `:after_open` callbacks are defined, it's the callback's
106
+ responsibility to call `#close` when the connection should be
107
+ closed. This allows you to build firehose streaming APIs with full
108
+ control of when to close connections.
109
+
110
+ ```ruby
111
+ use Rack::Stream
112
+
113
+ run lambda {|env|
114
+ stream = env['rack.stream']
115
+ stream.after_open do
116
+ stream.chunk "Chunky"
117
+ stream.chunk "Monkey"
118
+ stream.close # <-- It's your responsibility to close the connection
119
+ end
120
+ [200, {'Content-Type' => 'text/plain'}, ['Hello', 'World']] # <-- downstream response bodies are also streamed
121
+ }
122
+ ```
123
+
124
+ There are no `:before_open` callbacks. If you want something to be
125
+ done before streaming is started, simply return it as part of your
126
+ downstream response.
127
+
128
+ ### closed
129
+
130
+ An instance enters the `:closed` state after the method `#close` is
131
+ called on it. By default, any remainined queued content to be streamed
132
+ will be flushed before the connection is closed.
133
+
134
+ ```ruby
135
+ use Rack::Stream
136
+
137
+ run lambda {|env|
138
+ # to save typing, access the Rack::Stream instance with #instance_eval
139
+ env['rack.stream'].instance_eval do
140
+ before_close do
141
+ chunk "Goodbye!" # chunks can still be sent
142
+ end
143
+
144
+ after_close do
145
+ # any additional cleanup. Calling #chunk here will result in an error.
146
+ end
147
+ end
148
+ [200, {}, []]
149
+ }
150
+ ```
151
+
152
+ ### errored
153
+
154
+ An instance enters the `:errored` state if an illegal action is
155
+ performed in one of the states. Legal actions for the different states
156
+ are:
157
+
158
+ * **new** - `#status=`, `#headers=`
159
+ * **open** - `#chunk`, `#close`
160
+
161
+ All other actions are considered illegal. Manipulating headers after
162
+ `:new` is also illegal. The connection is closed immediately, and the
163
+ error is written to `env['rack.error']`
164
+
165
+ ## Manipuating Content
166
+
167
+ When a connection is open and streaming content, you can define
168
+ `:before_chunk` callbacks to manipulate the content before it's sent
169
+ out.
170
+
171
+ ```ruby
172
+ use Rack::Stream
173
+
174
+ run lambda {|env|
175
+ env['rack.stream'].instance_eval do
176
+ after_open do
177
+ chunk "chunky", "monkey"
178
+ end
179
+
180
+ before_chunk do |chunks|
181
+ # return the manipulated chunks of data to be sent
182
+ # this will stream MONKEYCHUNKY
183
+ chunks.map(&:upcase).reverse
184
+ end
185
+ end
186
+ }
187
+ ```
188
+
189
+ ## Multi-Protocol Support
190
+
191
+ `Rack::Stream` allows you to write an API endpoint that automatically
192
+ responds to different protocols based on the incoming request. This
193
+ allows you to write a single rack endpoint that can respond to normal
194
+ HTTP, WebSockets, or EventSource.
195
+
196
+ Assuming that rack-stream endpoint is running on port 3000. You can
197
+ access it with the following:
198
+
199
+ ### HTTP
200
+
201
+ ```
202
+ # -i prints headers, -N immediately displays output instead of buffering
203
+ curl -i -N http://localhost:3000/
204
+ ```
205
+
206
+ ### WebSockets
207
+
208
+ With Ruby:
209
+
210
+ ```ruby
211
+ require 'eventmachine'
212
+ require 'faye/websocket'
213
+
214
+ EM.run {
215
+ socket = Faye::WebSocket::Client.new('ws://localhost:3000/)
216
+ socket.onmessage = lambda {|e| puts e.data} # puts each streamed chunk
217
+ socket.onclose = lambda {|e| EM.stop}
218
+ }
219
+ ```
220
+
221
+ With Javascript:
222
+
223
+ ```js
224
+ var socket = new WebSocket("ws://localhost:3000/");
225
+ socket.onmessage = function(m) {console.log(m);}
226
+ socket.onclose = function() {console.log('socket closed');}
227
+ ```
228
+
229
+ ### EventSource
230
+
231
+ From Wikipedia:
232
+
233
+ > Server-sent events is a technology for providing push notifications
234
+ > from a server to a browser client in the form of DOM events. The
235
+ > Server-Sent Events EventSource API is now being standardized as part
236
+ > of HTML5 by the W3C.
237
+
238
+ With Ruby:
239
+
240
+ ```ruby
241
+ require 'em-eventsource'
242
+
243
+ EM.run do
244
+ source = EventMachine::EventSource.new("http://example.com/streaming")
245
+ source.message do |m|
246
+ puts m
247
+ end
248
+ source.start
249
+ end
250
+ ```
251
+
252
+ With Javascript:
253
+
254
+ ```js
255
+ var source = new EventSource('/');
256
+ source.addEventListener('message', function(e) {
257
+ console.log(e.data);
258
+ });
259
+ ```
260
+
261
+ ## Supported Runtimes
262
+
263
+ * 1.9.2
264
+ * 1.9.3
265
+
266
+ If a runtime is not listed above, it may still work. It just means I
267
+ haven't tried it yet.
268
+
269
+ ## Roadmap
270
+
271
+ * more protocols / custom protocols http://en.wikipedia.org/wiki/HTTP_Streaming
272
+ * integrate into [grape](http://github.com/intridea/grape)
273
+ * add sinatra example that serves page that uses JS to connect
274
+
275
+ ## Further Reading
276
+
277
+ * [Stream Updates With Server-Sent Events](http://www.html5rocks.com/en/tutorials/eventsource/basics/)
278
+ * [thin_async](https://github.com/macournoyer/thin_async) was where I got started
279
+ * [thin-async-test](https://github.com/phiggins/thin-async-test) used to simulate thin in tests
280
+ * [thin](https://github.com/macournoyer/thin)
281
+ * [faye-websocket-ruby](https://github.com/faye/faye-websocket-ruby) used for testing and handling different protocols
282
+ * [rack-chunked](http://rack.rubyforge.org/doc/Rack/Chunked.html)
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+ require "rspec/core/rake_task"
4
+
5
+ Bundler::GemHelper.install_tasks
6
+
7
+ RSpec::Core::RakeTask.new('spec') do |t|
8
+ t.rspec_opts = '--tag ~integration'
9
+ end
10
+
11
+ RSpec::Core::RakeTask.new('spec:integration') do |t|
12
+ t.pattern = 'spec/integration/*_spec.rb'
13
+ end
14
+
15
+ task :default => :spec
data/examples/basic.ru ADDED
@@ -0,0 +1,7 @@
1
+ require 'rack/stream'
2
+
3
+ use Rack::Stream
4
+
5
+ run lambda {|env|
6
+ [200, {'Content-Type' => 'text/plain'}, ['hello', ' ', 'world']]
7
+ }
data/examples/loop.ru ADDED
@@ -0,0 +1,20 @@
1
+ require 'rack/stream'
2
+
3
+ use Rack::Stream
4
+
5
+ run lambda {|env|
6
+ env["rack.stream"].instance_eval do
7
+ count = 0
8
+ after_open do
9
+ timer = EM::PeriodicTimer.new(0.1) do
10
+ if count > 10
11
+ timer.cancel
12
+ close
13
+ end
14
+ chunk "Chunky\n"
15
+ count += 1
16
+ end
17
+ end
18
+ end
19
+ [200, {}, []]
20
+ }
@@ -0,0 +1,4 @@
1
+ use Rack::Chunked
2
+ run lambda {|env|
3
+ [200, {'Content-Type' => 'text/plain'}, ['hello', 'world']]
4
+ }
@@ -0,0 +1,15 @@
1
+ require 'faye/websocket'
2
+
3
+ EM.run {
4
+ ws = Faye::WebSocket::Client.new('ws://localhost:3000/')
5
+ ws.onopen = lambda do |event|
6
+ ws.send("hello world")
7
+ end
8
+ ws.onmessage = lambda do |event|
9
+ puts "message: #{event.data}"
10
+ end
11
+ ws.onclose = lambda do |event|
12
+ puts "websocket closed"
13
+ EM.stop
14
+ end
15
+ }
@@ -0,0 +1,20 @@
1
+ require 'eventmachine'
2
+ require 'faye/websocket'
3
+
4
+ require 'rack/stream/handlers'
5
+ require 'rack/stream/deferrable_body'
6
+ require 'rack/stream/app'
7
+ require 'rack/stream/dsl'
8
+
9
+ module Rack
10
+ # Middleware for building multi-protocol streaming rack endpoints.
11
+ class Stream
12
+ def initialize(app, options={})
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ App.new(@app).call(env)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,136 @@
1
+ module Rack
2
+ class Stream
3
+ class App
4
+ class UnsupportedServerError < StandardError; end
5
+ class StateConstraintError < StandardError; end
6
+
7
+ # The state of the connection
8
+ # :new
9
+ # :open
10
+ # :closed
11
+ attr_reader :state
12
+
13
+ # @private
14
+ attr_reader :env
15
+
16
+ attr_reader :status, :headers
17
+
18
+ def initialize(app, options={})
19
+ @app = app
20
+ @state = :new
21
+ @callbacks = Hash.new {|h,k| h[k] = []}
22
+ end
23
+
24
+ def call(env)
25
+ @env = env
26
+ @env['rack.stream'] = self
27
+
28
+ app_status, app_headers, app_body = @app.call(@env)
29
+
30
+ @status = app_status
31
+ @headers = app_headers
32
+ @handler = Handlers.find(self)
33
+
34
+ # apply before_chunk to any response bodies
35
+ @callbacks[:after_open].unshift(lambda {chunk(*app_body)})
36
+
37
+ # By default, close a connection if no :after_open is specified
38
+ after_open {close} if @callbacks[:after_open].size == 1
39
+
40
+ EM.next_tick {
41
+ open!
42
+ }
43
+ ASYNC_RESPONSE
44
+ end
45
+
46
+ def status=(code)
47
+ require_state :new
48
+ @status = code
49
+ end
50
+
51
+ def headers=(hash)
52
+ require_state :new
53
+ @headers = hash
54
+ end
55
+
56
+ def chunk(*chunks)
57
+ require_state :open
58
+ run_callbacks(:chunk, chunks) {|mutated_chunks|
59
+ @handler.chunk(*mutated_chunks)
60
+ }
61
+ end
62
+ alias :<< :chunk
63
+
64
+ def close(flush = true)
65
+ require_state :open
66
+
67
+ # run in the next tick since it's more natural to call #chunk right
68
+ # before #close
69
+ EM.next_tick {
70
+ run_callbacks(:close) {
71
+ @state = :closed
72
+ @handler.close!(flush)
73
+ }
74
+ }
75
+ end
76
+
77
+ def new?; @state == :new end
78
+ def open?; @state == :open end
79
+ def closed?; @state == :closed end
80
+ def errored?; @state == :errored end
81
+
82
+ private
83
+ ASYNC_RESPONSE = [-1, {}, []].freeze
84
+
85
+ def require_state(*allowed_states)
86
+ unless allowed_states.include?(@state)
87
+ action = caller[0]
88
+ raise StateConstraintError.new("\nCalled\n '#{caller[0]}'\n Allowed :#{allowed_states * ','}\n Current :#{@state}")
89
+ end
90
+ end
91
+
92
+ # Transition state from :new to :open
93
+ #
94
+ # Freezes headers to prevent further modification
95
+ def open! #(server)
96
+ raise UnsupportedServerError.new "missing async.callback. run within thin or rainbows" unless @env['async.callback']
97
+ run_callbacks(:open) {
98
+ @state = :open
99
+ @headers.freeze
100
+ @handler.open!
101
+ }
102
+ end
103
+
104
+ # Skips any remaining chunks, and immediately closes the connection
105
+ def error!(e)
106
+ @env['rack.errors'].puts(e.message)
107
+ @status = 500 if new?
108
+ @state = :errored
109
+ @handler.close!(false)
110
+ end
111
+
112
+ def self.define_callbacks(name, *types)
113
+ types.each do |type|
114
+ callback_name = "#{type}_#{name.to_s}"
115
+ define_method callback_name do |&blk|
116
+ @callbacks[callback_name.to_sym] << blk
117
+ self
118
+ end
119
+ end
120
+ end
121
+ define_callbacks :open, :after
122
+ define_callbacks :chunk, :before, :after
123
+ define_callbacks :close, :before, :after
124
+
125
+ def run_callbacks(name, *args)
126
+ modified = @callbacks["before_#{name}".to_sym].inject(args) do |memo, cb|
127
+ [cb.call(*memo)]
128
+ end
129
+ yield(*modified) if block_given?
130
+ @callbacks["after_#{name}".to_sym].each {|cb| cb.call(*args)}
131
+ rescue StateConstraintError => e
132
+ error!(e)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,50 @@
1
+ module Rack
2
+ class Stream
3
+ # From [thin_async](https://github.com/macournoyer/thin_async)
4
+ class DeferrableBody
5
+ include EM::Deferrable
6
+
7
+ # @param chunks - object that responds to each. holds initial chunks of content
8
+ def initialize(chunks = [])
9
+ @queue = []
10
+ chunks.each {|c| chunk(c)}
11
+ end
12
+
13
+ # Enqueue a chunk of content to be flushed to stream at a later time
14
+ def chunk(*chunks)
15
+ @queue += chunks
16
+ schedule_dequeue
17
+ end
18
+
19
+ # When rack attempts to iterate over `body`, save the block,
20
+ # and execute at a later time when `@queue` has elements
21
+ def each(&blk)
22
+ @body_callback = blk
23
+ schedule_dequeue
24
+ end
25
+
26
+ def empty?
27
+ @queue.empty?
28
+ end
29
+
30
+ def close!(flush = true)
31
+ EM.next_tick {
32
+ succeed if !flush
33
+ succeed if flush && empty?
34
+ schedule_dequeue if flush && !empty?
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def schedule_dequeue
41
+ return unless @body_callback
42
+ EM.next_tick do
43
+ next unless c = @queue.shift
44
+ @body_callback.call(c)
45
+ schedule_dequeue unless empty?
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ require 'forwardable'
2
+
3
+ module Rack
4
+ class Stream
5
+ module DSL
6
+ def self.included(base)
7
+ base.class_eval do
8
+ extend Forwardable
9
+ def_delegators :rack_stream, :after_open, :before_chunk, :chunk, :after_chunk, :before_close, :close, :after_close
10
+
11
+ def rack_stream
12
+ env['rack.stream']
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,98 @@
1
+ module Rack
2
+ class Stream
3
+ module Handlers
4
+ def find(app)
5
+ if Faye::WebSocket.websocket?(app.env)
6
+ WebSocket.new(app)
7
+ elsif Faye::EventSource.eventsource?(app.env)
8
+ EventSource.new(app)
9
+ else
10
+ Http.new(app)
11
+ end
12
+ end
13
+ module_function :find
14
+
15
+ class AbstractHandler
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def chunk(*chunks)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def open!
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def close!(flush = true)
29
+ raise NotImplementedError
30
+ end
31
+ end
32
+
33
+ class Http < AbstractHandler
34
+ TERM = "\r\n".freeze
35
+ TAIL = "0#{TERM}#{TERM}".freeze
36
+
37
+ def initialize(app)
38
+ super
39
+ @app.headers['Transfer-Encoding'] = 'chunked'
40
+ @app.headers.delete('Content-Length')
41
+ @body = DeferrableBody.new # swap this out for different body types
42
+ end
43
+
44
+ def chunk(*chunks)
45
+ @body.chunk(*chunks.map {|c| encode_chunk(c)})
46
+ end
47
+
48
+ def open!
49
+ @app.env['async.callback'].call [@app.status, @app.headers, @body]
50
+ end
51
+
52
+ def close!(flush = true)
53
+ @body.chunk(TAIL) # tail is special and already encoded
54
+ @body.close!(flush)
55
+ end
56
+
57
+ private
58
+ def encode_chunk(c)
59
+ return nil if c.nil?
60
+
61
+ size = Rack::Utils.bytesize(c) # Rack::File?
62
+ return nil if size == 0
63
+ c.dup.force_encoding(Encoding::BINARY) if c.respond_to?(:force_encoding)
64
+ [size.to_s(16), TERM, c, TERM].join
65
+ end
66
+ end
67
+
68
+ class WebSocket < AbstractHandler
69
+ def chunk(*chunks)
70
+ # this is not called until after #open!, so @ws is always defined
71
+ chunks.each {|c| @ws.send(c)}
72
+ end
73
+
74
+ def close!(flush = true)
75
+ @ws.close(@app.status)
76
+ end
77
+
78
+ def open!
79
+ @ws = Faye::WebSocket.new(@app.env)
80
+ end
81
+ end
82
+
83
+ class EventSource < WebSocket
84
+ def chunk(*chunks)
85
+ chunks.each {|c| @es.send(c)}
86
+ end
87
+
88
+ def close!(flush = true)
89
+ @es.close
90
+ end
91
+
92
+ def open!
93
+ @es = Faye::EventSource.new(@app.env)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,24 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "rack-stream"
5
+ s.version = "0.0.2"
6
+ s.platform = Gem::Platform::RUBY
7
+ s.authors = ["Jerry Cheung"]
8
+ s.email = ["jerry@intridea.com"]
9
+ s.homepage = "https://github.com/jch/rack-stream"
10
+ s.summary = %q{Rack middleware for building multi-protocol streaming rack endpoints}
11
+ s.description = %q{Rack middleware for building multi-protocol streaming rack endpoints}
12
+ s.license = "BSD"
13
+
14
+ s.rubyforge_project = "rack-stream"
15
+
16
+ s.add_runtime_dependency 'rack'
17
+ s.add_runtime_dependency 'eventmachine'
18
+ s.add_runtime_dependency 'faye-websocket'
19
+
20
+ s.files = `git ls-files`.split("\n")
21
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
22
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+ s.require_paths = ["lib"]
24
+ end
@@ -0,0 +1,78 @@
1
+ # CI test. see .travis.yml for config variables
2
+ if ENV['SERVER']
3
+ require 'bundler/setup'
4
+ require 'rack'
5
+ require 'rack/stream'
6
+ require 'rspec'
7
+
8
+ require 'capybara/rspec'
9
+ require 'faraday'
10
+ require 'em-eventsource'
11
+ require 'capybara'
12
+ require 'capybara-webkit'
13
+
14
+ describe 'Integration', :integration => true, :type => :request, :driver => :webkit do
15
+ EXPECTED = 'HELLO WORLDCHUNKYMONKEYBROWNIEBATTERCLOSING'.freeze
16
+ let(:uri) {URI.parse(ENV['SERVER'])}
17
+
18
+ before :all do
19
+ Capybara.app_host = uri.to_s
20
+ Capybara.run_server = false
21
+ end
22
+
23
+ describe 'HTTP' do
24
+ it 'should stream with chunked transfer encoding' do
25
+ http = Faraday.new uri.to_s
26
+ 2.times.map do
27
+ res = http.get '/'
28
+ res.status.should == 200
29
+ res.headers['content-type'].should == 'text/plain'
30
+ res.headers['transfer-encoding'].should == 'chunked'
31
+ res.body.should == EXPECTED
32
+ end
33
+ end
34
+ end
35
+
36
+ describe 'WebSocket' do
37
+ it 'should stream with websockets' do
38
+ uri.scheme = 'ws'
39
+ EM.run {
40
+ ws = Faye::WebSocket::Client.new(uri.to_s)
41
+ # ws.onopen = lambda {|e| puts 'opened'}
42
+ $ws_chunks = []
43
+ ws.onmessage = lambda {|e| $ws_chunks << e.data}
44
+ ws.onclose = lambda do |e|
45
+ EM.stop
46
+ $ws_chunks.join('').should == EXPECTED
47
+ $ws_chunks = nil
48
+ end
49
+ }
50
+ end
51
+ end
52
+
53
+ describe 'EventSource' do
54
+ # em-eventsource needs to send 'Accept' => 'text/event-stream'
55
+ # not sure if the gem isn't working or if its rack-stream. a web integration spec would be nice
56
+ # it 'should stream with eventsource' do
57
+ # @chunks = ""
58
+ # source = EventMachine::EventSource.new(uri.to_s)
59
+ # source.message do |message|
60
+ # puts message
61
+ # @chunks << message
62
+ # source.stop if @chunks == EXPECTED
63
+ # end
64
+ # source.start
65
+ # end
66
+ end
67
+
68
+ describe 'Javascript', :type => :request, :driver => :webkit do
69
+ it 'should work from a js client' do
70
+ visit '/capybara'
71
+ # capybara doesn't distinguish between " " and ""
72
+ all('#ws li').map(&:text).should =~ ["socket opened", "HELLO", "", "WORLD", "CHUNKY", "MONKEY", "BROWNIE", "BATTER", "CLOSING", "socket closed"]
73
+
74
+ all('#es li').map(&:text).should =~ ["HELLO", "", "WORLD", "CHUNKY", "MONKEY", "BROWNIE", "BATTER", "CLOSING"]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,36 @@
1
+ require 'sinatra/base'
2
+ require 'rack/stream'
3
+
4
+ use Rack::Stream
5
+
6
+ class App < Sinatra::Base
7
+ include Rack::Stream::DSL
8
+
9
+ get '/capybara' do
10
+ erb :index
11
+ end
12
+
13
+ get '/' do
14
+ after_open do
15
+ chunk "Chunky", "Monkey"
16
+ EM.next_tick do
17
+ chunk "Brownie", "Batter"
18
+ close
19
+ end
20
+ end
21
+
22
+ before_chunk do |chunks|
23
+ chunks.map(&:upcase)
24
+ end
25
+
26
+ before_close do
27
+ chunk "closing"
28
+ end
29
+
30
+ status 200
31
+ headers 'Content-Type' => 'text/plain'
32
+ ['Hello', ' ', 'World']
33
+ end
34
+ end
35
+
36
+ run App
@@ -0,0 +1,36 @@
1
+ <html>
2
+ <head></head>
3
+ <body>
4
+ <h1>Rack::Stream Sinatra Example</h1>
5
+ <h2>Websocket</h2>
6
+ <ul id='ws'></ul>
7
+
8
+ <h2>EventSource</h2>
9
+ <ul id='es'></ul>
10
+
11
+ <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
12
+ <script type='text/javascript'>
13
+ var socket, source;
14
+
15
+ function ws(m) {
16
+ $('<li>').text(m).appendTo('#ws');
17
+ }
18
+
19
+ function es(m) {
20
+ $('<li>').text(m).appendTo('#es');
21
+ }
22
+
23
+ $(function() {
24
+ socket = new WebSocket("ws://localhost:8888/");
25
+ socket.onopen = function() {ws('socket opened');}
26
+ socket.onmessage = function(m) {ws(m.data);}
27
+ socket.onclose = function() {ws('socket closed');}
28
+
29
+ source = new EventSource('http://localhost:8888/');
30
+ source.addEventListener('message', function(e) {
31
+ es(e.data);
32
+ });
33
+ });
34
+ </script>
35
+ </body>
36
+ </html>
@@ -0,0 +1,125 @@
1
+ require 'spec_helper'
2
+
3
+ describe Rack::Stream do
4
+ def app
5
+ b = Rack::Builder.new
6
+ b.use Support::MockServer
7
+ b.use Rack::Stream
8
+ b.run endpoint
9
+ b
10
+ end
11
+
12
+ shared_examples_for 'invalid action' do
13
+ it "should raise invalid state" do
14
+ get '/'
15
+ last_response.errors.should =~ /Invalid action/
16
+ last_response.status.should == 500
17
+ end
18
+ end
19
+
20
+ let(:endpoint) {
21
+ lambda {|env| [201, {'Content-Type' => 'text/plain', 'Content-Length' => 11}, ["Hello world"]]}
22
+ }
23
+
24
+ before {get '/'}
25
+
26
+ context "defaults" do
27
+ it "should close connection with status" do
28
+ last_response.status.should == 201
29
+ end
30
+
31
+ it "should set headers" do
32
+ last_response.headers['Content-Type'].should == 'text/plain'
33
+ end
34
+
35
+ it "should not error" do
36
+ last_response.errors.should == ""
37
+ end
38
+
39
+ it "should remove Content-Length header" do
40
+ last_response.headers['Content-Length'].should be_nil
41
+ end
42
+
43
+ it "should use chunked transfer encoding" do
44
+ last_response.headers['Transfer-Encoding'].should == 'chunked'
45
+ end
46
+ end
47
+
48
+ context "basic streaming" do
49
+ let(:endpoint) {
50
+ lambda {|env|
51
+ env['rack.stream'].instance_eval do
52
+ after_open do
53
+ chunk "Chunky "
54
+ chunk "Monkey"
55
+ close
56
+ end
57
+ end
58
+ [200, {'Content-Length' => 0}, ['']]
59
+ }
60
+ }
61
+
62
+ it "should stream and close" do
63
+ last_response.status.should == 200
64
+ # last_response.body.should == "Chunky Monkey"
65
+ last_response.body.should == "7\r\nChunky \r\n6\r\nMonkey\r\n0\r\n\r\n"
66
+ end
67
+ end
68
+
69
+ context "before chunk" do
70
+ let(:endpoint) {
71
+ lambda {|env|
72
+ env['rack.stream'].instance_eval do
73
+ after_open do
74
+ chunk "Chunky", "Monkey"
75
+ close
76
+ end
77
+
78
+ before_chunk {|chunks| chunks.map(&:upcase)}
79
+ before_chunk {|chunks| chunks.reverse}
80
+ end
81
+ [200, {}, []]
82
+ }
83
+ }
84
+
85
+ it 'should allow modification of queued chunks' do
86
+ last_response.body.should == "6\r\nMONKEY\r\n6\r\nCHUNKY\r\n0\r\n\r\n"
87
+ end
88
+ end
89
+
90
+ context "before close" do
91
+ let(:endpoint) {
92
+ lambda {|env|
93
+ env['rack.stream'].instance_eval do
94
+ before_close do
95
+ chunk "Chunky "
96
+ chunk "Monkey"
97
+ end
98
+ end
99
+ [200, {}, []]
100
+ }
101
+ }
102
+
103
+ it "should stream and close" do
104
+ last_response.body.should == "7\r\nChunky \r\n6\r\nMonkey\r\n0\r\n\r\n"
105
+ end
106
+ end
107
+
108
+ context "after close" do
109
+ let(:endpoint) {
110
+ lambda {|env|
111
+ env['rack.stream'].instance_eval do
112
+ after_close do
113
+ $after_close_called = true
114
+ end
115
+ end
116
+ [200, {}, []]
117
+ }
118
+ }
119
+
120
+ it "should allow cleanup" do
121
+ $after_close_called.should be_true
122
+ $after_close_called = nil
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,35 @@
1
+ require 'bundler/setup'
2
+ require 'rack'
3
+ require 'rack/stream'
4
+ require 'rack/test'
5
+ require 'rspec'
6
+
7
+ Dir[File.expand_path('../support/**/*.rb', __FILE__)].each {|f| require f}
8
+
9
+ # Used by tests to untangle evented code, but not required for use w/ lib
10
+ require 'fiber'
11
+ require 'timeout'
12
+
13
+ # TODO: swap this with em-spec or something else
14
+ # Patch rspec to run examples in a reactor
15
+ # based on em-rspec, but with synchrony pattern and does not auto stop the reactor
16
+ RSpec::Core::Example.class_eval do
17
+ alias ignorant_run run
18
+
19
+ def run(example_group_instance, reporter)
20
+ EM.run do
21
+ Fiber.new do
22
+ EM.add_timer(2) {
23
+ raise Timeout::Error.new("aborting test due to timeout")
24
+ EM.stop
25
+ }
26
+ @ignorant_success = ignorant_run example_group_instance, reporter
27
+ end.resume
28
+ end
29
+ @ignorant_success
30
+ end
31
+ end
32
+
33
+ RSpec.configure do |c|
34
+ c.include Rack::Test::Methods
35
+ end
@@ -0,0 +1,35 @@
1
+ module Support
2
+ class MockServer
3
+ class Callback
4
+ attr_reader :status, :headers, :body
5
+
6
+ def initialize(&blk)
7
+ @succeed_callback = blk
8
+ end
9
+
10
+ def call(args)
11
+ @status, @headers, deferred_body = args
12
+ @body = []
13
+ deferred_body.each do |s|
14
+ @body << s
15
+ end
16
+ deferred_body.callback {@succeed_callback.call}
17
+ deferred_body.callback {EM.stop}
18
+ end
19
+ end
20
+
21
+ def initialize(app)
22
+ @app = app
23
+ end
24
+
25
+ def call(env)
26
+ f = Fiber.current
27
+ callback = Callback.new do
28
+ f.resume [callback.status, callback.headers, callback.body]
29
+ end
30
+ env['async.callback'] = callback
31
+ @app.call(env)
32
+ Fiber.yield # wait until deferred body is succeeded
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-stream
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jerry Cheung
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-05-14 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rack
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: eventmachine
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: faye-websocket
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ description: Rack middleware for building multi-protocol streaming rack endpoints
63
+ email:
64
+ - jerry@intridea.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - .gitignore
70
+ - .rspec
71
+ - .travis.yml
72
+ - .yardopts
73
+ - Gemfile
74
+ - Guardfile
75
+ - LICENSE
76
+ - README.md
77
+ - Rakefile
78
+ - examples/basic.ru
79
+ - examples/loop.ru
80
+ - examples/no_stream.ru
81
+ - examples/websocket_client.rb
82
+ - lib/rack/stream.rb
83
+ - lib/rack/stream/app.rb
84
+ - lib/rack/stream/deferrable_body.rb
85
+ - lib/rack/stream/dsl.rb
86
+ - lib/rack/stream/handlers.rb
87
+ - rack-stream.gemspec
88
+ - spec/integration/server_spec.rb
89
+ - spec/integration/sinatra.ru
90
+ - spec/integration/views/index.erb
91
+ - spec/lib/rack/stream_spec.rb
92
+ - spec/spec_helper.rb
93
+ - spec/support/mock_server.rb
94
+ homepage: https://github.com/jch/rack-stream
95
+ licenses:
96
+ - BSD
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ none: false
103
+ requirements:
104
+ - - ! '>='
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ none: false
109
+ requirements:
110
+ - - ! '>='
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubyforge_project: rack-stream
115
+ rubygems_version: 1.8.24
116
+ signing_key:
117
+ specification_version: 3
118
+ summary: Rack middleware for building multi-protocol streaming rack endpoints
119
+ test_files:
120
+ - spec/integration/server_spec.rb
121
+ - spec/integration/sinatra.ru
122
+ - spec/integration/views/index.erb
123
+ - spec/lib/rack/stream_spec.rb
124
+ - spec/spec_helper.rb
125
+ - spec/support/mock_server.rb
126
+ has_rdoc: