ruby

Server-sent Events and WebSockets in Rack for Ruby

Ayush Newatia

Ayush Newatia on

Server-sent Events and WebSockets in Rack for Ruby

In the previous part of this series, we discovered how to create persistent connections in Rack in theory, but now we'll put what we learned into practice.

The web has two formalized specifications for communication over a persistent connection: server-sent events (SSEs) and WebSockets.

WebSockets are widely used and highly popular, but SSEs are far less well-known. Let's explore them first.

Server-sent Events

Server-sent events (SSEs) enable a client to hold an open connection with the server, but only the server can publish messages to the client. It isn't a bi-directional protocol.

SSEs are a JavaScript API, so let's modify our app to serve an HTML page with the required script:

Ruby
class App def call(env) req = Rack::Request.new(env) path = req.path_info case path when "/" sse_js(env) end end private def sse_js(env) body = <<~HTML <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>SSE - Demo</title> <script type="text/javascript"> const eventSource = new EventSource("/sse") eventSource.addEventListener("message", event => { document.body.insertAdjacentHTML( "beforeend", `<p>${event.data}</p>` ) }) </script> </head> <body> </body> </html> HTML [200, { "content-type" => "text/html" }, [body]] end end

The API is encapsulated in the EventSource class and new messages from the server trigger events that we listen for. Next, we need to build the endpoint that sends the events:

Ruby
class App def call(env) req = Rack::Request.new(env) path = req.path_info case path when "/" sse_js(env) when "/sse" sse(env) end end private def sse_js(env) # ... end def sse(env) body = proc do |stream| Thread.new do 5.times do stream.write "data: #{Time.now}!\n\n" sleep 1 end ensure stream.close end end [200, { "content-type" => "text/event-stream" }, body] end end

From a server point of view, this is fairly similar to the streaming bodies example we used in the previous part of this series. It's worth noting the content-type header and the string format written back to the client.

Run the server (make sure you switch back to Puma):

Shell
$ bundle exec puma

Open up localhost:9292 on your web browser, and you'll see the time written to the document five times at one-second intervals.

This technique is great when the server just needs to notify the client about updates. The above example is fairly contrived, though, as it uses a loop, so let's look at how we can use this technique in a real application.

Ruby Queues

Ruby provides a Queue data structure for communication between threads. We can use that to publish data back to a client. Let's stick with the same use case of publishing the current time five times at one-second intervals, but now we'll publish from a background thread.

Ruby
class App def call(env) # ... end private def sse_js(env) # ... end def sse(env) queue = Thread::Queue.new trigger_background_loop(queue) body = proc do |stream| Thread.new do loop do data = queue.pop stream.write "data: #{data}!\n\n" end ensure stream.close end end [200, { "content-type" => "text/event-stream" }, body] end def trigger_background_loop(queue) Thread.new do 5.times do queue.push(Time.now) sleep 1 end end end end

In the above example, we spawn another background thread to push the current time to the queue every second. In the SSE thread, we call queue.pop, which blocks until something is added to the queue.

Using this technique, we can use a pub/sub system such as Redis to add data to the queue from a background thread, which is then published to the client.

That's SSEs covered! Next, let's look at WebSockets.

WebSockets

WebSockets are a bi-directional, full-duplex communication protocol that supports both binary and text data for client-server communication. They're widely used in the modern web and underpin Rails' Action Cable framework.

A WebSocket is created using an HTTP connection, but as a protocol, it's completely independent of HTTP.

To create a WebSocket connection, the client must make an HTTP request with these headers:

text
Connection: Upgrade Upgrade: websocket

The server will respond with the status 101, meaning Switching Protocols. The TCP connection used for the HTTP request is upgraded to a WebSocket connection.

We won't get into the nitty-gritty of the WebSocket protocol in this post. It's fairly fiddly since it's a binary protocol. If you're curious, Starr Horne has written an amazing article on WebSockets.

Let's look at how to upgrade a TCP socket to a WebSocket connection.

Upgrading from HTTP to WebSockets

As described above, we'll need to send a 101 response. After this, we'll write to the socket using WebSockets' binary protocol for the communication to work.

Ruby
require 'digest/sha1' class App def call(env) req = Rack::Request.new(env) key = req.get_header("HTTP_SEC_WEBSOCKET_KEY") response_key = Digest::SHA1.base64digest([key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join) body = proc do |stream| response = "Hello world!" output = [0b10000001, response.size, response] stream.write output.pack("CCA#{ response.size }") ensure stream.close end [101, { "Upgrade" => "websocket", "Connection" => 'upgrade', "Sec-WebSocket-Accept" => response_key }, body] end end

We have to create a response key to securely create the connection. The UUID used to generate it is a global constant found in the specification. We won't go into the binary format of the string we're writing into the WebSocket connection, but it's all described in 'Building a simple websockets server from scratch in Ruby' by Starr Horne if you're curious.

Demo

Run the server:

Shell
$ bundle exec puma

The easiest way to create a connection is to use a WebSocket client. I recommend websocat.

Shell
$ websocat ws://127.0.0.1:9292/

You'll see the string Hello world! printed out! The connection is now active. In theory, we can write and receive messages over this socket now. We still need to implement receiving or publishing messages on the server for it to work in practice, but that's for another post.

A final word of warning: always remember that persistent connections come with challenges when using a threaded web server like Puma. A persistent connection ties up a thread and can cause significant performance issues unless you open a sizeable can of worms to implement your own threading mechanism.

Wrapping Up

That concludes our three-part deep dive into Rack! We first looked at how to set up a basic Rack app, before diving into socket hijacking for persistent connections.

Lastly, in this part, we used two specifications provided by our web platform to communicate over persistent connections: server-sent events (SSEs) and WebSockets.

I hope you've found this series useful. Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Ayush Newatia

Ayush Newatia

Guest author Ayush is a freelance Ruby and Rails developer. He's the author of The Rails and Hotwire Codex and part of the Bridgetown core team. He also runs a privacy focused mailing list app called Scattergun.

All articles by Ayush Newatia

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps