Skip to content

Commit

Permalink
Further improvements to documentation + rails integration guide.
Browse files Browse the repository at this point in the history
  • Loading branch information
ioquatix committed Jun 20, 2024
1 parent 81d09ba commit fa30565
Show file tree
Hide file tree
Showing 11 changed files with 133 additions and 120 deletions.
1 change: 0 additions & 1 deletion examples/chat/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
require 'async'
require 'async/http/endpoint'
require_relative '../../lib/async/websocket/client'
require 'protocol/websocket/json_message'

USER = ARGV.pop || "anonymous"
URL = ARGV.pop || "https://localhost:8080"
Expand Down
23 changes: 23 additions & 0 deletions fixtures/async/websocket/rack_application.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2022, by Samuel Williams.

require 'sus/fixtures/async/http/server_context'
require 'protocol/rack/adapter'

module Async
module WebSocket
module RackApplication
include Sus::Fixtures::Async::HTTP::ServerContext

def builder
Rack::Builder.parse_file(File.expand_path('rack_application/config.ru', __dir__))
end

def app
Protocol::Rack::Adapter.new(builder)
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ClosedLogger
response = @app.call(env)

response[2] = Rack::BodyProxy.new(response[2]) do
Console.logger.debug(self, "Connection closed!")
Console.debug(self, "Connection closed!")
end

return response
Expand All @@ -24,7 +24,7 @@ end
# This wraps our response in a body proxy which ensures Falcon can handle the response not being an instance of `Protocol::HTTP::Body::Readable`.
use ClosedLogger

