├── .gem_release.yml ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docs-lint.yml │ ├── release.yml │ ├── rubocop.yml │ └── test.yml ├── .gitignore ├── .mdlrc ├── .rspec ├── .rubocop-md.yml ├── .rubocop.yml ├── .rubocop └── rubocop_rspec.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── examples └── sinatra │ ├── Gemfile │ ├── Procfile │ ├── README.md │ ├── app.rb │ ├── assets │ ├── app.css │ ├── cable.js │ └── reset.css │ ├── chat.rb │ ├── config.ru │ ├── config │ ├── anycable.yml │ └── environment.rb │ └── views │ ├── index.slim │ ├── layout.slim │ ├── login.slim │ ├── resetcss.slim │ └── room.slim ├── forspell.dict ├── gemfiles └── rubocop.gemfile ├── lib ├── lite_cable.rb ├── lite_cable │ ├── anycable.rb │ ├── broadcast_adapters.rb │ ├── broadcast_adapters │ │ ├── any_cable.rb │ │ ├── base.rb │ │ └── memory.rb │ ├── channel.rb │ ├── channel │ │ ├── base.rb │ │ ├── registry.rb │ │ └── streams.rb │ ├── coders.rb │ ├── coders │ │ ├── json.rb │ │ └── raw.rb │ ├── config.rb │ ├── connection.rb │ ├── connection │ │ ├── authorization.rb │ │ ├── base.rb │ │ ├── identification.rb │ │ ├── streams.rb │ │ └── subscriptions.rb │ ├── internal.rb │ ├── logging.rb │ ├── server.rb │ ├── server │ │ ├── client_socket.rb │ │ ├── client_socket │ │ │ ├── base.rb │ │ │ └── subscriptions.rb │ │ ├── heart_beat.rb │ │ ├── middleware.rb │ │ └── subscribers_map.rb │ └── version.rb └── litecable.rb ├── litecable.gemspec └── spec ├── integrations └── server_spec.rb ├── lite_cable ├── channel │ ├── base_spec.rb │ └── streams_spec.rb ├── config_spec.rb ├── connection │ ├── authorization_spec.rb │ ├── base_spec.rb │ ├── identification_spec.rb │ └── subscriptions_spec.rb └── server │ └── subscribers_map_spec.rb ├── litecable_spec.rb ├── spec_helper.rb └── support ├── async_helpers.rb ├── sync_client.rb ├── test_connection.rb └── test_socket.rb /.gem_release.yml: -------------------------------------------------------------------------------- 1 | bump: 2 | file: lib/lite_cable/version.rb 3 | skip_ci: true 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ### Tell us about your environment 9 | 10 | **Ruby version:** 11 | 12 | **`litecable` 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 | - "**/*.md" 9 | - ".github/workflows/docs-lint.yml" 10 | pull_request: 11 | paths: 12 | - "**/*.md" 13 | - ".github/workflows/docs-lint.yml" 14 | 15 | jobs: 16 | docs-lint: 17 | uses: anycable/github-actions/.github/workflows/docs-lint.yml@master 18 | with: 19 | forspell-args: README.md CHANGELOG.md 20 | lychee-args: README.md CHANGELOG.md -v 21 | mdl-path: README.md CHANGELOG.md 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release gems 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 3.2 22 | - name: Configure RubyGems Credentials 23 | uses: rubygems/configure-rubygems-credentials@main 24 | - name: Publish to RubyGems 25 | run: | 26 | gem install gem-release 27 | gem release 28 | -------------------------------------------------------------------------------- /.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 | env: 13 | BUNDLE_JOBS: 4 14 | BUNDLE_RETRY: 3 15 | BUNDLE_GEMFILE: gemfiles/rubocop.gemfile 16 | CI: true 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 3.1 22 | bundler-cache: true 23 | - name: Lint Ruby code with RuboCop 24 | run: | 25 | bundle exec rubocop 26 | -------------------------------------------------------------------------------- /.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 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 17 | CI: true 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3", "truffleruby"] 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run RSpec 29 | run: | 30 | bundle exec rspec 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | *.iml 13 | .idea/ 14 | 15 | # Sublime 16 | *.sublime-project 17 | *.sublime-workspace 18 | 19 | # OS or Editor folders 20 | .DS_Store 21 | .cache 22 | .project 23 | .settings 24 | .tmproj 25 | Thumbs.db 26 | 27 | .bundle/ 28 | log/*.log 29 | *.gz 30 | pkg/ 31 | spec/dummy/db/*.sqlite3 32 | spec/dummy/db/*.sqlite3-journal 33 | spec/dummy/tmp/ 34 | 35 | Gemfile.lock 36 | Gemfile.local 37 | .rspec 38 | *.gem 39 | tmp/ 40 | coverage/ 41 | gemfiles/*.lock 42 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033", "~MD041" 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop-md.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ".rubocop.yml" 2 | 3 | require: 4 | - rubocop-md 5 | 6 | AllCops: 7 | Include: 8 | - '**/*.md' 9 | 10 | Naming/FileName: 11 | Exclude: 12 | - '**/*.md' 13 | 14 | Layout/InitialIndentation: 15 | Exclude: 16 | - 'CHANGELOG.md' 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard/cop/block_single_line_braces 3 | 4 | inherit_gem: 5 | standard: config/base.yml 6 | 7 | inherit_from: 8 | - .rubocop/rubocop_rspec.yml 9 | 10 | AllCops: 11 | Exclude: 12 | - 'bin/*' 13 | - 'tmp/**/*' 14 | - 'Gemfile' 15 | - 'vendor/**/*' 16 | - 'gemfiles/**/*' 17 | - 'benchmarks/**/*' 18 | DisplayCopNames: true 19 | SuggestExtensions: false 20 | NewCops: disable 21 | TargetRubyVersion: 2.7 22 | 23 | Standard/BlockSingleLineBraces: 24 | Enabled: false 25 | 26 | Style/FrozenStringLiteralComment: 27 | Enabled: true 28 | 29 | -------------------------------------------------------------------------------- /.rubocop/rubocop_rspec.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | RSpec: 5 | Enabled: false 6 | 7 | RSpec/Focus: 8 | Enabled: true 9 | 10 | RSpec/EmptyExampleGroup: 11 | Enabled: true 12 | 13 | RSpec/EmptyLineAfterExampleGroup: 14 | Enabled: true 15 | 16 | RSpec/EmptyLineAfterFinalLet: 17 | Enabled: true 18 | 19 | RSpec/EmptyLineAfterHook: 20 | Enabled: true 21 | 22 | RSpec/EmptyLineAfterSubject: 23 | Enabled: true 24 | 25 | RSpec/HookArgument: 26 | Enabled: true 27 | 28 | RSpec/HooksBeforeExamples: 29 | Enabled: true 30 | 31 | RSpec/ImplicitExpect: 32 | Enabled: true 33 | 34 | RSpec/IteratedExpectation: 35 | Enabled: true 36 | 37 | RSpec/LetBeforeExamples: 38 | Enabled: true 39 | 40 | RSpec/MissingExampleGroupArgument: 41 | Enabled: true 42 | 43 | RSpec/ReceiveCounts: 44 | Enabled: true 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## master (unreleased) 4 | 5 | ## 0.8.2 (2024-04-26) 6 | 7 | - Fix handling reads on closed connections. ([@palkan][]) 8 | 9 | ## 0.8.1 (2023-08-22) 10 | 11 | - Handle closing socket already closed by server. ([@palkan][]) 12 | 13 | ## 0.8.0 (2023-03-08) 14 | 15 | - Allow using custom channel lookup logic. ([@palkan][]) 16 | 17 | - Ruby 2.7+ is required. 18 | 19 | ## 0.7.2 (2021-07-06) 20 | 21 | - Fixed Ruby 3.0.1 compatibility. 22 | 23 | ## 0.7.1 (2021-01-06) 24 | 25 | - Fix handling client disconnection during socket write. ([@palkan][]) 26 | 27 | ## 0.7.0 (2020-01-07) 28 | 29 | - Refactor AnyCable integration ([@palkan][]) 30 | 31 | Now you only need to set AnyCable broadcast adapter: 32 | 33 | ```ruby 34 | LiteCable.broadcast_adapter = :any_cable 35 | ``` 36 | 37 | ```sh 38 | # or via env/config 39 | LITECABLE_BROADCAST_ADAPTER=any_cable ruby my_app.rb 40 | ``` 41 | 42 | - Adapterize broadcast adapters ([@palkan][]) 43 | 44 | - Drop Ruby 2.4 support ([palkan][]) 45 | 46 | ## 0.6.0 (2019-04-12) 🚀 47 | 48 | - Drop Ruby 2.3 support ([@palkan][]) 49 | 50 | ## 0.5.0 (2017-12-20) 51 | 52 | - Upgrade for AnyCable 0.5.0 ([@palkan][]) 53 | 54 | ## 0.4.1 (2017-02-04) 55 | 56 | - Use `websocket-ruby` with sub-protocols support ([@palkan][]) 57 | 58 | ## 0.4.0 (2017-01-29) 59 | 60 | - Initial version. ([@palkan][]) 61 | 62 | [@palkan]: https://github.com/palkan 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | eval_gemfile "gemfiles/rubocop.gemfile" 4 | 5 | gem "debug", platform: :mri 6 | 7 | # Specify your gem's dependencies in litecable.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 palkan 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/litecable.svg)](https://rubygems.org/gems/litecable) 2 | [![Build](https://github.com/palkan/litecable/workflows/Build/badge.svg)](https://github.com/palkan/litecable/actions) 3 | 4 | # Lite Cable 5 | 6 | Lightweight ActionCable implementation. 7 | 8 | Contains application logic (channels, streams, broadcasting) and also (optional) Rack hijack based server (suitable only for development and test due to its simplicity). 9 | 10 | Compatible with [AnyCable](http://anycable.io) (for production usage). 11 | 12 | 13 | Sponsored by Evil Martians 14 | 15 | ## Examples 16 | 17 | - [Sinatra LiteCable Chat](https://github.com/palkan/litecable/tree/master/examples/sinatra) 18 | 19 | - [Connecting LiteCable to Hanami](http://gabrielmalakias.com.br/ruby/hanami/iot/2017/05/26/websockets-connecting-litecable-to-hanami.html) by [@GabrielMalakias](https://github.com/GabrielMalakias) 20 | 21 | ## Installation 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | ```ruby 26 | gem "litecable" 27 | ``` 28 | 29 | And run `bundle install`. 30 | 31 | ## Usage 32 | 33 | Please, checkout [Action Cable guides](http://guides.rubyonrails.org/action_cable_overview.html) for general information. Lite Cable aims to be compatible with Action Cable as much as possible without the loss of simplicity and _lightness_. 34 | 35 | You can use Action Cable javascript client without any change (precompiled version can be found [here](https://github.com/palkan/litecable/tree/master/examples/sinatra/assets/cable.js)). 36 | 37 | Here are the differences: 38 | 39 | - Use `LiteCable::Connection::Base` as a base class for your connection (instead of `ActionCable::Connection::Base`) 40 | 41 | - Use `LiteCable::Channel::Base` as a base class for your channels (instead of `ActionCable::Channel::Base`) 42 | 43 | - Use `LiteCable.broadcast` to broadcast messages (instead of `ActionCable.server.broadcast`) 44 | 45 | - Explicitly specify channels names: 46 | 47 | ```ruby 48 | class MyChannel < LiteCable::Channel::Base 49 | # Use this id in your client to create subscriptions 50 | identifier :chat 51 | end 52 | ``` 53 | 54 | ```js 55 | App.cable.subscriptions.create('chat', ...) 56 | ``` 57 | 58 | ### Using a custom channel registry 59 | 60 | Alternatively to eager loading all channel classes and providing identifiers, you can build a custom _channel registry_ object, which can perform channel class lookups: 61 | 62 | ```ruby 63 | # DummyRegistry which always returns a predefined channel class 64 | class DummyRegistry 65 | def lookup(channel_id) 66 | DummyChannel 67 | end 68 | end 69 | 70 | LiteCable.channel_registry = DummyRegistry.new 71 | ``` 72 | 73 | ### Using built-in server (middleware) 74 | 75 | Lite Cable comes with a simple Rack middleware for development/testing usage. 76 | To use Lite Cable server: 77 | 78 | - Add `gem "websocket"` to your Gemfile 79 | 80 | - Add `require "lite_cable/server"` 81 | 82 | - Add `LiteCable::Server::Middleware` to your Rack stack, for example: 83 | 84 | ```ruby 85 | Rack::Builder.new do 86 | map "/cable" do 87 | # You have to specify your app's connection class 88 | use LiteCable::Server::Middleware, connection_class: App::Connection 89 | run proc { |_| [200, {"Content-Type" => "text/plain"}, ["OK"]] } 90 | end 91 | end 92 | ``` 93 | 94 | ### Using with AnyCable 95 | 96 | Lite Cable is AnyCable-compatible out-of-the-box. 97 | 98 | If AnyCable gem is loaded, you don't need to configure Lite Cable at all. 99 | 100 | Otherwise, you must configure broadcast adapter manually: 101 | 102 | ```ruby 103 | LiteCable.broadcast_adapter = :any_cable 104 | ``` 105 | 106 | You can also do this via configuration, e.g., env var (`LITECABLE_BROADCAST_ADAPTER=any_cable`) or `broadcast_adapter: any_cable` in a YAML config. 107 | 108 | **At the AnyCable side**, you must configure a connection factory: 109 | 110 | ```ruby 111 | AnyCable.connection_factory = MyApp::Connection 112 | ``` 113 | 114 | Then run AnyCable along with the app: 115 | 116 | ```sh 117 | bundle exec anycable 118 | 119 | # add -r option to load the app if it's not ./config/anycable.rb or ./config/environment.rb 120 | bundle exec anycable -r ./my_app.rb 121 | ``` 122 | 123 | See [Sinatra example](https://github.com/palkan/litecable/tree/master/examples/sinatra) for more. 124 | 125 | ### Configuration 126 | 127 | Lite Cable uses [anyway_config](https://github.com/palkan/anyway_config) for configuration. 128 | 129 | See [config](https://github.com/palkan/litecable/blob/master/lib/lite_cable/config.rb) for available options. 130 | 131 | ### Unsupported features 132 | 133 | - Channel callbacks (`after_subscribe`, etc) 134 | 135 | - Stream callbacks (`stream_from "xyz" { |msg| ... }`) 136 | 137 | - Periodical timers 138 | 139 | - Remote connections. 140 | 141 | ## Contributing 142 | 143 | Bug reports and pull requests are welcome on GitHub at [https://github.com/palkan/litecable](https://github.com/palkan/litecable). 144 | 145 | ## License 146 | 147 | The gem is available as open source under the terms of the [MIT License](./LICENSE.txt). 148 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | begin 10 | require "rubocop/rake_task" 11 | RuboCop::RakeTask.new 12 | 13 | RuboCop::RakeTask.new("rubocop:md") do |task| 14 | task.options << %w[-c .rubocop-md.yml] 15 | end 16 | rescue LoadError 17 | task(:rubocop) {} 18 | task("rubocop:md") {} 19 | end 20 | 21 | task default: %w[rubocop rubocop:md spec] 22 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "litecable" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/sinatra/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sinatra" 6 | gem "sinatra-contrib" 7 | gem "slim" 8 | 9 | gem "puma" 10 | 11 | gem "pry-byebug" 12 | 13 | # litecable deps 14 | gem "litecable", path: "../../" 15 | 16 | # For Rack/Puma server 17 | gem "websocket", "~> 1.2.4" 18 | 19 | # For AnyCable 20 | gem "anycable", "1.0.0.rc1" 21 | -------------------------------------------------------------------------------- /examples/sinatra/Procfile: -------------------------------------------------------------------------------- 1 | web: CABLE_URL=ws://localhost:9293/cable LITECABLE_BROADCAST_ADAPTER=any_cable bundle exec puma 2 | rpc: LITECABLE_BROADCAST_ADAPTER=any_cable bundle exec anycable 3 | ws: anycable-go --debug --host localhost --port 9293 --broadcast_adapter=http 4 | -------------------------------------------------------------------------------- /examples/sinatra/README.md: -------------------------------------------------------------------------------- 1 | # Lite Cable Sinatra Demo 2 | 3 | Sample chat application built with [Sinatra](http://www.sinatrarb.com) and Lite Cable. 4 | 5 | ## Usage 6 | 7 | Install dependencies: 8 | 9 | ```sh 10 | bundle install 11 | ``` 12 | 13 | Run server: 14 | 15 | ```sh 16 | bundle exec puma 17 | ``` 18 | 19 | Open your browser at [localhost:9292](http://localhost:9292), enter your name and a chat room ID (anything you want). 20 | 21 | Then open another session (another browser, incognito window) and repeat all steps using the same room ID. 22 | 23 | Now you can chat with yourself! 24 | 25 | ## AnyCable usage 26 | 27 | **NOTE:** AnyCable v1.0 is required. 28 | 29 | This example also can be used with [AnyCable](http://anycable.io). 30 | 31 | You need [`anycable-go`](https://github.com/anycable/anycable-go) installed. 32 | 33 | Just run `Procfile` with your favourite tool ([hivemind](https://github.com/DarthSim/hivemind) or [overmind](https://github.com/DarthSim/overmind)): 34 | 35 | ```sh 36 | hivemind 37 | ``` 38 | 39 | ## Bonus: Testing via terminal 40 | 41 | You can check play with this app from your terminal using [ACLI](https://github.com/palkan/acli). Here are the example commands: 42 | 43 | ```sh 44 | # For AnyCable 45 | acli -u localhost:9293 --headers="cookie:user=john" -c chat --channel-params "id:1" 46 | 47 | # For Puma 48 | acli -u localhost:9292 --headers="cookie:user=john" -c chat --channel-params "id:1" 49 | ``` 50 | 51 | To send a message type: 52 | 53 | ```sh 54 | \p+ speak 55 | Enter key: message 56 | Enter value: 57 | Enter key: 58 | ``` 59 | -------------------------------------------------------------------------------- /examples/sinatra/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sinatra" 4 | require "sinatra/cookies" 5 | 6 | CABLE_URL = ENV.fetch("CABLE_URL", "/cable") 7 | 8 | class App < Sinatra::Application # :nodoc: 9 | set :public_folder, "assets" 10 | 11 | enable :sessions 12 | set :session_secret, "secret_key_with_size_of_32_bytes_dff054b19c2de43fc406f251376ad40" 13 | 14 | get "/" do 15 | if session[:user] 16 | slim :index 17 | else 18 | slim :login 19 | end 20 | end 21 | 22 | get "/sign_in" do 23 | slim :login 24 | end 25 | 26 | post "/sign_in" do 27 | if params["user"] 28 | session[:user] = params["user"] 29 | cookies["user"] = params["user"] 30 | redirect "/" 31 | else 32 | slim :login 33 | end 34 | end 35 | 36 | post "/rooms" do 37 | if params["id"] 38 | redirect "/rooms/#{params["id"]}" 39 | else 40 | slim :index 41 | end 42 | end 43 | 44 | get "/rooms/:id" do 45 | if session[:user] 46 | @room_id = params["id"] 47 | @user = session[:user] 48 | slim :room 49 | else 50 | slim :login 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /examples/sinatra/assets/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | min-width: 300px; 5 | } 6 | 7 | body { 8 | background: #fff; 9 | color: #363636; 10 | font: 18px/30px "Arial", sans-serif; 11 | } 12 | 13 | p, div, span, a, ul, li { 14 | box-sizing: border-box; 15 | } 16 | 17 | a { 18 | text-decoration: none; 19 | color: #ff5e5e; 20 | 21 | &:visited, 22 | &:active { 23 | color: #ff5e5e; 24 | } 25 | 26 | &:hover { 27 | opacity: 0.8; 28 | } 29 | } 30 | 31 | h1 { 32 | font-size: 70px; 33 | line-height: 90px; 34 | letter-spacing: 2px; 35 | font-weight: bold; 36 | } 37 | 38 | h2 { 39 | font-size: 40px; 40 | line-height: 50px; 41 | letter-spacing: 1.5px; 42 | margin: 20px 0; 43 | } 44 | 45 | .main { 46 | height: 100%; 47 | width: 100%; 48 | padding: 0 20%; 49 | } 50 | 51 | .header { 52 | width: 100%; 53 | display: flex; 54 | justify-content: center; 55 | } 56 | 57 | input[type="text"] { 58 | width: 100%; 59 | height: 40px; 60 | line-height: 40px; 61 | display: block; 62 | margin: 0; 63 | font-size: 18px; 64 | appearance: none; 65 | box-shadow: none; 66 | border-radius: none; 67 | } 68 | 69 | button:focus, input[type="text"]:focus { 70 | outline: none; 71 | } 72 | 73 | .btn { 74 | cursor: pointer; 75 | height: 40px; 76 | text-decoration: none; 77 | padding: 0 20px; 78 | text-align: center; 79 | background: #ff5e5e; 80 | transition: opacity 200ms; 81 | color: white; 82 | font-weight: bold; 83 | font-size: 16px; 84 | letter-spacing: 1.5px; 85 | display: flex; 86 | align-items: center; 87 | justify-content: center; 88 | width: 100px; 89 | margin-top: 30px; 90 | } 91 | 92 | .btn:hover { 93 | opacity: 0.8; 94 | } 95 | 96 | .message-form { 97 | position: fixed; 98 | bottom: 0; 99 | left: 0; 100 | width: 100%; 101 | border-top: 1px #e3e3e3 solid; 102 | z-index: 10; 103 | padding: 20px 20% 20px 20%; 104 | background: white; 105 | opacity: 0.9; 106 | } 107 | 108 | .messages { 109 | display: flex; 110 | flex-direction: column; 111 | padding-bottom: 160px; 112 | } 113 | 114 | .message { 115 | display: flex; 116 | flex-direction: column; 117 | } 118 | 119 | .message.me { 120 | align-self: flex-end; 121 | } 122 | 123 | .messages .message .author { 124 | color: #ff5e5e; 125 | } 126 | 127 | .messages .message.me .author { 128 | align-self: flex-end; 129 | color: #7ed321; 130 | } 131 | 132 | .messages .message.system .author { 133 | color: #9e9e9e; 134 | } 135 | 136 | @media (max-width: 800px) and (min-width: 601px) { 137 | body { 138 | font-size: 3vw; 139 | line-height: 5vw; 140 | } 141 | 142 | h1 { 143 | font-size: 14vw; 144 | line-height: 18vw; 145 | } 146 | 147 | h2 { 148 | font-size: 5vw; 149 | line-height: 7vw; 150 | } 151 | } 152 | 153 | 154 | @media (max-width: 600px) { 155 | body { 156 | font-size: 4vw; 157 | line-height: 6vw; 158 | } 159 | 160 | h1 { 161 | font-size: 14vw; 162 | line-height: 18vw; 163 | } 164 | 165 | h2 { 166 | font-size: 10vw; 167 | line-height: 12vw; 168 | } 169 | } -------------------------------------------------------------------------------- /examples/sinatra/assets/cable.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var slice = [].slice; 3 | 4 | this.ActionCable = { 5 | INTERNAL: { 6 | "message_types": { 7 | "welcome": "welcome", 8 | "ping": "ping", 9 | "confirmation": "confirm_subscription", 10 | "rejection": "reject_subscription" 11 | }, 12 | "default_mount_path": "/cable", 13 | "protocols": ["actioncable-v1-json", "actioncable-unsupported"] 14 | }, 15 | WebSocket: window.WebSocket, 16 | logger: window.console, 17 | createConsumer: function(url) { 18 | var ref; 19 | if (url == null) { 20 | url = (ref = this.getConfig("url")) != null ? ref : this.INTERNAL.default_mount_path; 21 | } 22 | return new ActionCable.Consumer(this.createWebSocketURL(url)); 23 | }, 24 | getConfig: function(name) { 25 | var element; 26 | element = document.head.querySelector("meta[name='action-cable-" + name + "']"); 27 | return element != null ? element.getAttribute("content") : void 0; 28 | }, 29 | createWebSocketURL: function(url) { 30 | var a; 31 | if (url && !/^wss?:/i.test(url)) { 32 | a = document.createElement("a"); 33 | a.href = url; 34 | a.href = a.href; 35 | a.protocol = a.protocol.replace("http", "ws"); 36 | return a.href; 37 | } else { 38 | return url; 39 | } 40 | }, 41 | startDebugging: function() { 42 | return this.debugging = true; 43 | }, 44 | stopDebugging: function() { 45 | return this.debugging = null; 46 | }, 47 | log: function() { 48 | var messages, ref; 49 | messages = 1 <= arguments.length ? slice.call(arguments, 0) : []; 50 | if (this.debugging) { 51 | messages.push(Date.now()); 52 | return (ref = this.logger).log.apply(ref, ["[ActionCable]"].concat(slice.call(messages))); 53 | } 54 | } 55 | }; 56 | 57 | }).call(this); 58 | (function() { 59 | var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; 60 | 61 | ActionCable.ConnectionMonitor = (function() { 62 | var clamp, now, secondsSince; 63 | 64 | ConnectionMonitor.pollInterval = { 65 | min: 3, 66 | max: 30 67 | }; 68 | 69 | ConnectionMonitor.staleThreshold = 6; 70 | 71 | function ConnectionMonitor(connection) { 72 | this.connection = connection; 73 | this.visibilityDidChange = bind(this.visibilityDidChange, this); 74 | this.reconnectAttempts = 0; 75 | } 76 | 77 | ConnectionMonitor.prototype.start = function() { 78 | if (!this.isRunning()) { 79 | this.startedAt = now(); 80 | delete this.stoppedAt; 81 | this.startPolling(); 82 | document.addEventListener("visibilitychange", this.visibilityDidChange); 83 | return ActionCable.log("ConnectionMonitor started. pollInterval = " + (this.getPollInterval()) + " ms"); 84 | } 85 | }; 86 | 87 | ConnectionMonitor.prototype.stop = function() { 88 | if (this.isRunning()) { 89 | this.stoppedAt = now(); 90 | this.stopPolling(); 91 | document.removeEventListener("visibilitychange", this.visibilityDidChange); 92 | return ActionCable.log("ConnectionMonitor stopped"); 93 | } 94 | }; 95 | 96 | ConnectionMonitor.prototype.isRunning = function() { 97 | return (this.startedAt != null) && (this.stoppedAt == null); 98 | }; 99 | 100 | ConnectionMonitor.prototype.recordPing = function() { 101 | return this.pingedAt = now(); 102 | }; 103 | 104 | ConnectionMonitor.prototype.recordConnect = function() { 105 | this.reconnectAttempts = 0; 106 | this.recordPing(); 107 | delete this.disconnectedAt; 108 | return ActionCable.log("ConnectionMonitor recorded connect"); 109 | }; 110 | 111 | ConnectionMonitor.prototype.recordDisconnect = function() { 112 | this.disconnectedAt = now(); 113 | return ActionCable.log("ConnectionMonitor recorded disconnect"); 114 | }; 115 | 116 | ConnectionMonitor.prototype.startPolling = function() { 117 | this.stopPolling(); 118 | return this.poll(); 119 | }; 120 | 121 | ConnectionMonitor.prototype.stopPolling = function() { 122 | return clearTimeout(this.pollTimeout); 123 | }; 124 | 125 | ConnectionMonitor.prototype.poll = function() { 126 | return this.pollTimeout = setTimeout((function(_this) { 127 | return function() { 128 | _this.reconnectIfStale(); 129 | return _this.poll(); 130 | }; 131 | })(this), this.getPollInterval()); 132 | }; 133 | 134 | ConnectionMonitor.prototype.getPollInterval = function() { 135 | var interval, max, min, ref; 136 | ref = this.constructor.pollInterval, min = ref.min, max = ref.max; 137 | interval = 5 * Math.log(this.reconnectAttempts + 1); 138 | return Math.round(clamp(interval, min, max) * 1000); 139 | }; 140 | 141 | ConnectionMonitor.prototype.reconnectIfStale = function() { 142 | if (this.connectionIsStale()) { 143 | ActionCable.log("ConnectionMonitor detected stale connection. reconnectAttempts = " + this.reconnectAttempts + ", pollInterval = " + (this.getPollInterval()) + " ms, time disconnected = " + (secondsSince(this.disconnectedAt)) + " s, stale threshold = " + this.constructor.staleThreshold + " s"); 144 | this.reconnectAttempts++; 145 | if (this.disconnectedRecently()) { 146 | return ActionCable.log("ConnectionMonitor skipping reopening recent disconnect"); 147 | } else { 148 | ActionCable.log("ConnectionMonitor reopening"); 149 | return this.connection.reopen(); 150 | } 151 | } 152 | }; 153 | 154 | ConnectionMonitor.prototype.connectionIsStale = function() { 155 | var ref; 156 | return secondsSince((ref = this.pingedAt) != null ? ref : this.startedAt) > this.constructor.staleThreshold; 157 | }; 158 | 159 | ConnectionMonitor.prototype.disconnectedRecently = function() { 160 | return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; 161 | }; 162 | 163 | ConnectionMonitor.prototype.visibilityDidChange = function() { 164 | if (document.visibilityState === "visible") { 165 | return setTimeout((function(_this) { 166 | return function() { 167 | if (_this.connectionIsStale() || !_this.connection.isOpen()) { 168 | ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = " + document.visibilityState); 169 | return _this.connection.reopen(); 170 | } 171 | }; 172 | })(this), 200); 173 | } 174 | }; 175 | 176 | now = function() { 177 | return new Date().getTime(); 178 | }; 179 | 180 | secondsSince = function(time) { 181 | return (now() - time) / 1000; 182 | }; 183 | 184 | clamp = function(number, min, max) { 185 | return Math.max(min, Math.min(max, number)); 186 | }; 187 | 188 | return ConnectionMonitor; 189 | 190 | })(); 191 | 192 | }).call(this); 193 | (function() { 194 | var i, message_types, protocols, ref, supportedProtocols, unsupportedProtocol, 195 | slice = [].slice, 196 | bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, 197 | indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 198 | 199 | ref = ActionCable.INTERNAL, message_types = ref.message_types, protocols = ref.protocols; 200 | 201 | supportedProtocols = 2 <= protocols.length ? slice.call(protocols, 0, i = protocols.length - 1) : (i = 0, []), unsupportedProtocol = protocols[i++]; 202 | 203 | ActionCable.Connection = (function() { 204 | Connection.reopenDelay = 500; 205 | 206 | function Connection(consumer) { 207 | this.consumer = consumer; 208 | this.open = bind(this.open, this); 209 | this.subscriptions = this.consumer.subscriptions; 210 | this.monitor = new ActionCable.ConnectionMonitor(this); 211 | this.disconnected = true; 212 | } 213 | 214 | Connection.prototype.send = function(data) { 215 | if (this.isOpen()) { 216 | this.webSocket.send(JSON.stringify(data)); 217 | return true; 218 | } else { 219 | return false; 220 | } 221 | }; 222 | 223 | Connection.prototype.open = function() { 224 | if (this.isActive()) { 225 | ActionCable.log("Attempted to open WebSocket, but existing socket is " + (this.getState())); 226 | return false; 227 | } else { 228 | ActionCable.log("Opening WebSocket, current state is " + (this.getState()) + ", subprotocols: " + protocols); 229 | if (this.webSocket != null) { 230 | this.uninstallEventHandlers(); 231 | } 232 | this.webSocket = new ActionCable.WebSocket(this.consumer.url, protocols); 233 | this.installEventHandlers(); 234 | this.monitor.start(); 235 | return true; 236 | } 237 | }; 238 | 239 | Connection.prototype.close = function(arg) { 240 | var allowReconnect, ref1; 241 | allowReconnect = (arg != null ? arg : { 242 | allowReconnect: true 243 | }).allowReconnect; 244 | if (!allowReconnect) { 245 | this.monitor.stop(); 246 | } 247 | if (this.isActive()) { 248 | return (ref1 = this.webSocket) != null ? ref1.close() : void 0; 249 | } 250 | }; 251 | 252 | Connection.prototype.reopen = function() { 253 | var error; 254 | ActionCable.log("Reopening WebSocket, current state is " + (this.getState())); 255 | if (this.isActive()) { 256 | try { 257 | return this.close(); 258 | } catch (error1) { 259 | error = error1; 260 | return ActionCable.log("Failed to reopen WebSocket", error); 261 | } finally { 262 | ActionCable.log("Reopening WebSocket in " + this.constructor.reopenDelay + "ms"); 263 | setTimeout(this.open, this.constructor.reopenDelay); 264 | } 265 | } else { 266 | return this.open(); 267 | } 268 | }; 269 | 270 | Connection.prototype.getProtocol = function() { 271 | var ref1; 272 | return (ref1 = this.webSocket) != null ? ref1.protocol : void 0; 273 | }; 274 | 275 | Connection.prototype.isOpen = function() { 276 | return this.isState("open"); 277 | }; 278 | 279 | Connection.prototype.isActive = function() { 280 | return this.isState("open", "connecting"); 281 | }; 282 | 283 | Connection.prototype.isProtocolSupported = function() { 284 | var ref1; 285 | return ref1 = this.getProtocol(), indexOf.call(supportedProtocols, ref1) >= 0; 286 | }; 287 | 288 | Connection.prototype.isState = function() { 289 | var ref1, states; 290 | states = 1 <= arguments.length ? slice.call(arguments, 0) : []; 291 | return ref1 = this.getState(), indexOf.call(states, ref1) >= 0; 292 | }; 293 | 294 | Connection.prototype.getState = function() { 295 | var ref1, state, value; 296 | for (state in WebSocket) { 297 | value = WebSocket[state]; 298 | if (value === ((ref1 = this.webSocket) != null ? ref1.readyState : void 0)) { 299 | return state.toLowerCase(); 300 | } 301 | } 302 | return null; 303 | }; 304 | 305 | Connection.prototype.installEventHandlers = function() { 306 | var eventName, handler; 307 | for (eventName in this.events) { 308 | handler = this.events[eventName].bind(this); 309 | this.webSocket["on" + eventName] = handler; 310 | } 311 | }; 312 | 313 | Connection.prototype.uninstallEventHandlers = function() { 314 | var eventName; 315 | for (eventName in this.events) { 316 | this.webSocket["on" + eventName] = function() {}; 317 | } 318 | }; 319 | 320 | Connection.prototype.events = { 321 | message: function(event) { 322 | var identifier, message, ref1, type; 323 | if (!this.isProtocolSupported()) { 324 | return; 325 | } 326 | ref1 = JSON.parse(event.data), identifier = ref1.identifier, message = ref1.message, type = ref1.type; 327 | switch (type) { 328 | case message_types.welcome: 329 | this.monitor.recordConnect(); 330 | return this.subscriptions.reload(); 331 | case message_types.ping: 332 | return this.monitor.recordPing(); 333 | case message_types.confirmation: 334 | return this.subscriptions.notify(identifier, "connected"); 335 | case message_types.rejection: 336 | return this.subscriptions.reject(identifier); 337 | default: 338 | return this.subscriptions.notify(identifier, "received", message); 339 | } 340 | }, 341 | open: function() { 342 | ActionCable.log("WebSocket onopen event, using '" + (this.getProtocol()) + "' subprotocol"); 343 | this.disconnected = false; 344 | if (!this.isProtocolSupported()) { 345 | ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting."); 346 | return this.close({ 347 | allowReconnect: false 348 | }); 349 | } 350 | }, 351 | close: function(event) { 352 | ActionCable.log("WebSocket onclose event"); 353 | if (this.disconnected) { 354 | return; 355 | } 356 | this.disconnected = true; 357 | this.monitor.recordDisconnect(); 358 | return this.subscriptions.notifyAll("disconnected", { 359 | willAttemptReconnect: this.monitor.isRunning() 360 | }); 361 | }, 362 | error: function() { 363 | return ActionCable.log("WebSocket onerror event"); 364 | } 365 | }; 366 | 367 | return Connection; 368 | 369 | })(); 370 | 371 | }).call(this); 372 | (function() { 373 | var slice = [].slice; 374 | 375 | ActionCable.Subscriptions = (function() { 376 | function Subscriptions(consumer) { 377 | this.consumer = consumer; 378 | this.subscriptions = []; 379 | } 380 | 381 | Subscriptions.prototype.create = function(channelName, mixin) { 382 | var channel, params, subscription; 383 | channel = channelName; 384 | params = typeof channel === "object" ? channel : { 385 | channel: channel 386 | }; 387 | subscription = new ActionCable.Subscription(this.consumer, params, mixin); 388 | return this.add(subscription); 389 | }; 390 | 391 | Subscriptions.prototype.add = function(subscription) { 392 | this.subscriptions.push(subscription); 393 | this.consumer.ensureActiveConnection(); 394 | this.notify(subscription, "initialized"); 395 | this.sendCommand(subscription, "subscribe"); 396 | return subscription; 397 | }; 398 | 399 | Subscriptions.prototype.remove = function(subscription) { 400 | this.forget(subscription); 401 | if (!this.findAll(subscription.identifier).length) { 402 | this.sendCommand(subscription, "unsubscribe"); 403 | } 404 | return subscription; 405 | }; 406 | 407 | Subscriptions.prototype.reject = function(identifier) { 408 | var i, len, ref, results, subscription; 409 | ref = this.findAll(identifier); 410 | results = []; 411 | for (i = 0, len = ref.length; i < len; i++) { 412 | subscription = ref[i]; 413 | this.forget(subscription); 414 | this.notify(subscription, "rejected"); 415 | results.push(subscription); 416 | } 417 | return results; 418 | }; 419 | 420 | Subscriptions.prototype.forget = function(subscription) { 421 | var s; 422 | this.subscriptions = (function() { 423 | var i, len, ref, results; 424 | ref = this.subscriptions; 425 | results = []; 426 | for (i = 0, len = ref.length; i < len; i++) { 427 | s = ref[i]; 428 | if (s !== subscription) { 429 | results.push(s); 430 | } 431 | } 432 | return results; 433 | }).call(this); 434 | return subscription; 435 | }; 436 | 437 | Subscriptions.prototype.findAll = function(identifier) { 438 | var i, len, ref, results, s; 439 | ref = this.subscriptions; 440 | results = []; 441 | for (i = 0, len = ref.length; i < len; i++) { 442 | s = ref[i]; 443 | if (s.identifier === identifier) { 444 | results.push(s); 445 | } 446 | } 447 | return results; 448 | }; 449 | 450 | Subscriptions.prototype.reload = function() { 451 | var i, len, ref, results, subscription; 452 | ref = this.subscriptions; 453 | results = []; 454 | for (i = 0, len = ref.length; i < len; i++) { 455 | subscription = ref[i]; 456 | results.push(this.sendCommand(subscription, "subscribe")); 457 | } 458 | return results; 459 | }; 460 | 461 | Subscriptions.prototype.notifyAll = function() { 462 | var args, callbackName, i, len, ref, results, subscription; 463 | callbackName = arguments[0], args = 2 <= arguments.length ? slice.call(arguments, 1) : []; 464 | ref = this.subscriptions; 465 | results = []; 466 | for (i = 0, len = ref.length; i < len; i++) { 467 | subscription = ref[i]; 468 | results.push(this.notify.apply(this, [subscription, callbackName].concat(slice.call(args)))); 469 | } 470 | return results; 471 | }; 472 | 473 | Subscriptions.prototype.notify = function() { 474 | var args, callbackName, i, len, results, subscription, subscriptions; 475 | subscription = arguments[0], callbackName = arguments[1], args = 3 <= arguments.length ? slice.call(arguments, 2) : []; 476 | if (typeof subscription === "string") { 477 | subscriptions = this.findAll(subscription); 478 | } else { 479 | subscriptions = [subscription]; 480 | } 481 | results = []; 482 | for (i = 0, len = subscriptions.length; i < len; i++) { 483 | subscription = subscriptions[i]; 484 | results.push(typeof subscription[callbackName] === "function" ? subscription[callbackName].apply(subscription, args) : void 0); 485 | } 486 | return results; 487 | }; 488 | 489 | Subscriptions.prototype.sendCommand = function(subscription, command) { 490 | var identifier; 491 | identifier = subscription.identifier; 492 | return this.consumer.send({ 493 | command: command, 494 | identifier: identifier 495 | }); 496 | }; 497 | 498 | return Subscriptions; 499 | 500 | })(); 501 | 502 | }).call(this); 503 | (function() { 504 | ActionCable.Subscription = (function() { 505 | var extend; 506 | 507 | function Subscription(consumer, params, mixin) { 508 | this.consumer = consumer; 509 | if (params == null) { 510 | params = {}; 511 | } 512 | this.identifier = JSON.stringify(params); 513 | extend(this, mixin); 514 | } 515 | 516 | Subscription.prototype.perform = function(action, data) { 517 | if (data == null) { 518 | data = {}; 519 | } 520 | data.action = action; 521 | return this.send(data); 522 | }; 523 | 524 | Subscription.prototype.send = function(data) { 525 | return this.consumer.send({ 526 | command: "message", 527 | identifier: this.identifier, 528 | data: JSON.stringify(data) 529 | }); 530 | }; 531 | 532 | Subscription.prototype.unsubscribe = function() { 533 | return this.consumer.subscriptions.remove(this); 534 | }; 535 | 536 | extend = function(object, properties) { 537 | var key, value; 538 | if (properties != null) { 539 | for (key in properties) { 540 | value = properties[key]; 541 | object[key] = value; 542 | } 543 | } 544 | return object; 545 | }; 546 | 547 | return Subscription; 548 | 549 | })(); 550 | 551 | }).call(this); 552 | (function() { 553 | ActionCable.Consumer = (function() { 554 | function Consumer(url) { 555 | this.url = url; 556 | this.subscriptions = new ActionCable.Subscriptions(this); 557 | this.connection = new ActionCable.Connection(this); 558 | } 559 | 560 | Consumer.prototype.send = function(data) { 561 | return this.connection.send(data); 562 | }; 563 | 564 | Consumer.prototype.connect = function() { 565 | return this.connection.open(); 566 | }; 567 | 568 | Consumer.prototype.disconnect = function() { 569 | return this.connection.close({ 570 | allowReconnect: false 571 | }); 572 | }; 573 | 574 | Consumer.prototype.ensureActiveConnection = function() { 575 | if (!this.connection.isActive()) { 576 | return this.connection.open(); 577 | } 578 | }; 579 | 580 | return Consumer; 581 | 582 | })(); 583 | 584 | }).call(this); 585 | -------------------------------------------------------------------------------- /examples/sinatra/assets/reset.css: -------------------------------------------------------------------------------- 1 | /* Reset 2 | ----------------------------------------------------------------------------- */ 3 | 4 | /* stylelint-disable */ 5 | 6 | html, body, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | address, code, img, 9 | dl, dt, dd, ol, ul, li, 10 | fieldset, form, label, 11 | table, th, td, 12 | article, aside, nav, section, figure, figcaption, footer, header, 13 | audio, video { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | blockquote, img, fieldset, form { 18 | border: 0; 19 | } 20 | a, strong, em, b, i, small, sub, sup, img, label, th, td, audio, video { 21 | vertical-align: baseline; 22 | } 23 | applet, object, iframe, 24 | abbr, acronym, big, cite, 25 | del, dfn, ins, kbd, q, s, samp, 26 | strike, tt, var, u, center, legend, 27 | caption, tbody, tfoot, thead, tr, 28 | canvas, details, embed, 29 | menu, output, ruby, summary, 30 | time, mark { 31 | margin: 0; 32 | padding: 0; 33 | border: 0; 34 | vertical-align: baseline; 35 | font: inherit; 36 | font-size: 100%; 37 | } 38 | ul, ol { 39 | list-style: none; 40 | } 41 | 42 | /* Border-box FTW 43 | https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */ 44 | *, 45 | *:before, 46 | *:after { 47 | box-sizing: inherit; 48 | } 49 | html { 50 | overflow-y: scroll; 51 | box-sizing: border-box; 52 | text-size-adjust: 100%; 53 | } 54 | a { 55 | background-color: transparent; /* Remove the gray background color from active links in IE 10. */ 56 | -webkit-text-decoration-skip: none; 57 | 58 | &:hover, 59 | &:active { 60 | outline: 0; 61 | } 62 | } 63 | img { 64 | vertical-align: middle; 65 | } 66 | strong, b { 67 | font-weight: bold; 68 | } 69 | em, i { 70 | font-style: italic; 71 | } 72 | h1, h2, h3, h4, h5, h6 { 73 | font-weight: bold; 74 | } 75 | table { 76 | border-spacing: 0; 77 | border-collapse: collapse; 78 | } 79 | th { 80 | font-weight: bold; 81 | } 82 | td { 83 | vertical-align: top; 84 | } 85 | input, 86 | select, 87 | textarea, 88 | button { 89 | margin: 0; 90 | vertical-align: middle; 91 | font-size: 100%; 92 | font-family: inherit; 93 | } 94 | 95 | /** 96 | * 1. Add the correct box sizing in IE 10-. 97 | * 2. Remove the padding in IE 10-. 98 | */ 99 | [type="checkbox"], 100 | [type="radio"] { 101 | box-sizing: border-box; /* 1 */ 102 | padding: 0; /* 2 */ 103 | } 104 | 105 | /** 106 | * Show the overflow in IE. 107 | * 1. Show the overflow in Edge. 108 | * 2. Show the overflow in Edge, Firefox, and IE. 109 | */ 110 | button, 111 | input, /* 1 */ 112 | select { /* 2 */ 113 | overflow: visible; 114 | } 115 | 116 | /** 117 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 118 | * All other form control elements do not inherit `text-transform` values. 119 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 120 | * Correct `select` style inheritance in Firefox. 121 | */ 122 | button, 123 | select { 124 | text-transform: none; 125 | } 126 | 127 | /** 128 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 129 | * controls in Android 4. 130 | * 2. Correct inability to style clickable `input` types in iOS. 131 | * 3. Improve usability and consistency of cursor style between image-type 132 | * `input` and others. 133 | */ 134 | button, 135 | html [type="button"], /* 1 */ 136 | [type="reset"], 137 | [type="submit"] { 138 | cursor: pointer; /* 3 */ 139 | -webkit-appearance: button; /* 2 */ 140 | } 141 | 142 | /** 143 | * Remove the inner border and padding in Firefox. 144 | */ 145 | button::-moz-focus-inner, 146 | input::-moz-focus-inner { 147 | padding: 0; 148 | border: 0; 149 | } 150 | /** 151 | * 1. Remove the default vertical scrollbar in IE. 152 | */ 153 | textarea { 154 | overflow: auto; /* 1 */ 155 | resize: vertical; 156 | } 157 | svg:not(:root) { 158 | overflow: hidden; /* Correct overflow not hidden in IE. */ 159 | } 160 | 161 | /** 162 | * Correct the odd appearance of search inputs in Chrome and Safari. 163 | */ 164 | [type="search"] { 165 | -webkit-appearance: textfield; 166 | } 167 | 168 | /** 169 | * Remove the inner padding and cancel buttons in Chrome on OS X and 170 | * Safari on OS X. 171 | */ 172 | [type="search"]::-webkit-search-cancel-button, 173 | [type="search"]::-webkit-search-decoration { 174 | -webkit-appearance: none; 175 | } 176 | /* stylelint-enable */ 177 | 178 | [role="button"], 179 | input[type="submit"], 180 | input[type="reset"], 181 | input[type="button"], 182 | button { 183 | -webkit-box-sizing: content-box; 184 | -moz-box-sizing: content-box; 185 | box-sizing: content-box; 186 | } 187 | 188 | /* Reset `button` and button-style `input` default styles */ 189 | input[type="submit"], 190 | input[type="reset"], 191 | input[type="button"], 192 | button { 193 | background: none; 194 | border: 0; 195 | color: inherit; 196 | /* cursor: default; */ 197 | font: inherit; 198 | line-height: normal; 199 | overflow: visible; 200 | padding: 0; 201 | -webkit-appearance: button; /* for input */ 202 | -webkit-user-select: none; /* for button */ 203 | -moz-user-select: none; 204 | -ms-user-select: none; 205 | } 206 | input::-moz-focus-inner, 207 | button::-moz-focus-inner { 208 | border: 0; 209 | padding: 0; 210 | } 211 | 212 | /* Make `a` like a button */ 213 | [role="button"] { 214 | color: inherit; 215 | cursor: default; 216 | display: inline-block; 217 | text-align: center; 218 | text-decoration: none; 219 | white-space: pre; 220 | -webkit-user-select: none; 221 | -moz-user-select: none; 222 | -ms-user-select: none; 223 | } 224 | -------------------------------------------------------------------------------- /examples/sinatra/chat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "litecable" 4 | require "anycable" 5 | 6 | # Sample chat application 7 | module Chat 8 | class Connection < LiteCable::Connection::Base # :nodoc: 9 | identified_by :user, :sid 10 | 11 | def connect 12 | self.user = cookies["user"] 13 | self.sid = request.params["sid"] 14 | reject_unauthorized_connection unless user 15 | $stdout.puts "#{user} connected" 16 | end 17 | 18 | def disconnect 19 | $stdout.puts "#{user} disconnected" 20 | end 21 | end 22 | 23 | class Channel < LiteCable::Channel::Base # :nodoc: 24 | identifier :chat 25 | 26 | def subscribed 27 | reject unless chat_id 28 | stream_from "chat_#{chat_id}" 29 | end 30 | 31 | def speak(data) 32 | LiteCable.broadcast "chat_#{chat_id}", {user: user, message: data["message"], sid: sid} 33 | end 34 | 35 | private 36 | 37 | def chat_id 38 | params.fetch("id") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /examples/sinatra/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "config/environment" 4 | 5 | app = Rack::Builder.new do 6 | map "/" do 7 | run App 8 | end 9 | end 10 | 11 | unless ENV["ANYCABLE"] 12 | # Start built-in rack hijack middleware to serve websockets 13 | require "lite_cable/server" 14 | 15 | app.map "/cable" do 16 | use LiteCable::Server::Middleware, connection_class: Chat::Connection 17 | run(proc { |_| [200, {"Content-Type" => "text/plain"}, ["OK"]] }) 18 | end 19 | end 20 | 21 | run app 22 | -------------------------------------------------------------------------------- /examples/sinatra/config/anycable.yml: -------------------------------------------------------------------------------- 1 | broadcast_adapter: http 2 | -------------------------------------------------------------------------------- /examples/sinatra/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../../../lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require_relative "../app" 7 | require_relative "../chat" 8 | 9 | LiteCable.config.log_level = Logger::DEBUG 10 | 11 | AnyCable.connection_factory = Chat::Connection 12 | -------------------------------------------------------------------------------- /examples/sinatra/views/index.slim: -------------------------------------------------------------------------------- 1 | h2 Room Id 2 | 3 | form action="/rooms" method="POST" 4 | .row 5 | input type="text" required="required" name="id" 6 | .row 7 | button.btn type="submit" 8 | span Go! 9 | -------------------------------------------------------------------------------- /examples/sinatra/views/layout.slim: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title LiteCable Sinatra Demo 5 | meta name="viewport" content="width=device-width" 6 | meta charset="UTF-8" 7 | link rel="stylesheet" href="/reset.css" 8 | link rel="stylesheet" href="/app.css" 9 | script type="text/javascript" src="/cable.js" 10 | body 11 | .header 12 | h1.title 13 | a href='/' LiteCable 14 | .container.main 15 | == yield 16 | -------------------------------------------------------------------------------- /examples/sinatra/views/login.slim: -------------------------------------------------------------------------------- 1 | h2 Your Name 2 | 3 | form action="/sign_in" method="POST" 4 | .row 5 | input type="text" required="required" name="user" 6 | .row 7 | button.btn type="submit" 8 | span Go! 9 | -------------------------------------------------------------------------------- /examples/sinatra/views/resetcss.slim: -------------------------------------------------------------------------------- 1 | css: 2 | /* Reset 3 | ----------------------------------------------------------------------------- */ 4 | 5 | /* stylelint-disable */ 6 | 7 | html, body, 8 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 9 | address, code, img, 10 | dl, dt, dd, ol, ul, li, 11 | fieldset, form, label, 12 | table, th, td, 13 | article, aside, nav, section, figure, figcaption, footer, header, 14 | audio, video { 15 | margin: 0; 16 | padding: 0; 17 | } 18 | blockquote, img, fieldset, form { 19 | border: 0; 20 | } 21 | a, strong, em, b, i, small, sub, sup, img, label, th, td, audio, video { 22 | vertical-align: baseline; 23 | } 24 | applet, object, iframe, 25 | abbr, acronym, big, cite, 26 | del, dfn, ins, kbd, q, s, samp, 27 | strike, tt, var, u, center, legend, 28 | caption, tbody, tfoot, thead, tr, 29 | canvas, details, embed, 30 | menu, output, ruby, summary, 31 | time, mark { 32 | margin: 0; 33 | padding: 0; 34 | border: 0; 35 | vertical-align: baseline; 36 | font: inherit; 37 | font-size: 100%; 38 | } 39 | ul, ol { 40 | list-style: none; 41 | } 42 | 43 | /* Border-box FTW 44 | https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */ 45 | *, 46 | *:before, 47 | *:after { 48 | box-sizing: inherit; 49 | } 50 | html { 51 | overflow-y: scroll; 52 | box-sizing: border-box; 53 | text-size-adjust: 100%; 54 | } 55 | a { 56 | background-color: transparent; /* Remove the gray background color from active links in IE 10. */ 57 | -webkit-text-decoration-skip: none; 58 | 59 | &:hover, 60 | &:active { 61 | outline: 0; 62 | } 63 | } 64 | img { 65 | vertical-align: middle; 66 | } 67 | strong, b { 68 | font-weight: bold; 69 | } 70 | em, i { 71 | font-style: italic; 72 | } 73 | h1, h2, h3, h4, h5, h6 { 74 | font-weight: bold; 75 | } 76 | table { 77 | border-spacing: 0; 78 | border-collapse: collapse; 79 | } 80 | th { 81 | font-weight: bold; 82 | } 83 | td { 84 | vertical-align: top; 85 | } 86 | input, 87 | select, 88 | textarea, 89 | button { 90 | margin: 0; 91 | vertical-align: middle; 92 | font-size: 100%; 93 | font-family: inherit; 94 | } 95 | 96 | /** 97 | * 1. Add the correct box sizing in IE 10-. 98 | * 2. Remove the padding in IE 10-. 99 | */ 100 | [type="checkbox"], 101 | [type="radio"] { 102 | box-sizing: border-box; /* 1 */ 103 | padding: 0; /* 2 */ 104 | } 105 | 106 | /** 107 | * Show the overflow in IE. 108 | * 1. Show the overflow in Edge. 109 | * 2. Show the overflow in Edge, Firefox, and IE. 110 | */ 111 | button, 112 | input, /* 1 */ 113 | select { /* 2 */ 114 | overflow: visible; 115 | } 116 | 117 | /** 118 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 119 | * All other form control elements do not inherit `text-transform` values. 120 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 121 | * Correct `select` style inheritance in Firefox. 122 | */ 123 | button, 124 | select { 125 | text-transform: none; 126 | } 127 | 128 | /** 129 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 130 | * controls in Android 4. 131 | * 2. Correct inability to style clickable `input` types in iOS. 132 | * 3. Improve usability and consistency of cursor style between image-type 133 | * `input` and others. 134 | */ 135 | button, 136 | html [type="button"], /* 1 */ 137 | [type="reset"], 138 | [type="submit"] { 139 | cursor: pointer; /* 3 */ 140 | -webkit-appearance: button; /* 2 */ 141 | } 142 | 143 | /** 144 | * Remove the inner border and padding in Firefox. 145 | */ 146 | button::-moz-focus-inner, 147 | input::-moz-focus-inner { 148 | padding: 0; 149 | border: 0; 150 | } 151 | /** 152 | * 1. Remove the default vertical scrollbar in IE. 153 | */ 154 | textarea { 155 | overflow: auto; /* 1 */ 156 | resize: vertical; 157 | } 158 | svg:not(:root) { 159 | overflow: hidden; /* Correct overflow not hidden in IE. */ 160 | } 161 | 162 | /** 163 | * Correct the odd appearance of search inputs in Chrome and Safari. 164 | */ 165 | [type="search"] { 166 | -webkit-appearance: textfield; 167 | } 168 | 169 | /** 170 | * Remove the inner padding and cancel buttons in Chrome on OS X and 171 | * Safari on OS X. 172 | */ 173 | [type="search"]::-webkit-search-cancel-button, 174 | [type="search"]::-webkit-search-decoration { 175 | -webkit-appearance: none; 176 | } 177 | /* stylelint-enable */ 178 | 179 | [role="button"], 180 | input[type="submit"], 181 | input[type="reset"], 182 | input[type="button"], 183 | button { 184 | -webkit-box-sizing: content-box; 185 | -moz-box-sizing: content-box; 186 | box-sizing: content-box; 187 | } 188 | 189 | /* Reset `button` and button-style `input` default styles */ 190 | input[type="submit"], 191 | input[type="reset"], 192 | input[type="button"], 193 | button { 194 | background: none; 195 | border: 0; 196 | color: inherit; 197 | /* cursor: default; */ 198 | font: inherit; 199 | line-height: normal; 200 | overflow: visible; 201 | padding: 0; 202 | -webkit-appearance: button; /* for input */ 203 | -webkit-user-select: none; /* for button */ 204 | -moz-user-select: none; 205 | -ms-user-select: none; 206 | } 207 | input::-moz-focus-inner, 208 | button::-moz-focus-inner { 209 | border: 0; 210 | padding: 0; 211 | } 212 | 213 | /* Make `a` like a button */ 214 | [role="button"] { 215 | color: inherit; 216 | cursor: default; 217 | display: inline-block; 218 | text-align: center; 219 | text-decoration: none; 220 | white-space: pre; 221 | -webkit-user-select: none; 222 | -moz-user-select: none; 223 | -ms-user-select: none; 224 | } 225 | -------------------------------------------------------------------------------- /examples/sinatra/views/room.slim: -------------------------------------------------------------------------------- 1 | h2 ="Room: #{@room_id}" 2 | 3 | .messages#message_list 4 | 5 | .message-form 6 | form#message_form 7 | .row 8 | input#message_txt type="text" required="required" 9 | .row 10 | button.btn type="submit" 11 | span Send! 12 | 13 | javascript: 14 | var roomId = "#{{ @room_id }}"; 15 | var user = "#{{ @user }}"; 16 | var socketId = Date.now(); 17 | 18 | var messageList = document.getElementById("message_list"); 19 | var messageForm = document.getElementById("message_form"); 20 | var textInput = document.getElementById("message_txt"); 21 | 22 | messageForm.onsubmit = function(e){ 23 | e.preventDefault(); 24 | var msg = textInput.value; 25 | console.log("Send message", msg); 26 | textInput.value = null; 27 | chatChannel.perform('speak', { message: msg }); 28 | }; 29 | 30 | var escape = function(str) { 31 | return ('' + str).replace(/&/g, '&') 32 | .replace(//g, '>') 34 | .replace(/"/g, '"'); 35 | } 36 | 37 | var addMessage = function(data){ 38 | var node = document.createElement('div'); 39 | var me = data['user'] == user && data['sid'] == socketId 40 | node.className = "message" + (me ? ' me' : '') + (data['system'] ? ' system' : ''); 41 | node.innerHTML = 42 | '
' + escape(data['user']) + '
' + 43 | '
' + escape(data['message']) + '
'; 44 | messageList.appendChild(node); 45 | }; 46 | 47 | ActionCable.startDebugging(); 48 | var cable = ActionCable.createConsumer('#{{ CABLE_URL }}?sid=' + socketId); 49 | 50 | var chatChannel = cable.subscriptions.create( 51 | { channel: 'chat', id: roomId }, 52 | { 53 | connected: function(){ 54 | console.log("Connected"); 55 | addMessage({ user: 'BOT', message: "I'm connected", system: true }); 56 | }, 57 | 58 | disconnected: function(){ 59 | console.log("Connected"); 60 | addMessage({ user: 'BOT', message: "Sorry, but you've been disconnected(", system: true }); 61 | }, 62 | 63 | received: function(data){ 64 | console.log("Received", data); 65 | addMessage(data); 66 | } 67 | } 68 | ) -------------------------------------------------------------------------------- /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 | Adapterize 6 | Hanami 7 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" do 2 | gem "rubocop-md", "~> 1.0" 3 | gem "rubocop-rspec" 4 | gem "standard", "~> 1.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/lite_cable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "lite_cable/version" 4 | require "lite_cable/internal" 5 | require "lite_cable/logging" 6 | 7 | # Lightwieght ActionCable implementation. 8 | # 9 | # Contains application logic (channels, streams, broadcasting) and 10 | # also (optional) Rack hijack based server (suitable only for development and test). 11 | # 12 | # Compatible with AnyCable (for production usage). 13 | module LiteCable 14 | require "lite_cable/connection" 15 | require "lite_cable/channel" 16 | require "lite_cable/coders" 17 | require "lite_cable/config" 18 | require "lite_cable/broadcast_adapters" 19 | require "lite_cable/anycable" 20 | 21 | class << self 22 | def config 23 | @config ||= Config.new 24 | end 25 | 26 | attr_accessor :channel_registry 27 | 28 | # Broadcast encoded message to the stream 29 | def broadcast(stream, message, coder: LiteCable.config.coder) 30 | broadcast_adapter.broadcast(stream, message, coder: coder) 31 | end 32 | 33 | def broadcast_adapter 34 | return @broadcast_adapter if defined?(@broadcast_adapter) 35 | self.broadcast_adapter = LiteCable.config.broadcast_adapter.to_sym 36 | @broadcast_adapter 37 | end 38 | 39 | def broadcast_adapter=(adapter) 40 | if adapter.is_a?(Symbol) || adapter.is_a?(Array) 41 | adapter = BroadcastAdapters.lookup_adapter(adapter) 42 | end 43 | 44 | unless adapter.respond_to?(:broadcast) 45 | raise ArgumentError, "BroadcastAdapter must implement #broadcast method. " \ 46 | "#{adapter.class} doesn't implement it." 47 | end 48 | 49 | @broadcast_adapter = adapter 50 | end 51 | end 52 | 53 | self.channel_registry = Channel::Registry 54 | end 55 | -------------------------------------------------------------------------------- /lib/lite_cable/anycable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable # :nodoc: 4 | # AnyCable extensions 5 | module AnyCable 6 | module Connection # :nodoc: 7 | def self.extended(base) 8 | base.prepend InstanceMethods 9 | end 10 | 11 | def call(socket, **options) 12 | new(socket, **options) 13 | end 14 | 15 | module InstanceMethods # :nodoc: 16 | def initialize(socket, subscriptions: nil, **hargs) 17 | super(socket, **hargs) 18 | # Initialize channels if any 19 | subscriptions&.each { |id| @subscriptions.add(id, false) } 20 | end 21 | 22 | def request 23 | @request ||= Rack::Request.new(socket.env) 24 | end 25 | 26 | def handle_channel_command(identifier, command, data) 27 | channel = subscriptions.add(identifier, false) 28 | case command 29 | when "subscribe" 30 | !subscriptions.send(:subscribe_channel, channel).nil? 31 | when "unsubscribe" 32 | subscriptions.remove(identifier) 33 | true 34 | when "message" 35 | subscriptions.perform_action identifier, data 36 | true 37 | else 38 | false 39 | end 40 | rescue LiteCable::Connection::Subscriptions::Error, 41 | LiteCable::Channel::Error, 42 | LiteCable::Channel::Registry::Error => e 43 | log(:error, log_fmt("Connection command failed: #{e}")) 44 | close 45 | false 46 | end 47 | end 48 | end 49 | end 50 | 51 | # Patch Lite Cable with AnyCable functionality 52 | def self.anycable! 53 | LiteCable::Connection::Base.extend LiteCable::AnyCable::Connection 54 | end 55 | end 56 | 57 | if defined?(AnyCable) 58 | AnyCable.configure_server do 59 | # Make sure broadcast adapter is valid 60 | require "lite_cable/broadcast_adapters/any_cable" 61 | unless LiteCable::BroadcastAdapters::AnyCable === LiteCable.broadcast_adapter 62 | raise "You should use :any_cable broadcast adapter (current: #{LiteCable.broadcast_adapter.class}). " \ 63 | "Set it via LITECABLE_BROADCAST_ADAPTER=any_cable or in the code/YML." 64 | end 65 | 66 | # Turn AnyCable compatibility mode for anycable RPC server automatically 67 | LiteCable.anycable! 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/lite_cable/broadcast_adapters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "lite_cable/broadcast_adapters/base" 4 | 5 | module LiteCable 6 | module BroadcastAdapters # :nodoc: 7 | module_function 8 | 9 | def lookup_adapter(args) 10 | adapter, options = Array(args) 11 | path_to_adapter = "lite_cable/broadcast_adapters/#{adapter}" 12 | adapter_class_name = adapter.to_s.split("_").map(&:capitalize).join 13 | 14 | unless BroadcastAdapters.const_defined?(adapter_class_name, false) 15 | begin 16 | require path_to_adapter 17 | rescue LoadError => e 18 | # We couldn't require the adapter itself. 19 | if e.path == path_to_adapter 20 | raise e.class, "Couldn't load the '#{adapter}' broadcast adapter for LiteCable", 21 | e.backtrace 22 | # Bubbled up from the adapter require. 23 | else 24 | raise e.class, "Error loading the '#{adapter}' broadcast adapter for LiteCable", 25 | e.backtrace 26 | end 27 | end 28 | end 29 | 30 | BroadcastAdapters.const_get(adapter_class_name, false).new(**(options || {})) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/lite_cable/broadcast_adapters/any_cable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module BroadcastAdapters 5 | class AnyCable < Base 6 | def broadcast(stream, message, coder:) 7 | ::AnyCable.broadcast stream, coder.encode(message) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/lite_cable/broadcast_adapters/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module BroadcastAdapters 5 | class Base 6 | def initialize(**options) 7 | @options = options 8 | end 9 | 10 | private 11 | 12 | attr_reader :options 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/lite_cable/broadcast_adapters/memory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module BroadcastAdapters 5 | class Memory < Base 6 | def broadcast(stream, message, coder:) 7 | Server.subscribers_map.broadcast stream, message, coder 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/lite_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Channel # :nodoc: 5 | require "lite_cable/channel/registry" 6 | require "lite_cable/channel/streams" 7 | require "lite_cable/channel/base" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/lite_cable/channel/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Channel 5 | class Error < StandardError; end 6 | 7 | class RejectedError < Error; end 8 | 9 | class UnproccessableActionError < Error; end 10 | 11 | # The channel provides the basic structure of grouping behavior into logical units when communicating over the connection. 12 | # You can think of a channel like a form of controller, but one that's capable of pushing content to the subscriber in addition to simply 13 | # responding to the subscriber's direct requests. 14 | # 15 | # == Identification 16 | # 17 | # Each channel must have a unique identifier, which is used by the connection to resolve the channel's class. 18 | # 19 | # Example: 20 | # 21 | # class SecretChannel < LiteCable::Channel::Base 22 | # identifier 'my_super_secret_channel' 23 | # end 24 | # 25 | # # client-side 26 | # App.cable.subscriptions.create('my_super_secret_channel') 27 | # 28 | # == Action processing 29 | # 30 | # You can declare any public method on the channel (optionally taking a `data` argument), 31 | # and this method is automatically exposed as callable to the client. 32 | # 33 | # Example: 34 | # 35 | # class AppearanceChannel < LiteCable::Channel::Base 36 | # def unsubscribed 37 | # # here `current_user` is a connection identifier 38 | # current_user.disappear 39 | # end 40 | # 41 | # def appear(data) 42 | # current_user.appear on: data['appearing_on'] 43 | # end 44 | # 45 | # def away 46 | # current_user.away 47 | # end 48 | # end 49 | # 50 | # == Rejecting subscription requests 51 | # 52 | # A channel can reject a subscription request in the #subscribed callback by 53 | # invoking the #reject method: 54 | # 55 | # class ChatChannel < ApplicationCable::Channel 56 | # def subscribed 57 | # room = Chat::Room[params['room_number']] 58 | # reject unless current_user.can_access?(room) 59 | # end 60 | # end 61 | # 62 | # In this example, the subscription will be rejected if the 63 | # current_user does not have access to the chat room. On the 64 | # client-side, the Channel#rejected callback will get invoked when 65 | # the server rejects the subscription request. 66 | class Base 67 | class << self 68 | # A set of method names that should be considered actions. 69 | # This includes all public instance methods on a channel except from Channel::Base methods. 70 | def action_methods 71 | @action_methods ||= begin 72 | # All public instance methods of this class, including ancestors 73 | methods = (public_instance_methods(true) - 74 | # Except for public instance methods of Base and its ancestors 75 | LiteCable::Channel::Base.public_instance_methods(true) + 76 | # Be sure to include shadowed public instance methods of this class 77 | public_instance_methods(false)).uniq.map(&:to_s) 78 | methods.to_set 79 | end 80 | end 81 | 82 | attr_reader :id 83 | 84 | # Register the channel by its unique identifier 85 | # (in order to resolve the channel's class for connections) 86 | def identifier(id) 87 | Registry.add(id.to_s, self) 88 | @id = id 89 | end 90 | end 91 | 92 | include Logging 93 | prepend Streams 94 | 95 | attr_reader :connection, :identifier, :params 96 | 97 | def initialize(connection, identifier, params) 98 | @connection = connection 99 | @identifier = identifier 100 | @params = params.freeze 101 | 102 | delegate_connection_identifiers 103 | end 104 | 105 | def handle_subscribe 106 | subscribed if respond_to?(:subscribed) 107 | end 108 | 109 | def handle_unsubscribe 110 | unsubscribed if respond_to?(:unsubscribed) 111 | end 112 | 113 | def handle_action(encoded_message) 114 | perform_action connection.coder.decode(encoded_message) 115 | end 116 | 117 | protected 118 | 119 | def reject 120 | raise RejectedError 121 | end 122 | 123 | def transmit(data) 124 | connection.transmit identifier: identifier, message: data 125 | end 126 | 127 | # Extract the action name from the passed data and process it via the channel. 128 | def perform_action(data) 129 | action = extract_action(data) 130 | 131 | raise UnproccessableActionError unless processable_action?(action) 132 | 133 | log(:debug) { log_fmt("Perform action #{action}(#{data})") } 134 | dispatch_action(action, data) 135 | end 136 | 137 | def dispatch_action(action, data) 138 | if method(action).arity == 1 139 | public_send action, data 140 | else 141 | public_send action 142 | end 143 | end 144 | 145 | def extract_action(data) 146 | data.delete("action") || "receive" 147 | end 148 | 149 | def processable_action?(action) 150 | self.class.action_methods.include?(action) 151 | end 152 | 153 | def delegate_connection_identifiers 154 | connection.identifiers.each do |identifier| 155 | define_singleton_method(identifier) do 156 | connection.send(identifier) 157 | end 158 | end 159 | end 160 | 161 | # Add prefix to channel log messages 162 | def log_fmt(msg) 163 | "[connection:#{connection.identifier}] [channel:#{self.class.id}] #{msg}" 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/lite_cable/channel/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Channel 5 | # Stores channels identifiers and corresponding classes. 6 | module Registry 7 | class Error < StandardError; end 8 | 9 | class AlreadyRegisteredError < Error; end 10 | 11 | class UnknownChannelError < Error; end 12 | 13 | class << self 14 | def add(id, channel_class) 15 | raise AlreadyRegisteredError if find(id) 16 | 17 | channels[id] = channel_class 18 | end 19 | 20 | def find(id) 21 | channels[id] 22 | end 23 | 24 | alias_method :lookup, :find 25 | 26 | def find!(id) 27 | channel_class = find(id) 28 | raise UnknownChannelError unless channel_class 29 | 30 | channel_class 31 | end 32 | 33 | private 34 | 35 | def channels 36 | @channels ||= {} 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/lite_cable/channel/streams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Channel 5 | # Streams allow channels to route broadcastings to the subscriber. A broadcasting is a pubsub queue where any data 6 | # placed into it is automatically sent to the clients that are connected at that time. 7 | 8 | # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between 9 | # the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new 10 | # comments on a given page: 11 | # 12 | # class CommentsChannel < ApplicationCable::Channel 13 | # def follow(data) 14 | # stream_from "comments_for_#{data['recording_id']}" 15 | # end 16 | # 17 | # def unfollow 18 | # stop_all_streams 19 | # end 20 | # end 21 | # 22 | # Based on the above example, the subscribers of this channel will get whatever data is put into the, 23 | # let's say, `comments_for_45` broadcasting as soon as it's put there. 24 | # 25 | # An example broadcasting for this channel looks like so: 26 | # 27 | # LiteCable.server.broadcast "comments_for_45", author: 'Donald Duck', content: 'Quack-quack-quack' 28 | # 29 | # You can stop streaming from all broadcasts by calling #stop_all_streams or use #stop_from to stop streaming broadcasts from the specified stream. 30 | module Streams 31 | def handle_unsubscribe 32 | stop_all_streams 33 | super 34 | end 35 | 36 | # Start streaming from the named broadcasting pubsub queue. 37 | def stream_from(broadcasting) 38 | log(:debug) { log_fmt("Stream from #{broadcasting}") } 39 | connection.streams.add(identifier, broadcasting) 40 | end 41 | 42 | # Stop streaming from the named broadcasting pubsub queue. 43 | def stop_stream(broadcasting) 44 | log(:debug) { log_fmt("Stop stream from #{broadcasting}") } 45 | connection.streams.remove(identifier, broadcasting) 46 | end 47 | 48 | # Unsubscribes all streams associated with this channel from the pubsub queue. 49 | def stop_all_streams 50 | log(:debug) { log_fmt("Stop all streams") } 51 | connection.streams.remove_all(identifier) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/lite_cable/coders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Coders # :nodoc: 5 | require "lite_cable/coders/raw" 6 | require "lite_cable/coders/json" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/lite_cable/coders/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module LiteCable 6 | module Coders 7 | # Wrapper over JSON 8 | module JSON 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 | -------------------------------------------------------------------------------- /lib/lite_cable/coders/raw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Coders 5 | # No-op coder 6 | module Raw 7 | class << self 8 | def decode(val) 9 | val 10 | end 11 | 12 | alias_method :encode, :decode 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/lite_cable/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anyway_config" 4 | require "logger" 5 | 6 | module LiteCable 7 | # AnyCable configuration 8 | class Config < Anyway::Config 9 | require "lite_cable/coders/json" 10 | require "lite_cable/coders/raw" 11 | 12 | config_name :litecable 13 | 14 | attr_config :logger, 15 | coder: Coders::JSON, 16 | broadcast_adapter: defined?(::AnyCable::VERSION) ? :any_cable : :memory, 17 | identifier_coder: Coders::Raw, 18 | log_level: Logger::INFO 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/lite_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Connection # :nodoc: 5 | require "lite_cable/connection/authorization" 6 | require "lite_cable/connection/identification" 7 | require "lite_cable/connection/base" 8 | require "lite_cable/connection/streams" 9 | require "lite_cable/connection/subscriptions" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/lite_cable/connection/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Connection 5 | class UnauthorizedError < StandardError; end 6 | 7 | # Include methods to control authorization flow 8 | module Authorization 9 | def reject_unauthorized_connection 10 | raise UnauthorizedError 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/lite_cable/connection/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Connection 5 | # A Connection object represents a client "connected" to the application. 6 | # It contains all of the channel subscriptions. Incoming messages are then routed to these channel subscriptions 7 | # based on an identifier sent by the consumer. 8 | # The Connection itself does not deal with any specific application logic beyond authentication and authorization. 9 | # 10 | # Here's a basic example: 11 | # 12 | # module MyApplication 13 | # class Connection < LiteCable::Connection::Base 14 | # identified_by :current_user 15 | # 16 | # def connect 17 | # self.current_user = find_verified_user 18 | # end 19 | # 20 | # def disconnect 21 | # # Any cleanup work needed when the cable connection is cut. 22 | # end 23 | # 24 | # private 25 | # def find_verified_user 26 | # User.find_by_identity(cookies[:identity]) || 27 | # reject_unauthorized_connection 28 | # end 29 | # end 30 | # end 31 | # 32 | # First, we declare that this connection can be identified by its current_user. This allows us to later be able to find all connections 33 | # established for that current_user (and potentially disconnect them). You can declare as many 34 | # identification indexes as you like. Declaring an identification means that an attr_accessor is automatically set for that key. 35 | # 36 | # Second, we rely on the fact that the connection is established with the cookies from the domain being sent along. This makes 37 | # it easy to use cookies that were set when logging in via a web interface to authorize the connection. 38 | # 39 | class Base 40 | include Authorization 41 | prepend Identification 42 | include Logging 43 | 44 | attr_reader :subscriptions, :streams, :coder 45 | 46 | def initialize(socket, coder: nil) 47 | @socket = socket 48 | @coder = coder || LiteCable.config.coder 49 | 50 | @subscriptions = Subscriptions.new(self) 51 | @streams = Streams.new(socket) 52 | end 53 | 54 | def handle_open 55 | connect if respond_to?(:connect) 56 | send_welcome_message 57 | log(:debug) { log_fmt("Opened") } 58 | rescue UnauthorizedError 59 | log(:debug) { log_fmt("Authorization failed") } 60 | close 61 | end 62 | 63 | def handle_close 64 | disconnected! 65 | subscriptions.remove_all 66 | 67 | disconnect if respond_to?(:disconnect) 68 | log(:debug) { log_fmt("Closed") } 69 | end 70 | 71 | def handle_command(websocket_message) 72 | command = decode(websocket_message) 73 | subscriptions.execute_command command 74 | rescue Subscriptions::Error, Channel::Error, Channel::Registry::Error => e 75 | log(:error, log_fmt("Connection command failed: #{e}")) 76 | close 77 | end 78 | 79 | def transmit(cable_message) 80 | return if disconnected? 81 | 82 | socket.transmit encode(cable_message) 83 | end 84 | 85 | def close 86 | socket.close 87 | end 88 | 89 | # Rack::Request instance of underlying socket 90 | def request 91 | socket.request 92 | end 93 | 94 | # Request cookies 95 | def cookies 96 | request.cookies 97 | end 98 | 99 | def disconnected? 100 | @_disconnected == true 101 | end 102 | 103 | private 104 | 105 | attr_reader :socket 106 | 107 | def disconnected! 108 | @_disconnected = true 109 | end 110 | 111 | def send_welcome_message 112 | # Send welcome message to the internal connection monitor channel. 113 | # This ensures the connection monitor state is reset after a successful 114 | # websocket connection. 115 | transmit type: LiteCable::INTERNAL[:message_types][:welcome] 116 | end 117 | 118 | def encode(cable_message) 119 | coder.encode cable_message 120 | end 121 | 122 | def decode(websocket_message) 123 | coder.decode websocket_message 124 | end 125 | 126 | def log_fmt(msg) 127 | "[connection:#{identifier}] #{msg}" 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/lite_cable/connection/identification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | module LiteCable 6 | module Connection 7 | module Identification # :nodoc: 8 | module ClassMethods # :nodoc: 9 | # Mark a key as being a connection identifier index 10 | # that can then be used to find the specific connection again later. 11 | def identified_by(*identifiers) 12 | Array(identifiers).each do |identifier| 13 | attr_writer identifier 14 | define_method(identifier) do 15 | return instance_variable_get(:"@#{identifier}") if 16 | instance_variable_defined?(:"@#{identifier}") 17 | 18 | fetch_identifier(identifier.to_s) 19 | end 20 | end 21 | 22 | self.identifiers += identifiers 23 | end 24 | end 25 | 26 | def self.prepended(base) 27 | base.class_eval do 28 | class << self 29 | attr_writer :identifiers 30 | 31 | def identifiers 32 | @identifiers ||= Set.new 33 | end 34 | 35 | include ClassMethods 36 | end 37 | end 38 | end 39 | 40 | def initialize(socket, identifiers: nil, **hargs) 41 | @encoded_ids = identifiers ? JSON.parse(identifiers) : {} 42 | super(socket, **hargs) 43 | end 44 | 45 | def identifiers 46 | self.class.identifiers 47 | end 48 | 49 | # Return a single connection identifier 50 | # that combines the value of all the registered identifiers into a single id. 51 | # 52 | # You can specify a custom identifier_coder in config 53 | # to implement specific logic of encoding/decoding 54 | # custom classes to identifiers. 55 | # 56 | # By default uses Raw coder. 57 | def identifier 58 | unless defined? @identifier 59 | values = identifiers_hash.values.compact 60 | @identifier = values.empty? ? nil : values.map(&:to_s).sort.join(":") 61 | end 62 | 63 | @identifier 64 | end 65 | 66 | # Generate identifiers info as hash. 67 | def identifiers_hash 68 | identifiers.each_with_object({}) do |id, acc| 69 | obj = instance_variable_get("@#{id}") 70 | next unless obj 71 | 72 | acc[id.to_s] = LiteCable.config.identifier_coder.encode(obj) 73 | end 74 | end 75 | 76 | def identifiers_json 77 | identifiers_hash.to_json 78 | end 79 | 80 | # Fetch identifier and deserialize if neccessary 81 | def fetch_identifier(name) 82 | val = @encoded_ids[name] 83 | val = LiteCable.config.identifier_coder.decode(val) unless val.nil? 84 | instance_variable_set( 85 | :"@#{name}", 86 | val 87 | ) 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/lite_cable/connection/streams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Connection 5 | # Manage the connection streams 6 | class Streams 7 | attr_reader :socket 8 | 9 | def initialize(socket) 10 | @socket = socket 11 | end 12 | 13 | # Start streaming from broadcasting to the channel. 14 | def add(channel_id, broadcasting) 15 | socket.subscribe(channel_id, broadcasting) 16 | end 17 | 18 | # Stop streaming from broadcasting to the channel. 19 | def remove(channel_id, broadcasting) 20 | socket.unsubscribe(channel_id, broadcasting) 21 | end 22 | 23 | # Stop all streams for the channel 24 | def remove_all(channel_id) 25 | socket.unsubscribe_from_all(channel_id) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/lite_cable/connection/subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | class UnknownChannelError < StandardError 5 | attr_reader :channel_id 6 | 7 | def initialize(channel_id) 8 | @channel_id = channel_id 9 | super("Unknown channel: #{channel_id}") 10 | end 11 | end 12 | 13 | module Connection 14 | # Manage the connection channels and route messages 15 | class Subscriptions 16 | class Error < StandardError; end 17 | 18 | class AlreadySubscribedError < Error; end 19 | 20 | class UnknownCommandError < Error; end 21 | 22 | class ChannelNotFoundError < Error; end 23 | 24 | include Logging 25 | 26 | def initialize(connection) 27 | @connection = connection 28 | @subscriptions = {} 29 | end 30 | 31 | def identifiers 32 | subscriptions.keys 33 | end 34 | 35 | def add(identifier, subscribe = true) 36 | raise AlreadySubscribedError if find(identifier) 37 | 38 | params = connection.coder.decode(identifier) 39 | 40 | channel_id = params.delete("channel") 41 | 42 | channel_class = LiteCable.channel_registry.lookup(channel_id) 43 | 44 | raise UnknownChannelError, channel_id unless channel_class 45 | 46 | subscriptions[identifier] = channel_class.new(connection, identifier, params) 47 | subscribe ? subscribe_channel(subscriptions[identifier]) : subscriptions[identifier] 48 | end 49 | 50 | def remove(identifier) 51 | channel = find!(identifier) 52 | subscriptions.delete(identifier) 53 | channel.handle_unsubscribe 54 | log(:debug) { log_fmt("Unsubscribed from channel #{channel.class.id}") } 55 | transmit_subscription_cancel(channel.identifier) 56 | end 57 | 58 | def remove_all 59 | subscriptions.keys.each(&method(:remove)) 60 | end 61 | 62 | def perform_action(identifier, data) 63 | channel = find!(identifier) 64 | channel.handle_action data 65 | end 66 | 67 | def execute_command(data) 68 | command = data.delete("command") 69 | case command 70 | when "subscribe" then add(data["identifier"]) 71 | when "unsubscribe" then remove(data["identifier"]) 72 | when "message" then perform_action(data["identifier"], data["data"]) 73 | else 74 | raise UnknownCommandError, "Command not found #{command}" 75 | end 76 | end 77 | 78 | def find(identifier) 79 | subscriptions[identifier] 80 | end 81 | 82 | def find!(identifier) 83 | channel = find(identifier) 84 | raise ChannelNotFoundError unless channel 85 | 86 | channel 87 | end 88 | 89 | private 90 | 91 | attr_reader :connection, :subscriptions 92 | 93 | def subscribe_channel(channel) 94 | channel.handle_subscribe 95 | log(:debug) { log_fmt("Subscribed to channel #{channel.class.id}") } 96 | transmit_subscription_confirmation(channel.identifier) 97 | channel 98 | rescue Channel::RejectedError 99 | subscriptions.delete(channel.identifier) 100 | transmit_subscription_rejection(channel.identifier) 101 | nil 102 | end 103 | 104 | def transmit_subscription_confirmation(identifier) 105 | connection.transmit identifier: identifier, 106 | type: LiteCable::INTERNAL[:message_types][:confirmation] 107 | end 108 | 109 | def transmit_subscription_rejection(identifier) 110 | connection.transmit identifier: identifier, 111 | type: LiteCable::INTERNAL[:message_types][:rejection] 112 | end 113 | 114 | def transmit_subscription_cancel(identifier) 115 | connection.transmit identifier: identifier, 116 | type: LiteCable::INTERNAL[:message_types][:cancel] 117 | end 118 | 119 | def log_fmt(msg) 120 | "[connection:#{connection.identifier}] #{msg}" 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/lite_cable/internal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | INTERNAL = { 5 | message_types: { 6 | welcome: "welcome", 7 | ping: "ping", 8 | confirmation: "confirm_subscription", 9 | rejection: "reject_subscription", 10 | cancel: "cancel_subscription" 11 | }.freeze, 12 | protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze 13 | }.freeze 14 | end 15 | -------------------------------------------------------------------------------- /lib/lite_cable/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module LiteCable 6 | module Logging # :nodoc: 7 | PREFIX = "LiteCable" 8 | 9 | class << self 10 | def logger 11 | return @logger if instance_variable_defined?(:@logger) 12 | 13 | @logger = LiteCable.config.logger 14 | return if @logger == false 15 | 16 | @logger ||= ::Logger.new($stderr).tap do |logger| 17 | logger.level = LiteCable.config.log_level 18 | end 19 | end 20 | end 21 | 22 | private 23 | 24 | def log(level, message = nil) 25 | return unless LiteCable::Logging.logger 26 | 27 | LiteCable::Logging.logger.send(level, PREFIX) { message || yield } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/lite_cable/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | # Rack middleware to hijack sockets. 5 | # 6 | # Uses thread-per-connection model (thus recommended only for development and test usage). 7 | # 8 | # Inspired by https://github.com/ngauthier/tubesock/blob/master/lib/tubesock.rb 9 | module Server 10 | require "websocket" 11 | require "lite_cable/server/subscribers_map" 12 | require "lite_cable/server/client_socket" 13 | require "lite_cable/server/heart_beat" 14 | require "lite_cable/server/middleware" 15 | 16 | class << self 17 | attr_accessor :subscribers_map 18 | end 19 | 20 | self.subscribers_map = SubscribersMap.new 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/lite_cable/server/client_socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Server 5 | module ClientSocket # :nodoc: 6 | require "lite_cable/server/client_socket/subscriptions" 7 | require "lite_cable/server/client_socket/base" 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/lite_cable/server/client_socket/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Server 5 | module ClientSocket 6 | # Wrapper over web socket 7 | class Base 8 | include Logging 9 | include Subscriptions 10 | 11 | attr_reader :version, :active 12 | 13 | def initialize(env, socket, version) 14 | log(:debug, "WebSocket version #{version}") 15 | @env = env 16 | @socket = socket 17 | @version = version 18 | @active = true 19 | 20 | @open_handlers = [] 21 | @message_handlers = [] 22 | @close_handlers = [] 23 | @error_handlers = [] 24 | 25 | @close_on_error = true 26 | end 27 | 28 | def prevent_close_on_error 29 | @close_on_error = false 30 | end 31 | 32 | def transmit(data, type: :text) 33 | frame = WebSocket::Frame::Outgoing::Server.new( 34 | version: version, 35 | data: data, 36 | type: type 37 | ) 38 | socket.write frame.to_s 39 | rescue EOFError, Errno::ECONNRESET => e 40 | log(:debug, "Socket gone: #{e}") 41 | close 42 | rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT => e 43 | log(:error, "Socket send failed: #{e}") 44 | close 45 | end 46 | 47 | def request 48 | @request ||= Rack::Request.new(@env) 49 | end 50 | 51 | def onopen(&block) 52 | @open_handlers << block 53 | end 54 | 55 | def onmessage(&block) 56 | @message_handlers << block 57 | end 58 | 59 | def onclose(&block) 60 | @close_handlers << block 61 | end 62 | 63 | def onerror(&block) 64 | @error_handlers << block 65 | end 66 | 67 | def listen 68 | keepalive 69 | Thread.new do 70 | Thread.current.abort_on_exception = true 71 | begin 72 | @open_handlers.each(&:call) 73 | each_frame do |data| 74 | @message_handlers.each do |h| 75 | h.call(data) 76 | rescue => e 77 | log(:error, "Socket receive failed: #{e}") 78 | @error_handlers.each { |eh| eh.call(e, data) } 79 | close if close_on_error 80 | end 81 | end 82 | ensure 83 | close 84 | end 85 | end 86 | end 87 | 88 | def close 89 | return unless @active 90 | 91 | @close_handlers.each(&:call) 92 | close! 93 | 94 | @active = false 95 | end 96 | 97 | def closed? 98 | @socket.closed? 99 | end 100 | 101 | private 102 | 103 | attr_reader :socket, :close_on_error 104 | 105 | def close! 106 | if @socket.respond_to?(:closed?) 107 | close_socket unless @socket.closed? 108 | else 109 | close_socket 110 | end 111 | end 112 | 113 | def close_socket 114 | frame = WebSocket::Frame::Outgoing::Server.new(version: version, type: :close, code: 1000) 115 | @socket.write(frame.to_s) if frame.supported? 116 | @socket.close 117 | rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT, Errno::ECONNRESET 118 | # already closed 119 | end 120 | 121 | def keepalive 122 | thread = Thread.new do 123 | Thread.current.abort_on_exception = true 124 | loop do 125 | sleep 5 126 | transmit nil, type: :ping 127 | end 128 | end 129 | 130 | onclose do 131 | thread.kill 132 | end 133 | end 134 | 135 | def each_frame 136 | framebuffer = WebSocket::Frame::Incoming::Server.new(version: version) 137 | 138 | while socket.wait_readable 139 | data = socket.respond_to?(:recv) ? socket.recv(2000) : socket.readpartial(2000) 140 | break if data.nil? || data.empty? 141 | 142 | framebuffer << data 143 | while frame = framebuffer.next # rubocop:disable Lint/AssignmentInCondition 144 | case frame.type 145 | when :close 146 | return 147 | when :text, :binary 148 | yield frame.data 149 | end 150 | end 151 | end 152 | rescue Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET, IOError, Errno::EBADF => e 153 | log(:debug, "Socket frame error: #{e}") 154 | nil # client disconnected or timed out 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/lite_cable/server/client_socket/subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Server 5 | module ClientSocket 6 | # Handle socket subscriptions 7 | module Subscriptions 8 | def subscribe(channel, broadcasting) 9 | LiteCable::Server.subscribers_map 10 | .add_subscriber(broadcasting, self, channel) 11 | end 12 | 13 | def unsubscribe(channel, broadcasting) 14 | LiteCable::Server.subscribers_map 15 | .remove_subscriber(broadcasting, self, channel) 16 | end 17 | 18 | def unsubscribe_from_all(channel) 19 | LiteCable::Server.subscribers_map.remove_socket(self, channel) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/lite_cable/server/heart_beat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Server 5 | # Sends pings to sockets 6 | class HeartBeat 7 | BEAT_INTERVAL = 3 8 | 9 | def initialize 10 | @sockets = [] 11 | run 12 | end 13 | 14 | def add(socket) 15 | @sockets << socket 16 | end 17 | 18 | def remove(socket) 19 | @sockets.delete(socket) 20 | end 21 | 22 | def stop 23 | @stopped = true 24 | end 25 | 26 | def run 27 | Thread.new do 28 | Thread.current.abort_on_exception = true 29 | loop do 30 | break if @stopped 31 | 32 | unless @sockets.empty? 33 | msg = ping_message Time.now.to_i 34 | @sockets.each do |socket| 35 | socket.transmit msg 36 | end 37 | end 38 | 39 | sleep BEAT_INTERVAL 40 | end 41 | end 42 | end 43 | 44 | private 45 | 46 | def ping_message(time) 47 | {type: LiteCable::INTERNAL[:message_types][:ping], message: time}.to_json 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/lite_cable/server/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Server 5 | # Rack middleware to hijack the socket 6 | class Middleware 7 | class HijackNotAvailable < RuntimeError; end 8 | 9 | def initialize(_app, connection_class:) 10 | @connection_class = connection_class 11 | @heart_beat = HeartBeat.new 12 | end 13 | 14 | def call(env) 15 | return [404, {"Content-Type" => "text/plain"}, ["Not Found"]] unless 16 | env["HTTP_UPGRADE"] == "websocket" 17 | 18 | raise HijackNotAvailable unless env["rack.hijack"] 19 | 20 | env["rack.hijack"].call 21 | handshake = send_handshake(env) 22 | 23 | socket = ClientSocket::Base.new env, env["rack.hijack_io"], handshake.version 24 | init_connection socket 25 | init_heartbeat socket 26 | socket.listen 27 | [-1, {}, []] 28 | end 29 | 30 | private 31 | 32 | def send_handshake(env) 33 | handshake = WebSocket::Handshake::Server.new( 34 | protocols: LiteCable::INTERNAL[:protocols] 35 | ) 36 | 37 | handshake.from_rack env 38 | env["rack.hijack_io"].write handshake.to_s 39 | handshake 40 | end 41 | 42 | def init_connection(socket) 43 | connection = @connection_class.new(socket) 44 | 45 | socket.onopen { connection.handle_open } 46 | socket.onclose { connection.handle_close } 47 | socket.onmessage { |data| connection.handle_command(data) } 48 | end 49 | 50 | def init_heartbeat(socket) 51 | @heart_beat.add(socket) 52 | socket.onclose { @heart_beat.remove(socket) } 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/lite_cable/server/subscribers_map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | module Server 5 | # From https://github.com/rails/rails/blob/v5.0.1/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb 6 | class SubscribersMap 7 | attr_reader :streams, :sockets 8 | 9 | def initialize 10 | @streams = Hash.new do |streams, stream_id| 11 | streams[stream_id] = Hash.new { |channels, channel_id| channels[channel_id] = [] } 12 | end 13 | @sockets = Hash.new { |h, k| h[k] = [] } 14 | @sync = Mutex.new 15 | end 16 | 17 | def add_subscriber(stream, socket, channel) 18 | @sync.synchronize do 19 | @streams[stream][channel] << socket 20 | @sockets[socket] << [channel, stream] 21 | end 22 | end 23 | 24 | def remove_subscriber(stream, socket, channel) 25 | @sync.synchronize do 26 | @streams[stream][channel].delete(socket) 27 | @sockets[socket].delete([channel, stream]) 28 | cleanup stream, socket, channel 29 | end 30 | end 31 | 32 | def remove_socket(socket, channel) 33 | list = @sync.synchronize do 34 | return unless @sockets.key?(socket) 35 | 36 | @sockets[socket].dup 37 | end 38 | 39 | list.each do |(channel_id, stream)| 40 | remove_subscriber(stream, socket, channel) if channel == channel_id 41 | end 42 | end 43 | 44 | def broadcast(stream, message, coder) 45 | list = @sync.synchronize do 46 | return unless @streams.key?(stream) 47 | 48 | @streams[stream].to_a 49 | end 50 | 51 | list.each do |(channel_id, sockets)| 52 | cmessage = channel_message(channel_id, message, coder) 53 | sockets.each { |s| s.transmit cmessage } 54 | end 55 | end 56 | 57 | private 58 | 59 | def cleanup(stream, socket, channel) 60 | @streams[stream].delete(channel) if @streams[stream][channel].empty? 61 | @streams.delete(stream) if @streams[stream].empty? 62 | @sockets.delete(socket) if @sockets[socket].empty? 63 | end 64 | 65 | def channel_message(channel_id, message, coder) 66 | coder.encode(identifier: channel_id, message: message) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/lite_cable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LiteCable 4 | VERSION = "0.8.2" 5 | end 6 | -------------------------------------------------------------------------------- /lib/litecable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "lite_cable" 4 | -------------------------------------------------------------------------------- /litecable.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/lite_cable/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "litecable" 7 | spec.version = LiteCable::VERSION 8 | spec.authors = ["palkan"] 9 | spec.email = ["dementiev.vm@gmail.com"] 10 | 11 | spec.summary = "Fat-free ActionCable implementation" 12 | spec.description = "Fat-free ActionCable implementation for using with AnyCable (and without Rails)" 13 | spec.homepage = "https://github.com/palkan/litecable" 14 | spec.license = "MIT" 15 | spec.metadata = { 16 | "bug_tracker_uri" => "http://github.com/palkan/litecable/issues", 17 | "changelog_uri" => "https://github.com/palkan/litecable/blob/master/CHANGELOG.md", 18 | "documentation_uri" => "http://github.com/palkan/litecable", 19 | "homepage_uri" => "http://github.com/palkan/litecable", 20 | "source_code_uri" => "http://github.com/palkan/litecable" 21 | } 22 | 23 | spec.files = Dir.glob("lib/**/*") + %w[README.md LICENSE.txt CHANGELOG.md] 24 | spec.require_paths = ["lib"] 25 | 26 | spec.required_ruby_version = ">= 2.7.0" 27 | 28 | spec.add_dependency "anyway_config", ">= 1.0" 29 | 30 | spec.add_development_dependency "rack", "~> 2.0" 31 | spec.add_development_dependency "websocket", "~> 1.2.4" 32 | spec.add_development_dependency "websocket-client-simple", "~> 0.3.0" 33 | spec.add_development_dependency "concurrent-ruby", "~> 1.1" 34 | spec.add_development_dependency "puma", ">= 6.0" 35 | 36 | spec.add_development_dependency "bundler", ">= 1.13" 37 | spec.add_development_dependency "rake", ">= 10.0" 38 | spec.add_development_dependency "rspec", ">= 3.0" 39 | end 40 | -------------------------------------------------------------------------------- /spec/integrations/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "puma" 6 | 7 | describe "Lite Cable server", :async do 8 | module ServerTest # rubocop:disable Lint/ConstantDefinitionInBlock 9 | class << self 10 | def logs 11 | @logs ||= [] 12 | end 13 | end 14 | 15 | class Connection < LiteCable::Connection::Base 16 | identified_by :user, :sid 17 | 18 | def connect 19 | reject_unauthorized_connection unless cookies["user"] 20 | @user = cookies["user"] 21 | @sid = request.params["sid"] 22 | end 23 | 24 | def disconnect 25 | ServerTest.logs << "#{user} disconnected" 26 | end 27 | end 28 | 29 | class EchoChannel < LiteCable::Channel::Base 30 | identifier :echo 31 | 32 | def subscribed 33 | stream_from "global" 34 | end 35 | 36 | def unsubscribed 37 | transmit message: "Goodbye, #{user}!" 38 | end 39 | 40 | def ding(data) 41 | transmit(dong: data["message"]) 42 | end 43 | 44 | def delay(data) 45 | sleep 1 46 | transmit(dong: data["message"]) 47 | end 48 | 49 | def bulk(data) 50 | LiteCable.broadcast "global", {message: data["message"], from: user, sid: sid} 51 | end 52 | end 53 | end 54 | 55 | before(:all) do 56 | @server = ::Puma::Server.new( 57 | LiteCable::Server::Middleware.new(nil, connection_class: ServerTest::Connection), 58 | nil, 59 | {min_threads: 1, max_threads: 4} 60 | ) 61 | @server.add_tcp_listener "127.0.0.1", 3099 62 | 63 | @server_t = Thread.new { @server.run.join } 64 | end 65 | 66 | after(:all) do 67 | @server&.stop(true) 68 | @server_t&.join 69 | end 70 | 71 | let(:cookies) { "user=john" } 72 | let(:path) { "/?sid=123" } 73 | let(:client) { @client = SyncClient.new("ws://127.0.0.1:3099#{path}", cookies: cookies) } 74 | let(:logs) { ServerTest.logs } 75 | 76 | after { logs.clear } 77 | 78 | describe "connect" do 79 | it "receives welcome message" do 80 | expect(client.read_message).to eq("type" => "welcome") 81 | end 82 | 83 | context "when unauthorized" do 84 | let(:cookies) { "" } 85 | 86 | it "disconnects" do 87 | client.wait_for_close 88 | expect(client).to be_closed 89 | end 90 | end 91 | end 92 | 93 | describe "disconnect" do 94 | it "calls disconnect handlers" do 95 | expect(client.read_message).to eq("type" => "welcome") 96 | client.close 97 | client.wait_for_close 98 | expect(client).to be_closed 99 | 100 | wait { !logs.size.zero? } 101 | 102 | expect(logs.last).to include "john disconnected" 103 | end 104 | end 105 | 106 | describe "channels" do 107 | it "subscribes to channels and perform actions" do 108 | expect(client.read_message).to eq("type" => "welcome") 109 | 110 | client.send_message command: "subscribe", identifier: JSON.generate(channel: "echo") 111 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "confirm_subscription") 112 | 113 | client.send_message command: "message", identifier: JSON.generate(channel: "echo"), data: JSON.generate(action: "ding", message: "hello") 114 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"dong" => "hello"}) 115 | end 116 | 117 | it "unsubscribes from channels and receive cleanup messages" do 118 | expect(client.read_message).to eq("type" => "welcome") 119 | 120 | client.send_message command: "subscribe", identifier: JSON.generate(channel: "echo") 121 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "confirm_subscription") 122 | 123 | client.send_message command: "unsubscribe", identifier: JSON.generate(channel: "echo") 124 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"message" => "Goodbye, john!"}) 125 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "cancel_subscription") 126 | end 127 | end 128 | 129 | describe "broadcasts" do 130 | let(:client2) { @client2 = SyncClient.new("ws://127.0.0.1:3099/?sid=234", cookies: "user=alice") } 131 | 132 | let(:clients) { [client, client2] } 133 | 134 | before do 135 | concurrently(clients) do |c| 136 | expect(c.read_message).to eq("type" => "welcome") 137 | 138 | c.send_message command: "subscribe", identifier: JSON.generate(channel: "echo") 139 | expect(c.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "confirm_subscription") 140 | end 141 | end 142 | 143 | it "transmit messages to connected clients" do 144 | client.send_message command: "message", identifier: JSON.generate(channel: "echo"), data: JSON.generate(action: "bulk", message: "Good news, everyone!") 145 | 146 | concurrently(clients) do |c| 147 | expect(c.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"message" => "Good news, everyone!", "from" => "john", "sid" => "123"}) 148 | end 149 | 150 | client2.send_message command: "message", identifier: JSON.generate(channel: "echo"), data: JSON.generate(action: "bulk", message: "A-W-E-S-O-M-E") 151 | 152 | concurrently(clients) do |c| 153 | expect(c.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"message" => "A-W-E-S-O-M-E", "from" => "alice", "sid" => "234"}) 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/lite_cable/channel/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class TestChannel < LiteCable::Channel::Base 6 | attr_reader :subscribe, :unsubscribe, :follows, :received 7 | 8 | def subscribed 9 | reject if params["reject"] 10 | @subscribe = true 11 | end 12 | 13 | def unsubscribed 14 | @unsubscribe = true 15 | end 16 | 17 | def receive(data) 18 | @received = data 19 | end 20 | 21 | def follow_all 22 | @follows = true 23 | end 24 | 25 | def follow(data) 26 | transmit follow_id: data["id"] 27 | end 28 | end 29 | 30 | describe TestChannel do 31 | let(:user) { "john" } 32 | let(:socket) { TestSocket.new } 33 | let(:connection) { TestConnection.new(socket, identifiers: {"user" => user}.to_json) } 34 | let(:params) { {} } 35 | 36 | subject { described_class.new(connection, "test", params) } 37 | 38 | describe "connection identifiers" do 39 | specify { expect(subject.user).to eq "john" } 40 | end 41 | 42 | describe "#handle_subscribe" do 43 | it "calls #subscribed method" do 44 | subject.handle_subscribe 45 | expect(subject.subscribe).to eq true 46 | end 47 | 48 | context "when rejects" do 49 | let(:params) { {"reject" => true} } 50 | 51 | it "raises error" do 52 | expect { subject.handle_subscribe }.to raise_error(LiteCable::Channel::RejectedError) 53 | end 54 | end 55 | end 56 | 57 | describe "#handle_unsubscribe" do 58 | it "calls #unsubscribed method" do 59 | subject.handle_unsubscribe 60 | expect(subject.unsubscribe).to eq true 61 | end 62 | end 63 | 64 | describe "#handle_action" do 65 | it "call actions without parameters" do 66 | subject.handle_action({"action" => "follow_all"}.to_json) 67 | expect(subject.follows).to eq true 68 | end 69 | 70 | it "call actions with parameters" do 71 | expect { subject.handle_action({"action" => "follow", "id" => 15}.to_json) }.to change(socket.transmissions, :size).by(1) 72 | expect(socket.last_transmission).to eq("message" => {"follow_id" => 15}, "identifier" => "test") 73 | end 74 | 75 | it "calls 'receive' when no action param" do 76 | subject.handle_action({"message" => "Recieve me!"}.to_json) 77 | expect(subject.received).to eq("message" => "Recieve me!") 78 | end 79 | 80 | it "raises error when action is not public" do 81 | expect { subject.handle_action({"action" => "reject"}.to_json) }.to raise_error(LiteCable::Channel::UnproccessableActionError) 82 | end 83 | 84 | it "raises error when action doesn't exist" do 85 | expect { subject.handle_action({"action" => "foobar"}.to_json) }.to raise_error(LiteCable::Channel::UnproccessableActionError) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/lite_cable/channel/streams_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class TestStreamsChannel < LiteCable::Channel::Base 6 | attr_reader :all_stopped 7 | 8 | def subscribed 9 | stream_from "notifications_#{user}" 10 | end 11 | 12 | def follow(data) 13 | stream_from "chat_#{data["id"]}" 14 | end 15 | 16 | def unfollow(data) 17 | stop_stream "chat_#{data["id"]}" 18 | end 19 | 20 | def stop_all_streams 21 | super 22 | @all_stopped = true 23 | end 24 | end 25 | 26 | describe TestStreamsChannel do 27 | let(:user) { "john" } 28 | let(:socket) { TestSocket.new } 29 | let(:connection) { TestConnection.new(socket, identifiers: {"user" => user}.to_json) } 30 | let(:params) { {} } 31 | 32 | subject { described_class.new(connection, "test", params) } 33 | 34 | describe "#stream_from" do 35 | it "subscribes channel to stream" do 36 | subject.handle_subscribe 37 | expect(socket.streams["test"]).to eq(["notifications_john"]) 38 | end 39 | end 40 | 41 | describe "#stop_stream" do 42 | it "unsubscribes channel from stream", :aggregate_failures do 43 | subject.handle_action({"action" => "follow", "id" => 1}.to_json) 44 | expect(socket.streams["test"]).to eq(["chat_1"]) 45 | 46 | subject.handle_action({"action" => "unfollow", "id" => 1}.to_json) 47 | expect(socket.streams["test"]).to eq([]) 48 | end 49 | end 50 | 51 | describe "#stop_all_streams" do 52 | it "call stop_all_streams on unsubscribe", :aggregate_failures do 53 | subject.handle_subscribe 54 | expect(socket.streams["test"]).to eq(["notifications_john"]) 55 | 56 | subject.handle_unsubscribe 57 | expect(subject.all_stopped).to eq true 58 | expect(socket.streams["test"]).to be_nil 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/lite_cable/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe LiteCable::Config do 6 | let(:config) { LiteCable.config } 7 | 8 | it "sets defailts", :aggregate_failures do 9 | expect(config.coder).to eq LiteCable::Coders::JSON 10 | expect(config.identifier_coder).to eq LiteCable::Coders::Raw 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/lite_cable/connection/authorization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class TestAuthorizationConnection < LiteCable::Connection::Base 6 | attr_reader :connected 7 | 8 | def connect 9 | reject_unauthorized_connection unless cookies["user"] 10 | @connected = true 11 | end 12 | end 13 | 14 | describe TestAuthorizationConnection do 15 | let(:cookies) { "" } 16 | let(:socket_params) { {env: {"HTTP_COOKIE" => cookies}} } 17 | let(:socket) { TestSocket.new(**socket_params) } 18 | 19 | subject { described_class.new(socket) } 20 | 21 | describe "#handle_open" do 22 | it "raises exception if rejected" do 23 | expect(subject).to receive(:close) 24 | expect { subject.handle_open }.not_to change(socket.transmissions, :size) 25 | end 26 | 27 | context "when accepted" do 28 | let(:cookies) { "user=john;" } 29 | 30 | it "succesfully connects" do 31 | subject.handle_open 32 | expect(subject.connected).to eq true 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lite_cable/connection/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class TestBaseConnection < LiteCable::Connection::Base 6 | attr_reader :connected, :disconnected 7 | 8 | def connect 9 | @connected = true 10 | end 11 | 12 | def disconnect 13 | @disconnected = true 14 | end 15 | end 16 | 17 | describe TestBaseConnection do 18 | let(:socket_params) { {} } 19 | let(:socket) { TestSocket.new(**socket_params) } 20 | 21 | subject { described_class.new(socket) } 22 | 23 | describe "#handle_open" do 24 | it "calls #connect method" do 25 | subject.handle_open 26 | expect(subject.connected).to eq true 27 | end 28 | 29 | it "sends welcome message" do 30 | expect { subject.handle_open }.to change(socket.transmissions, :size).by(1) 31 | expect(socket.last_transmission).to eq("type" => "welcome") 32 | end 33 | end 34 | 35 | describe "#handle_close" do 36 | it "calls #disconnect method" do 37 | subject.handle_close 38 | expect(subject.disconnected).to eq true 39 | expect(subject).to be_disconnected 40 | end 41 | 42 | it "calls #unsubscribe_from_all on subscriptions" do 43 | expect(subject.subscriptions).to receive(:remove_all) 44 | subject.handle_close 45 | end 46 | end 47 | 48 | describe "#close" do 49 | it "closes socket" do 50 | subject.close 51 | expect(socket).to be_closed 52 | end 53 | end 54 | 55 | describe "#transmit" do 56 | context "when disconnected" do 57 | it "doesn't transmit messages" do 58 | subject.handle_close 59 | expect { subject.transmit(data: "I'm alive!") }.not_to change(socket.transmissions, :size) 60 | end 61 | end 62 | 63 | context "with non-default coder" do 64 | subject { described_class.new(socket, coder: LiteCable::Coders::Raw) } 65 | 66 | it "uses specified coder" do 67 | subject.transmit '{"coder": "raw"}' 68 | expect(socket.last_transmission).to eq("coder" => "raw") 69 | end 70 | end 71 | end 72 | 73 | describe "#handle_command" do 74 | it "runs subscriptions #execute_command" do 75 | expect(subject.subscriptions).to receive(:execute_command).with({"command" => "test"}) 76 | subject.handle_command('{"command":"test"}') 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/lite_cable/connection/identification_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class TestIdentificationConnection < LiteCable::Connection::Base 6 | identified_by :user, :john 7 | 8 | def connect 9 | @user = cookies["user"] 10 | @john = @user == "john" 11 | end 12 | end 13 | 14 | class CustomIdCoder 15 | class << self 16 | def encode(val) 17 | if val.is_a?(String) 18 | val.reverse 19 | else 20 | val 21 | end 22 | end 23 | 24 | alias_method :decode, :encode 25 | end 26 | end 27 | 28 | describe TestIdentificationConnection do 29 | let(:cookies) { "user=john;" } 30 | let(:socket_params) { {env: {"HTTP_COOKIE" => cookies}} } 31 | let(:socket) { TestSocket.new(**socket_params) } 32 | 33 | subject do 34 | described_class.new(socket).tap(&:handle_open) 35 | end 36 | 37 | it "create accessors" do 38 | expect(subject.user).to eq "john" 39 | expect(subject.john).to eq true 40 | end 41 | 42 | describe "#identifier" do 43 | it "returns string identifier" do 44 | expect(subject.identifier).to eq("john:true") 45 | end 46 | 47 | context "when some identifiers are nil" do 48 | let(:cookies) { "user=jack" } 49 | 50 | it "returns string identifier" do 51 | expect(subject.identifier).to eq("jack") 52 | end 53 | end 54 | 55 | context "when all identifiers are nil" do 56 | let(:cookies) { "" } 57 | 58 | it "returns string identifier" do 59 | expect(subject.identifier).to be_nil 60 | end 61 | end 62 | 63 | context "with custom identifier coder" do 64 | prepend_before { allow(LiteCable.config).to receive(:identifier_coder).and_return(CustomIdCoder) } 65 | 66 | it "uses custom id coder" do 67 | expect(subject.identifier).to eq("nhoj:true") 68 | end 69 | end 70 | end 71 | 72 | describe "#identifiers_hash" do 73 | it "returns a hash" do 74 | expect(subject.identifiers_hash).to eq("user" => "john", "john" => true) 75 | end 76 | end 77 | 78 | context "with encoded_identifiers" do 79 | prepend_before { allow(LiteCable.config).to receive(:identifier_coder).and_return(CustomIdCoder) } 80 | 81 | let(:identifiers) { {"user" => "kcaj", "john" => false}.to_json } 82 | 83 | subject { described_class.new(socket, identifiers: identifiers) } 84 | 85 | it "deserialize values from provided hash" do 86 | expect(subject.user).to eq "jack" 87 | expect(subject.john).to eq false 88 | end 89 | 90 | it "calls decoded only once" do 91 | expect(CustomIdCoder).to receive(:decode).once 92 | subject.user 93 | subject.user 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/lite_cable/connection/subscriptions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | Class.new(LiteCable::Channel::Base) do 6 | identifier "subscription_test" 7 | 8 | def subscribed 9 | reject if params["reject"] == true 10 | @subscribed = true 11 | end 12 | 13 | def subscribed? 14 | @subscribed == true 15 | end 16 | 17 | def unsubscribed 18 | @unsubscribed = true 19 | end 20 | 21 | def unsubscribed? 22 | @unsubscribed == true 23 | end 24 | end 25 | 26 | Class.new(LiteCable::Channel::Base) do 27 | identifier "subscription_test2" 28 | end 29 | 30 | describe LiteCable::Connection::Subscriptions do 31 | let(:socket) { TestSocket.new } 32 | let(:connection) { LiteCable::Connection::Base.new(socket) } 33 | 34 | subject { described_class.new(connection) } 35 | 36 | describe "#add" do 37 | it "creates channel", :aggregate_failures do 38 | id = {channel: "subscription_test"}.to_json 39 | channel = subject.add(id) 40 | expect(channel).to be_subscribed 41 | expect(subject.identifiers).to include(id) 42 | end 43 | 44 | it "sends confirmation" do 45 | id = {channel: "subscription_test"}.to_json 46 | expect { subject.add(id) }.to change(socket.transmissions, :size).by(1) 47 | expect(socket.last_transmission).to eq("identifier" => id, "type" => "confirm_subscription") 48 | end 49 | 50 | it "handles params and identifier", :aggregate_failures do 51 | id = {channel: "subscription_test", id: 1, type: "test"}.to_json 52 | channel = subject.add(id) 53 | expect(channel.identifier).to eq id 54 | expect(channel.params).to eq("id" => 1, "type" => "test") 55 | end 56 | 57 | it "handles rejection", :aggregate_failures do 58 | id = {channel: "subscription_test", reject: true}.to_json 59 | channel = subject.add(id) 60 | expect(channel).to be_nil 61 | expect(subject.identifiers).not_to include(id) 62 | expect(socket.last_transmission).to eq("identifier" => id, "type" => "reject_subscription") 63 | end 64 | end 65 | 66 | describe "#remove" do 67 | let(:id) { {channel: "subscription_test"}.to_json } 68 | let!(:channel) { subject.add(id) } 69 | 70 | it "removes subscription and send cancel confirmation", :aggregate_failures do 71 | subject.remove(id) 72 | expect(channel).to be_unsubscribed 73 | expect(subject.identifiers).not_to include(id) 74 | expect(socket.last_transmission).to eq("identifier" => id, "type" => "cancel_subscription") 75 | end 76 | end 77 | 78 | describe "#remove_all" do 79 | let(:id) { {channel: "subscription_test"}.to_json } 80 | let(:id2) { {channel: "subscription_test2"}.to_json } 81 | 82 | let(:channel) { subject.add(id) } 83 | let(:channel2) { subject.add(id2) } 84 | 85 | it "removes all subscriptions and send confirmations", :aggregate_failures do 86 | expect(channel).to receive(:handle_unsubscribe) 87 | expect(channel2).to receive(:handle_unsubscribe) 88 | 89 | subject.remove_all 90 | expect(subject.identifiers).to eq([]) 91 | end 92 | end 93 | 94 | describe "#execute_command" do 95 | it "handles subscribe" do 96 | expect(subject).to receive(:add).with("subscription_test") 97 | subject.execute_command("command" => "subscribe", "identifier" => "subscription_test") 98 | end 99 | 100 | it "handles unsubscribe" do 101 | expect(subject).to receive(:remove).with("subscription_test") 102 | subject.execute_command("command" => "unsubscribe", "identifier" => "subscription_test") 103 | end 104 | 105 | it "handles message" do 106 | channel = double("channel") 107 | expect(subject).to receive(:find).with("subscription_test").and_return(channel) 108 | expect(channel).to receive(:handle_action).with('{"action":"test"}') 109 | subject.execute_command("command" => "message", "identifier" => "subscription_test", "data" => {action: "test"}.to_json) 110 | end 111 | 112 | it "raises error on unknown command error" do 113 | expect { subject.execute_command("command" => "test") }.to raise_error(LiteCable::Connection::Subscriptions::UnknownCommandError) 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/lite_cable/server/subscribers_map_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe LiteCable::Server::SubscribersMap do 6 | let(:socket) { spy("socket") } 7 | let(:socket2) { spy("socket2") } 8 | let(:coder) { LiteCable::Coders::JSON } 9 | 10 | subject { described_class.new } 11 | 12 | describe "#add_subscriber" do 13 | it "adds one socket" do 14 | subject.add_subscriber "test", socket, "channel" 15 | subject.broadcast "test", "blabla", coder 16 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json) 17 | end 18 | 19 | it "adds several sockets", :aggregate_failures do 20 | subject.add_subscriber "test", socket, "channel" 21 | subject.add_subscriber "test", socket2, "channel2" 22 | subject.add_subscriber "test2", socket, "channel" 23 | 24 | subject.broadcast "test", "blabla", coder 25 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json) 26 | expect(socket2).to have_received(:transmit).with({identifier: "channel2", message: "blabla"}.to_json) 27 | 28 | subject.broadcast "test2", "blublu", coder 29 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blublu"}.to_json) 30 | expect(socket2).not_to have_received(:transmit).with({identifier: "channel2", message: "blublu"}.to_json) 31 | end 32 | end 33 | 34 | describe "#remove_subscriber" do 35 | before do 36 | subject.add_subscriber "test", socket, "channel" 37 | subject.add_subscriber "test2", socket, "channel" 38 | end 39 | 40 | it "removes socket from stream" do 41 | subject.remove_subscriber "test", socket, "channel" 42 | subject.broadcast "test", "blabla", coder 43 | subject.broadcast "test2", "blublu", coder 44 | 45 | expect(socket).not_to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json) 46 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blublu"}.to_json) 47 | end 48 | end 49 | 50 | describe "#remove_socket" do 51 | before do 52 | subject.add_subscriber "test", socket, "channel" 53 | subject.add_subscriber "test2", socket, "channel" 54 | subject.add_subscriber "test3", socket, "channel2" 55 | end 56 | 57 | it "removes socket from all streams" do 58 | subject.remove_socket socket, "channel" 59 | subject.broadcast "test", "blabla", coder 60 | subject.broadcast "test2", "blublu", coder 61 | subject.broadcast "test3", "brobro", coder 62 | 63 | expect(socket).not_to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json) 64 | expect(socket).not_to have_received(:transmit).with({identifier: "channel", message: "blublu"}.to_json) 65 | expect(socket).to have_received(:transmit).with({identifier: "channel2", message: "brobro"}.to_json) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/litecable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe LiteCable do 6 | it "has a version number" do 7 | expect(LiteCable::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "debug" unless ENV["CI"] == "true" 5 | rescue LoadError 6 | end 7 | 8 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 9 | require "lite_cable" 10 | require "lite_cable/server" 11 | 12 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } 13 | 14 | RSpec.configure do |config| 15 | config.mock_with :rspec do |mocks| 16 | mocks.verify_partial_doubles = true 17 | end 18 | 19 | config.include AsyncHelpers, async: true 20 | 21 | config.example_status_persistence_file_path = "tmp/rspec_examples.txt" 22 | config.filter_run :focus 23 | config.run_all_when_everything_filtered = true 24 | 25 | config.order = :random 26 | Kernel.srand config.seed 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/async_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AsyncHelpers 4 | # Wait for block to return true of raise error 5 | def wait(timeout = 1) 6 | until yield 7 | sleep 0.1 8 | timeout -= 0.1 9 | raise "Timeout error" unless timeout > 0 10 | end 11 | end 12 | 13 | def concurrently(enum) 14 | enum.map { |*x| Concurrent::Future.execute { yield(*x) } }.map(&:value!) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/sync_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Synchronous websocket client 4 | # Based on https://github.com/rails/rails/blob/v5.0.1/actioncable/test/client_test.rb 5 | class SyncClient 6 | require "websocket-client-simple" 7 | require "concurrent" 8 | 9 | WAIT_WHEN_EXPECTING_EVENT = 5 10 | WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5 11 | 12 | attr_reader :pings 13 | 14 | def initialize(url, cookies: "") 15 | messages = @messages = Queue.new 16 | closed = @closed = Concurrent::Event.new 17 | has_messages = @has_messages = Concurrent::Semaphore.new(0) 18 | pings = @pings = Concurrent::AtomicFixnum.new(0) 19 | 20 | open = Concurrent::Promise.new 21 | 22 | @ws = WebSocket::Client::Simple.connect( 23 | url, 24 | headers: { 25 | "COOKIE" => cookies 26 | } 27 | ) do |ws| 28 | ws.on(:error) do |event| 29 | event = RuntimeError.new(event.message) unless event.is_a?(Exception) 30 | 31 | if open.pending? 32 | open.fail(event) 33 | else 34 | messages << event 35 | has_messages.release 36 | end 37 | end 38 | 39 | ws.on(:open) do |_event| 40 | open.set(true) 41 | end 42 | 43 | ws.on(:message) do |event| 44 | if event.type == :close 45 | closed.set 46 | else 47 | message = JSON.parse(event.data) 48 | if message["type"] == "ping" 49 | pings.increment 50 | else 51 | messages << message 52 | has_messages.release 53 | end 54 | end 55 | end 56 | 57 | ws.on(:close) do |_event| 58 | closed.set 59 | end 60 | end 61 | 62 | open.wait!(WAIT_WHEN_EXPECTING_EVENT) 63 | end 64 | 65 | def read_message 66 | @has_messages.try_acquire(1, WAIT_WHEN_EXPECTING_EVENT) 67 | 68 | msg = @messages.pop(true) 69 | raise msg if msg.is_a?(Exception) 70 | 71 | msg 72 | end 73 | 74 | def read_messages(expected_size = 0) 75 | list = [] 76 | loop do 77 | break unless @has_messages.try_acquire(1, (list.size < expected_size) ? WAIT_WHEN_EXPECTING_EVENT : WAIT_WHEN_NOT_EXPECTING_EVENT) 78 | 79 | msg = @messages.pop(true) 80 | raise msg if msg.is_a?(Exception) 81 | 82 | list << msg 83 | end 84 | list 85 | end 86 | 87 | def send_message(message) 88 | @ws.send(JSON.generate(message)) 89 | end 90 | 91 | def close 92 | sleep WAIT_WHEN_NOT_EXPECTING_EVENT 93 | 94 | raise "#{@messages.size} messages unprocessed" unless @messages.empty? 95 | 96 | @ws.close 97 | wait_for_close 98 | end 99 | 100 | def wait_for_close 101 | @closed.wait(WAIT_WHEN_EXPECTING_EVENT) 102 | end 103 | 104 | def closed? 105 | @closed.set? 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/support/test_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Test connection with `user` identifier 4 | class TestConnection < LiteCable::Connection::Base 5 | identified_by :user 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/test_socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | # Stub connection socket 5 | class TestSocket 6 | attr_reader :transmissions, :streams 7 | 8 | def initialize(coder: LiteCable::Coders::JSON, env: {}) 9 | @transmissions = [] 10 | @streams = {} 11 | @coder = coder 12 | @env = env 13 | end 14 | 15 | def transmit(websocket_message) 16 | @transmissions << websocket_message 17 | end 18 | 19 | def last_transmission 20 | decode(@transmissions.last) if @transmissions.any? 21 | end 22 | 23 | def decode(websocket_message) 24 | @coder.decode websocket_message 25 | end 26 | 27 | def subscribe(channel, broadcasting) 28 | streams[channel] ||= [] 29 | streams[channel] << broadcasting 30 | end 31 | 32 | def unsubscribe(channel, broadcasting) 33 | streams[channel]&.delete(broadcasting) 34 | end 35 | 36 | def unsubscribe_from_all(channel) 37 | streams.delete(channel) 38 | end 39 | 40 | def close 41 | @closed = true 42 | end 43 | 44 | def closed? 45 | @closed == true 46 | end 47 | 48 | def request 49 | @request ||= Rack::Request.new(@env) 50 | end 51 | end 52 | --------------------------------------------------------------------------------