├── .gitignore ├── guides ├── links.yaml ├── getting-started │ └── readme.md └── rails-integration │ └── readme.md ├── config ├── external.yaml └── sus.rb ├── examples ├── chat │ ├── .env │ ├── client.rb │ ├── multi-client.rb │ ├── config.ru │ └── readme.md ├── rack │ ├── gems.rb │ ├── config.ru │ ├── client.rb │ ├── readme.md │ └── gems.locked ├── binance │ └── client.rb └── mud │ ├── client.rb │ └── config.ru ├── .editorconfig ├── lib └── async │ ├── websocket │ ├── version.rb │ ├── adapters │ │ ├── rails.rb │ │ ├── rack.rb │ │ └── http.rb │ ├── error.rb │ ├── server.rb │ ├── response.rb │ ├── connect_response.rb │ ├── upgrade_response.rb │ ├── connection.rb │ ├── request.rb │ ├── connect_request.rb │ ├── upgrade_request.rb │ └── client.rb │ └── websocket.rb ├── .mailmap ├── .github └── workflows │ ├── rubocop.yaml │ ├── documentation-coverage.yaml │ ├── test-external.yaml │ ├── test.yaml │ ├── documentation.yaml │ └── test-coverage.yaml ├── test └── async │ └── websocket │ ├── response.rb │ ├── connection.rb │ ├── request.rb │ ├── adapters │ └── rack.rb │ ├── server.rb │ └── client.rb ├── gems.rb ├── fixtures └── async │ └── websocket │ ├── rack_application.rb │ └── rack_application │ └── config.ru ├── .rubocop.yml ├── async-websocket.gemspec ├── license.md ├── readme.md └── release.cert /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 1 3 | rails-integration: 4 | order: 2 5 | -------------------------------------------------------------------------------- /config/external.yaml: -------------------------------------------------------------------------------- 1 | live: 2 | url: https://github.com/socketry/live.git 3 | command: bundle exec bake test 4 | -------------------------------------------------------------------------------- /examples/chat/.env: -------------------------------------------------------------------------------- 1 | export RUBY_FIBER_VM_STACK_SIZE=0 2 | export RUBY_FIBER_MACHINE_STACK_SIZE=0 3 | export RUBY_SHARED_FIBER_POOL_FREE_STACKS=0 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2024, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /lib/async/websocket/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | module Async 7 | module WebSocket 8 | VERSION = "0.30.0" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/async/websocket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2015-2024, by Samuel Williams. 5 | 6 | require_relative "websocket/version" 7 | require_relative "websocket/server" 8 | require_relative "websocket/client" 9 | -------------------------------------------------------------------------------- /examples/rack/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "puma" 9 | gem "falcon" 10 | gem "async-websocket", path: "../../" 11 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Juan Antonio Martín Lucas 2 | Aurora Nockert 3 | Thomas Morgan 4 | Peter Runich <43861241+PeterRunich@users.noreply.github.com> 5 | Simon Crocker 6 | Ryu Sato 7 | -------------------------------------------------------------------------------- /examples/rack/config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S falcon serve --bind http://127.0.0.1:7070 --count 1 -c 2 | # frozen_string_literal: true 3 | 4 | require "async/websocket/adapters/rack" 5 | 6 | app = lambda do |env| 7 | response = Async::WebSocket::Adapters::Rack.open(env) do |connection| 8 | while message = connection.read 9 | connection.write message 10 | end 11 | end or [404, {}, []] 12 | end 13 | 14 | run app 15 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /test/async/websocket/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "async/websocket/response" 7 | 8 | describe Async::WebSocket::Response do 9 | it "fails if the version is not recognized" do 10 | request = Protocol::HTTP::Request.new(nil, nil, "GET", "/", "frob/2.0") 11 | 12 | expect do 13 | subject.for(request) 14 | end.to raise_exception(Async::WebSocket::UnsupportedVersionError, message: be =~ /Unsupported HTTP version/) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.3" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2015-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | # gem "protocol-websocket", path: "../protocol-websocket" 11 | 12 | group :maintenance, optional: true do 13 | gem "bake-gem" 14 | gem "bake-modernize" 15 | 16 | gem "utopia-project" 17 | end 18 | 19 | group :test do 20 | gem "sus" 21 | gem "covered" 22 | gem "decode" 23 | gem "rubocop" 24 | 25 | gem "sus-fixtures-async-http" 26 | 27 | gem "bake-test" 28 | gem "bake-test-external" 29 | end 30 | -------------------------------------------------------------------------------- /lib/async/websocket/adapters/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | # Copyright, 2023, by Emily Love Mills. 6 | 7 | require_relative "rack" 8 | 9 | module Async 10 | module WebSocket 11 | module Adapters 12 | module Rails 13 | def self.open(request, **options, &block) 14 | if response = Rack.open(request.env, **options, &block) 15 | ::Rack::Response[*response] 16 | else 17 | ::ActionDispatch::Response.new(404) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /examples/rack/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/endpoint" 9 | require "async/websocket/client" 10 | 11 | URL = ARGV.pop || "http://127.0.0.1:7070" 12 | 13 | Async do |task| 14 | endpoint = Async::HTTP::Endpoint.parse(URL) 15 | 16 | Async::WebSocket::Client.connect(endpoint) do |connection| 17 | 1000.times do 18 | connection.send_text("Hello World") 19 | connection.flush 20 | 21 | puts connection.read 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /examples/binance/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2021-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http" 9 | require "async/websocket" 10 | 11 | URL = "wss://stream.binance.com:9443/ws/btcusdt@bookTicker" 12 | 13 | Async do |task| 14 | endpoint = Async::HTTP::Endpoint.parse(URL, alpn_protocols: Async::HTTP::Protocol::HTTP11.names) 15 | 16 | Async::WebSocket::Client.connect(endpoint) do |connection| 17 | while message = connection.read 18 | $stdout.puts message.parse 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /fixtures/async/websocket/rack_application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2024, by Samuel Williams. 5 | 6 | require "sus/fixtures/async/http/server_context" 7 | require "protocol/rack/adapter" 8 | 9 | module Async 10 | module WebSocket 11 | module RackApplication 12 | include Sus::Fixtures::Async::HTTP::ServerContext 13 | 14 | def builder 15 | Rack::Builder.parse_file(File.expand_path("rack_application/config.ru", __dir__)) 16 | end 17 | 18 | def app 19 | Protocol::Rack::Adapter.new(builder) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /examples/rack/readme.md: -------------------------------------------------------------------------------- 1 | # Rack Example 2 | 3 | This example shows how to host a WebSocket server using Rack. 4 | 5 | ## Usage 6 | 7 | Install the dependencies: 8 | 9 | ~~~ bash 10 | $ bundle update 11 | ~~~ 12 | 13 | Then start the server: 14 | 15 | ~~~ bash 16 | $ bundle exec falcon serve --bind "http://localhost:9292" 17 | ~~~ 18 | 19 | You can connect to the server using a WebSocket client: 20 | 21 | ~~~ bash 22 | $ bundle exec ./client.rb "http://localhost:9292" 23 | ~~~ 24 | 25 | ### Using Puma 26 | 27 | You can also use Puma to host the server: 28 | 29 | ~~~ bash 30 | $ bundle exec puma --bind "tcp://localhost:9292" 31 | ~~~ 32 | 33 | The command for running the client is the same. 34 | -------------------------------------------------------------------------------- /lib/async/websocket/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "protocol/websocket/error" 7 | 8 | module Async 9 | module WebSocket 10 | class ProtocolError < ::Protocol::WebSocket::ProtocolError 11 | end 12 | 13 | class Error < ::Protocol::WebSocket::Error 14 | end 15 | 16 | class UnsupportedVersionError < Error 17 | end 18 | 19 | class ConnectionError < Error 20 | def initialize(message, response) 21 | super(message) 22 | 23 | @response = response 24 | end 25 | 26 | # The failed HTTP response. 27 | attr :response 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/async/websocket/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2015-2024, by Samuel Williams. 5 | # Copyright, 2019, by Simon Crocker. 6 | 7 | require "async/websocket/connection" 8 | 9 | describe Async::WebSocket::Connection do 10 | let(:framer) {Protocol::WebSocket::Framer.new(nil)} 11 | let(:connection) {subject.new(framer)} 12 | 13 | it "is not reusable" do 14 | expect(connection).not.to be(:reusable?) 15 | end 16 | 17 | it "should use mask if specified" do 18 | mock(framer) do |mock| 19 | mock.replace(:write_frame) do |frame| 20 | expect(frame.mask).to be == connection.mask 21 | end 22 | end 23 | 24 | connection.send_text("Hello World") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/async/websocket/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2019, by Simon Crocker. 6 | 7 | require_relative "connection" 8 | require_relative "response" 9 | 10 | require "protocol/http/middleware" 11 | 12 | module Async 13 | module WebSocket 14 | class Server < ::Protocol::HTTP::Middleware 15 | include ::Protocol::WebSocket::Headers 16 | 17 | def initialize(delegate, **options, &block) 18 | super(delegate) 19 | 20 | @options = options 21 | @block = block 22 | end 23 | 24 | def call(request) 25 | Async::WebSocket::Adapters::HTTP.open(request, **@options, &@block) or super 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{matrix.ruby}} 32 | bundler-cache: true 33 | 34 | - name: Run tests 35 | timeout-minutes: 10 36 | run: bundle exec bake test:external 37 | -------------------------------------------------------------------------------- /lib/async/websocket/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "upgrade_response" 7 | require_relative "connect_response" 8 | 9 | require_relative "error" 10 | 11 | module Async 12 | module WebSocket 13 | module Response 14 | # Send the request to the given connection. 15 | def self.for(request, headers = nil, **options, &body) 16 | if request.version =~ /http\/1/i 17 | return UpgradeResponse.new(request, headers, **options, &body) 18 | elsif request.version =~ /http\/2/i 19 | return ConnectResponse.new(request, headers, **options, &body) 20 | end 21 | 22 | raise UnsupportedVersionError, "Unsupported HTTP version: #{request.version}!" 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/async/websocket/adapters/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "http" 7 | require "protocol/rack/request" 8 | require "protocol/rack/adapter" 9 | 10 | module Async 11 | module WebSocket 12 | module Adapters 13 | module Rack 14 | include ::Protocol::WebSocket::Headers 15 | 16 | def self.websocket?(env) 17 | HTTP.websocket?( 18 | ::Protocol::Rack::Request[env] 19 | ) 20 | end 21 | 22 | def self.open(env, **options, &block) 23 | request = ::Protocol::Rack::Request[env] 24 | 25 | if response = HTTP.open(request, **options, &block) 26 | return Protocol::Rack::Adapter.make_response(env, response) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /examples/chat/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2018-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/endpoint" 9 | require_relative "../../lib/async/websocket/client" 10 | 11 | USER = ARGV.pop || "anonymous" 12 | URL = ARGV.pop || "https://localhost:8080" 13 | ENDPOINT = Async::HTTP::Endpoint.parse(URL) 14 | 15 | Async do |task| 16 | Async::WebSocket::Client.connect(ENDPOINT) do |connection| 17 | input_task = task.async do 18 | while line = $stdin.gets 19 | message = Protocol::WebSocket::TextMessage.generate({text: line}) 20 | message.send(connection) 21 | connection.flush 22 | end 23 | end 24 | 25 | puts "Connected..." 26 | while message = connection.read 27 | puts "> #{message.to_h}" 28 | end 29 | ensure 30 | input_task&.stop 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /examples/mud/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | # Copyright, 2020, by Juan Antonio Martín Lucas. 7 | 8 | require "async" 9 | require "async/http/endpoint" 10 | require "async/websocket/client" 11 | 12 | USER = ARGV.pop || "anonymous" 13 | URL = ARGV.pop || "http://127.0.0.1:7070" 14 | 15 | Async do |task| 16 | endpoint = Async::HTTP::Endpoint.parse(URL) 17 | 18 | Async::WebSocket::Client.connect(endpoint) do |connection| 19 | task.async do 20 | $stdout.write "> " 21 | 22 | while line = $stdin.gets 23 | connection.write(Protocol::WebSocket::TextMessage.generate({input: line})) 24 | connection.flush 25 | 26 | $stdout.write "> " 27 | end 28 | end 29 | 30 | while message = connection.read 31 | # Rewind to start of line: 32 | $stdout.write "\r" 33 | 34 | # Clear line: 35 | $stdout.write "\e[2K" 36 | 37 | # Print message: 38 | $stdout.puts message.to_h 39 | 40 | $stdout.write "> " 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/async/websocket/connect_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "protocol/http/response" 7 | require "async/http/body/hijack" 8 | 9 | module Async 10 | module WebSocket 11 | # The response from the server back to the client for negotiating HTTP/2 WebSockets. 12 | class ConnectResponse < ::Protocol::HTTP::Response 13 | include ::Protocol::WebSocket::Headers 14 | 15 | def initialize(request, headers = nil, protocol: nil, &block) 16 | headers = ::Protocol::HTTP::Headers[headers] 17 | 18 | if protocol 19 | headers.add(SEC_WEBSOCKET_PROTOCOL, protocol) 20 | end 21 | 22 | # For compatibility with HTTP/1 websockets proxied over HTTP/2, we process the accept nounce here: 23 | if accept_nounce = request.headers[SEC_WEBSOCKET_KEY]&.first 24 | headers.add(SEC_WEBSOCKET_ACCEPT, Nounce.accept_digest(accept_nounce)) 25 | end 26 | 27 | body = Async::HTTP::Body::Hijack.wrap(request, &block) 28 | 29 | super(request.version, 200, headers, body) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/async/websocket/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "async/websocket/request" 7 | 8 | describe Async::WebSocket::Request do 9 | let(:request) {subject.new("https", "localhost", "/")} 10 | let(:connection) {Async::WebSocket::Connection.new(nil)} 11 | 12 | it "can detect websocket requests" do 13 | expect(subject).to be(:websocket?, request) 14 | end 15 | 16 | it "should be idempotent" do 17 | expect(request).to be(:idempotent?) 18 | end 19 | 20 | it "fails if the version is not supported" do 21 | expect(connection).to receive(:http1?).and_return(false) 22 | expect(connection).to receive(:http2?).and_return(false) 23 | expect(connection).to receive(:version).and_return("frob/2.0") 24 | 25 | expect do 26 | request.call(connection) 27 | end.to raise_exception(Async::WebSocket::UnsupportedVersionError, message: be =~ /Unsupported HTTP version/) 28 | end 29 | 30 | with "#to_s" do 31 | it "should generate string representation" do 32 | expect(request.to_s).to be =~ %r{https://localhost/} 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | 28 | experimental: [false] 29 | 30 | include: 31 | - os: ubuntu 32 | ruby: truffleruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: jruby 36 | experimental: true 37 | - os: ubuntu 38 | ruby: head 39 | experimental: true 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{matrix.ruby}} 46 | bundler-cache: true 47 | 48 | - name: Run tests 49 | timeout-minutes: 10 50 | run: bundle exec bake test 51 | -------------------------------------------------------------------------------- /lib/async/websocket/upgrade_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | 7 | require "async/http/body/hijack" 8 | require "protocol/http/response" 9 | require "protocol/websocket/headers" 10 | 11 | module Async 12 | module WebSocket 13 | # The response from the server back to the client for negotiating HTTP/1.x WebSockets. 14 | class UpgradeResponse < ::Protocol::HTTP::Response 15 | include ::Protocol::WebSocket::Headers 16 | 17 | def initialize(request, headers = nil, protocol: nil, &block) 18 | headers = ::Protocol::HTTP::Headers[headers] 19 | 20 | if accept_nounce = request.headers[SEC_WEBSOCKET_KEY]&.first 21 | headers.add(SEC_WEBSOCKET_ACCEPT, Nounce.accept_digest(accept_nounce)) 22 | 23 | if protocol 24 | headers.add(SEC_WEBSOCKET_PROTOCOL, protocol) 25 | end 26 | 27 | body = Async::HTTP::Body::Hijack.wrap(request, &block) 28 | 29 | super(request.version, 101, headers, body, PROTOCOL) 30 | else 31 | super(request.version, 400, headers) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /fixtures/async/websocket/rack_application/config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S falcon serve --bind http://localhost:7070 --count 1 -c 2 | # frozen_string_literal: true 3 | 4 | require "async/websocket/adapters/rack" 5 | require "set" 6 | 7 | $connections = Set.new 8 | 9 | class ClosedLogger 10 | def initialize(app) 11 | @app = app 12 | end 13 | 14 | def call(env) 15 | response = @app.call(env) 16 | 17 | response[2] = Rack::BodyProxy.new(response[2]) do 18 | Console.debug(self, "Connection closed!") 19 | end 20 | 21 | return response 22 | end 23 | end 24 | 25 | # This wraps our response in a body proxy which ensures Falcon can handle the response not being an instance of `Protocol::HTTP::Body::Readable`. 26 | use ClosedLogger 27 | 28 | run do |env| 29 | Async::WebSocket::Adapters::Rack.open(env, protocols: ["ws"]) do |connection| 30 | $connections << connection 31 | 32 | begin 33 | while message = connection.read 34 | $connections.each do |connection| 35 | connection.write(message) 36 | connection.flush 37 | end 38 | end 39 | rescue => error 40 | Console.error(self, error) 41 | ensure 42 | $connections.delete(connection) 43 | end 44 | end or [200, {}, ["Hello World"]] 45 | end 46 | -------------------------------------------------------------------------------- /lib/async/websocket/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2019, by Janko Marohnić. 6 | 7 | require "protocol/websocket/connection" 8 | require "protocol/websocket/headers" 9 | 10 | require "json" 11 | 12 | module Async 13 | module WebSocket 14 | Frame = ::Protocol::WebSocket::Frame 15 | 16 | # This is a basic synchronous websocket client: 17 | class Connection < ::Protocol::WebSocket::Connection 18 | include ::Protocol::WebSocket::Headers 19 | 20 | def self.call(framer, protocol = [], extensions = nil, **options) 21 | instance = self.new(framer, Array(protocol).first, **options) 22 | 23 | extensions&.apply(instance) 24 | 25 | return instance unless block_given? 26 | 27 | begin 28 | yield instance 29 | ensure 30 | instance.close 31 | end 32 | end 33 | 34 | def initialize(framer, protocol = nil, **options) 35 | super(framer, **options) 36 | 37 | @protocol = protocol 38 | end 39 | 40 | def reusable? 41 | false 42 | end 43 | 44 | attr :protocol 45 | 46 | def inspect 47 | "#<#{self.class} state=#{@state}>" 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /async-websocket.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/async/websocket/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "async-websocket" 7 | spec.version = Async::WebSocket::VERSION 8 | 9 | spec.summary = "An async websocket library on top of protocol-websocket." 10 | spec.authors = ["Samuel Williams", "Simon Crocker", "Olle Jonsson", "Thomas Morgan", "Aurora Nockert", "Bryan Powell", "Emily Love Mills", "Gleb Sinyavskiy", "Janko Marohnić", "Juan Antonio Martín Lucas", "Michel Boaventura", "Peter Runich", "Ryu Sato"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/async-websocket" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/async-websocket/", 20 | "funding_uri" => "https://github.com/sponsors/ioquatix", 21 | "source_code_uri" => "https://github.com/socketry/async-websocket.git", 22 | } 23 | 24 | spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 25 | 26 | spec.required_ruby_version = ">= 3.1" 27 | 28 | spec.add_dependency "async-http", "~> 0.76" 29 | spec.add_dependency "protocol-http", "~> 0.34" 30 | spec.add_dependency "protocol-rack", "~> 0.7" 31 | spec.add_dependency "protocol-websocket", "~> 0.17" 32 | end 33 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.3" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.3" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.3" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /lib/async/websocket/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "connect_request" 7 | require_relative "upgrade_request" 8 | require_relative "error" 9 | 10 | module Async 11 | module WebSocket 12 | class Request 13 | include ::Protocol::WebSocket::Headers 14 | 15 | def self.websocket?(request) 16 | Array(request.protocol).include?(PROTOCOL) 17 | end 18 | 19 | def initialize(scheme = nil, authority = nil, path = nil, headers = nil, **options, &block) 20 | @scheme = scheme 21 | @authority = authority 22 | @path = path 23 | @headers = headers 24 | 25 | @options = options 26 | 27 | @body = nil 28 | end 29 | 30 | def protocol 31 | PROTOCOL 32 | end 33 | 34 | attr_accessor :scheme 35 | attr_accessor :authority 36 | attr_accessor :path 37 | attr_accessor :headers 38 | 39 | attr_accessor :body 40 | 41 | # Send the request to the given connection. 42 | def call(connection) 43 | if connection.http1? 44 | return UpgradeRequest.new(self, **@options).call(connection) 45 | elsif connection.http2? 46 | return ConnectRequest.new(self, **@options).call(connection) 47 | end 48 | 49 | raise UnsupportedVersionError, "Unsupported HTTP version: #{connection.version}!" 50 | end 51 | 52 | def idempotent? 53 | true 54 | end 55 | 56 | def to_s 57 | uri = "#{@scheme}://#{@authority}#{@path}" 58 | "\#<#{self.class} uri=#{uri.inspect}>" 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2015-2024, by Samuel Williams. 4 | Copyright, 2019, by Bryan Powell. 5 | Copyright, 2019, by Simon Crocker. 6 | Copyright, 2019, by Michel Boaventura. 7 | Copyright, 2019, by Janko Marohnić. 8 | Copyright, 2020-2021, by Olle Jonsson. 9 | Copyright, 2020, by Juan Antonio Martín Lucas. 10 | Copyright, 2021, by Gleb Sinyavskiy. 11 | Copyright, 2021, by Aurora Nockert. 12 | Copyright, 2023, by Peter Runich. 13 | Copyright, 2023, by Thomas Morgan. 14 | Copyright, 2023, by Emily Love Mills. 15 | Copyright, 2024, by Ryu Sato. 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Async::WebSocket 2 | 3 | An asynchronous websocket client/server implementation for [HTTP/1](https://tools.ietf.org/html/rfc6455) and [HTTP/2](https://tools.ietf.org/html/rfc8441). 4 | 5 | [![Development Status](https://github.com/socketry/async-websocket/workflows/Test/badge.svg)](https://github.com/socketry/async-websocket/actions?workflow=Test) 6 | 7 | ## Usage 8 | 9 | Please see the [project documentation](https://socketry.github.io/async-websocket/) for more details. 10 | 11 | - [Getting Started](https://socketry.github.io/async-websocket/guides/getting-started/index) - This guide shows you how to implement a basic client and server. 12 | 13 | - [Rails Integration](https://socketry.github.io/async-websocket/guides/rails-integration/index) - This guide explains how to use `async-websocket` with Rails. 14 | 15 | ## Contributing 16 | 17 | We welcome contributions to this project. 18 | 19 | 1. Fork it. 20 | 2. Create your feature branch (`git checkout -b my-new-feature`). 21 | 3. Commit your changes (`git commit -am 'Add some feature'`). 22 | 4. Push to the branch (`git push origin my-new-feature`). 23 | 5. Create new Pull Request. 24 | 25 | ### Developer Certificate of Origin 26 | 27 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 28 | 29 | ### Community Guidelines 30 | 31 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 32 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /lib/async/websocket/adapters/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | # Copyright, 2021, by Aurora Nockert. 6 | 7 | require_relative "../connection" 8 | require_relative "../response" 9 | 10 | require "protocol/websocket/extensions" 11 | 12 | module Async 13 | module WebSocket 14 | module Adapters 15 | module HTTP 16 | include ::Protocol::WebSocket::Headers 17 | 18 | def self.websocket?(request) 19 | Array(request.protocol).any? { |e| e.casecmp?(PROTOCOL) } 20 | end 21 | 22 | def self.open(request, headers: [], protocols: [], handler: Connection, extensions: ::Protocol::WebSocket::Extensions::Server.default, **options, &block) 23 | if websocket?(request) 24 | headers = Protocol::HTTP::Headers[headers] 25 | 26 | # Select websocket sub-protocol: 27 | if requested_protocol = request.headers[SEC_WEBSOCKET_PROTOCOL] 28 | protocol = (requested_protocol & protocols).first 29 | end 30 | 31 | if extensions and extension_headers = request.headers[SEC_WEBSOCKET_EXTENSIONS] 32 | extensions.accept(extension_headers) do |header| 33 | headers.add(SEC_WEBSOCKET_EXTENSIONS, header.join(";")) 34 | end 35 | end 36 | 37 | response = Response.for(request, headers, protocol: protocol, **options) do |stream| 38 | framer = Protocol::WebSocket::Framer.new(stream) 39 | connection = handler.call(framer, protocol, extensions) 40 | 41 | yield connection 42 | ensure 43 | connection&.close 44 | stream.close 45 | end 46 | 47 | # Once we get to this point, we no longer need to hold on to the request: 48 | request = nil 49 | 50 | return response 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/async/websocket/adapters/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2015-2024, by Samuel Williams. 5 | # Copyright, 2019, by Simon Crocker. 6 | 7 | require "async/websocket" 8 | require "async/websocket/client" 9 | require "async/websocket/adapters/rack" 10 | require "async/websocket/rack_application" 11 | 12 | describe Async::WebSocket::Adapters::Rack do 13 | it "can determine whether a rack env is a websocket request" do 14 | expect(Async::WebSocket::Adapters::Rack.websocket?(Rack::MockRequest.env_for("/"))).to be == false 15 | expect(Async::WebSocket::Adapters::Rack.websocket?(Rack::MockRequest.env_for("/", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket"))).to be == true 16 | end 17 | 18 | with "rack application" do 19 | include Async::WebSocket::RackApplication 20 | 21 | it "can make non-websocket connection to server" do 22 | response = client.get("/") 23 | 24 | expect(response).to be(:success?) 25 | expect(response.read).to be == "Hello World" 26 | 27 | client.close 28 | end 29 | 30 | let(:message) do 31 | Protocol::WebSocket::TextMessage.generate({text: "Hello World"}) 32 | end 33 | 34 | it "can make websocket connection to server" do 35 | Async::WebSocket::Client.connect(client_endpoint) do |connection| 36 | connection.write(message) 37 | 38 | expect(connection.read).to be == message 39 | 40 | connection.close 41 | end 42 | end 43 | 44 | it "should use mask over insecure connection" do 45 | expect(endpoint).not.to be(:secure?) 46 | 47 | Async::WebSocket::Client.connect(client_endpoint) do |connection| 48 | expect(connection.mask).not.to be_nil 49 | end 50 | end 51 | 52 | it "should negotiate protocol" do 53 | Async::WebSocket::Client.connect(client_endpoint, protocols: ["ws"]) do |connection| 54 | expect(connection.protocol).to be == "ws" 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/async/websocket/connect_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | 7 | require "protocol/http/request" 8 | require "protocol/http/headers" 9 | require "protocol/websocket/headers" 10 | require "protocol/http/body/readable" 11 | 12 | require "async/variable" 13 | 14 | module Async 15 | module WebSocket 16 | # This is required for HTTP/2 to establish a connection using the WebSocket protocol. 17 | # See https://tools.ietf.org/html/rfc8441 for more details. 18 | class ConnectRequest < ::Protocol::HTTP::Request 19 | include ::Protocol::WebSocket::Headers 20 | 21 | class Wrapper 22 | def initialize(stream, response) 23 | @response = response 24 | @stream = stream 25 | end 26 | 27 | def close 28 | @response.close 29 | end 30 | 31 | def unwrap 32 | @response.buffered! 33 | end 34 | 35 | attr_accessor :response 36 | attr_accessor :stream 37 | 38 | def stream? 39 | @response.success? and @stream 40 | end 41 | 42 | def status 43 | @response.status 44 | end 45 | 46 | def headers 47 | @response.headers 48 | end 49 | end 50 | 51 | class Hijack < Protocol::HTTP::Body::Readable 52 | def initialize(request) 53 | @request = request 54 | @stream = Async::Variable.new 55 | end 56 | 57 | def stream? 58 | true 59 | end 60 | 61 | def stream 62 | @stream.value 63 | end 64 | 65 | def call(stream) 66 | @stream.resolve(stream) 67 | end 68 | end 69 | 70 | def initialize(request, protocols: [], version: 13, &block) 71 | body = Hijack.new(self) 72 | 73 | headers = ::Protocol::HTTP::Headers[request.headers] 74 | 75 | headers.add(SEC_WEBSOCKET_VERSION, String(version)) 76 | 77 | if protocols.any? 78 | headers.add(SEC_WEBSOCKET_PROTOCOL, protocols.join(",")) 79 | end 80 | 81 | super(request.scheme, request.authority, ::Protocol::HTTP::Methods::CONNECT, request.path, nil, headers, body, PROTOCOL) 82 | end 83 | 84 | def call(connection) 85 | response = super 86 | 87 | Wrapper.new(@body.stream, response) 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide shows you how to implement a basic client and server. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add async-websocket 11 | ~~~ 12 | 13 | ## Overview Video 14 | 15 | 16 | 17 | ## Client Implementation 18 | 19 | ~~~ ruby 20 | #!/usr/bin/env ruby 21 | 22 | require 'async' 23 | require 'async/http/endpoint' 24 | require 'async/websocket/client' 25 | 26 | USER = ARGV.pop || "anonymous" 27 | URL = ARGV.pop || "http://localhost:7070" 28 | 29 | Async do |task| 30 | endpoint = Async::HTTP::Endpoint.parse(URL) 31 | 32 | Async::WebSocket::Client.connect(endpoint) do |connection| 33 | input_task = task.async do 34 | while line = $stdin.gets 35 | connection.write({user: USER, text: line}) 36 | connection.flush 37 | end 38 | end 39 | 40 | # Generate a text message by geneating a JSON payload from a hash: 41 | connection.write(Protocol::WebSocket::TextMessage.generate({ 42 | user: USER, 43 | status: "connected", 44 | })) 45 | 46 | while message = connection.read 47 | puts message.inspect 48 | end 49 | ensure 50 | input_task&.stop 51 | end 52 | end 53 | ~~~ 54 | 55 | ### Force HTTP/1 Connection 56 | 57 | This forces the endpoint to connect using `HTTP/1.1`. 58 | 59 | ~~~ ruby 60 | endpoint = Async::HTTP::Endpoint.parse("https://remote-server.com", alpn_protocols: Async::HTTP::Protocol::HTTP11.names) 61 | 62 | Async::WebSocket::Client.connect(endpoint) do ... 63 | ~~~ 64 | 65 | You may want to use this if the server advertises `HTTP/2` but doesn't support `HTTP/2` for WebSocket connections. 66 | 67 | ## Server Side with Rack & Falcon 68 | 69 | ~~~ ruby 70 | #!/usr/bin/env -S falcon serve --bind http://localhost:7070 --count 1 -c 71 | 72 | require 'async/websocket/adapters/rack' 73 | require 'set' 74 | 75 | $connections = Set.new 76 | 77 | run lambda {|env| 78 | Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection| 79 | $connections << connection 80 | 81 | while message = connection.read 82 | $connections.each do |connection| 83 | connection.write(message) 84 | connection.flush 85 | end 86 | end 87 | ensure 88 | $connections.delete(connection) 89 | end or [200, {}, ["Hello World"]] 90 | } 91 | ~~~ 92 | -------------------------------------------------------------------------------- /examples/rack/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | async-websocket (0.26.1) 5 | async-http (~> 0.54) 6 | protocol-rack (~> 0.5) 7 | protocol-websocket (~> 0.14) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.12.0) 13 | console (~> 1.25, >= 1.25.2) 14 | fiber-annotation 15 | io-event (~> 1.6) 16 | async-container (0.18.2) 17 | async (~> 2.10) 18 | async-http (0.67.1) 19 | async (>= 2.10.2) 20 | async-pool (>= 0.6.1) 21 | io-endpoint (~> 0.10, >= 0.10.3) 22 | io-stream (~> 0.4) 23 | protocol-http (~> 0.26.0) 24 | protocol-http1 (~> 0.19.0) 25 | protocol-http2 (~> 0.18.0) 26 | traces (>= 0.10.0) 27 | async-http-cache (0.4.3) 28 | async-http (~> 0.56) 29 | async-pool (0.6.1) 30 | async (>= 1.25) 31 | async-service (0.12.0) 32 | async 33 | async-container (~> 0.16) 34 | console (1.25.2) 35 | fiber-annotation 36 | fiber-local (~> 1.1) 37 | json 38 | falcon (0.47.6) 39 | async 40 | async-container (~> 0.18) 41 | async-http (~> 0.66, >= 0.66.3) 42 | async-http-cache (~> 0.4.0) 43 | async-service (~> 0.10) 44 | bundler 45 | localhost (~> 1.1) 46 | openssl (~> 3.0) 47 | process-metrics (~> 0.2.0) 48 | protocol-rack (~> 0.5) 49 | samovar (~> 2.3) 50 | fiber-annotation (0.2.0) 51 | fiber-local (1.1.0) 52 | fiber-storage 53 | fiber-storage (0.1.2) 54 | io-endpoint (0.10.3) 55 | io-event (1.6.4) 56 | io-stream (0.4.0) 57 | json (2.7.2) 58 | localhost (1.3.1) 59 | mapping (1.1.1) 60 | nio4r (2.7.3) 61 | openssl (3.2.0) 62 | process-metrics (0.2.1) 63 | console (~> 1.8) 64 | samovar (~> 2.1) 65 | protocol-hpack (1.4.3) 66 | protocol-http (0.26.5) 67 | protocol-http1 (0.19.1) 68 | protocol-http (~> 0.22) 69 | protocol-http2 (0.18.0) 70 | protocol-hpack (~> 1.4) 71 | protocol-http (~> 0.18) 72 | protocol-rack (0.6.0) 73 | protocol-http (~> 0.23) 74 | rack (>= 1.0) 75 | protocol-websocket (0.14.0) 76 | protocol-http (~> 0.2) 77 | puma (6.4.2) 78 | nio4r (~> 2.0) 79 | rack (3.1.3) 80 | samovar (2.3.0) 81 | console (~> 1.0) 82 | mapping (~> 1.0) 83 | traces (0.11.1) 84 | 85 | PLATFORMS 86 | arm64-darwin-21 87 | arm64-darwin-23 88 | 89 | DEPENDENCIES 90 | async-websocket! 91 | falcon 92 | puma 93 | 94 | BUNDLED WITH 95 | 2.5.9 96 | -------------------------------------------------------------------------------- /examples/chat/multi-client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/semaphore" 9 | require "async/clock" 10 | require "async/http/endpoint" 11 | require_relative "../../lib/async/websocket/client" 12 | 13 | require "samovar" 14 | 15 | # GC.disable 16 | GC::Profiler.enable 17 | 18 | class Command < Samovar::Command 19 | options do 20 | option "-c/--count ", "The total number of connections to make.", default: 1000, type: Integer 21 | option "--bind
", "The local address to bind to before making a connection." 22 | option "--connect ", "The remote server to connect to.", default: "https://localhost:8080" 23 | 24 | option "-s/--semaphore ", "The number of simultaneous connections to perform." 25 | end 26 | 27 | def local_address 28 | if bind = @options[:bind] 29 | Addrinfo.tcp(bind, 0) 30 | end 31 | end 32 | 33 | def call 34 | endpoint = Async::HTTP::Endpoint.parse(@options[:connect]) 35 | # endpoint = endpoint.each.first 36 | 37 | count = @options[:count] 38 | 39 | connections = Async::Queue.new 40 | 41 | Async do |task| 42 | task.async do |subtask| 43 | while connection = connections.dequeue 44 | subtask.async(connection) do |subtask, connection| 45 | while message = connection.read 46 | puts "> #{message.to_h}" 47 | end 48 | ensure 49 | connection.close 50 | end 51 | end 52 | 53 | GC.start 54 | end 55 | 56 | client = Async::WebSocket::Client.open(endpoint) 57 | start_time = Async::Clock.now 58 | 59 | progress = Console.logger.progress(client, count) 60 | 61 | count.times do |i| 62 | connections.enqueue(client.connect(endpoint.authority, endpoint.path)) 63 | 64 | progress.increment 65 | 66 | if (i % 10000).zero? 67 | count = i+1 68 | duration = Async::Clock.now - start_time 69 | Console.logger.info(self) {"Made #{count} connections: #{(count/duration).round(2)} connections/second..."} 70 | end 71 | 72 | # if (i % 10000).zero? 73 | # duration = Async::Clock.measure{GC.start(full_mark: false, immediate_sweep: false)} 74 | # Console.logger.info(self) {"GC.start duration=#{duration.round(2)}s GC.count=#{GC.count}"} 75 | # end 76 | end 77 | 78 | connections.enqueue(nil) 79 | 80 | Console.logger.info(self) {"Finished top level connection loop..."} 81 | 82 | GC::Profiler.report 83 | end 84 | end 85 | end 86 | 87 | Command.call 88 | -------------------------------------------------------------------------------- /lib/async/websocket/upgrade_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | # Copyright, 2024, by Ryu Sato. 7 | 8 | require "protocol/http/middleware" 9 | require "protocol/http/request" 10 | 11 | require "protocol/http/headers" 12 | require "protocol/websocket/headers" 13 | 14 | require_relative "error" 15 | 16 | module Async 17 | module WebSocket 18 | # This is required for HTTP/1.x to upgrade the connection to the WebSocket protocol. 19 | # See https://tools.ietf.org/html/rfc6455 20 | class UpgradeRequest < ::Protocol::HTTP::Request 21 | include ::Protocol::WebSocket::Headers 22 | 23 | class Wrapper 24 | def initialize(response, verified:) 25 | @response = response 26 | @stream = nil 27 | @verified = verified 28 | end 29 | 30 | def close 31 | @response.close 32 | end 33 | 34 | def unwrap 35 | @response.buffered! 36 | end 37 | 38 | attr_accessor :response 39 | 40 | def stream? 41 | @response.status == 101 && @verified 42 | end 43 | 44 | def status 45 | @response.status 46 | end 47 | 48 | def headers 49 | @response.headers 50 | end 51 | 52 | def stream 53 | @stream ||= @response.hijack! 54 | end 55 | end 56 | 57 | def initialize(request, protocols: [], version: 13, &block) 58 | @key = Nounce.generate_key 59 | 60 | headers = ::Protocol::HTTP::Headers[request.headers] 61 | 62 | headers.add(SEC_WEBSOCKET_KEY, @key) 63 | headers.add(SEC_WEBSOCKET_VERSION, String(version)) 64 | 65 | if protocols.any? 66 | headers.add(SEC_WEBSOCKET_PROTOCOL, protocols.join(",")) 67 | end 68 | 69 | super(request.scheme, request.authority, ::Protocol::HTTP::Methods::GET, request.path, nil, headers, nil, PROTOCOL) 70 | end 71 | 72 | def call(connection) 73 | response = super 74 | 75 | if accept_digest = response.headers[SEC_WEBSOCKET_ACCEPT]&.first 76 | expected_accept_digest = Nounce.accept_digest(@key) 77 | 78 | unless accept_digest and accept_digest == expected_accept_digest 79 | raise ProtocolError, "Invalid accept digest, expected #{expected_accept_digest.inspect}, got #{accept_digest.inspect}!" 80 | end 81 | end 82 | 83 | verified = accept_digest && Array(response.protocol).map(&:downcase) == %w(websocket) && response.headers["connection"]&.include?("upgrade") 84 | 85 | return Wrapper.new(response, verified: verified) 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /examples/mud/config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S falcon serve --count 1 --bind http://127.0.0.1:7070 -c 2 | # frozen_string_literal: true 3 | 4 | require "async/websocket/adapters/rack" 5 | 6 | class Room 7 | def initialize(name, description = nil) 8 | @name = name 9 | @description = description 10 | 11 | @actions = {} 12 | @users = [] 13 | end 14 | 15 | attr :name 16 | attr :description 17 | 18 | attr :actions 19 | 20 | def connect(key, room) 21 | @actions[key] = lambda do |user| 22 | self.exit(user) 23 | room.enter(user) 24 | end 25 | 26 | return room 27 | end 28 | 29 | def broadcast(message) 30 | @users.each do |user| 31 | user.write(message) 32 | user.flush 33 | end 34 | end 35 | 36 | def enter(user) 37 | user.notify("You have entered the #{@name}.") 38 | user.room = self 39 | 40 | @users << user 41 | 42 | @users.each do |user| 43 | user.notify("#{user.name} entered the room.") 44 | end 45 | end 46 | 47 | def exit(user) 48 | if @users.delete(user) 49 | @users.each do |user| 50 | user.notify("#{user.name} left the room.") 51 | end 52 | end 53 | end 54 | 55 | def as_json 56 | { 57 | name: @name, 58 | description: @description, 59 | actions: @actions.keys, 60 | } 61 | end 62 | end 63 | 64 | module Command 65 | def self.split(line) 66 | line.scan(/(?:"")|(?:"(.*[^\\])")|(\w+)/).flatten.compact 67 | end 68 | end 69 | 70 | class User < Async::WebSocket::Connection 71 | def initialize(*) 72 | super 73 | 74 | @name = name 75 | @room = nil 76 | @inventory = [] 77 | end 78 | 79 | attr_accessor :room 80 | 81 | ANONYMOUS = "Anonymous" 82 | 83 | def name 84 | @name || ANONYMOUS 85 | end 86 | 87 | def send_message(value) 88 | self.write(Protocol::WebSocket::TextMessage.generate(value)) 89 | end 90 | 91 | def handle(message) 92 | key, *arguments = Command.split(message.parse[:input]) 93 | case key 94 | when "name" 95 | @name = arguments.first 96 | when "look" 97 | self.send_message({room: @room.as_json}) 98 | else 99 | if action = @room.actions[key] 100 | action.call(self, *arguments) 101 | else 102 | @room.broadcast(message) 103 | end 104 | end 105 | end 106 | 107 | def notify(text) 108 | self.send_message({notify: text}) 109 | self.flush 110 | end 111 | 112 | def close 113 | if @room 114 | @room.exit(self) 115 | end 116 | 117 | super 118 | end 119 | end 120 | 121 | class Server 122 | def initialize(app) 123 | @app = app 124 | 125 | @entrance = Room.new("Portal", "A vast entrance foyer with a flowing portal.") 126 | @entrance.connect("forward", Room.new("Training Room")) 127 | @entrance.connect("corridor", Room.new("Shop")) 128 | end 129 | 130 | def call(env) 131 | Async::WebSocket::Adapters::Rack.open(env, handler: User) do |user| 132 | @entrance.enter(user) 133 | 134 | while message = user.read 135 | user.handle(message) 136 | end 137 | ensure 138 | # Console.logger.error(self, $!) if $! 139 | user.close 140 | end or @app.call(env) 141 | end 142 | end 143 | 144 | use Server 145 | 146 | run lambda {|env| [200, {}, []]} 147 | -------------------------------------------------------------------------------- /lib/async/websocket/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2015-2024, by Samuel Williams. 5 | # Copyright, 2019, by Bryan Powell. 6 | # Copyright, 2019, by Janko Marohnić. 7 | # Copyright, 2023, by Thomas Morgan. 8 | 9 | require_relative "request" 10 | require_relative "connection" 11 | 12 | require "protocol/websocket/headers" 13 | require "protocol/websocket/extensions" 14 | require "protocol/http/middleware" 15 | 16 | require "async/http/client" 17 | 18 | require "delegate" 19 | 20 | module Async 21 | module WebSocket 22 | # This is a basic synchronous websocket client: 23 | class Client < ::Protocol::HTTP::Middleware 24 | include ::Protocol::WebSocket::Headers 25 | 26 | # @return [Client] a client which can be used to establish websocket connections to the given endpoint. 27 | def self.open(endpoint, **options, &block) 28 | client = self.new(HTTP::Client.new(endpoint, **options), mask: true) 29 | 30 | return client unless block_given? 31 | 32 | begin 33 | yield client 34 | ensure 35 | client.close 36 | end 37 | end 38 | 39 | class ClientCloseDecorator < SimpleDelegator 40 | def initialize(client, connection) 41 | @client = client 42 | super(connection) 43 | end 44 | 45 | def close(...) 46 | super(...) 47 | 48 | if @client 49 | @client.close 50 | @client = nil 51 | end 52 | end 53 | end 54 | 55 | # @return [Connection] an open websocket connection to the given endpoint. 56 | def self.connect(endpoint, *arguments, **options, &block) 57 | Sync do 58 | client = self.open(endpoint, *arguments) 59 | connection = client.connect(endpoint.authority, endpoint.path, **options) 60 | 61 | return ClientCloseDecorator.new(client, connection) unless block_given? 62 | 63 | begin 64 | yield connection 65 | 66 | ensure 67 | connection&.close 68 | client&.close 69 | end 70 | end 71 | end 72 | 73 | def initialize(client, **options) 74 | super(client) 75 | 76 | @options = options 77 | end 78 | 79 | class Framer < ::Protocol::WebSocket::Framer 80 | def initialize(pool, connection, stream) 81 | super(stream) 82 | @pool = pool 83 | @connection = connection 84 | end 85 | 86 | attr :connection 87 | 88 | def close 89 | super 90 | 91 | if @pool 92 | @pool.release(@connection) 93 | @pool = nil 94 | @connection = nil 95 | end 96 | end 97 | end 98 | 99 | def connect(authority, path, scheme: @delegate.scheme, headers: nil, handler: Connection, extensions: ::Protocol::WebSocket::Extensions::Client.default, **options, &block) 100 | headers = ::Protocol::HTTP::Headers[headers] 101 | 102 | extensions&.offer do |extension| 103 | headers.add(SEC_WEBSOCKET_EXTENSIONS, extension.join("; ")) 104 | end 105 | 106 | request = Request.new(scheme, authority, path, headers, **options) 107 | 108 | pool = @delegate.pool 109 | connection = pool.acquire 110 | 111 | response = request.call(connection) 112 | 113 | unless response.stream? 114 | raise ConnectionError.new("Failed to negotiate connection!", response.unwrap) 115 | end 116 | 117 | protocol = response.headers[SEC_WEBSOCKET_PROTOCOL]&.first 118 | stream = response.stream 119 | 120 | framer = Framer.new(pool, connection, stream) 121 | 122 | connection = nil 123 | 124 | if extension_headers = response.headers[SEC_WEBSOCKET_EXTENSIONS] 125 | extensions.accept(extension_headers) 126 | end 127 | 128 | response = nil 129 | stream = nil 130 | 131 | return handler.call(framer, protocol, extensions, **@options, &block) 132 | ensure 133 | pool.release(connection) if connection 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /examples/chat/config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S falcon serve --bind https://localhost:8080 --count 1 -c 2 | # frozen_string_literal: true 3 | 4 | require_relative "../../lib/async/websocket/adapters/rack" 5 | require "async/clock" 6 | require "async/semaphore" 7 | 8 | require "set" 9 | 10 | # GC.disable 11 | 12 | class Room 13 | def initialize 14 | @connections = Set.new 15 | @semaphore = Async::Semaphore.new(512) 16 | 17 | @count = 0 18 | @profile = nil 19 | end 20 | 21 | def connect connection 22 | @connections << connection 23 | 24 | @count += 1 25 | 26 | # if (@count % 10000).zero? 27 | # # (full_mark: false, immediate_sweep: false) 28 | # duration = Async::Clock.measure{GC.start} 29 | # Console.logger.info(self) {"GC.start duration=#{duration.round(2)}s GC.count=#{GC.count} @connections.count=#{@connections.count}"} 30 | # end 31 | end 32 | 33 | def disconnect connection 34 | @connections.delete(connection) 35 | end 36 | 37 | def each(&block) 38 | @connections.each(&block) 39 | end 40 | 41 | def allocations 42 | counts = Hash.new{|h,k| h[k] = 0} 43 | 44 | ObjectSpace.each_object do |object| 45 | counts[object.class] += 1 46 | end 47 | 48 | return counts 49 | end 50 | 51 | def show_allocations(key, limit = 1000) 52 | Console.logger.info(self) do |buffer| 53 | ObjectSpace.each_object(key).each do |object| 54 | buffer.puts object 55 | end 56 | end 57 | end 58 | 59 | def print_allocations(minimum = @connections.count) 60 | count = 0 61 | 62 | Console.logger.info(self) do |buffer| 63 | allocations.select{|k,v| v >= minimum}.sort_by{|k,v| -v}.each do |key, value| 64 | count += value 65 | buffer.puts "#{key}: #{value} allocations" 66 | end 67 | 68 | buffer.puts "** #{count.to_f / @connections.count} objects per connection." 69 | end 70 | end 71 | 72 | def start_profile 73 | require "ruby-prof" unless defined?(RubyProf) 74 | 75 | return false if @profile 76 | 77 | @profile = RubyProf::Profile.new(merge_fibers: true) 78 | @profile.start 79 | end 80 | 81 | def stop_profile 82 | return false unless @profile 83 | 84 | result = @profile.stop 85 | printer = RubyProf::FlatPrinter.new(result) 86 | printer.print(STDOUT, min_percent: 0.5) 87 | 88 | # printer = RubyProf::GraphPrinter.new(result) 89 | # printer.print(STDOUT, min_percent: 0.5) 90 | 91 | @profile = nil 92 | end 93 | 94 | def command(code) 95 | Console.logger.warn self, "eval(#{code})" 96 | 97 | eval(code) 98 | end 99 | 100 | def broadcast(message) 101 | Console.logger.info "Broadcast: #{message.inspect}" 102 | start_time = Async::Clock.now 103 | 104 | @connections.each do |connection| 105 | @semaphore.async do 106 | connection.write(message) 107 | connection.flush 108 | end 109 | end 110 | 111 | end_time = Async::Clock.now 112 | Console.logger.info "Duration: #{(end_time - start_time).round(3)}s for #{@connections.count} connected clients." 113 | end 114 | 115 | def open(connection) 116 | self.connect(connection) 117 | 118 | if @connections.size == 1_000_000 119 | connection.write("Congratulations, you have completed the journey to one million! 🥳 👏👏👏🏼") 120 | end 121 | 122 | while message = connection.read 123 | event = message.to_h 124 | 125 | if event and event[:text] =~ /^\/(.*?)$/ 126 | begin 127 | result = self.command($1) 128 | 129 | if result.is_a? Hash 130 | Protocol::WebSocket::TextMessage.generate(result).send(connection) 131 | else 132 | Protocol::WebSocket::TextMessage.generate({result: result}).send(connection) 133 | end 134 | rescue => error 135 | Protocol::WebSocket::TextMessage.generate({error: error}).send(connection) 136 | end 137 | else 138 | self.broadcast(message) 139 | end 140 | end 141 | 142 | connection.close 143 | ensure 144 | self.disconnect(connection) 145 | end 146 | 147 | def call(env) 148 | Async::WebSocket::Adapters::Rack.open(env, &self.method(:open)) 149 | end 150 | end 151 | 152 | run Room.new 153 | -------------------------------------------------------------------------------- /guides/rails-integration/readme.md: -------------------------------------------------------------------------------- 1 | # Rails Integration 2 | 3 | This guide explains how to use `async-websocket` with Rails. 4 | 5 | ## Project Setup 6 | 7 | Firstly, we will create a new project for the purpose of this guide: 8 | 9 | ~~~ bash 10 | $ rails new websockets 11 | --- snip --- 12 | ~~~ 13 | 14 | Then, we need to add the `Async::WebSocket` gem: 15 | 16 | ~~~ bash 17 | $ bundle add async-websocket 18 | ~~~ 19 | 20 | ## Adding the WebSocket Controller 21 | 22 | Firstly, generate the controller with a single method: 23 | 24 | ~~~ bash 25 | $ rails generate controller home index 26 | ~~~ 27 | 28 | Then edit your controller implementation: 29 | 30 | ~~~ ruby 31 | require 'async/websocket/adapters/rails' 32 | 33 | class HomeController < ApplicationController 34 | # WebSocket clients may not send CSRF tokens, so we need to disable this check. 35 | skip_before_action :verify_authenticity_token, only: [:index] 36 | 37 | def index 38 | self.response = Async::WebSocket::Adapters::Rails.open(request) do |connection| 39 | message = Protocol::WebSocket::TextMessage.generate({message: "Hello World"}) 40 | connection.write(message) 41 | end 42 | end 43 | end 44 | ~~~ 45 | 46 | ### Testing 47 | 48 | You can quickly test that the above controller is working. First, start the Rails server: 49 | 50 | ~~~ bash 51 | $ rails s 52 | => Booting Puma 53 | => Rails 7.2.0.beta2 application starting in development 54 | => Run `bin/rails server --help` for more startup options 55 | ~~~ 56 | 57 | Then you can connect to the server using a WebSocket client: 58 | 59 | ~~~ bash 60 | $ websocat ws://localhost:3000/home/index 61 | {"message":"Hello World"} 62 | ~~~ 63 | 64 | ### Using Falcon 65 | 66 | 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. 67 | 68 | We need to remove Puma and add Falcon:: 69 | 70 | ~~~ bash 71 | $ bundle remove puma 72 | $ bundle add falcon 73 | ~~~ 74 | 75 | Now when you start the server you should see something like this: 76 | 77 | ~~~ bash 78 | $ rails s 79 | => Booting Falcon v0.47.7 80 | => Rails 7.2.0.beta2 application starting in development http://localhost:3000 81 | => Run `bin/rails server --help` for more startup options 82 | ~~~ 83 | 84 | 85 | ### Using HTTP/2 86 | 87 | 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. 88 | 89 | 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: 90 | 91 | ~~~ ruby 92 | Rails.application.routes.draw do 93 | # Previously it was this: 94 | # get "home/index" 95 | match "home/index", to: "home#index", via: [:get, :connect] 96 | end 97 | ~~~ 98 | 99 | Once this is done, you need to bind falcon to an `https` endpoint: 100 | 101 | ~~~ bash 102 | $ falcon serve --bind "https://localhost:3000" 103 | ~~~ 104 | 105 | It's a bit more tricky to test this, but you can do so with the following Ruby code: 106 | 107 | ~~~ ruby 108 | require 'async/http/endpoint' 109 | require 'async/websocket/client' 110 | 111 | endpoint = Async::HTTP::Endpoint.parse("https://localhost:3000/home/index") 112 | 113 | Async::WebSocket::Client.connect(endpoint) do |connection| 114 | puts connection.framer.connection.class 115 | # Async::HTTP::Protocol::HTTP2::Client 116 | 117 | while message = connection.read 118 | puts message.inspect 119 | end 120 | end 121 | ~~~ 122 | -------------------------------------------------------------------------------- /test/async/websocket/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "protocol/http/middleware/builder" 7 | 8 | require "async/websocket/client" 9 | require "async/websocket/server" 10 | require "async/websocket/adapters/http" 11 | 12 | require "sus/fixtures/async/http/server_context" 13 | 14 | ServerExamples = Sus::Shared("a websocket server") do 15 | it "can establish connection" do 16 | connection = websocket_client.connect(endpoint.authority, "/server") 17 | 18 | connection.send_text("Hello World!") 19 | message = connection.read 20 | expect(message.to_str).to be == "Hello World!" 21 | 22 | connection.shutdown 23 | ensure 24 | connection.close 25 | end 26 | 27 | it "can establish connection with block" do 28 | websocket_client.connect(endpoint.authority, "/server") do |connection| 29 | connection.send_text("Hello World!") 30 | message = connection.read 31 | expect(message.to_str).to be == "Hello World!" 32 | 33 | connection.shutdown 34 | end 35 | end 36 | 37 | it "can open client and establish client with block" do 38 | Async::WebSocket::Client.open(client_endpoint) do |client| 39 | client.connect(endpoint.authority, "/server") do |connection| 40 | connection.send_text("Hello World!") 41 | message = connection.read 42 | expect(message.to_str).to be == "Hello World!" 43 | 44 | connection.shutdown 45 | end 46 | end 47 | end 48 | 49 | with "headers" do 50 | let(:headers) {{"foo" => "bar"}} 51 | 52 | let(:app) do 53 | Protocol::HTTP::Middleware.for do |request| 54 | Async::WebSocket::Adapters::HTTP.open(request) do |connection| 55 | message = Protocol::WebSocket::TextMessage.generate(request.headers.fields) 56 | message.send(connection) 57 | 58 | connection.close 59 | end or Protocol::HTTP::Response[404, {}, []] 60 | end 61 | end 62 | 63 | it "can send headers" do 64 | connection = websocket_client.connect(endpoint.authority, "/headers", headers: headers) 65 | 66 | begin 67 | message = connection.read 68 | 69 | expect(message.to_h).to have_keys(*headers.keys) 70 | expect(connection.read).to be_nil 71 | expect(connection).to be(:closed?) 72 | ensure 73 | connection.close 74 | end 75 | end 76 | end 77 | 78 | with "server middleware" do 79 | let(:app) do 80 | Protocol::HTTP::Middleware.build do 81 | use Async::WebSocket::Server, protocols: ["echo", "baz"] do |connection| 82 | connection.send_text("protocol: #{connection.protocol}") 83 | connection.close 84 | end 85 | end 86 | end 87 | 88 | it "can establish connection with explicit protocol" do 89 | connection = websocket_client.connect(endpoint.authority, "/server", protocols: ["echo", "foo", "bar"]) 90 | 91 | # The negotiated protocol: 92 | expect(connection.protocol).to be == "echo" 93 | 94 | begin 95 | expect(connection.read).to be == "protocol: echo" 96 | expect(connection.read).to be_nil 97 | expect(connection).to be(:closed?) 98 | ensure 99 | connection.close 100 | end 101 | end 102 | end 103 | end 104 | 105 | describe Async::WebSocket::Server do 106 | include Sus::Fixtures::Async::HTTP::ServerContext 107 | 108 | let(:websocket_client) {Async::WebSocket::Client.open(client_endpoint)} 109 | 110 | let(:app) do 111 | Protocol::HTTP::Middleware.for do |request| 112 | Async::WebSocket::Adapters::HTTP.open(request) do |connection| 113 | while message = connection.read 114 | connection.write(message) 115 | end 116 | end or Protocol::HTTP::Response[404, {}, []] 117 | end 118 | end 119 | 120 | with "http/1" do 121 | let(:protocol) {Async::HTTP::Protocol::HTTP1} 122 | it_behaves_like ServerExamples 123 | 124 | it "fails with bad request if missing nounce" do 125 | request = Protocol::HTTP::Request["GET", "/", { 126 | "upgrade" => "websocket", 127 | "connection" => "upgrade", 128 | }] 129 | 130 | response = client.call(request) 131 | 132 | expect(response).to be(:bad_request?) 133 | end 134 | 135 | let(:timeout) {nil} 136 | 137 | with "broken server" do 138 | let(:app) do 139 | Protocol::HTTP::Middleware.for do |request| 140 | response = Async::WebSocket::Adapters::HTTP.open(request) do |connection| 141 | while message = connection.read 142 | connection.write(message) 143 | end 144 | end 145 | 146 | if response 147 | response.tap do 148 | response.headers.set("sec-websocket-accept", "2badsheep") 149 | end 150 | else 151 | Protocol::HTTP::Response[404, {}, []] 152 | end 153 | end 154 | end 155 | 156 | it "fails with protocol error if nounce doesn't match" do 157 | expect do 158 | websocket_client.connect(endpoint.authority, "/server") {} 159 | end.to raise_exception(Protocol::WebSocket::ProtocolError) 160 | end 161 | end 162 | end 163 | 164 | with "http/2" do 165 | let(:protocol) {Async::HTTP::Protocol::HTTP2} 166 | it_behaves_like ServerExamples 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/async/websocket/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | 7 | require "async/websocket/client" 8 | require "async/websocket/adapters/http" 9 | 10 | require "sus/fixtures/async/http/server_context" 11 | 12 | ClientExamples = Sus::Shared("a websocket client") do 13 | let(:app) do 14 | Protocol::HTTP::Middleware.for do |request| 15 | Async::WebSocket::Adapters::HTTP.open(request) do |connection| 16 | while message = connection.read 17 | connection.write(message) 18 | end 19 | 20 | connection.shutdown 21 | rescue Protocol::WebSocket::ClosedError 22 | # Ignore this error. 23 | ensure 24 | connection.close 25 | end or Protocol::HTTP::Response[404, {}, []] 26 | end 27 | end 28 | 29 | with "#send_close" do 30 | it "can read incoming messages and then close" do 31 | connection = Async::WebSocket::Client.connect(client_endpoint) 32 | 3.times do 33 | connection.send_text("Hello World!") 34 | end 35 | 36 | # This informs the server we are done echoing messages: 37 | connection.send_close 38 | 39 | # Collect all the echoed messages: 40 | messages = [] 41 | while message = connection.read 42 | messages << message 43 | end 44 | 45 | expect(messages.size).to be == 3 46 | expect(connection).to be(:closed?) 47 | ensure 48 | connection&.close 49 | end 50 | end 51 | 52 | with "#close" do 53 | it "can connect to a websocket server and close underlying client" do 54 | Async do |task| 55 | connection = Async::WebSocket::Client.connect(client_endpoint) 56 | connection.send_text("Hello World!") 57 | message = connection.read 58 | expect(message.to_str).to be == "Hello World!" 59 | 60 | connection.close 61 | 62 | expect(task.children).to be(:empty?) 63 | end.wait 64 | end 65 | 66 | it "can connect to a websocket server and close underlying client with an error code" do 67 | Async do |task| 68 | connection = Async::WebSocket::Client.connect(client_endpoint) 69 | connection.send_text("Hello World!") 70 | message = connection.read 71 | expect(message.to_str).to be == "Hello World!" 72 | 73 | connection.close(Protocol::WebSocket::Error::GOING_AWAY, "Bye!") 74 | 75 | expect(task.children).to be(:empty?) 76 | end.wait 77 | end 78 | end 79 | 80 | with "#close(1001)" do 81 | let(:app) do 82 | Protocol::HTTP::Middleware.for do |request| 83 | Async::WebSocket::Adapters::HTTP.open(request) do |connection| 84 | connection.send_text("Hello World!") 85 | connection.close(1001) 86 | end 87 | end 88 | end 89 | 90 | it "closes with custom error" do 91 | connection = Async::WebSocket::Client.connect(client_endpoint) 92 | message = connection.read 93 | 94 | expect do 95 | connection.read 96 | end.to raise_exception(Protocol::WebSocket::Error).and(have_attributes(code: be == 1001)) 97 | end 98 | end 99 | 100 | with "#connect" do 101 | let(:app) do 102 | Protocol::HTTP::Middleware.for do |request| 103 | Async::WebSocket::Adapters::HTTP.open(request) do |connection| 104 | connection.send_text("authority: #{request.authority}") 105 | connection.send_text("path: #{request.path}") 106 | connection.send_text("protocol: #{Array(request.protocol).inspect}") 107 | connection.send_text("scheme: #{request.scheme}") 108 | connection.close 109 | end or Protocol::HTTP::Response[404, {}, []] 110 | end 111 | end 112 | 113 | it "fully populates the request" do 114 | connection = Async::WebSocket::Client.connect(client_endpoint) 115 | expect(connection.read.to_str).to be =~ /authority: localhost:\d+/ 116 | expect(connection.read.to_str).to be == "path: /" 117 | expect(connection.read.to_str).to be == 'protocol: ["websocket"]' 118 | expect(connection.read.to_str).to be == "scheme: http" 119 | ensure 120 | connection&.close 121 | end 122 | end 123 | 124 | with "missing support for websockets" do 125 | let(:app) do 126 | Protocol::HTTP::Middleware.for do |request| 127 | Protocol::HTTP::Response[404, {}, []] 128 | end 129 | end 130 | 131 | it "raises an error when the server doesn't support websockets" do 132 | expect do 133 | Async::WebSocket::Client.connect(client_endpoint) {} 134 | end.to raise_exception(Async::WebSocket::ConnectionError, message: be =~ /Failed to negotiate connection/) 135 | end 136 | end 137 | 138 | with "deliberate failure response" do 139 | let(:app) do 140 | Protocol::HTTP::Middleware.for do |request| 141 | Protocol::HTTP::Response[401, {}, ["You are not allowed!"]] 142 | end 143 | end 144 | 145 | it "raises a connection error when the server responds with an error" do 146 | begin 147 | Async::WebSocket::Client.connect(client_endpoint) {} 148 | rescue Async::WebSocket::ConnectionError => error 149 | expect(error.response.status).to be == 401 150 | expect(error.response.read).to be == "You are not allowed!" 151 | end 152 | end 153 | end 154 | end 155 | 156 | FailedToNegotiate = Sus::Shared("a failed websocket request") do 157 | it "raises an error" do 158 | expect do 159 | Async::WebSocket::Client.connect(client_endpoint) {} 160 | end.to raise_exception(Async::WebSocket::ConnectionError, message: be =~ /Failed to negotiate connection/) 161 | end 162 | end 163 | 164 | describe Async::WebSocket::Client do 165 | include Sus::Fixtures::Async::HTTP::ServerContext 166 | 167 | with "http/1" do 168 | let(:protocol) {Async::HTTP::Protocol::HTTP1} 169 | it_behaves_like ClientExamples 170 | 171 | def valid_headers(request) 172 | { 173 | "connection" => "upgrade", 174 | "upgrade" => "websocket", 175 | "sec-websocket-accept" => Protocol::WebSocket::Headers::Nounce.accept_digest(request.headers["sec-websocket-key"].first) 176 | } 177 | end 178 | 179 | with "invalid connection header" do 180 | let(:app) do 181 | Protocol::HTTP::Middleware.for do |request| 182 | Protocol::HTTP::Response[101, valid_headers(request).except("connection"), []] 183 | end 184 | end 185 | 186 | it_behaves_like FailedToNegotiate 187 | end 188 | 189 | with "invalid upgrade header" do 190 | let(:app) do 191 | Protocol::HTTP::Middleware.for do |request| 192 | Protocol::HTTP::Response[101, valid_headers(request).except("upgrade"), []] 193 | end 194 | end 195 | 196 | it_behaves_like FailedToNegotiate 197 | end 198 | 199 | with "invalid sec-websocket-accept header" do 200 | let(:app) do 201 | Protocol::HTTP::Middleware.for do |request| 202 | Protocol::HTTP::Response[101, valid_headers(request).merge("sec-websocket-accept"=>"wrong-digest"), []] 203 | end 204 | end 205 | 206 | it "raises an error" do 207 | expect do 208 | Async::WebSocket::Client.connect(client_endpoint) {} 209 | end.to raise_exception(Async::WebSocket::ProtocolError, message: be =~ /Invalid accept digest/) 210 | end 211 | end 212 | 213 | with "missing sec-websocket-accept header" do 214 | let(:app) do 215 | Protocol::HTTP::Middleware.for do |request| 216 | Protocol::HTTP::Response[101, valid_headers(request).except("sec-websocket-accept"), []] 217 | end 218 | end 219 | 220 | it_behaves_like FailedToNegotiate 221 | end 222 | 223 | with "invalid status" do 224 | let(:app) do 225 | Protocol::HTTP::Middleware.for do |request| 226 | Protocol::HTTP::Response[403, valid_headers(request), []] 227 | end 228 | end 229 | 230 | it_behaves_like FailedToNegotiate 231 | end 232 | end 233 | 234 | with "http/2" do 235 | let(:protocol) {Async::HTTP::Protocol::HTTP2} 236 | it_behaves_like ClientExamples 237 | 238 | with "invalid status" do 239 | let(:app) do 240 | Protocol::HTTP::Middleware.for do |request| 241 | Protocol::HTTP::Response[403, {}, []] 242 | end 243 | end 244 | 245 | it_behaves_like FailedToNegotiate 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /examples/chat/readme.md: -------------------------------------------------------------------------------- 1 | # The Journey to One Million 2 | 3 | ## Allocations per Connection 4 | 5 | ``` 6 | Array: 188498 allocations 7 | Hash: 137041 allocations 8 | String: 91387 allocations 9 | Proc: 81242 allocations 10 | Fiber: 30169 allocations 11 | Async::Task: 30168 allocations 12 | Async::IO::Buffer: 20904 allocations 13 | Protocol::HTTP2::Window: 20162 allocations 14 | Set: 20091 allocations 15 | Async::Queue: 20082 allocations 16 | Method: 20006 allocations 17 | Protocol::HTTP::Headers::Merged: 10100 allocations 18 | Protocol::HTTP::Headers: 10100 allocations 19 | Async::Condition: 10002 allocations 20 | Protocol::WebSocket::Framer: 10001 allocations 21 | Async::HTTP::Body::Stream: 10001 allocations 22 | Async::HTTP::Body::Hijack: 10001 allocations 23 | Async::WebSocket::ConnectResponse: 10001 allocations 24 | Async::WebSocket::Connection: 10001 allocations 25 | Async::HTTP::Body::Writable: 10001 allocations 26 | Async::HTTP::Protocol::HTTP2::Request::Stream: 10001 allocations 27 | Async::HTTP::Protocol::HTTP2::Request: 10001 allocations 28 | Falcon::Adapters::Input: 10001 allocations 29 | Protocol::HTTP::Headers::Split: 10001 allocations 30 | Async::HTTP::Protocol::HTTP2::Stream::Input: 10001 allocations 31 | Async::HTTP::Protocol::HTTP2::Stream::Output: 10001 allocations 32 | ** 80.98830116988302 objects per connection. 33 | ``` 34 | 35 | ## System Limits 36 | 37 | ### Fiber Performance 38 | 39 | To improve fiber performance: 40 | 41 | export RUBY_FIBER_VM_STACK_SIZE=0 42 | export RUBY_FIBER_MACHINE_STACK_SIZE=0 43 | export RUBY_SHARED_FIBER_POOL_FREE_STACKS=0 44 | 45 | `RUBY_SHARED_FIBER_POOL_FREE_STACKS` is an experimental feature on `ruby-head`. 46 | 47 | ### FiberError: can't set a guard page: Cannot allocate memory 48 | 49 | This error occurs because the operating system has limited resources for allocating fiber stacks. 50 | 51 | You can find the current limit: 52 | 53 | % sysctl vm.max_map_count 54 | vm.max_map_count = 65530 55 | 56 | You can increase it: 57 | 58 | % sysctl -w vm.max_map_count=2500000 59 | 60 | ## Logs 61 | 62 | ### 2024 63 | 64 | ### 2020 65 | 66 | ``` 67 | koyoko% ./multi-client.rb -c 100000 68 | 0.15s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:11:39 +1300] 69 | | Made 1 connections: 202.88 connections/second... 70 | 0.15s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:11:39 +1300] 71 | | GC.start duration=0.0s GC.count=27 72 | 8.82s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:11:47 +1300] 73 | | Made 10001 connections: 1152.85 connections/second... 74 | 8.89s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:11:47 +1300] 75 | | GC.start duration=0.06s GC.count=28 76 | 17.7s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:11:56 +1300] 77 | | Made 20001 connections: 1139.39 connections/second... 78 | 17.84s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:11:56 +1300] 79 | | GC.start duration=0.14s GC.count=29 80 | 26.71s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:05 +1300] 81 | | Made 30001 connections: 1129.44 connections/second... 82 | 26.9s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:05 +1300] 83 | | GC.start duration=0.19s GC.count=30 84 | 35.97s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:14 +1300] 85 | | Made 40001 connections: 1116.59 connections/second... 86 | 36.22s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:15 +1300] 87 | | GC.start duration=0.25s GC.count=31 88 | 45.41s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:24 +1300] 89 | | Made 50001 connections: 1104.74 connections/second... 90 | 45.65s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:24 +1300] 91 | | GC.start duration=0.24s GC.count=32 92 | 54.94s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:33 +1300] 93 | | Made 60001 connections: 1095.18 connections/second... 94 | 55.3s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:34 +1300] 95 | | GC.start duration=0.37s GC.count=33 96 | 1m4s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:43 +1300] 97 | | Made 70001 connections: 1085.98 connections/second... 98 | 1m5s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:43 +1300] 99 | | GC.start duration=0.44s GC.count=34 100 | 1m14s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:53 +1300] 101 | | Made 80001 connections: 1074.6 connections/second... 102 | 1m14s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:12:53 +1300] 103 | | GC.start duration=0.36s GC.count=35 104 | 1m24s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:13:03 +1300] 105 | | Made 90001 connections: 1066.37 connections/second... 106 | 1m24s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:13:03 +1300] 107 | | GC.start duration=0.35s GC.count=36 108 | 1m34s info: Command [oid=0x2f8] [pid=820099] [2020-02-04 23:13:13 +1300] 109 | | Finished top level connection loop... 110 | ``` 111 | 112 | ### 2019 113 | 114 | This report is affected by `tty-progressbar` bugs. 115 | 116 | ``` 117 | koyoko% bundle exec ./multi-client.rb --count 100000 118 | 145.94 connection/s [ ] 1/100000 ( 6s/ 0s) 119 | 0.11s info: # [pid=14815] [2019-07-08 00:29:58 +1200] 120 | | GC.start -> 0.01s 121 | 591.10 connection/s [========== ] 10001/100000 ( 1m52s/12s) 122 | 12.92s info: # [pid=14815] [2019-07-08 00:30:11 +1200] 123 | | GC.start -> 0.3s 124 | 455.06 connection/s [==================== ] 20001/100000 ( 1m42s/25s) 125 | 26.17s info: # [pid=14815] [2019-07-08 00:30:24 +1200] 126 | | GC.start -> 0.45s 127 | 294.11 connection/s [============================= ] 30001/100000 ( 1m31s/39s) 128 | 39.95s info: # [pid=14815] [2019-07-08 00:30:38 +1200] 129 | | GC.start -> 0.68s 130 | 153.08 connection/s [======================================= ] 40001/100000 ( 1m19s/53s) 131 | 53.9s info: # [pid=14815] [2019-07-08 00:30:52 +1200] 132 | | GC.start -> 0.8s 133 | 23.03 connection/s [================================================ ] 50001/100000 ( 1m 7s/ 1m 7s) 134 | 1m8s info: # [pid=14815] [2019-07-08 00:31:07 +1200] 135 | | GC.start -> 0.95s 136 | 0.87552 connection/s [========================================================== ] 60001/100000 (55s/ 1m23s) 137 | 1m24s info: # [pid=14815] [2019-07-08 00:31:23 +1200] 138 | | GC.start -> 1.04s 139 | 0.74375 connection/s [==================================================================== ] 70001/100000 (43s/ 1m42s) 140 | 1m43s info: # [pid=14815] [2019-07-08 00:31:42 +1200] 141 | | GC.start -> 1.17s 142 | 0.64832 connection/s [============================================================================== ] 80001/100000 (30s/ 2m 2s) 143 | 2m4s info: # [pid=14815] [2019-07-08 00:32:02 +1200] 144 | | GC.start -> 1.29s 145 | 0.57842 connection/s [======================================================================================= ] 90001/100000 (16s/ 2m26s) 146 | 2m27s info: # [pid=14815] [2019-07-08 00:32:26 +1200] 147 | | GC.start -> 1.55s 148 | 435.05 connection/s [=================================================================================================] 100000/100000 ( 0s/ 2m50s) 149 | ``` 150 | --------------------------------------------------------------------------------