Samuel Williams Monday, 11 November 2024

Rack 3 introduced support for input and output streaming. This is a powerful feature that improves both performance and provides opportunities for enhanced interactivity in web applications. We will explore how to use streaming input and output in Rack, including server-sent events, streaming templates, streaming uploads, and bi-directional streaming. We will also discuss how to use these features in Ruby on Rails.

Rack 3 Streaming

All the source code discussed in this article is available on GitHub. I recommend you check it out so you can follow along with the examples and try it out for yourself.

Streaming Output

Streaming output is a feature that allows you to send data to the client as it is generated. This is useful for sending large amounts of data, such as video or audio streams, or for sending data that is generated over time, such as a log file or a real-time feed. Streaming output is supported in Rack by returning a callable object as the response body. This callable object will get invoked with a stream argument, which you can read and write to from within the block.

The following example shows how to emit the current date and time in CSV format:

streaming-output/config.ru
require 'csv'

run do |env|
	request = Rack::Request.new(env)
	
	body = proc do |stream|
		csv = CSV.new(stream)
		
		csv << ['Date', 'Time']
		
		while true
			now = Time.now
			csv << [now.strftime('%Y-%m-%d'), now.strftime('%H:%M:%S')]
			sleep(1)
		end
	end
	
	[200, {'content-type' => 'text/csv'}, body]
end

You can use the curl command to test the streaming output:

$ curl --insecure https://localhost:9292
Date,Time
2024-11-11,16:58:13
2024-11-11,16:58:14
2024-11-11,16:58:15
2024-11-11,16:58:16
2024-11-11,16:58:17
^C

Use cases for streaming CSV output include:

Large Report Exports
Users can download extensive datasets as they're being processed, reducing wait times and enhancing user experience.
Financial Data Feeds
Deliver real-time transaction or portfolio data in CSV format for continuous access by analysts and clients.
Log Monitoring
Stream application or server logs in CSV format for immediate analysis and rapid response to issues.
Real-Time Business Intelligence
Stream CSV data from sources like CRMs or IoT devices for up-to-date business intelligence and reporting.

Streaming Server-Sent Events (SSE)

Streaming output can be a bit tricky to use in web browsers, as they may buffer the response. One way to work around this is to use Server-Sent Events (SSE), which are specifically handled by web browsers and are guaranteed to be unbuffered.

The following example shows how to use streaming output with SSE:

streaming-sse/config.ru
def server_sent_events?(env)
	env['HTTP_ACCEPT'].include?('text/event-stream')
end

run do |env|
	if server_sent_events?(env)
		body = proc do |stream|
			while true
				stream << "data: The time is #{Time.now}\n\n"
				sleep 1
			end
		rescue => error
		ensure
			stream.close(error)
		end
		
		[200, {'content-type' => 'text/event-stream'}, body]
	else
		# Else the request is for the index page, return the contents of index.html:
		[200, {'content-type' => 'text/html'}, [File.read('index.html')]]
	end
end

Of note, is the error handling. It's important to be aware of the various failure cases that can occur when streaming data. In the above example, we stream data indefinitely. If there is a failure for some reason, we will close the stream with the error. Closing the stream with an error will communicate to the client that the stream was terminated prematurely.

On the client side, you'll need to use the EventSource interface to receive the events. Here's an example of how you might do that:

streaming-sse/index.html
<!doctype html>
<html>
	<head>
		<title>Server-Sent Events</title>
	</head>
	
	<body>
		<h1>SSE Messages</h1>
		<ul id="messages"></ul>
		<script type="text/javascript">
			const history = document.getElementById('messages');
			const eventSource = new EventSource("events");
			
			eventSource.onmessage = (event) => {
				const container = document.createElement("li");
				container.innerText = event.data;
				
				messages.appendChild(container);
				messages.scrollTop = messages.scrollHeight;
			};
		</script>
	</body>
</html>

Event streams are easy to use - they are supported by all modern browsers and handle reconnections and other edge cases automatically. This makes them a great choice for streaming data to the client.

Use cases for streaming SSE include:

