diff --git a/async-cable.gemspec b/async-cable.gemspec index da95f60..78a3209 100644 --- a/async-cable.gemspec +++ b/async-cable.gemspec @@ -24,7 +24,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "actioncable-next" + # Requires the `ActionCable::Server::Socket` abstraction introduced by + # https://github.com/rails/rails/pull/50979 (Rails 8.1+). + spec.add_dependency "actioncable", ">= 8.1.0.alpha" spec.add_dependency "async", "~> 2.9" spec.add_dependency "async-websocket" end diff --git a/gems.rb b/gems.rb index 3585cc0..06f869e 100644 --- a/gems.rb +++ b/gems.rb @@ -7,8 +7,10 @@ gemspec -# Use the fix branch until https://github.com/anycable/actioncable-next/pull/17 is merged and released. -gem "actioncable-next", github: "ioquatix/actioncable-next", branch: "fix/close-ensure-socket" +# The `next` branch targets Rails main, which now includes the +# `ActionCable::Server::Socket` abstraction (rails/rails#50979) that +# previously required actioncable-next. +gem "rails", github: "rails/rails", branch: "main" gem "async" diff --git a/readme.md b/readme.md index 49ad304..a5c8884 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,8 @@ # Async::Cable -This is a proof-of-concept adapter for Rails 7.2+. It depends on `actioncable-next` which completely replaces the internal implementation of Action Cable with a pluggable one. +This is a proof-of-concept adapter for Action Cable. + +The `next` branch tracks Rails `main` and relies on the `ActionCable::Server::Socket` abstraction introduced by [rails/rails#50979](https://github.com/rails/rails/pull/50979) (Rails 8.1+). For stable Rails (≤ 8.0), use the `main` branch, which depends on [`actioncable-next`](https://github.com/anycable/actioncable-next). [![Development Status](https://github.com/socketry/async-cable/workflows/Test/badge.svg)](https://github.com/socketry/async-cable/actions?workflow=Test) diff --git a/test/async/cable/middleware.rb b/test/async/cable/middleware.rb index 87f491e..54d6a9d 100644 --- a/test/async/cable/middleware.rb +++ b/test/async/cable/middleware.rb @@ -72,6 +72,81 @@ def url end end + with "non-websocket requests" do + let(:inner_app) do + lambda do |env| + [200, {"content-type" => "text/plain"}, ["hello"]] + end + end + + let(:app) do + Protocol::Rack::Adapter.new(subject.new(inner_app, server: cable_server)) + end + + it "forwards non-websocket requests on the cable path to the inner app" do + response = client.get("/cable") + expect(response.read).to be == "hello" + ensure + response&.close + end + + it "forwards requests on other paths to the inner app" do + response = client.get("/other") + expect(response.read).to be == "hello" + ensure + response&.close + end + end + + with "#allow_request_origin?" do + let(:middleware) {subject.new(nil, server: cable_server)} + + before do + cable_server.config.disable_request_forgery_protection = false + end + + it "allows requests matching the configured origin host" do + cable_server.config.allow_same_origin_as_host = true + env = {"HTTP_ORIGIN" => "http://example.com", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + expect(middleware.__send__(:allow_request_origin?, env)).to be == true + end + + it "allows requests from explicitly allowed origins" do + cable_server.config.allowed_request_origins = ["http://allowed.example"] + env = {"HTTP_ORIGIN" => "http://allowed.example", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + expect(middleware.__send__(:allow_request_origin?, env)).to be == true + end + + it "rejects requests from disallowed origins" do + cable_server.config.allowed_request_origins = ["http://allowed.example"] + env = {"HTTP_ORIGIN" => "http://evil.example", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + expect(middleware.__send__(:allow_request_origin?, env)).to be == false + end + end + + with "#handle_incoming_websocket" do + include Sus::Fixtures::Async::ReactorContext + + let(:middleware) {subject.new(nil, server: cable_server)} + + # Minimal websocket double whose `read` raises an unexpected error, + # exercising the abnormal-failure rescue branch. + let(:failing_websocket) do + Class.new do + def read; raise "unexpected"; end + def send_text(_); end + def flush; end + def closed?; true; end + def close_write(_ = nil); end + end.new + end + + it "logs unexpected errors and cleans up the connection" do + env = {"PATH_INFO" => "/cable", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + middleware.__send__(:handle_incoming_websocket, env, failing_websocket) + end + end + it "can connect and receive welcome messages" do welcome_message = connection.read.parse diff --git a/test/async/cable/socket.rb b/test/async/cable/socket.rb index 19bf180..b896264 100644 --- a/test/async/cable/socket.rb +++ b/test/async/cable/socket.rb @@ -1,10 +1,17 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "async/cable/socket" require "action_cable" +require "action_dispatch" +require "action_dispatch/http/request" +require "sus/fixtures/async" describe Async::Cable::Socket do - let(:socket) {subject.new({}, nil, ::ActionCable::Server::Base.new)} + let(:server) {::ActionCable::Server::Base.new} + let(:socket) {subject.new({}, nil, server)} it "cannot transmit after close" do socket.close @@ -18,4 +25,57 @@ socket.close socket.close end + + with "#request" do + it "builds an ActionDispatch::Request from the Rack environment" do + request = socket.request + expect(request).to be_a(ActionDispatch::Request) + end + + it "memoizes the request" do + expect(socket.request).to be_equal(socket.request) + end + + it "merges Rails.application env_config when available" do + fake_application = Object.new + def fake_application.env_config; {"rails.test" => true}; end + + rails_was_defined = defined?(Rails) + Object.const_set(:Rails, Module.new) unless rails_was_defined + previous = Rails.respond_to?(:application) ? Rails.application : nil + Rails.define_singleton_method(:application){fake_application} + + request = socket.request + expect(request.env["rails.test"]).to be == true + ensure + if rails_was_defined + Rails.define_singleton_method(:application){previous} + else + Object.send(:remove_const, :Rails) + end + end + end + + with "#run" do + include Sus::Fixtures::Async::ReactorContext + + # Minimal websocket double that raises on send_text, exercising the rescue + # branch in Socket#run. + let(:failing_websocket) do + Class.new do + def send_text(_buffer); raise "boom"; end + def flush; end + def closed?; true; end + end.new + end + + let(:socket) {subject.new({}, failing_websocket, server)} + + it "logs errors raised while draining the output queue" do + task = socket.run + socket.transmit({type: "ping"}) + socket.close + task.wait + end + end end