Skip to content
Open
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
3 changes: 2 additions & 1 deletion async-cable.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = ">= 3.3"

# Requires the `ActionCable::Server::Socket` abstraction introduced by
# https://github.com/rails/rails/pull/50979 (currently Rails main).
# https://github.com/rails/rails/pull/50979 and the configurable server hook
# from https://github.com/rails/rails/pull/57803 (currently Rails main).
spec.add_dependency "actioncable", ">= 8.2.0.alpha"
spec.add_dependency "async", "~> 2.9"
spec.add_dependency "async-websocket"
Expand Down
7 changes: 3 additions & 4 deletions gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@

gemspec

# 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"
# The `next` branch targets Rails main, plus the configurable Action Cable
# server hook proposed in rails/rails#57803.
gem "rails", github: "samuel-williams-shopify/rails", branch: "action-cable-configurable-server"

gem "async"

Expand Down
2 changes: 1 addition & 1 deletion guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ To use `async-cable`, you need to add the following to your `config/application.
require "async/cable"
~~~

This will automatically add the {ruby Async::Cable::Middleware} to your middleware stack which will handle incoming WebSocket connections and integrates with Action Cable.
This will automatically configure {ruby Async::Cable::Server} as the Action Cable server implementation. Rails will mount it at the configured Action Cable mount path, where it handles incoming WebSocket connections using Async.
1 change: 1 addition & 0 deletions lib/async/cable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Copyright, 2024, by Samuel Williams.

require_relative "cable/version"
require_relative "cable/server"

begin
require "rails/railtie"
Expand Down
8 changes: 4 additions & 4 deletions lib/async/cable/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
# Released under the MIT License.
# Copyright, 2024, by Samuel Williams.

require_relative "middleware"
require_relative "server"

module Async
module Cable
# Rails integration that automatically inserts {Middleware} into the application middleware stack during initialization.
# Rails integration that configures Action Cable to use {Server}.
class Railtie < Rails::Railtie
initializer "async.cable.configure_rails_initialization" do |app|
app.middleware.use Async::Cable::Middleware
initializer "async.cable.configure_action_cable", before: "action_cable.set_configs" do |app|
app.config.action_cable.server = Async::Cable::Server
end
end
end
Expand Down
73 changes: 73 additions & 0 deletions lib/async/cable/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "async/websocket/adapters/rack"
require "action_cable"

require_relative "executor"
require_relative "socket"

module Async
module Cable
# Action Cable server implementation backed by Async WebSocket handling.
class Server < ::ActionCable::Server::Base
# Initialize the server with the given Action Cable configuration.
# @parameter config [ActionCable::Configuration] The Action Cable configuration.
def initialize(config: self.class.config)
super

@coder = ActiveSupport::JSON
@protocols = ::ActionCable::INTERNAL[:protocols]
end

# Executor used by pub/sub callbacks, heartbeat timers, and periodic channel timers.
def executor
@executor || @mutex.synchronize{@executor ||= Executor.new}
end

# Called by Rack to handle the mounted Action Cable endpoint.
def call(env)
return config.health_check_application.call(env) if env["PATH_INFO"] == config.health_check_path

if Async::WebSocket::Adapters::Rack.websocket?(env) and allow_request_origin?(env)
Async::WebSocket::Adapters::Rack.open(env, protocols: @protocols) do |websocket|
handle_incoming_websocket(env, websocket)
end
else
[404, {Rack::CONTENT_TYPE => "text/plain; charset=utf-8"}, ["Page not found"]]
end
end

private

def handle_incoming_websocket(env, websocket)
socket = Socket.new(env, websocket, self, coder: @coder)
connection = config.connection_class.call.new(self, socket)

connection.handle_open
add_connection(connection)
setup_heartbeat_timer

socket_task = socket.run

while message = websocket.read
# Console.debug(self, "Received cable data:", message.buffer)
connection.handle_incoming(@coder.decode(message.buffer))
end
rescue Protocol::WebSocket::ClosedError, EOFError
# This is a normal disconnection.
rescue => error
Console.warn(self, "Abnormal client failure!", error)
ensure
if connection
remove_connection(connection)
connection.handle_close
end

socket_task&.stop
end
end
end
end
4 changes: 2 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

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). For stable Rails (≤ 8.1), use the `main` branch, which depends on [`actioncable-next`](https://github.com/anycable/actioncable-next).
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), and the configurable Action Cable server hook proposed in [rails/rails#57803](https://github.com/rails/rails/pull/57803). For stable Rails (≤ 8.1), use the `main` branch, which depends on [`actioncable-next`](https://github.com/anycable/actioncable-next).

## Rails Compatibility

This branch requires unreleased Action Cable changes from Rails `main`, currently versioned as `8.2.0.alpha`. Released Rails 8.1.x does not include `ActionCable::Server::Socket`; in Rails 8.1, `ActionCable::Connection::Base` still accepts `(server, env, coder: ...)` rather than `(server, socket)`.
This branch requires unreleased Action Cable changes from Rails `main`, currently versioned as `8.2.0.alpha`. Released Rails 8.1.x does not include `ActionCable::Server::Socket` or `config.action_cable.server`; in Rails 8.1, `ActionCable::Connection::Base` still accepts `(server, env, coder: ...)` rather than `(server, socket)`.

The gemspec therefore pins `actioncable >= 8.2.0.alpha` to prevent accidentally resolving against Rails 8.1.x. Once Rails ships a stable release containing rails/rails#50979, this constraint should be changed to that released version.

Expand Down
1 change: 1 addition & 0 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Add {ruby Async::Cable::Socket#raw_transmit} for pushing pre-encoded payloads to the client without re-encoding. Enables "fastlane" broadcasts that encode the message once and share it across many connections.
- Add {ruby Async::Cable::Executor}, a fiber-based replacement for `ActionCable::Server::ThreadedExecutor`. Tasks posted from inside a reactor run on the caller's reactor (no thread hop); tasks posted from outside, and all recurring timers, run on a dedicated reactor thread owned by the executor.
- Add {ruby Async::Cable::Server} and configure it as the Action Cable server implementation when Rails exposes `config.action_cable.server`.

## v0.3.0

Expand Down
109 changes: 109 additions & 0 deletions test/async/cable/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "async/cable/server"

require "protocol/rack/adapter"
require "async/websocket/client"
require "sus/fixtures/async/http/server_context"

require "test_channel"

describe Async::Cable::Server do
include Sus::Fixtures::Async::HTTP::ServerContext

def url
"http://localhost:0/"
end

let(:cable_server) {subject.new}

before do
cable_server.config.disable_request_forgery_protection = true
cable_server.config.logger = Console
cable_server.config.cable = {"adapter" => "async"}
end

let(:app) do
Protocol::Rack::Adapter.new(cable_server)
end

let(:connection) {Async::WebSocket::Client.connect(client_endpoint)}

let(:identifier) {JSON.dump(channel: "TestChannel")}

it "uses an async executor" do
expect(cable_server.executor).to be_a(Async::Cable::Executor)
end

it "can connect and receive welcome messages" do
welcome_message = connection.read.parse

expect(welcome_message).to have_keys(
type: be == "welcome"
)

connection.shutdown
ensure
connection.close
end

it "can connect and send broadcast messages" do
subscribe_message = ::Protocol::WebSocket::TextMessage.generate({
command: "subscribe",
identifier: identifier,
})

subscribe_message.send(connection)

while message = connection.read
confirmation = message.parse

if confirmation[:type] == "confirm_subscription"
break
end
end

expect(confirmation).to have_keys(
identifier: be == identifier
)

broadcast_data = {action: "broadcast", payload: "Hello, World!"}

broadcast_message = Protocol::WebSocket::TextMessage.generate(
command: "message",
identifier: identifier,
data: broadcast_data.to_json
)

broadcast_message.send(connection)
connection.flush

while message = connection.read
broadcast = message.parse

if broadcast[:identifier] == identifier
break
end
end

expect(broadcast).to have_keys(
identifier: be == identifier
)

connection.shutdown
ensure
connection.close
end

it "returns a not found response for non-websocket requests" do
response = client.get("/")

expect(response.status).to be == 404
expect(response.read).to be == "Page not found"
ensure
response&.close
end
end
Loading