Rack::Stream is rack middleware that lets you write streaming API endpoints that understand HTTP, WebSockets, and EventSource. It comes with a DSL and can be used alongside other rackable web frameworks such as Sinatra and Grape.
Normally, when an HTTP request is made, the server closes the connection when it's done processing the request. For streaming HTTP, also known as Comet, the main difference is that the server doesn't close the connection and can continue sending data to the client at a later time.
To prevent the connection from closing, rack-stream uses Thin's 'async.callback' to defer closing the connection until either the server decides to close the connection, or the client disconnects.
Rack::Stream is rack middleware that lets you write streaming HTTP endpoints that can understand multiple protocols. Multiple protocols means that you can write an API endpoint that works with curl, but that same endpoint would also works with WebSockets in the browser. The simplest streaming API you can make is:
# config.ru
# run with `thin start -p 9292`
require 'rack-stream'
class App
def call(env)
[200, {'Content-Type' => 'text/plain'}, ["Hello", " ", "World"]]
end
end
use Rack::Stream
run App
If you ran this basic rack app, you could then use curl to stream it's response:
> curl -i -N http://localhost:9292/
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: close
Server: thin 1.3.1 codename Triple Espresso
Hello World
This isn't very exciting, but you'll notice that the Transfer-Encoding
for the response is set to chunked
. By default, rack-stream will take any downstream application's response bodies and stream them over in chunks. You can read more about chunked transfer encoding on Wikipedia.
Let's spice it up a bit and build an actual firehose. This next application will keep sending data to the client until the client disconnects:
require 'rack-stream'
class Firehose
include Rack::Stream::DSL
def call(env)
EM.add_periodic_timer(0.1) {
chunk "\nChunky Monkey"
}
[200, {'Content-Type' => 'text/plain'}, ['Hello']]
end
end
use Rack::Stream
run Firehose
The first thing to notice is the Firehose rack endpoint includes Rack::Stream::DSL
. This are convenience methods that allow you to access env['rack.stream']
, which is injected into env
whenever you use Rack::Stream
. When a request comes in, the #call
method schedules a timer that runs every 0.1 seconds and uses the #chunk
method to stream data. If you run curl, you would see:
> curl -i -N http://localhost:9292/
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Connection: close
Server: thin 1.3.1 codename Triple Espresso
Hello
Chunky Monkey
Chunky Monkey
Chunky Monkey
# ... more monkeys
rack-stream also allows you to register callbacks for manipulating response chunks, and controlling when something is sent with different callbacks. Here's a more advanced example with callbacks added:
require 'rack-stream'
class Firehose
include Rack::Stream::DSL
def call(env)
after_open do
chunk "\nChunky Monkey"
close # start closing the connection
end
before_chunk do |chunks|
chunks.map(&:upcase) # manipulate chunks
end
before_close do
chunk "\nGoodbye!" # send something before we close
end
[200, {'Content-Type' => 'text/plain'}, ['Hello']]
end
end
use Rack::Stream
run Firehose
If you ran curl now, you would see:
> curl -i -N http://localhost:9292/
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Connection: close
Server: thin 1.3.1 codename Triple Espresso
HELLO
CHUNKY MONKEY
GOODBYE!
For details about the callbacks, see the project page.
Up until this point, I've only used curl to demonstrate hitting the rack endpoint, but one of the big benefits of rack-stream is that it'll automatically recognize WebSocket and EventSource requests and stream through those as well. For example, you could write an html file that accesses that same endpoint:
<html>
<body>
<script type='text/javascript'>
var socket = new WebSocket('ws://localhost:9292/');
socket.onopen = function() {alert("socket opened")};
socket.onmessage = function(m) {alert(m.data)};
socket.onclose = function() {alert("socket closed")};
</script>
</body>
</html>
Whether you access the endpoint with curl, ajax, or WebSockets, your backend API logic doesn't have to change.
For the last example, I'll show a basic chat application using Grape and Rails. The full runnable source is included in the examples/rails
directory.
require 'grape'
require 'rack/stream'
require 'redis'
require 'redis/connection/synchrony'
class API < Grape::API
default_format :txt
helpers do
include Rack::Stream::DSL
def redis
@redis ||= Redis.new
end
def build_message(text)
redis.rpush 'messages', text
redis.ltrim 'messages', 0, 50
redis.publish 'messages', text
text
end
end
resources :messages do
get do
after_open do
# subscribe after_open b/c this runs until the connection is closed
redis.subscribe 'messages' do |on|
on.message do |channel, msg|
chunk msg
end
end
end
status 200
header 'Content-Type', 'application/json'
chunk *redis.lrange('messages', 0, 50)
""
end
post do
status 201
build_message(params[:text])
end
end
end
This example uses redis pubsub to push out messages that are created from #post
. Thanks to em-synchrony, requests are not blocked when no messages are being sent. It's important do the redis subscribe after the connection has been opened. Otherwise, the initial response won't be sent.
socket.io is great because it provides many transport fallbacks to give maximum compatibility with many different browsers, but its pubsub interface is too low level for capturing common app semantics. The application developer doesn't have nice REST features like HTTP verbs, resource URIs, parameter and response encoding, and request headers.
The goal of rack-stream is to provide clean REST-like semantics when you're developing, but allow you to swap out different transport protocols. Currently, it supports normal HTTP, WebSockets, and EventSource. But the goal is to support more protocols over time and allow custom protocols. This architecture allows socket.io to become another protocol handler that can be plugged into rack-stream. If you wanted to use Pusher as a protocol, that could also be written as a handler for rack-stream.
rack-stream aims to be a thin abstraction that lets Ruby developers write streaming APIs with their preferred frameworks. I plan to broaden support and test against common use cases and popular frameworks like Sinatra and Rails. If you have any questions or comments, feel free to submit an issue or leave a comment below!