├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docs-lint.yml │ ├── rubocop.yml │ └── test.yml ├── .gitignore ├── .mdlrc ├── .rubocop-md.yml ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── anycable-rack-server.gemspec ├── forspell.dict ├── gemfiles ├── rails6.gemfile ├── rails7.gemfile ├── railsmaster.gemfile └── rubocop.gemfile ├── lib ├── anycable-rack-server.rb └── anycable │ └── rack │ ├── broadcast_subscribers │ ├── base_subscriber.rb │ ├── http_subscriber.rb │ └── redis_subscriber.rb │ ├── coders │ ├── json.rb │ ├── msgpack.rb │ ├── proto │ │ └── message_pb.rb │ └── protobuf.rb │ ├── config.rb │ ├── connection.rb │ ├── errors.rb │ ├── hub.rb │ ├── logging.rb │ ├── middleware.rb │ ├── pinger.rb │ ├── railtie.rb │ ├── rpc │ ├── client.rb │ └── rpc.proto │ ├── server.rb │ ├── socket.rb │ └── version.rb └── test ├── anycable └── test_hub.rb └── support ├── rack └── config.ru └── rails ├── config.ru └── config └── environment.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: anycable 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ### Tell us about your environment 9 | 10 | **Ruby version:** 11 | 12 | **`anycable-rack-server` gem version:** 13 | 14 | **`anycable` gem version:** 15 | 16 | **`grpc` gem version:** 17 | 18 | ### What did you do? 19 | 20 | ### What did you expect to happen? 21 | 22 | ### What actually happened? 23 | 24 | 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Summary 8 | 9 | 14 | 15 | ## Changes 16 | 17 | - [ ] Change A 18 | 19 | ### Checklist 20 | 21 | - [ ] I've added tests for this change 22 | - [ ] I've added a Changelog entry 23 | - [ ] I've updated Readme 24 | 25 | 32 | -------------------------------------------------------------------------------- /.github/workflows/docs-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "README.md" 9 | - "CHANGELOG.md" 10 | pull_request: 11 | paths: 12 | - "README.md" 13 | - "CHANGELOG.md" 14 | 15 | jobs: 16 | markdownlint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: 2.7 23 | - name: Run Markdown linter 24 | run: | 25 | gem install mdl 26 | mdl CHANGELOG.md README.md 27 | rubocop: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: 2.7 34 | - name: Lint Markdown files with RuboCop 35 | run: | 36 | gem install bundler 37 | bundle install --gemfile gemfiles/rubocop.gemfile --jobs 4 --retry 3 38 | bundle exec --gemfile gemfiles/rubocop.gemfile rubocop -c .rubocop-md.yml 39 | forspell: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v2 43 | - name: Install Hunspell 44 | run: | 45 | sudo apt-get install hunspell 46 | - uses: ruby/setup-ruby@v1 47 | with: 48 | ruby-version: 2.7 49 | - name: Cache installed gems 50 | uses: actions/cache@v1 51 | with: 52 | path: /home/runner/.rubies/ruby-2.7.0/lib/ruby/gems/2.7.0 53 | key: gems-cache-${{ runner.os }} 54 | - name: Install Forspell 55 | run: gem install forspell 56 | - name: Run Forspell 57 | run: forspell CHANGELOG.md README.md 58 | liche: 59 | runs-on: ubuntu-latest 60 | env: 61 | GO111MODULE: on 62 | steps: 63 | - uses: actions/checkout@v2 64 | - name: Set up Go 65 | uses: actions/setup-go@v1 66 | with: 67 | go-version: 1.13.x 68 | - name: Run liche 69 | run: | 70 | export PATH=$PATH:$(go env GOPATH)/bin 71 | go get -u github.com/raviqqe/liche 72 | liche README.md CHANGELOG.md 73 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Lint Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | rubocop: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: 2.7 17 | - name: Lint Ruby code with RuboCop 18 | run: | 19 | gem install bundler 20 | bundle install --gemfile gemfiles/rubocop.gemfile --jobs 4 --retry 3 21 | bundle exec --gemfile gemfiles/rubocop.gemfile rubocop 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_JOBS: 4 14 | BUNDLE_RETRY: 3 15 | BUNDLE_FORCE_RUBY_PLATFORM: 1 16 | ANYT_TIMEOUT_MULTIPLIER: 2 17 | ANYCABLE_DEBUG: 1 18 | CI: true 19 | services: 20 | redis: 21 | image: redis:5.0-alpine 22 | ports: ["6379:6379"] 23 | options: --health-cmd="redis-cli ping" --health-interval 1s --health-timeout 3s --health-retries 30 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | ruby: ["2.7"] 28 | gemfile: ["gemfiles/rails6.gemfile"] 29 | include: 30 | - ruby: "3.0" 31 | gemfile: "gemfiles/railsmaster.gemfile" 32 | - ruby: "3.1" 33 | gemfile: "gemfiles/rails7.gemfile" 34 | steps: 35 | - uses: actions/checkout@v2 36 | - uses: actions/cache@v1 37 | with: 38 | path: /home/runner/bundle 39 | key: bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ hashFiles('**/*.gemspec') }}-${{ hashFiles('**/Gemfile') }} 40 | restore-keys: | 41 | bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}- 42 | - uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby }} 45 | - name: Install system deps 46 | run: | 47 | sudo apt-get update 48 | sudo apt-get install libsqlite3-dev 49 | - name: Bundle install 50 | run: | 51 | bundle config path /home/runner/bundle 52 | bundle config --global gemfile ${{ matrix.gemfile }} 53 | bundle install 54 | bundle update 55 | - name: Run unit tests 56 | run: | 57 | bundle exec rake test 58 | - name: Run conformance tests for Rails 59 | run: | 60 | bundle exec rake anyt:rails || bundle exec rake anyt:rails || bundle exec rake anyt:rails 61 | - name: Run conformance tests for Rack 62 | run: | 63 | bundle exec rake anyt:rack || bundle exec rake anyt:rack || bundle exec rake anyt:rack 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .DS_Store 3 | Gemfile.lock 4 | gemfiles/*.lock 5 | tmp/ 6 | Gemfile.local 7 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033", "~MD041" 2 | -------------------------------------------------------------------------------- /.rubocop-md.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ".rubocop.yml" 2 | 3 | require: 4 | - rubocop-md 5 | 6 | 7 | AllCops: 8 | Include: 9 | - '**/*.md' 10 | 11 | Lint/Void: 12 | Exclude: 13 | - '**/*.md' 14 | 15 | Lint/DuplicateMethods: 16 | Exclude: 17 | - '**/*.md' 18 | 19 | # See https://github.com/rubocop-hq/rubocop/issues/4222 20 | Lint/AmbiguousBlockAssociation: 21 | Exclude: 22 | - '**/*.md' 23 | 24 | 25 | Naming/FileName: 26 | Exclude: 27 | - '**/*.md' 28 | 29 | Layout/InitialIndentation: 30 | Exclude: 31 | - 'CHANGELOG.md' 32 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard/cop/semantic_blocks 3 | 4 | inherit_gem: 5 | standard: config/base.yml 6 | 7 | AllCops: 8 | Exclude: 9 | - 'bin/*' 10 | - 'tmp/**/*' 11 | - 'Gemfile' 12 | - 'vendor/**/*' 13 | - 'gemfiles/**/*' 14 | DisplayCopNames: true 15 | TargetRubyVersion: 2.5 16 | 17 | Standard/SemanticBlocks: 18 | Enabled: false 19 | 20 | Style/FrozenStringLiteralComment: 21 | Enabled: true 22 | 23 | Style/TrailingCommaInArrayLiteral: 24 | EnforcedStyleForMultiline: no_comma 25 | 26 | Style/TrailingCommaInHashLiteral: 27 | EnforcedStyleForMultiline: no_comma 28 | 29 | Naming/FileName: 30 | Exclude: 31 | - 'lib/anycable-rack-server.rb' 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## master 4 | 5 | ## 0.5.1 (2023-06-29) 6 | 7 | - Accept `actioncable-v1-ext-json` clients. ([@palkan][]) 8 | 9 | Only support protocol API, not features. 10 | 11 | ## 0.5.0 (2022-01-11) 12 | 13 | - Added Protobuf coder support. ([@prog-supdex][]) 14 | 15 | ## 0.4.0 (2020-05-14) 16 | 17 | - Added Msgpack coder support. ([@palkan][]) 18 | 19 | ## 0.3.0 (2020-05-12) 20 | 21 | - AnyCable v1.1 compatibility. ([@palkan][]) 22 | 23 | ## 0.2.1 (2020-09-08) 24 | 25 | - Add channel states to `disconnect` requests. ([@palkan][]) 26 | 27 | ## 0.2.0 (2020-07-01) 28 | 29 | - Use connection pool for gRPC clients. ([@palkan][]) 30 | 31 | - Embed RPC server into the running process instead of spawning a new one. ([@palkan][]) 32 | 33 | Use `AnyCable::CLI.new(embedded: true)` when `config.run_rpc = true` instead of spawning a new process. 34 | 35 | - Added HTTP broadcast support. ([@palkan]) 36 | 37 | Broadcast adapter is inherited from AnyCable (i.e, no need to specify it twice). 38 | 39 | - **Breaking** Server initialization and configuration API changes. ([@palkan][]) 40 | 41 | Now you need to pass a config object as the only option to `AnyCable::Rack::Server.new`: 42 | 43 | ```ruby 44 | server = AnyCable::Server::Rack.new(config: AnyCable::Rack::Config.new(**params)) 45 | ``` 46 | 47 | You can omit the config altogether. In this case, a default, global, configuration would be used (`AnyCable::Rack.config`). 48 | 49 | When using Rails, `config.anycable_rack` points to `AnyCable::Rack.config`. 50 | 51 | Env variables prefix for configuration changed from `ANYCABLE_RACK_` to `ANYCABLE_` 52 | That would allow us to re-use common parameters between `anycable` and `anycable-rack-server`. 53 | 54 | - Make compatible with AnyCable v1.0. ([@palkan][]) 55 | 56 | ## 0.1.0 (2019-01-06) 57 | 58 | - Initial implementation. ([@tuwukee][]) 59 | 60 | [@palkan]: https://github.com/palkan 61 | [@tuwukee]: https://github.com/tuwukee 62 | [@prog-supdex]: https://github.com/prog-supdex 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem "pry-byebug" 6 | 7 | eval_gemfile "gemfiles/rubocop.gemfile" 8 | 9 | local_gemfile = "#{File.dirname(__FILE__)}/Gemfile.local" 10 | 11 | if File.exist?(local_gemfile) 12 | eval(File.read(local_gemfile)) # rubocop:disable Lint/Eval 13 | else 14 | gem "rails", "~> 6.1" 15 | end 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Yulia Oletskaya, Vladimir Dementyev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Cult Of Martians](http://cultofmartians.com/assets/badges/badge.svg)](https://cultofmartians.com/tasks/anycable-ruby-server.html) 2 | [![Gem Version](https://badge.fury.io/rb/anycable-rack-server.svg)](https://rubygems.org/gems/anycable-rack-server) 3 | [![Build](https://github.com/anycable/anycable-rack-server/workflows/Build/badge.svg)](https://github.com/anycable/anycable-rack-server/actions) 4 | 5 | # anycable-rack-server 6 | 7 | [AnyCable](https://anycable.io)-compatible Rack hijack based Ruby Web Socket server designed for development and testing purposes. 8 | 9 | ## Using with Rack 10 | 11 | ```ruby 12 | # Initialize server instance first. 13 | ws_server = AnyCable::Rack::Server.new 14 | 15 | app = Rack::Builder.new do 16 | map "/cable" do 17 | run ws_server 18 | end 19 | end 20 | 21 | # NOTE: don't forget to call `start!` method 22 | ws_server.start! 23 | 24 | run app 25 | ``` 26 | 27 | ## Usage with Rails 28 | 29 | Add `gem "anycable-rack-server"` to you `Gemfile` and make sure your Action Cable adapter is set to `:any_cable`. That's it! We automatically start AnyCable Rack server for you at `/cable` path. 30 | 31 | ## Configuration 32 | 33 | AnyCable Rack Server uses [`anyway_config`](https://github.com/palkan/anyway_config) gem for configuration; thus it is possible to set configuration parameters through environment vars (prefixed with `ANYCABLE_`), `config/anycable.yml` file or `secrets.yml` when using Rails. 34 | 35 | **NOTE:** AnyCable Rack Server uses the same config name (i.e., env prefix, YML file name, etc.) as AnyCable itself. 36 | 37 | You can pass a config object as the option to `AnyCable::Rack::Server.new`: 38 | 39 | ```ruby 40 | server = AnyCable::Server::Rack.new(config: AnyCable::Rack::Config.new(**params)) 41 | ``` 42 | 43 | If no config is passed, a default, global, configuration would be used (`AnyCable::Rack.config`). 44 | 45 | When using Rails, `config.anycable_rack` points to `AnyCable::Rack.config`. 46 | 47 | ### Headers 48 | 49 | You can customize the headers being sent with each gRPC request. 50 | 51 | Default headers: `'cookie', 'x-api-token'`. 52 | 53 | Can be specified via configuration: 54 | 55 | ```ruby 56 | AnyCable::Rack.config.headers = ["cookie", "x-my-header"] 57 | ``` 58 | 59 | Or in Rails: 60 | 61 | ```ruby 62 | # .rb 63 | config.any_cable_rack.headers = %w[cookie] 64 | ``` 65 | 66 | ### Rails-specific options 67 | 68 | ```ruby 69 | # Mount WebSocket server at the specified path 70 | config.any_cable_rack.mount_path = "/cable" 71 | # NOTE: here we specify only the port (we assume that a server is running locally) 72 | config.any_cable_rack.rpc_port = 50051 73 | ``` 74 | 75 | ## Broadcast adapters 76 | 77 | AnyCable Rack supports Redis (default) and HTTP broadcast adapters 78 | (see [the documentation](https://docs.anycable.io/ruby/broadcast_adapters)). 79 | 80 | Broadcast adapter is inherited from AnyCable configuration (so, you don't need to configure it twice). 81 | 82 | ### Using HTTP broadcast adapter 83 | 84 | ### With Rack 85 | 86 | ```ruby 87 | AnyCable::Rack.config.broadast_adapter = :http 88 | 89 | ws_server = AnyCable::Rack::Server 90 | 91 | app = Rack::Builder.new do 92 | map "/cable" do 93 | run ws_server 94 | end 95 | 96 | map "/_anycable_rack_broadcast" do 97 | run ws_server.broadcast 98 | end 99 | end 100 | ``` 101 | 102 | ### With Rails 103 | 104 | By default, we mount broadcasts endpoint at `/_anycable_rack_broadcast`. 105 | 106 | You can change this setting: 107 | 108 | ```ruby 109 | config.any_cable_rack.http_broadcast_path = "/_my_broadcast" 110 | ``` 111 | 112 | **NOTE:** Don't forget to configure `http_broadcast_url` for AnyCable pointing to your web server and the specified broadcast path. 113 | 114 | ## Testing 115 | 116 | Run units with `bundle exec rake`. 117 | 118 | ## Contributing 119 | 120 | Bug reports and pull requests are welcome on GitHub at [https://github.com/anycable/anycable-rack-server](https://github.com/anycable/anycable-rack-server). 121 | 122 | ## License 123 | 124 | The gem is available as open source under the terms of the [MIT License](./LICENSE). 125 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rubocop/rake_task" 5 | require "rake/testtask" 6 | 7 | RuboCop::RakeTask.new 8 | 9 | Rake::TestTask.new do |t| 10 | t.test_files = FileList["test/anycable/test_*.rb", 11 | "test/anycable/**/test_*.rb"] 12 | end 13 | 14 | namespace :anyt do 15 | task :rack do 16 | sh 'anyt -c "puma test/support/rack/config.ru" --except features/server_restart' 17 | end 18 | 19 | task :rails do 20 | Dir.chdir(File.join(__dir__, "test/support/rails")) do 21 | sh "ANYCABLE_BROADCAST_ADAPTER=http ANYCABLE_HTTP_BROADCAST_SECRET=any_secret " \ 22 | "ANYCABLE_HTTP_BROADCAST_URL=http://localhost:9292/_anycable_rack_broadcast " \ 23 | "anyt -c \"puma config.ru\" --wait-command=5 --except features/server_restart" 24 | end 25 | end 26 | end 27 | 28 | task anyt: ["anyt:rack", "anyt:rails"] 29 | 30 | task default: [:rubocop, :test, :anyt] 31 | -------------------------------------------------------------------------------- /anycable-rack-server.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/anycable/rack/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "anycable-rack-server" 7 | s.version = AnyCable::Rack::VERSION 8 | s.summary = "AnyCable Rack Server" 9 | s.description = "AnyCable-compatible Ruby Rack middleware" 10 | s.authors = ["Yulia Oletskaya", "Vladimir Dementyev"] 11 | s.email = "yulia.oletskaya@gmail.com" 12 | s.license = "MIT" 13 | 14 | s.files = Dir["lib/**/*", "LICENSE", "README.md"] 15 | s.require_paths = ["lib"] 16 | 17 | s.add_dependency "anyway_config", ">= 2.1.0" 18 | s.add_dependency "anycable", "> 1.0.99", "< 2.0" 19 | s.add_dependency "connection_pool", "~> 2.2" 20 | s.add_dependency "websocket", "~> 1.2" 21 | 22 | s.add_development_dependency "anyt" 23 | s.add_development_dependency "minitest", "~> 5.10" 24 | s.add_development_dependency "puma" 25 | s.add_development_dependency "rake", ">= 13.0" 26 | s.add_development_dependency "redis", "~> 4" 27 | s.add_development_dependency "rubocop", ">= 0.80" 28 | end 29 | -------------------------------------------------------------------------------- /forspell.dict: -------------------------------------------------------------------------------- 1 | # Format: one word per line. Empty lines and #-comments are supported too. 2 | # If you want to add word with its forms, you can write 'word: example' (without quotes) on the line, 3 | # where 'example' is existing word with the same possible forms (endings) as your word. 4 | # Example: deduplicate: duplicate 5 | tuwukee 6 | Msgpack 7 | Protobuf 8 | -------------------------------------------------------------------------------- /gemfiles/rails6.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "anycable", github: "anycable/anycable" 4 | gem "anycable-rails", github: "anycable/anycable-rails" 5 | gem "anyt", github: "anycable/anyt" 6 | 7 | gem "rails", "~> 6.0" 8 | gem "sqlite3", "~> 1.4" 9 | 10 | gemspec path: ".." 11 | -------------------------------------------------------------------------------- /gemfiles/rails7.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "anycable", github: "anycable/anycable" 4 | gem "anycable-rails", github: "anycable/anycable-rails" 5 | gem "anyt", github: "anycable/anyt" 6 | 7 | gem "rails", "~> 7.0" 8 | 9 | gemspec path: ".." 10 | -------------------------------------------------------------------------------- /gemfiles/railsmaster.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "anycable", github: "anycable/anycable" 4 | gem "anycable-rails", github: "anycable/anycable-rails" 5 | gem "anyt", github: "anycable/anyt" 6 | 7 | gem "rails", github: "rails/rails" 8 | gem "sqlite3", "~> 1.4" 9 | 10 | gemspec path: ".." 11 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" do 2 | gem "rubocop-md", "~> 0.3" 3 | gem "standard", "~> 0.2.0" 4 | end 5 | -------------------------------------------------------------------------------- /lib/anycable-rack-server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anycable/rack/version" 4 | require "anycable/rack/config" 5 | require "anycable/rack/server" 6 | 7 | module AnyCable 8 | module Rack 9 | class << self 10 | def config 11 | @config ||= Config.new 12 | end 13 | end 14 | end 15 | end 16 | 17 | require "anycable/rack/railtie" if defined?(Rails) 18 | -------------------------------------------------------------------------------- /lib/anycable/rack/broadcast_subscribers/base_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module AnyCable 6 | module Rack 7 | module BroadcastSubscribers 8 | class BaseSubscriber 9 | include Logging 10 | 11 | attr_reader :hub, :coder 12 | 13 | def initialize(hub:, coder:, **options) 14 | @hub = hub 15 | @coder = coder 16 | end 17 | 18 | def start 19 | # no-op 20 | end 21 | 22 | def stop 23 | # no-op 24 | end 25 | 26 | private 27 | 28 | def handle_message(msg) 29 | log(:debug) { "Received pub/sub message: #{msg}" } 30 | 31 | data = JSON.parse(msg) 32 | if data["stream"] 33 | hub.broadcast(data["stream"], data["data"], coder) 34 | elsif data["command"] == "disconnect" 35 | hub.disconnect(data["payload"]["identifier"], data["payload"]["reconnect"], coder) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/anycable/rack/broadcast_subscribers/http_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module AnyCable 6 | module Rack 7 | module BroadcastSubscribers 8 | # HTTP Pub/Sub subscriber 9 | class HTTPSubscriber < BaseSubscriber 10 | attr_reader :token, :path 11 | 12 | def initialize(**options) 13 | super 14 | @token = options[:token] 15 | @path = options[:path] 16 | end 17 | 18 | def start 19 | log(:info) { "Accepting pub/sub request at #{path}" } 20 | end 21 | 22 | def call(env) 23 | req = ::Rack::Request.new(env) 24 | 25 | return invalid_request unless req.post? 26 | 27 | if token && req.get_header("HTTP_AUTHORIZATION") != "Bearer #{token}" 28 | return invalid_request(401) 29 | end 30 | 31 | handle_message req.body.read 32 | 33 | [201, {"Content-Type" => "text/plain"}, ["OK"]] 34 | end 35 | 36 | private 37 | 38 | def invalid_request(code = 422) 39 | [code, {"Content-Type" => "text/plain"}, ["Invalid request"]] 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/anycable/rack/broadcast_subscribers/redis_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "redis", "~> 4" 4 | 5 | require "redis" 6 | require "json" 7 | 8 | module AnyCable 9 | module Rack 10 | module BroadcastSubscribers 11 | # Redis Pub/Sub subscriber 12 | class RedisSubscriber < BaseSubscriber 13 | attr_reader :redis_conn, :thread, :channel 14 | 15 | def initialize(hub:, coder:, channel:, **options) 16 | super 17 | @redis_conn = ::Redis.new(options) 18 | @channel = channel 19 | end 20 | 21 | def start 22 | subscribe(channel) 23 | 24 | log(:info) { "Subscribed to #{channel}" } 25 | end 26 | 27 | def stop 28 | thread&.terminate 29 | end 30 | 31 | def subscribe(channel) 32 | @thread ||= Thread.new do 33 | Thread.current.abort_on_exception = true 34 | 35 | redis_conn.without_reconnect do 36 | redis_conn.subscribe(channel) do |on| 37 | on.subscribe do |chan, count| 38 | log(:debug) { "Redis subscriber connected to #{chan} (#{count})" } 39 | end 40 | 41 | on.unsubscribe do |chan, count| 42 | log(:debug) { "Redis subscribed disconnected from #{chan} (#{count})" } 43 | end 44 | 45 | on.message do |_channel, msg| 46 | handle_message(msg) 47 | rescue 48 | log(:error) { "Failed to broadcast message: #{msg}" } 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/anycable/rack/coders/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module AnyCable 6 | module Rack 7 | module Coders 8 | module Json # :nodoc: 9 | class << self 10 | def decode(json_str) 11 | ::JSON.parse(json_str) 12 | end 13 | 14 | def encode(ruby_obj) 15 | ruby_obj.to_json 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/anycable/rack/coders/msgpack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "msgpack", "~> 1.4" 4 | require "msgpack" 5 | 6 | module AnyCable 7 | module Rack 8 | module Coders 9 | module Msgpack # :nodoc: 10 | class << self 11 | def decode(bin) 12 | MessagePack.unpack(bin) 13 | end 14 | 15 | def encode(ruby_obj, binary_frame_wrap: true) 16 | message_packed = MessagePack.pack(ruby_obj) 17 | 18 | return message_packed unless binary_frame_wrap 19 | 20 | BinaryFrame.new(message_packed) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/anycable/rack/coders/proto/message_pb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "google/protobuf" 4 | 5 | Google::Protobuf::DescriptorPool.generated_pool.build do 6 | add_message "action_cable.Message" do 7 | optional :type, :enum, 1, "action_cable.Type" 8 | optional :command, :enum, 2, "action_cable.Command" 9 | optional :identifier, :string, 3 10 | optional :data, :string, 4 11 | optional :message, :bytes, 5 12 | optional :reason, :string, 6 13 | optional :reconnect, :bool, 7 14 | end 15 | add_enum "action_cable.Type" do 16 | value :no_type, 0 17 | value :welcome, 1 18 | value :disconnect, 2 19 | value :ping, 3 20 | value :confirm_subscription, 4 21 | value :reject_subscription, 5 22 | end 23 | add_enum "action_cable.Command" do 24 | value :unknown_command, 0 25 | value :subscribe, 1 26 | value :unsubscribe, 2 27 | value :message, 3 28 | end 29 | end 30 | 31 | module ActionCable 32 | Message = Google::Protobuf::DescriptorPool.generated_pool.lookup("action_cable.Message").msgclass 33 | Type = Google::Protobuf::DescriptorPool.generated_pool.lookup("action_cable.Type").enummodule 34 | Command = Google::Protobuf::DescriptorPool.generated_pool.lookup("action_cable.Command").enummodule 35 | end 36 | -------------------------------------------------------------------------------- /lib/anycable/rack/coders/protobuf.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "google-protobuf", "~> 3.19", ">= 3.19.1" 4 | require_relative "./proto/message_pb" 5 | require_relative "./msgpack" 6 | 7 | module AnyCable 8 | module Rack 9 | module Coders 10 | module Protobuf # :nodoc: 11 | class << self 12 | def decode(bin) 13 | decoded_message = ActionCable::Message.decode(bin).to_h 14 | 15 | decoded_message[:command] = decoded_message[:command].to_s 16 | if decoded_message[:message].present? 17 | decoded_message[:message] = Msgpack.decode(decoded_message[:message]) 18 | end 19 | 20 | decoded_message.each_with_object({}) { |(k, v), h| h[k.to_s] = v } 21 | end 22 | 23 | def encode(ruby_obj) 24 | message = ruby_obj.delete(:message) 25 | 26 | data = ActionCable::Message.new(ruby_obj) 27 | 28 | if message 29 | data.message = Msgpack.encode(message, binary_frame_wrap: false) 30 | end 31 | 32 | BinaryFrame.new(ActionCable::Message.encode(data)) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/anycable/rack/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anyway_config" 4 | 5 | module AnyCable 6 | module Rack 7 | class Config < Anyway::Config 8 | DEFAULT_HEADERS = %w[cookie x-api-token].freeze 9 | 10 | config_name :anycable 11 | env_prefix "ANYCABLE" 12 | 13 | attr_config mount_path: "/cable", 14 | headers: DEFAULT_HEADERS, 15 | coder: :json, 16 | rpc_addr: "localhost:50051", 17 | rpc_client_pool_size: 5, 18 | rpc_client_timeout: 5, 19 | http_broadcast_path: "/_anycable_rack_broadcast" 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/anycable/rack/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | require "set" 5 | require "json" 6 | 7 | require "anycable/rack/rpc/client" 8 | require "anycable/rack/logging" 9 | require "anycable/rack/errors" 10 | 11 | module AnyCable 12 | module Rack 13 | class Connection # :nodoc: 14 | include Logging 15 | 16 | attr_reader :coder, 17 | :headers, 18 | :hub, 19 | :socket, 20 | :rpc_client, 21 | :sid 22 | 23 | def initialize(socket, hub:, coder:, rpc_client:, headers:) 24 | @socket = socket 25 | @coder = coder 26 | @headers = headers 27 | @hub = hub 28 | @sid = SecureRandom.hex(6) 29 | 30 | @rpc_client = rpc_client 31 | 32 | @_identifiers = "{}" 33 | @_subscriptions = Set.new 34 | @_istate = {} 35 | end 36 | 37 | def handle_open 38 | response = rpc_connect 39 | process_open(response) 40 | end 41 | 42 | def handle_close 43 | response = rpc_disconnect 44 | process_close(response) 45 | reset_connection 46 | end 47 | 48 | def handle_command(websocket_message) 49 | decoded = decode(websocket_message) 50 | command = decoded.delete("command") 51 | 52 | channel_identifier = decoded["identifier"] 53 | 54 | log(:debug) { "Command: #{decoded}" } 55 | 56 | case command 57 | when "subscribe" then subscribe(channel_identifier) 58 | when "unsubscribe" then unsubscribe(channel_identifier) 59 | when "message" then send_message(channel_identifier, decoded["data"]) 60 | when "history" then send_history(channel_identifier, decoded["history"]) 61 | else 62 | log(:error, "Command not found #{command}") 63 | end 64 | rescue Exception => e # rubocop:disable Lint/RescueException 65 | log(:error, "Failed to execute command #{command}: #{e.message}") 66 | end 67 | 68 | private 69 | 70 | def transmit(cable_message) 71 | encoded = encode(cable_message) 72 | socket.transmit(encoded) 73 | end 74 | 75 | def close 76 | socket.close 77 | end 78 | 79 | def request 80 | socket.request 81 | end 82 | 83 | def rpc_connect 84 | rpc_client.connect(headers: headers, url: request.url) 85 | end 86 | 87 | def rpc_disconnect 88 | rpc_client.disconnect( 89 | identifiers: @_identifiers, 90 | subscriptions: @_subscriptions.to_a, 91 | headers: headers, 92 | url: request.url, 93 | state: @_cstate, 94 | channels_state: @_istate 95 | ) 96 | end 97 | 98 | def rpc_command(command, identifier, data = "") 99 | rpc_client.command( 100 | command: command, 101 | identifier: identifier, 102 | connection_identifiers: @_identifiers, 103 | data: data, 104 | headers: headers, 105 | url: request.url, 106 | connection_state: @_cstate, 107 | state: @_istate[identifier] 108 | ) 109 | end 110 | 111 | def subscribe(identifier) 112 | response = rpc_command("subscribe", identifier) 113 | if response.status == :SUCCESS 114 | @_subscriptions.add(identifier) 115 | elsif response.status == :ERROR 116 | log(:error, "RPC subscribe command failed: #{response.inspect}") 117 | end 118 | process_command(response, identifier) 119 | end 120 | 121 | def unsubscribe(identifier) 122 | response = rpc_command("unsubscribe", identifier) 123 | if response.status == :SUCCESS 124 | @_subscriptions.delete(identifier) 125 | elsif response.status == :ERROR 126 | log(:error, "RPC unsubscribe command failed: #{response.inspect}") 127 | end 128 | process_command(response, identifier) 129 | end 130 | 131 | def send_message(identifier, data) 132 | response = rpc_command("message", identifier, data) 133 | log(:error, "RPC message command failed: #{response.inspect}") if response.status == :ERROR 134 | process_command(response, identifier) 135 | end 136 | 137 | def send_history(identifier, history) 138 | log(:debug) { "History is not truly supported. Responding with reject_history" } 139 | transmit({"type" => "reject_history"}) 140 | end 141 | 142 | def process_command(response, identifier) 143 | response.transmissions.each { |transmission| transmit(decode_transmission(transmission)) } 144 | hub.remove_channel(socket, identifier) if response.stop_streams 145 | response.streams.each { |stream| hub.add_subscriber(stream, socket, identifier) } 146 | response.stopped_streams.each { |stream| hub.remove_subscriber(stream, socket, identifier) } 147 | 148 | @_istate[identifier] ||= {} 149 | @_istate[identifier].merge!(response.env.istate&.to_h || {}) 150 | 151 | close_connection if response.disconnect 152 | end 153 | 154 | def process_open(response) 155 | response.transmissions&.each { |transmission| transmit(decode_transmission(transmission)) } 156 | if response.status == :SUCCESS 157 | @_identifiers = response.identifiers 158 | @_cstate = response.env.cstate&.to_h || {} 159 | hub.add_socket(socket, @_identifiers) 160 | log(:debug) { "Opened" } 161 | else 162 | log(:error, "RPC connection command failed: #{response.inspect}") 163 | close_connection 164 | end 165 | end 166 | 167 | def process_close(response) 168 | if response.status == :SUCCESS 169 | log(:debug) { "Closed" } 170 | else 171 | log(:error, "RPC disconnection command failed: #{response.inspect}") 172 | end 173 | end 174 | 175 | def reset_connection 176 | @_identifiers = "{}" 177 | @_subscriptions = [] 178 | 179 | hub.remove_socket(socket) 180 | end 181 | 182 | def close_connection 183 | reset_connection 184 | close 185 | end 186 | 187 | def encode(cable_message) 188 | coder.encode(cable_message) 189 | end 190 | 191 | def decode_transmission(json_message) 192 | JSON.parse(json_message) 193 | end 194 | 195 | def decode(websocket_message) 196 | coder.decode(websocket_message) 197 | end 198 | 199 | def log(level, msg = nil) 200 | super(level, msg ? log_fmt(msg) : nil) { log_fmt(yield) } 201 | end 202 | 203 | def log_fmt(msg) 204 | "[sid=#{sid}] #{msg}" 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/anycable/rack/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AnyCable 4 | module Rack 5 | module Errors 6 | class HijackNotAvailable < RuntimeError; end 7 | class MiddlewareSetup < StandardError; end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/anycable/rack/hub.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | module AnyCable 6 | module Rack 7 | # From https://github.com/rails/rails/blob/v5.0.1/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb 8 | class Hub 9 | INTERNAL_STREAM = :__internal__ 10 | 11 | attr_reader :streams, :sockets 12 | 13 | def initialize 14 | @streams = Hash.new do |streams, stream_id| 15 | streams[stream_id] = Hash.new { |channels, channel_id| channels[channel_id] = Set.new } 16 | end 17 | @sockets = Hash.new { |h, k| h[k] = Set.new } 18 | @sync = Mutex.new 19 | end 20 | 21 | def add_socket(socket, identifier) 22 | @sync.synchronize do 23 | @streams[INTERNAL_STREAM][identifier] << socket 24 | end 25 | end 26 | 27 | def add_subscriber(stream, socket, channel) 28 | @sync.synchronize do 29 | @streams[stream][channel] << socket 30 | @sockets[socket] << [channel, stream] 31 | end 32 | end 33 | 34 | def remove_subscriber(stream, socket, channel) 35 | @sync.synchronize do 36 | @streams[stream][channel].delete(socket) 37 | @sockets[socket].delete([channel, stream]) 38 | cleanup stream, socket, channel 39 | end 40 | end 41 | 42 | def remove_channel(socket, channel) 43 | list = @sync.synchronize do 44 | return unless @sockets.key?(socket) 45 | 46 | @sockets[socket].dup 47 | end 48 | 49 | list.each do |(channel_id, stream)| 50 | remove_subscriber(stream, socket, channel) if channel == channel_id 51 | end 52 | end 53 | 54 | def remove_socket(socket) 55 | list = @sync.synchronize do 56 | return unless @sockets.key?(socket) 57 | 58 | @sockets[socket].dup 59 | end 60 | 61 | list.each do |(channel_id, stream)| 62 | remove_subscriber(stream, socket, channel_id) 63 | end 64 | end 65 | 66 | def broadcast(stream, message, coder) 67 | list = @sync.synchronize do 68 | return unless @streams.key?(stream) 69 | 70 | @streams[stream].to_a 71 | end 72 | 73 | list.each do |(channel_id, sockets)| 74 | decoded = JSON.parse(message) 75 | cmessage = channel_message(channel_id, decoded, coder) 76 | sockets.each { |socket| socket.transmit(cmessage) } 77 | end 78 | end 79 | 80 | def broadcast_all(message) 81 | sockets.each_key { |socket| socket.transmit(message) } 82 | end 83 | 84 | def disconnect(identifier, reconnect, coder) 85 | sockets = @sync.synchronize do 86 | return unless @streams[INTERNAL_STREAM].key?(identifier) 87 | 88 | @streams[INTERNAL_STREAM][identifier].to_a 89 | end 90 | 91 | msg = disconnect_message("remote", reconnect, coder) 92 | 93 | sockets.each do |socket| 94 | socket.transmit(msg) 95 | socket.close 96 | end 97 | end 98 | 99 | def close_all 100 | hub.sockets.dup.each do |socket| 101 | hub.remove_socket(socket) 102 | socket.close 103 | end 104 | end 105 | 106 | private 107 | 108 | def cleanup(stream, socket, channel) 109 | @streams[stream].delete(channel) if @streams[stream][channel].empty? 110 | @streams.delete(stream) if @streams[stream].empty? 111 | @sockets.delete(socket) if @sockets[socket].empty? 112 | end 113 | 114 | def channel_message(channel_id, message, coder) 115 | coder.encode(identifier: channel_id, message: message) 116 | end 117 | 118 | def disconnect_message(reason, reconnect, coder) 119 | coder.encode({type: :disconnect, reason: reason, reconnect: reconnect}) 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/anycable/rack/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AnyCable 4 | module Rack 5 | module Logging # :nodoc: 6 | PREFIX = "AnyCableRackServer" 7 | 8 | private 9 | 10 | def log(level, message = nil, logger = AnyCable.logger) 11 | logger.send(level, PREFIX) { message || yield } 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/anycable/rack/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "websocket" 4 | 5 | require "anycable/rack/connection" 6 | require "anycable/rack/errors" 7 | require "anycable/rack/socket" 8 | 9 | module AnyCable 10 | module Rack 11 | class Middleware # :nodoc: 12 | PROTOCOLS = %w[actioncable-v1-json actioncable-v1-ext-json actioncable-v1-msgpack actioncable-unsupported actioncable-v1-protobuf].freeze 13 | 14 | attr_reader :pinger, 15 | :hub, 16 | :coder, 17 | :rpc_client, 18 | :header_names 19 | 20 | def initialize(pinger:, hub:, coder:, rpc_client:, header_names:) 21 | @pinger = pinger 22 | @hub = hub 23 | @coder = coder 24 | @rpc_client = rpc_client 25 | @header_names = header_names 26 | end 27 | 28 | def call(env) 29 | return not_found unless websocket?(env) 30 | 31 | rack_hijack(env) 32 | listen_socket(env) 33 | 34 | [-1, {}, []] 35 | end 36 | 37 | private 38 | 39 | def handshake 40 | @handshake ||= WebSocket::Handshake::Server.new(protocols: PROTOCOLS) 41 | end 42 | 43 | def rack_hijack(env) 44 | raise Errors::HijackNotAvailable unless env["rack.hijack"] 45 | 46 | env["rack.hijack"].call 47 | send_handshake(env) 48 | end 49 | 50 | def send_handshake(env) 51 | handshake.from_rack(env) 52 | env["rack.hijack_io"].write(handshake.to_s) 53 | end 54 | 55 | def listen_socket(env) 56 | socket = Socket.new(env, env["rack.hijack_io"], handshake.version) 57 | init_connection(socket) 58 | init_pinger(socket) 59 | socket.listen 60 | end 61 | 62 | def not_found 63 | [404, {"Content-Type" => "text/plain"}, ["Not Found"]] 64 | end 65 | 66 | def websocket?(env) 67 | env["HTTP_UPGRADE"] == "websocket" 68 | end 69 | 70 | def init_connection(socket) 71 | connection = Connection.new( 72 | socket, 73 | hub: hub, 74 | coder: coder, 75 | rpc_client: rpc_client, 76 | headers: fetch_headers(socket.request) 77 | ) 78 | socket.onopen { connection.handle_open } 79 | socket.onclose { connection.handle_close } 80 | socket.onmessage { |data| connection.handle_command(data) } 81 | end 82 | 83 | def init_pinger(socket) 84 | pinger.add(socket) 85 | socket.onclose { pinger.remove(socket) } 86 | end 87 | 88 | def fetch_headers(request) 89 | header_names.each_with_object({}) do |name, acc| 90 | header_val = request.env["HTTP_#{name.tr("-", "_").upcase}"] 91 | acc[name] = header_val unless header_val.nil? || header_val.empty? 92 | end 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/anycable/rack/pinger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module AnyCable 6 | module Rack 7 | # Sends pings to sockets 8 | class Pinger 9 | INTERVAL = 3 10 | 11 | attr_reader :coder 12 | 13 | def initialize(coder) 14 | @coder = coder 15 | @_sockets = [] 16 | @_stopped = false 17 | end 18 | 19 | def add(socket) 20 | @_sockets << socket 21 | end 22 | 23 | def remove(socket) 24 | @_sockets.delete(socket) 25 | end 26 | 27 | def stop 28 | @_stopped = true 29 | end 30 | 31 | def run 32 | Thread.new do 33 | loop do 34 | break if @_stopped 35 | 36 | unless @_sockets.empty? 37 | msg = ping_message(Time.now.to_i) 38 | @_sockets.each do |socket| 39 | socket.transmit(msg) 40 | end 41 | end 42 | 43 | sleep(INTERVAL) 44 | end 45 | end 46 | end 47 | 48 | private 49 | 50 | def ping_message(time) 51 | coder.encode({type: :ping, message: time}) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/anycable/rack/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AnyCable 4 | module Rack 5 | class Railtie < ::Rails::Railtie # :nodoc: all 6 | config.before_configuration do 7 | config.any_cable_rack = AnyCable::Rack.config 8 | end 9 | 10 | initializer "anycable.rack.mount", after: "action_cable.routes" do 11 | config.after_initialize do |app| 12 | config = app.config.any_cable_rack 13 | 14 | next unless config.mount_path 15 | 16 | server = AnyCable::Rack::Server.new 17 | 18 | app.routes.prepend do 19 | mount server => config.mount_path 20 | 21 | if AnyCable.config.broadcast_adapter.to_s == "http" 22 | mount server.broadcast => config.http_broadcast_path 23 | end 24 | end 25 | 26 | server.start! 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/anycable/rack/rpc/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "connection_pool" 4 | require "anycable/grpc" 5 | 6 | module AnyCable 7 | module Rack 8 | module RPC 9 | # AnyCable RPC client 10 | class Client 11 | attr_reader :pool, :metadata 12 | 13 | def initialize(host:, size:, timeout:) 14 | @pool = ConnectionPool.new(size: size, timeout: timeout) do 15 | AnyCable::GRPC::Service.rpc_stub_class.new(host, :this_channel_is_insecure) 16 | end 17 | @metadata = {metadata: {"protov" => "v1"}}.freeze 18 | end 19 | 20 | def connect(headers:, url:) 21 | request = ConnectionRequest.new(env: Env.new(headers: headers, url: url)) 22 | pool.with do |stub| 23 | stub.connect(request, metadata) 24 | end 25 | end 26 | 27 | def command(command:, identifier:, connection_identifiers:, data:, headers:, url:, connection_state: nil, state: nil) 28 | message = CommandMessage.new( 29 | command: command, 30 | identifier: identifier, 31 | connection_identifiers: connection_identifiers, 32 | data: data, 33 | env: Env.new( 34 | headers: headers, 35 | url: url, 36 | cstate: connection_state, 37 | istate: state 38 | ) 39 | ) 40 | pool.with do |stub| 41 | stub.command(message, metadata) 42 | end 43 | end 44 | 45 | def disconnect(identifiers:, subscriptions:, headers:, url:, state: nil, channels_state: nil) 46 | request = DisconnectRequest.new( 47 | identifiers: identifiers, 48 | subscriptions: subscriptions, 49 | env: Env.new( 50 | headers: headers, 51 | url: url, 52 | cstate: state, 53 | istate: encode_istate(channels_state) 54 | ) 55 | ) 56 | pool.with do |stub| 57 | stub.disconnect(request, metadata) 58 | end 59 | end 60 | 61 | private 62 | 63 | # We need a string -> string Hash here 64 | def encode_istate(state) 65 | state.transform_values(&:to_json) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/anycable/rack/rpc/rpc.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package anycable; 4 | 5 | service RPC { 6 | rpc Connect (ConnectionRequest) returns (ConnectionResponse) {} 7 | rpc Command (CommandMessage) returns (CommandResponse) {} 8 | rpc Disconnect (DisconnectRequest) returns (DisconnectResponse) {} 9 | } 10 | 11 | enum Status { 12 | ERROR = 0; 13 | SUCCESS = 1; 14 | FAILURE = 2; 15 | } 16 | 17 | message Env { 18 | string url = 1; 19 | map headers = 2; 20 | map cstate = 3; 21 | map istate = 4; 22 | } 23 | 24 | message EnvResponse { 25 | map cstate = 1; 26 | map istate = 2; 27 | } 28 | 29 | message ConnectionRequest { 30 | Env env = 3; 31 | } 32 | 33 | message ConnectionResponse { 34 | Status status = 1; 35 | string identifiers = 2; 36 | repeated string transmissions = 3; 37 | string error_msg = 4; 38 | EnvResponse env = 5; 39 | } 40 | 41 | message CommandMessage { 42 | string command = 1; 43 | string identifier = 2; 44 | string connection_identifiers = 3; 45 | string data = 4; 46 | Env env = 5; 47 | } 48 | 49 | message CommandResponse { 50 | Status status = 1; 51 | bool disconnect = 2; 52 | bool stop_streams = 3; 53 | repeated string streams = 4; 54 | repeated string transmissions = 5; 55 | string error_msg = 6; 56 | EnvResponse env = 7; 57 | repeated string stopped_streams = 8; 58 | } 59 | 60 | message DisconnectRequest { 61 | string identifiers = 1; 62 | repeated string subscriptions = 2; 63 | Env env = 5; 64 | } 65 | 66 | message DisconnectResponse { 67 | Status status = 1; 68 | string error_msg = 2; 69 | } 70 | -------------------------------------------------------------------------------- /lib/anycable/rack/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anycable" 4 | 5 | require "anycable/rack/hub" 6 | require "anycable/rack/pinger" 7 | require "anycable/rack/errors" 8 | require "anycable/rack/middleware" 9 | require "anycable/rack/logging" 10 | require "anycable/rack/broadcast_subscribers/base_subscriber" 11 | 12 | module AnyCable # :nodoc: all 13 | module Rack 14 | class Server 15 | include Logging 16 | 17 | attr_reader :config, 18 | :broadcast, 19 | :coder, 20 | :hub, 21 | :middleware, 22 | :pinger, 23 | :rpc_client, 24 | :headers, 25 | :rpc_cli 26 | 27 | def initialize(config: AnyCable::Rack.config) 28 | @config = config 29 | @hub = Hub.new 30 | @coder = resolve_coder(config.coder) 31 | @pinger = Pinger.new(coder) 32 | 33 | @broadcast = resolve_broadcast_adapter 34 | @rpc_client = RPC::Client.new( 35 | host: config.rpc_addr, 36 | size: config.rpc_client_pool_size, 37 | timeout: config.rpc_client_timeout 38 | ) 39 | 40 | @middleware = Middleware.new( 41 | header_names: config.headers, 42 | pinger: pinger, 43 | hub: hub, 44 | rpc_client: rpc_client, 45 | coder: coder 46 | ) 47 | 48 | log(:info) { "Connecting to RPC server at #{config.rpc_addr}" } 49 | end 50 | # rubocop:enable 51 | 52 | def start! 53 | log(:info) { "Starting..." } 54 | 55 | pinger.run 56 | 57 | broadcast.start 58 | 59 | @_started = true 60 | end 61 | 62 | def shutdown 63 | log(:info) { "Shutting down..." } 64 | Rack.rpc_server&.shutdown 65 | hub.broadcast_all(coder.encode(type: "disconnect", reason: "server_restart", reconnect: true)) 66 | end 67 | 68 | def started? 69 | @_started == true 70 | end 71 | 72 | def stop 73 | return unless started? 74 | 75 | @_started = false 76 | broadcast_subscriber.stop 77 | pinger.stop 78 | hub.close_all 79 | end 80 | 81 | def call(env) 82 | middleware.call(env) 83 | end 84 | 85 | def inspect 86 | "#" 87 | end 88 | 89 | private 90 | 91 | def resolve_broadcast_adapter 92 | adapter = AnyCable.config.broadcast_adapter.to_s 93 | require "anycable/rack/broadcast_subscribers/#{adapter}_subscriber" 94 | 95 | if adapter.to_s == "redis" 96 | BroadcastSubscribers::RedisSubscriber.new( 97 | hub: hub, 98 | coder: coder, 99 | channel: AnyCable.config.redis_channel, 100 | **AnyCable.config.to_redis_params 101 | ) 102 | elsif adapter.to_s == "http" 103 | BroadcastSubscribers::HTTPSubscriber.new( 104 | hub: hub, 105 | coder: coder, 106 | token: AnyCable.config.http_broadcast_secret, 107 | path: config.http_broadcast_path 108 | ) 109 | else 110 | raise ArgumentError, "Unsupported broadcast adatper: #{adapter}. AnyCable Rack server only supports: redis, http" 111 | end 112 | end 113 | 114 | def resolve_coder(name) 115 | require "anycable/rack/coders/#{name}" 116 | AnyCable::Rack::Coders.const_get(name.capitalize) 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/anycable/rack/socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anycable/rack/logging" 4 | 5 | module AnyCable 6 | module Rack 7 | # Wrapper for outgoing data used to correctly set the WS frame type 8 | class BinaryFrame 9 | def initialize(data) 10 | @data = data 11 | end 12 | 13 | def to_s 14 | @data.to_s 15 | end 16 | end 17 | 18 | # Socket wrapper 19 | class Socket 20 | include Logging 21 | attr_reader :version, :socket 22 | 23 | def initialize(env, socket, version) 24 | log(:debug, "WebSocket version #{version}") 25 | @env = env 26 | @socket = socket 27 | @version = version 28 | 29 | @_open_handlers = [] 30 | @_message_handlers = [] 31 | @_close_handlers = [] 32 | @_error_handlers = [] 33 | @_active = true 34 | end 35 | 36 | def transmit(data, type: nil) 37 | # p "DATA: #{data.class} — #{data.to_s}" 38 | type ||= data.is_a?(BinaryFrame) ? :binary : :text 39 | frame = WebSocket::Frame::Outgoing::Server.new( 40 | version: version, 41 | data: data, 42 | type: type 43 | ) 44 | socket.write(frame.to_s) 45 | rescue Exception => e # rubocop:disable Lint/RescueException 46 | log(:error, "Socket send failed: #{e}") 47 | close 48 | end 49 | 50 | def request 51 | @request ||= ::Rack::Request.new(@env) 52 | end 53 | 54 | def onopen(&block) 55 | @_open_handlers << block 56 | end 57 | 58 | def onmessage(&block) 59 | @_message_handlers << block 60 | end 61 | 62 | def onclose(&block) 63 | @_close_handlers << block 64 | end 65 | 66 | def onerror(&block) 67 | @_error_handlers << block 68 | end 69 | 70 | def listen 71 | keepalive 72 | Thread.new do 73 | Thread.current.report_on_exception = true 74 | begin 75 | @_open_handlers.each(&:call) 76 | each_frame do |data| 77 | @_message_handlers.each do |handler| 78 | handler.call(data) 79 | rescue => e # rubocop: disable Style/RescueStandardError 80 | log(:error, "Socket receive failed: #{e}") 81 | @_error_handlers.each { |eh| eh.call(e, data) } 82 | close 83 | end 84 | end 85 | ensure 86 | close 87 | end 88 | end 89 | end 90 | 91 | def close 92 | return unless @_active 93 | 94 | @_close_handlers.each(&:call) 95 | close! 96 | 97 | @_active = false 98 | end 99 | 100 | def closed? 101 | socket.closed? 102 | end 103 | 104 | private 105 | 106 | def close! 107 | if socket.respond_to?(:closed?) 108 | close_socket unless @socket.closed? 109 | else 110 | close_socket 111 | end 112 | end 113 | 114 | def close_socket 115 | frame = WebSocket::Frame::Outgoing::Server.new(version: version, type: :close, code: 1000) 116 | socket.write(frame.to_s) if frame.supported? 117 | socket.close 118 | rescue Exception # rubocop:disable Lint/RescueException 119 | # already closed 120 | end 121 | 122 | def keepalive 123 | thread = Thread.new do 124 | Thread.current.abort_on_exception = true 125 | loop do 126 | sleep 5 127 | transmit nil, type: :ping 128 | end 129 | end 130 | 131 | onclose do 132 | thread.kill 133 | end 134 | end 135 | 136 | def each_frame 137 | framebuffer = WebSocket::Frame::Incoming::Server.new(version: version) 138 | while IO.select([socket]) 139 | if socket.respond_to?(:recvfrom) 140 | data, _addrinfo = socket.recvfrom(2000) 141 | else 142 | data = socket.readpartial(2000) 143 | _addrinfo = socket.peeraddr 144 | end 145 | 146 | break if data.empty? 147 | 148 | framebuffer << data 149 | 150 | while frame = framebuffer.next # rubocop:disable Lint/AssignmentInCondition 151 | case frame.type 152 | when :close 153 | return 154 | when :text, :binary 155 | yield frame.data 156 | end 157 | end 158 | end 159 | rescue Exception => e # rubocop:disable Lint/RescueException 160 | log(:error, "Socket frame error: #{e}\n #{e.backtrace.take(4).join("\n")}") 161 | nil # client disconnected or timed out 162 | end 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/anycable/rack/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AnyCable 4 | module Rack 5 | VERSION = "0.5.1" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/anycable/test_hub.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest/autorun" 4 | require "anycable-rack-server" 5 | require "anycable/rack/coders/json" 6 | require "securerandom" 7 | require "set" 8 | 9 | class TestHub < Minitest::Test 10 | attr_reader :coder, 11 | :hub, 12 | :channel, 13 | :msg, 14 | :stream 15 | 16 | def setup 17 | @coder = AnyCable::Rack::Coders::Json 18 | @hub = AnyCable::Rack::Hub.new 19 | @channel = "channel" 20 | @msg = {data: :test}.to_json 21 | @stream = "stream" 22 | end 23 | 24 | def test_add_subscriber 25 | setup_helper_data 26 | hub.add_subscriber(stream, @socket, channel) 27 | 28 | assert_equal [@socket], hub.sockets.keys 29 | assert_equal [stream], hub.streams.keys 30 | assert_equal [{channel => @set}], hub.streams.values 31 | end 32 | 33 | def test_broadcast 34 | socket = Minitest::Mock.new 35 | 3.times { socket.expect(:hash, 123) } 36 | socket.expect(:transmit, true, [{identifier: channel, message: coder.decode(msg)}.to_json]) 37 | hub.add_subscriber(stream, socket, channel) 38 | hub.broadcast(stream, msg, coder) 39 | end 40 | 41 | def test_remove_subscriber 42 | setup_helper_data 43 | 44 | hub.add_subscriber(stream, @socket, channel) 45 | hub.remove_subscriber(stream, @socket, channel) 46 | 47 | assert_equal [], hub.sockets.keys 48 | assert_equal [], hub.streams.keys 49 | assert_equal [], hub.streams.values 50 | end 51 | 52 | def test_remove_subscriber_multiple_streams 53 | setup_helper_data 54 | 55 | hub.add_subscriber(stream, @socket, channel) 56 | hub.add_subscriber(@stream2, @socket, channel) 57 | hub.remove_subscriber(stream, @socket, channel) 58 | 59 | assert_equal [@socket], hub.sockets.keys 60 | assert_equal [@stream2], hub.streams.keys 61 | assert_equal [{channel => @set}], hub.streams.values 62 | end 63 | 64 | def test_remove_channel 65 | setup_helper_data 66 | 67 | hub.add_subscriber(stream, @socket, channel) 68 | hub.add_subscriber(@stream2, @socket, channel) 69 | hub.remove_channel(@socket, channel) 70 | 71 | assert_equal [], hub.sockets.keys 72 | assert_equal [], hub.streams.keys 73 | assert_equal [], hub.streams.values 74 | end 75 | 76 | def test_remove_channel_multiple_streams 77 | setup_helper_data 78 | 79 | hub.add_subscriber(stream, @socket, channel) 80 | hub.add_subscriber(@stream2, @socket, channel) 81 | hub.remove_channel(@socket, channel) 82 | 83 | assert_equal [], hub.sockets.keys 84 | assert_equal [], hub.streams.keys 85 | assert_equal [], hub.streams.values 86 | end 87 | 88 | def test_remove_channel_multiple_channels 89 | setup_helper_data 90 | channel2 = "channel2" 91 | 92 | hub.add_subscriber(stream, @socket, channel) 93 | hub.add_subscriber(stream, @socket, channel2) 94 | hub.remove_channel(@socket, channel) 95 | 96 | assert_equal [@socket], hub.sockets.keys 97 | assert_equal [stream], hub.streams.keys 98 | assert_equal [{channel2 => @set}], hub.streams.values 99 | end 100 | 101 | def test_remove_socket 102 | setup_helper_data 103 | 104 | hub.add_subscriber(stream, @socket, channel) 105 | hub.add_subscriber(@stream2, @socket, channel) 106 | hub.remove_socket(@socket) 107 | 108 | assert_equal [], hub.sockets.keys 109 | assert_equal [], hub.streams.keys 110 | assert_equal [], hub.streams.values 111 | end 112 | 113 | def test_remove_socket_multiple_streams_and_channels 114 | setup_helper_data 115 | channel2 = "channel2" 116 | 117 | hub.add_subscriber(stream, @socket, channel) 118 | hub.add_subscriber(@stream2, @socket, channel) 119 | hub.add_subscriber(@stream2, @socket, channel2) 120 | hub.remove_socket(@socket) 121 | 122 | assert_equal [], hub.sockets.keys 123 | assert_equal [], hub.streams.keys 124 | assert_equal [], hub.streams.values 125 | end 126 | 127 | private 128 | 129 | def setup_helper_data 130 | @socket = "mock" 131 | @stream2 = "stream2" 132 | @set = Set.new 133 | @set << @socket 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/support/rack/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("../../lib", __dir__) 4 | require "anycable-rack-server" 5 | 6 | ws_server = AnyCable::Rack::Server.new 7 | 8 | app = Rack::Builder.new do 9 | map "/cable" do 10 | run ws_server 11 | end 12 | end 13 | 14 | ws_server.start! 15 | 16 | run app 17 | -------------------------------------------------------------------------------- /test/support/rails/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "./config/environment.rb" 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /test/support/rails/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("../../../lib", __dir__) 4 | 5 | require "anyt/dummy/application" 6 | 7 | Rails.application.config.root = File.join(__dir__, "..") 8 | 9 | # Rails.application.config.log_level = :debug 10 | 11 | require "anyt/tests" 12 | 13 | ActionCable.server.config.cable = {"adapter" => "any_cable"} 14 | 15 | require "anycable-rack-server" 16 | 17 | # Load channels from tests 18 | Anyt::Tests.load_all_tests 19 | 20 | Rails.application.initialize! 21 | --------------------------------------------------------------------------------