Live Notifications
Push real-time notifications for events like promotions, user activity updates, or order status changes to keep users informed instantly.
Real-Time Dashboards
Stream continuous updates to data visualization dashboards for monitoring systems, sales metrics, or analytics without manual refreshes.
Live Polls and Q&A
Stream responses and results for interactive polls, Q&A sessions, or audience participation during live events and webinars.
Chat and Messaging Apps
Deliver new messages instantly as they arrive, creating a more responsive chat experience for users.

Streaming Templates

Most web frameworks use templates to generate HTML. These templates are typically rendered in full before being sent to the client. However, in some cases it's possible to stream the template as it is being generated. This greatly improves the time to first byte, at the expense of slightly more complex error handling. By allowing the server to send chunks from the template as they are generated, the client can start rendering the page sooner. This is particularly useful for large pages, or pages with complex logic that takes time to render.

Here is an example of a template with sleep statements to simulate a slow rendering page:

streaming-template/template.xrb
<!doctype html><html><head><title>Green Bottles Song</title></head><body>
	<?r self[:count].downto(1) do |i| ?>
	<p>#{i} green bottles hanging on the wall,</br>
		<?r sleep 1 ?>#{i} green bottles hanging on the wall,</br>
		<?r sleep 1 ?>and if one green bottle should accidentally fall,</br>
		<?r sleep 1 ?>there'll be #{(i-1)} green bottles hanging on the wall.</br>
		<?r sleep 1 ?>
	</p>
	<?r end ?>
</body></html>

The following example shows how to use streaming templates with Falcon:

streaming-template/config.ru
require 'xrb/template'

TEMPLATE = XRB::Template.load_file("template.xrb")

run do |env|
	scope = {
		count: env['QUERY_STRING'].then do |query_string|
			query_string.empty? ? 10 : Integer(query_string)
		end
	}
	
	[200, {"content-type" => "text/html"}, TEMPLATE.to_proc(scope)]
end

You can also do this with ERB templates, but you need to use a streaming-compatible renderer. Rails does not fully support this right now, but we are working on it!

Streaming Input

Streaming input is a feature that allows you to read data from the client as it is received. This is useful for processing large amounts of data, such as file uploads, or for processing data that is generated over time, such as a real-time feed. Streaming input is supported in Rack by reading from the input stream provided by the request environment.

The following example shows how to read a file upload from the client and process it chunk by chunk to generate a chuecksum:

streaming-input/config.ru
require 'digest'

run do |env|
	input = env['rack.input']
	
	checksum = Digest::SHA256.new
	
	# Read each chunk at a time, to avoid buffering the entire file in memory:
	input.each do |chunk|
		checksum.update(chunk)
	end
	
	[200, {'content-type' => 'text/csv'}, [checksum.hexdigest]]
end

If you upload a file (even a large one), it will be sent in chunks, and the checksum will be computed without buffering the entire input:

$ curl --insecure -X POST --data-binary @config.ru https://localhost:9292
f97a5502301d41c3ca77599468e40d186e4d245f83c925e08f731e8276b008a0

Bi-directional Streaming

Full bi-directional streaming is a useful feature for building interactive applications. By combining streaming input and streaming output, you can read and write data to the client in real-time.

The following example shows how to implement a simple chat server using bi-directional streaming:

streaming-bidirectional/config.ru
require 'async/redis'

client = Async::Redis::Client.new

run do |env|
	body = proc do |stream|
		subscription_task = Async do
			# Subscribe to the redis channel and forward messages to the client:
			client.subscribe("chat") do |context|
				context.each do |type, name, message|
					stream.write(message)
				end
			end
		end
		
		stream.each do |message|
			# Read messages from the client and publish them to the redis channel:
			client.publish("chat", message)
		end
	rescue => error
	ensure
		subscription_task&.stop
		stream.close(error)
	end
	
	[200, {'content-type' => 'text/plain'}, body]
end

Very few clients (e.g. curl) support bi-directional streaming, so we implemented a client to go with the example:

streaming-bidirectional/client.rb
#!/usr/bin/env ruby

require 'async/http'

url = ARGV.pop || "https://localhost:9292"
endpoint = Async::HTTP::Endpoint.parse(url)

