diff --git a/async-cable.gemspec b/async-cable.gemspec index 84c80a9..915165f 100644 --- a/async-cable.gemspec +++ b/async-cable.gemspec @@ -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" diff --git a/gems.rb b/gems.rb index 06f869e..874090c 100644 --- a/gems.rb +++ b/gems.rb @@ -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" diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 3852457..5166f97 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -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. diff --git a/lib/async/cable.rb b/lib/async/cable.rb index c0d6f08..4b05a6d 100644 --- a/lib/async/cable.rb +++ b/lib/async/cable.rb @@ -4,6 +4,7 @@ # Copyright, 2024, by Samuel Williams. require_relative "cable/version" +require_relative "cable/server" begin require "rails/railtie" diff --git a/lib/async/cable/railtie.rb b/lib/async/cable/railtie.rb index d6e0dba..6da8e26 100644 --- a/lib/async/cable/railtie.rb +++ b/lib/async/cable/railtie.rb @@ -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 diff --git a/lib/async/cable/server.rb b/lib/async/cable/server.rb new file mode 100644 index 0000000..9d7e00d --- /dev/null +++ b/lib/async/cable/server.rb @@ -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 diff --git a/readme.md b/readme.md index d1e96b5..8661f03 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/releases.md b/releases.md index 759019d..4e2fe42 100644 --- a/releases.md +++ b/releases.md @@ -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 diff --git a/test/async/cable/server.rb b/test/async/cable/server.rb new file mode 100644 index 0000000..b47d3bd --- /dev/null +++ b/test/async/cable/server.rb @@ -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