From fa305656093fe3ea6a91f6f40893485844d86bff Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 20 Jun 2024 20:15:38 +0900 Subject: [PATCH] Further improvements to documentation + rails integration guide. --- examples/chat/client.rb | 1 - fixtures/async/websocket/rack_application.rb | 23 +++++ .../websocket}/rack_application/config.ru | 8 +- fixtures/rack_application.rb | 19 ---- fixtures/rack_application/client.rb | 37 -------- fixtures/upgrade_application.rb | 28 ------ guides/getting-started/readme.md | 5 +- guides/rails-integration/readme.md | 95 ++++++++++++++++--- lib/async/websocket/client.rb | 24 +++-- test/async/websocket/adapters/rack.rb | 6 +- test/async/websocket/server.rb | 7 +- 11 files changed, 133 insertions(+), 120 deletions(-) create mode 100644 fixtures/async/websocket/rack_application.rb rename fixtures/{ => async/websocket}/rack_application/config.ru (88%) delete mode 100644 fixtures/rack_application.rb delete mode 100644 fixtures/rack_application/client.rb delete mode 100644 fixtures/upgrade_application.rb diff --git a/examples/chat/client.rb b/examples/chat/client.rb index e4ee326..015a49f 100755 --- a/examples/chat/client.rb +++ b/examples/chat/client.rb @@ -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" diff --git a/fixtures/async/websocket/rack_application.rb b/fixtures/async/websocket/rack_application.rb new file mode 100644 index 0000000..3941ea8 --- /dev/null +++ b/fixtures/async/websocket/rack_application.rb @@ -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 diff --git a/fixtures/rack_application/config.ru b/fixtures/async/websocket/rack_application/config.ru similarity index 88% rename from fixtures/rack_application/config.ru rename to fixtures/async/websocket/rack_application/config.ru index 0ebb3a8..03d934f 100644 --- a/fixtures/rack_application/config.ru +++ b/fixtures/async/websocket/rack_application/config.ru @@ -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 @@ -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 @@ -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 diff --git a/fixtures/rack_application.rb b/fixtures/rack_application.rb deleted file mode 100644 index 792e077..0000000 --- a/fixtures/rack_application.rb +++ /dev/null @@ -1,19 +0,0 @@ -# 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 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 diff --git a/fixtures/rack_application/client.rb b/fixtures/rack_application/client.rb deleted file mode 100644 index 1261de5..0000000 --- a/fixtures/rack_application/client.rb +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2024, by Samuel Williams. - -require 'async' -require 'async/http/endpoint' -require 'async/websocket/client' - -USER = ARGV.pop || "anonymous" -URL = ARGV.pop || "http://localhost:7070" - -Async do |task| - endpoint = Async::HTTP::Endpoint.parse(URL) - headers = {'token' => 'wubalubadubdub'} - - Async::WebSocket::Client.open(endpoint, headers: headers) do |connection| - input_task = task.async do - while line = $stdin.gets - connection.write({user: USER, text: line}) - connection.flush - end - end - - connection.write({ - user: USER, - status: "connected", - }) - - while message = connection.read - puts message.inspect - end - ensure - input_task&.stop - end -end diff --git a/fixtures/upgrade_application.rb b/fixtures/upgrade_application.rb deleted file mode 100644 index b61f8e7..0000000 --- a/fixtures/upgrade_application.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2019-2023, by Samuel Williams. - -require 'async/websocket/adapters/rack' - -class UpgradeApplication - def initialize(app) - @app = app - end - - def call(env) - Async::WebSocket::Adapters::Rack.open(env) do |connection| - read, write = IO.pipe - - Process.spawn("ls -lah", :out => write) - write.close - - read.each_line do |line| - connection.send_text(line) - end - - # Gracefully close the connection: - connection.close - end or @app.call(env) - end -end diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 50f64a6..9f5c28b 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -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 diff --git a/guides/rails-integration/readme.md b/guides/rails-integration/readme.md index 9baf5b3..50fd0bb 100644 --- a/guides/rails-integration/readme.md +++ b/guides/rails-integration/readme.md @@ -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 @@ -34,12 +28,16 @@ $ 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 @@ -47,4 +45,77 @@ 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 +~~~ diff --git a/lib/async/websocket/client.rb b/lib/async/websocket/client.rb index 53b9c73..fc58356 100644 --- a/lib/async/websocket/client.rb +++ b/lib/async/websocket/client.rb @@ -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 @@ -80,6 +82,8 @@ def initialize(pool, connection, stream) @connection = connection end + attr :connection + def close super diff --git a/test/async/websocket/adapters/rack.rb b/test/async/websocket/adapters/rack.rb index 3db624e..fece471 100644 --- a/test/async/websocket/adapters/rack.rb +++ b/test/async/websocket/adapters/rack.rb @@ -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 @@ -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("/") @@ -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 diff --git a/test/async/websocket/server.rb b/test/async/websocket/server.rb index 80464e7..14630d1 100644 --- a/test/async/websocket/server.rb +++ b/test/async/websocket/server.rb @@ -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' @@ -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 @@ -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