run lambda {|env|
run do |env|
Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection|
$connections << connection

Expand All @@ -36,9 +36,9 @@ run lambda {|env|
end
end
rescue => error
Console.logger.error(self, error)
Console.error(self, error)
ensure
$connections.delete(connection)
end
end or [200, {}, ["Hello World"]]
}
end
19 changes: 0 additions & 19 deletions fixtures/rack_application.rb

This file was deleted.

37 changes: 0 additions & 37 deletions fixtures/rack_application/client.rb

This file was deleted.

28 changes: 0 additions & 28 deletions fixtures/upgrade_application.rb

This file was deleted.

5 changes: 3 additions & 2 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ Async do |task|
end
end

connection.write({
# Generate a text message by geneating a JSON payload from a hash:
connection.write(Protocol::WebSocket::TextMessage.generate({
user: USER,
status: "connected",
})
}))

while message = connection.read
puts message.inspect
Expand Down
95 changes: 83 additions & 12 deletions guides/rails-integration/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,10 @@ $ rails new websockets
--- snip ---
~~~

Then, we need to add the [Falcon](https://github.com/socketry/falcon) web server and the `Async::WebSocket` gem:
Then, we need to add the `Async::WebSocket` gem:

~~~ bash
$ bundle add falcon async-websocket
$ bundle remove puma
--- snip ---
$ rails s
=> Booting Falcon
=> Rails 6.0.3.1 application starting in development http://localhost:3000
=> Run `rails server --help` for more startup options
$ bundle add async-websocket
~~~

## Adding the WebSocket Controller
Expand All @@ -34,17 +28,94 @@ $ rails generate controller home index
Then edit your controller implementation:

~~~ ruby
require 'async/websocket/adapters/rack'
require 'async/websocket/adapters/rails'

class HomeController < ApplicationController
# WebSocket clients may not send CSRF tokens, so we need to disable this check.
skip_before_action :verify_authenticity_token, only: [:index]

def index
self.response = Async::WebSocket::Adapters::Rack.open(request.env) do |connection|
connection.write({message: "Hello World"})
self.response = Async::WebSocket::Adapters::Rails.open(request) do |connection|
message = Protocol::WebSocket::TextMessage.generate({message: "Hello World"})
connection.write(message)
end
end
end
~~~

### Testing

You can quickly test that the above controller is working using a websocket client:
You can quickly test that the above controller is working. First, start the Rails server:

~~~ bash
$ rails s
=> Booting Puma
=> Rails 7.2.0.beta2 application starting in development
=> Run `bin/rails server --help` for more startup options
~~~

Then you can connect to the server using a WebSocket client:

~~~ bash
$ websocat ws://localhost:3000/home/index
{"message":"Hello World"}
~~~

### Using Falcon

The default Rails server (Puma) is not suitable for handling a large number of connected WebSocket clients, as it has a limited number of threads (typically between 8 and 16). Each WebSocket connection will require a thread, so the server will quickly run out of threads and be unable to accept new connections. To solve this problem, we can use [Falcon](https://github.com/socketry/falcon) instead, which uses a fiber-per-request architecture and can handle a large number of connections.

We need to remove Puma and add Falcon::

~~~ bash
$ bundle remove puma
$ bundle add falcon
~~~

Now when you start the server you should see something like this:

~~~ bash
$ rails s
=> Booting Falcon v0.47.7
=> Rails 7.2.0.beta2 application starting in development http://localhost:3000
=> Run `bin/rails server --help` for more startup options
~~~


### Using HTTP/2

Falcon supports HTTP/2, which can be used to improve the performance of WebSocket connections. HTTP/1.1 requires a separate TCP connection for each WebSocket connection, while HTTP/2 can handle multiple requessts and WebSocket connections over a single TCP connection. To use HTTP/2, you'd typically use `https`, which allows the client browser to use application layer protocol negotiation (ALPN) to negotiate the use of HTTP/2.

HTTP/2 WebSockets are a bit different from HTTP/1.1 WebSockets. In HTTP/1, the client sends a `GET` request with the `upgrade:` header. In HTTP/2, the client sends a `CONNECT` request with the `:protocol` pseud-header. The Rails routes must be adjusted to accept both methods:

~~~ ruby
Rails.application.routes.draw do
# Previously it was this:
# get "home/index"
match "home/index", to: "home#index", via: [:get, :connect]
end
~~~

Once this is done, you need to bind falcon to an `https` endpoint:

~~~ bash
$ falcon serve --bind "https://localhost:3000"
~~~

It's a bit more tricky to test this, but you can do so with the following Ruby code:

~~~ ruby
require 'async/http/endpoint'
require 'async/websocket/client'

endpoint = Async::HTTP::Endpoint.parse("https://localhost:3000/home/index")

Async::WebSocket::Client.connect(endpoint) do |connection|
puts connection.framer.connection.class
# Async::HTTP::Protocol::HTTP2::Client

while message = connection.read
puts message.inspect
end
end
~~~
24 changes: 14 additions & 10 deletions lib/async/websocket/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,18 @@ def close(...)

# @return [Connection] an open websocket connection to the given endpoint.
def self.connect(endpoint, *arguments, **options, &block)
client = self.open(endpoint, *arguments)
connection = client.connect(endpoint.authority, endpoint.path, **options)

return ClientCloseDecorator.new(client, connection) unless block_given?

begin
yield connection
ensure
connection.close
client.close
Sync do
client = self.open(endpoint, *arguments)
connection = client.connect(endpoint.authority, endpoint.path, **options)

return ClientCloseDecorator.new(client, connection) unless block_given?

begin
yield connection
ensure
connection.close
client.close
end
end
end

Expand All @@ -80,6 +82,8 @@ def initialize(pool, connection, stream)
@connection = connection
end

attr :connection

def close
super

Expand Down
6 changes: 3 additions & 3 deletions test/async/websocket/adapters/rack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
require 'async/websocket'
require 'async/websocket/client'
require 'async/websocket/adapters/rack'
require 'rack_application'
require 'async/websocket/rack_application'

describe Async::WebSocket::Adapters::Rack do
it "can determine whether a rack env is a websocket request" do
Expand All @@ -16,7 +16,7 @@
end

with 'rack application' do
include RackApplication
include Async::WebSocket::RackApplication

it "can make non-websocket connection to server" do
response = client.get("/")
Expand All @@ -28,7 +28,7 @@
end

let(:message) do
Protocol::WebSocket::JSONMessage.generate({text: "Hello World"})
Protocol::WebSocket::TextMessage.generate({text: "Hello World"})
end

it "can make websocket connection to server" do
Expand Down
7 changes: 3 additions & 4 deletions test/async/websocket/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# Released under the MIT License.
# Copyright, 2019-2023, by Samuel Williams.

require 'protocol/websocket/json_message'
require 'protocol/http/middleware/builder'

require 'async/websocket/client'
Expand Down Expand Up @@ -47,7 +46,7 @@
let(:app) do
Protocol::HTTP::Middleware.for do |request|
Async::WebSocket::Adapters::HTTP.open(request) do |connection|
message = Protocol::WebSocket::JSONMessage.generate(request.headers.fields)
message = Protocol::WebSocket::TextMessage.generate(request.headers.fields)
message.send(connection)

connection.close
Expand All @@ -59,9 +58,9 @@
connection = websocket_client.connect(endpoint.authority, "/headers", headers: headers)

begin
json_message = Protocol::WebSocket::JSONMessage.wrap(connection.read)
message = connection.read

expect(json_message.to_h).to have_keys(*headers.keys)
expect(message.to_h).to have_keys(*headers.keys)
expect(connection.read).to be_nil
expect(connection).to be(:closed?)
ensure
Expand Down

0 comments on commit fa30565

Please sign in to comment.