Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion async-cable.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 3 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
75 changes: 75 additions & 0 deletions test/async/cable/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 61 additions & 1 deletion test/async/cable/socket.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Loading