Async do |task|
	client = Async::HTTP::Client.new(endpoint)
	
	body = Protocol::HTTP::Body::Writable.new
	
	input_task = task.async do
		while line = $stdin.gets
			body.write(line)
		end
	ensure
		body.close_write
	end
	
	response = client.post("/", body: body)
	
	response.each do |chunk|
		$stdout.write("> #{chunk}")
	end
ensure
	input_task&.stop
	response&.close
end

Due to the limitations of web browser implementations, bi-directional streaming is not well-supported in modern interfaces like fetch. However, it is still useful for scenarios that don't involve a browser, or for wrapping higher level protocols:

Real-Time Transcription & Translation
Stream audio data to a server for real-time transcription and translation, and stream the results back to the client for immediate feedback.
Efficient HTTP RPC
Some RPC protocols, like gRPC, can be implemented over HTTP using bi-directional streaming for efficient communication between clients and servers.
WebSockets
In Rack, WebSockets can and are implemented using bi-directional streaming.

Bi-directional WebSockets

To work around the limitations of bi-directional streaming in web browsers, you can use WebSockets. WebSockets provide a full-duplex communication channel over an HTTP connection. This reduces the difficulty of implementing real-time communication in web browsers.

The following example shows how to use WebSockets with Falcon:

streaming-websockets/config.ru
require "async/websocket/adapters/rack"

run do |env|
	if Async::WebSocket::Adapters::Rack.websocket?(env)
		Async::WebSocket::Adapters::Rack.open(env) do |connection|
			while true
				connection.write("The time is #{Time.now}")
				connection.flush
				sleep 1
			end
		end
	else
		# Else the request is for the index page, return the contents of index.html:
		[200, {'content-type' => 'text/html'}, [File.read('index.html')]]
	end
end

On the client side, you can use the WebSocket API to send and receive messages. Here's an example of how you might do that:

streaming-websockets/index.html
<!doctype html>
<html>
	<head>
		<title>WebSockets</title>
	</head>
	
	<body>
		<h1>WebSocket Messages</h1>
		<ul id="messages"></ul>
		<script type="text/javascript">
			const messages = document.getElementById('messages');
			const url = window.location.href.replace('http', 'ws');
			const webSocket = new WebSocket(url);
			
			webSocket.onmessage = (event) => {
				const container = document.createElement('li');
				container.innerText = event.data;
				
				messages.appendChild(container);
				messages.scrollTop = messages.scrollHeight;
			};
		</script>
	</body>
</html>

Use cases for WebSockets include:

Progressive Enhancement
Enhance traditional web applications with incremental updates and real-time features for a more interactive user experience.
Live Collaboration
Support real-time editing and collaboration on documents, spreadsheets, or codebases for remote teams.
Interactive Games
Facilitate real-time multiplayer gaming experiences with features like chat, leaderboards, and live updates.

What about Rails?

Rails builds on top of Rack, so most of the examples above work unchanged, provided you use self.response = Rack::Response[...] in the controller action, for example, the server-sent events example can be done like this:

class MyController
	def streaming_sse
		body = proc do |stream|
			while true
				stream << "data: The time is #{Time.now}\n\n"
				sleep 1
			end
		rescue => error
		ensure
			stream.close(error)
		end
		
		self.response = Rack::Response[200, {"content-type" => "text/event-stream"}, body]
	end
end

Of course, for the best performance, you should be running on top of Falcon. You can also check out falcon-rails-example for the above examples (and more) adapted to Rails. You might also like to check out my previous article on interactive Rails applications.

RubyConf AU 2023 Presentation

In this talk, I discuss asynchronous execution models in Ruby, and how we can build streaming applications using Rack and Falcon. I discuss some of the above approaches in the context of Ruby on Rails.

You can find the slides for my presentation here.

Conclusion

Rack 3 introduces powerful streaming features that improve performance and provide opportunities for enhanced interactivity in web applications. By leveraging streaming input and output, you can build applications that are more responsive and efficient, delivering real-time experiences that were previously challenging to achieve. By making full-duplex streaming easy to use in Rack and Falcon, we aim to position Ruby and Rails as an exceptional choice for building modern, interactive applications that require high performance and real-time capabilities.