diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 81f3c65..d72c844 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -24,7 +24,7 @@ jobs: - "3.2" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} @@ -34,7 +34,7 @@ jobs: timeout-minutes: 5 run: bundle exec bake test - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: name: coverage-${{matrix.os}}-${{matrix.ruby}} path: .covered.db @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: "3.2" diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 3d483fc..e2e0f93 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: @@ -43,7 +43,7 @@ jobs: run: bundle exec bake utopia:project:static --force no - name: Upload documentation artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v2 with: path: docs @@ -58,4 +58,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v3 diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml index 214149c..876b250 100644 --- a/.github/workflows/test-external.yaml +++ b/.github/workflows/test-external.yaml @@ -20,13 +20,12 @@ jobs: - macos ruby: - - "2.7" - "3.0" - "3.1" - "3.2" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5c765b6..3310dec 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,6 @@ jobs: - macos ruby: - - "2.7" - "3.0" - "3.1" - "3.2" @@ -40,7 +39,7 @@ jobs: experimental: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} diff --git a/.mailmap b/.mailmap index a55b445..4d28a24 100644 --- a/.mailmap +++ b/.mailmap @@ -1,2 +1,4 @@ Juan Antonio Martín Lucas Aurora Nockert +Thomas Morgan +Peter Runich <43861241+PeterRunich@users.noreply.github.com> diff --git a/async-websocket.gemspec b/async-websocket.gemspec index 20bdacd..523f6e1 100644 --- a/async-websocket.gemspec +++ b/async-websocket.gemspec @@ -7,7 +7,7 @@ Gem::Specification.new do |spec| spec.version = Async::WebSocket::VERSION spec.summary = "An async websocket library on top of websocket-driver." - spec.authors = ["Samuel Williams", "destructobeam", "Olle Jonsson", "Aurora Nockert", "Bryan Powell", "Gleb Sinyavskiy", "Janko Marohnić", "Juan Antonio Martín Lucas", "Michel Boaventura"] + spec.authors = ["Samuel Williams", "destructobeam", "Olle Jonsson", "Thomas Morgan", "Aurora Nockert", "Bryan Powell", "Emily Love Mills", "Gleb Sinyavskiy", "Janko Marohnić", "Juan Antonio Martín Lucas", "Michel Boaventura", "Peter Runich"] spec.license = "MIT" spec.cert_chain = ['release.cert'] @@ -15,15 +15,17 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/socketry/async-websocket" + spec.metadata = { + "documentation_uri" => "https://socketry.github.io/async-websocket/", + "funding_uri" => "https://github.com/sponsors/ioquatix", + } + spec.files = Dir.glob(['{lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) + spec.required_ruby_version = ">= 3.0" + spec.add_dependency "async-http", "~> 0.54" spec.add_dependency "async-io", "~> 1.23" spec.add_dependency "protocol-rack", "~> 0.1" spec.add_dependency "protocol-websocket", "~> 0.11" - - spec.add_development_dependency "bundler" - spec.add_development_dependency "covered" - spec.add_development_dependency "sus", "~> 0.18" - spec.add_development_dependency "sus-fixtures-async-http", "~> 0.2.3" end diff --git a/examples/mud/client.rb b/examples/mud/client.rb index 4850c21..67e7991 100755 --- a/examples/mud/client.rb +++ b/examples/mud/client.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2022, by Samuel Williams. +# Copyright, 2019-2023, by Samuel Williams. # Copyright, 2020, by Juan Antonio Martín Lucas. require 'async' diff --git a/examples/utopia/bake.rb b/examples/utopia/bake.rb index 0a7e0de..631ae0b 100644 --- a/examples/utopia/bake.rb +++ b/examples/utopia/bake.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2020-2022, by Samuel Williams. +# Copyright, 2018-2023, by Samuel Williams. # Prepare the application for start/restart. def deploy diff --git a/fixtures/rack_application/client.rb b/fixtures/rack_application/client.rb index 851e4d2..98eca72 100644 --- a/fixtures/rack_application/client.rb +++ b/fixtures/rack_application/client.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2018-2022, by Samuel Williams. +# Copyright, 2018-2023, by Samuel Williams. require 'async' require 'async/io/stream' diff --git a/fixtures/upgrade_application.rb b/fixtures/upgrade_application.rb index d67f128..b61f8e7 100644 --- a/fixtures/upgrade_application.rb +++ b/fixtures/upgrade_application.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2022, by Samuel Williams. +# Copyright, 2019-2023, by Samuel Williams. require 'async/websocket/adapters/rack' diff --git a/gems.rb b/gems.rb index bc04589..82adbfa 100644 --- a/gems.rb +++ b/gems.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2015-2022, by Samuel Williams. +# Copyright, 2015-2023, by Samuel Williams. source 'https://rubygems.org' @@ -20,3 +20,8 @@ end # gem "protocol-websocket", path: "../protocol-websocket" + +# Moved Development Dependencies +gem "covered" +gem "sus", "~> 0.18" +gem "sus-fixtures-async-http", "~> 0.2.3" diff --git a/guides/links.yaml b/guides/links.yaml index 7f527b0..ac980b4 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,2 +1,4 @@ getting-started: order: 1 +rails-integration: + order: 2 diff --git a/lib/async/websocket.rb b/lib/async/websocket.rb index ac9edc5..761640c 100644 --- a/lib/async/websocket.rb +++ b/lib/async/websocket.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2015-2022, by Samuel Williams. +# Copyright, 2015-2023, by Samuel Williams. require_relative 'websocket/version' require_relative 'websocket/server' diff --git a/lib/async/websocket/adapters/rails.rb b/lib/async/websocket/adapters/rails.rb index 9e635e8..5c10325 100644 --- a/lib/async/websocket/adapters/rails.rb +++ b/lib/async/websocket/adapters/rails.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2021-2022, by Samuel Williams. +# Copyright, 2021-2023, by Samuel Williams. +# Copyright, 2023, by Emily Love Mills. require_relative 'rack' @@ -13,7 +14,7 @@ def self.open(request, **options, &block) if response = Rack.open(request.env, **options, &block) ::Rack::Response[*response] else - ::ActionDispatch::Response.new(404, [], []) + ::ActionDispatch::Response.new(404) end end end diff --git a/lib/async/websocket/client.rb b/lib/async/websocket/client.rb index e21339a..53b9c73 100644 --- a/lib/async/websocket/client.rb +++ b/lib/async/websocket/client.rb @@ -4,6 +4,7 @@ # Copyright, 2015-2023, by Samuel Williams. # Copyright, 2019, by Bryan Powell. # Copyright, 2019, by Janko Marohnić. +# Copyright, 2023, by Thomas Morgan. require_relative 'request' require_relative 'connection' @@ -90,14 +91,14 @@ def close end end - def connect(authority, path, headers: nil, handler: Connection, extensions: ::Protocol::WebSocket::Extensions::Client.default, **options, &block) + def connect(authority, path, scheme: @delegate.scheme, headers: nil, handler: Connection, extensions: ::Protocol::WebSocket::Extensions::Client.default, **options, &block) headers = ::Protocol::HTTP::Headers[headers] extensions&.offer do |extension| headers.add(SEC_WEBSOCKET_EXTENSIONS, extension.join("; ")) end - request = Request.new(nil, authority, path, headers, **options) + request = Request.new(scheme, authority, path, headers, **options) pool = @delegate.pool connection = pool.acquire diff --git a/lib/async/websocket/connect_request.rb b/lib/async/websocket/connect_request.rb index 05e00f8..31f2306 100644 --- a/lib/async/websocket/connect_request.rb +++ b/lib/async/websocket/connect_request.rb @@ -2,6 +2,7 @@ # Released under the MIT License. # Copyright, 2019-2023, by Samuel Williams. +# Copyright, 2023, by Thomas Morgan. require 'protocol/http/request' require 'protocol/http/headers' @@ -12,7 +13,7 @@ module Async module WebSocket - # This is required for HTTP/1.x to upgrade the connection to the WebSocket protocol. + # This is required for HTTP/2 to establish a connection using the WebSocket protocol. # See https://tools.ietf.org/html/rfc8441 for more details. class ConnectRequest < ::Protocol::HTTP::Request include ::Protocol::WebSocket::Headers diff --git a/lib/async/websocket/upgrade_request.rb b/lib/async/websocket/upgrade_request.rb index eab7a06..817a37b 100644 --- a/lib/async/websocket/upgrade_request.rb +++ b/lib/async/websocket/upgrade_request.rb @@ -2,6 +2,7 @@ # Released under the MIT License. # Copyright, 2019-2023, by Samuel Williams. +# Copyright, 2023, by Thomas Morgan. require 'protocol/http/middleware' require 'protocol/http/request' @@ -16,13 +17,15 @@ module Async module WebSocket # This is required for HTTP/1.x to upgrade the connection to the WebSocket protocol. + # See https://tools.ietf.org/html/rfc6455 class UpgradeRequest < ::Protocol::HTTP::Request include ::Protocol::WebSocket::Headers class Wrapper - def initialize(response) + def initialize(response, verified:) @response = response @stream = nil + @verified = verified end def close @@ -32,7 +35,7 @@ def close attr_accessor :response def stream? - @response.status == 101 + @response.status == 101 && @verified end def status @@ -73,8 +76,9 @@ def call(connection) raise ProtocolError, "Invalid accept digest, expected #{expected_accept_digest.inspect}, got #{accept_digest.inspect}!" end end + verified = accept_digest && Array(response.protocol) == %w(websocket) && response.headers['connection']&.include?('upgrade') - return Wrapper.new(response) + return Wrapper.new(response, verified: verified) end end end diff --git a/lib/async/websocket/upgrade_response.rb b/lib/async/websocket/upgrade_response.rb index 14b454e..0fd5e4b 100644 --- a/lib/async/websocket/upgrade_response.rb +++ b/lib/async/websocket/upgrade_response.rb @@ -2,6 +2,7 @@ # Released under the MIT License. # Copyright, 2019-2023, by Samuel Williams. +# Copyright, 2023, by Thomas Morgan. require 'async/http/body/hijack' require 'protocol/http/response' @@ -18,7 +19,6 @@ def initialize(request, headers = nil, protocol: nil, &block) if accept_nounce = request.headers[SEC_WEBSOCKET_KEY]&.first headers.add(SEC_WEBSOCKET_ACCEPT, Nounce.accept_digest(accept_nounce)) - status = 101 if protocol headers.add(SEC_WEBSOCKET_PROTOCOL, protocol) diff --git a/lib/async/websocket/version.rb b/lib/async/websocket/version.rb index 6fccbc9..57dca78 100644 --- a/lib/async/websocket/version.rb +++ b/lib/async/websocket/version.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2018-2022, by Samuel Williams. +# Copyright, 2018-2023, by Samuel Williams. module Async module WebSocket - VERSION = "0.25.1" + VERSION = "0.26.0" end end diff --git a/license.md b/license.md index d5fc2bb..99acbe4 100644 --- a/license.md +++ b/license.md @@ -9,6 +9,9 @@ Copyright, 2020-2021, by Olle Jonsson. Copyright, 2020, by Juan Antonio Martín Lucas. Copyright, 2021, by Gleb Sinyavskiy. Copyright, 2021, by Aurora Nockert. +Copyright, 2023, by Peter Runich. +Copyright, 2023, by Thomas Morgan. +Copyright, 2023, by Emily Love Mills. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/readme.md b/readme.md index fb996a1..95efb10 100644 --- a/readme.md +++ b/readme.md @@ -6,14 +6,26 @@ An asynchronous websocket client/server implementation for [HTTP/1](https://tool ## Usage -Please see the [project documentation](https://socketry.github.io/async-websocket/) or serve it locally using `bake utopia:project:serve`. +Please see the [project documentation](https://socketry.github.io/async-websocket/) for more details. + + - [Getting Started](https://socketry.github.io/async-websocket/guides/getting-started/index) - This guide shows you how to implement a basic client and server. + + - [Rails Integration](https://socketry.github.io/async-websocket/guides/rails-integration/index) - This guide explains how to use `async-websocket` with `falcon`. ## Contributing We welcome contributions to this project. -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +1. Fork it. +2. Create your feature branch (`git checkout -b my-new-feature`). +3. Commit your changes (`git commit -am 'Add some feature'`). +4. Push to the branch (`git push origin my-new-feature`). +5. Create new Pull Request. + +### Developer Certificate of Origin + +This project uses the [Developer Certificate of Origin](https://developercertificate.org/). All contributors to this project must agree to this document to have their contributions accepted. + +### Contributor Covenant + +This project is governed by the [Contributor Covenant](https://www.contributor-covenant.org/). All contributors and participants agree to abide by its terms. diff --git a/test/async/websocket/adapters/rack.rb b/test/async/websocket/adapters/rack.rb index 4df422d..17c6a30 100644 --- a/test/async/websocket/adapters/rack.rb +++ b/test/async/websocket/adapters/rack.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2023, by Samuel Williams. +# Copyright, 2015-2023, by Samuel Williams. +# Copyright, 2019, by destructobeam. require 'async/websocket' require 'async/websocket/client' diff --git a/test/async/websocket/client.rb b/test/async/websocket/client.rb index ae71654..4a0efde 100644 --- a/test/async/websocket/client.rb +++ b/test/async/websocket/client.rb @@ -2,8 +2,10 @@ # Released under the MIT License. # Copyright, 2023, by Samuel Williams. +# Copyright, 2023, by Thomas Morgan. require 'async/websocket/client' +require 'async/websocket/adapters/http' require 'sus/fixtures/async/http/server_context' @@ -89,6 +91,30 @@ end end + with '#connect' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Async::WebSocket::Adapters::HTTP.open(request) do |connection| + connection.send_text("authority: #{request.authority}") + connection.send_text("path: #{request.path}") + connection.send_text("protocol: #{Array(request.protocol).inspect}") + connection.send_text("scheme: #{request.scheme}") + connection.close + end or Protocol::HTTP::Response[404, {}, []] + end + end + + it "fully populates the request" do + connection = Async::WebSocket::Client.connect(client_endpoint) + expect(connection.read.to_str).to be =~ /authority: localhost:\d+/ + expect(connection.read.to_str).to be == 'path: /' + expect(connection.read.to_str).to be == 'protocol: ["websocket"]' + expect(connection.read.to_str).to be == 'scheme: http' + ensure + connection&.close + end + end + with 'missing support for websockets' do let(:app) do Protocol::HTTP::Middleware.for do |request| @@ -104,16 +130,96 @@ end end +FailedToNegotiate = Sus::Shared("a failed websocket request") do + it 'raises an error' do + expect do + Async::WebSocket::Client.connect(client_endpoint) {} + end.to raise_exception(Async::WebSocket::ProtocolError, message: be =~ /Failed to negotiate connection/) + end +end + describe Async::WebSocket::Client do include Sus::Fixtures::Async::HTTP::ServerContext with 'http/1' do let(:protocol) {Async::HTTP::Protocol::HTTP1} it_behaves_like ClientExamples + + def valid_headers(request) + { + 'connection' => 'upgrade', + 'upgrade' => 'websocket', + 'sec-websocket-accept' => Protocol::WebSocket::Headers::Nounce.accept_digest(request.headers['sec-websocket-key'].first) + } + end + + with 'invalid connection header' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[101, valid_headers(request).except('connection'), []] + end + end + + it_behaves_like FailedToNegotiate + end + + with 'invalid upgrade header' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[101, valid_headers(request).except('upgrade'), []] + end + end + + it_behaves_like FailedToNegotiate + end + + with 'invalid sec-websocket-accept header' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[101, valid_headers(request).merge('sec-websocket-accept'=>'wrong-digest'), []] + end + end + + it 'raises an error' do + expect do + Async::WebSocket::Client.connect(client_endpoint) {} + end.to raise_exception(Async::WebSocket::ProtocolError, message: be =~ /Invalid accept digest/) + end + end + + with 'missing sec-websocket-accept header' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[101, valid_headers(request).except('sec-websocket-accept'), []] + end + end + + it_behaves_like FailedToNegotiate + end + + with 'invalid status' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[403, valid_headers(request), []] + end + end + + it_behaves_like FailedToNegotiate + end end with 'http/2' do let(:protocol) {Async::HTTP::Protocol::HTTP2} it_behaves_like ClientExamples + + with 'invalid status' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[403, {}, []] + end + end + + it_behaves_like FailedToNegotiate + end end end