From 50de79202a96d9f7013e4cd183713a7f7b340fdd Mon Sep 17 00:00:00 2001 From: Vladimir Dementyev Date: Mon, 30 Sep 2024 12:36:51 -0700 Subject: [PATCH] PR -> gem --- .github/workflows/lint.yml | 32 ++ .github/workflows/test.yml | 53 +++ .gitignore | 44 ++ .rubocop.yml | 404 ++++++++++++++++++ CHANGELOG.md | 5 + Gemfile | 47 ++ MIT-LICENSE | 20 + README.md | 17 + Rakefile | 29 ++ actioncable-next.gemspec | 36 ++ lib/action_cable.rb | 81 ++++ lib/action_cable/channel/base.rb | 335 +++++++++++++++ lib/action_cable/channel/broadcasting.rb | 50 +++ lib/action_cable/channel/callbacks.rb | 76 ++++ lib/action_cable/channel/naming.rb | 28 ++ lib/action_cable/channel/periodic_timers.rb | 81 ++++ lib/action_cable/channel/streams.rb | 213 +++++++++ lib/action_cable/channel/test_case.rb | 329 ++++++++++++++ lib/action_cable/connection/authorization.rb | 18 + lib/action_cable/connection/base.rb | 165 +++++++ lib/action_cable/connection/callbacks.rb | 57 +++ lib/action_cable/connection/identification.rb | 51 +++ .../connection/internal_channel.rb | 50 +++ lib/action_cable/connection/subscriptions.rb | 124 ++++++ lib/action_cable/connection/test_case.rb | 294 +++++++++++++ lib/action_cable/deprecator.rb | 9 + lib/action_cable/engine.rb | 98 +++++ lib/action_cable/gem_version.rb | 19 + .../helpers/action_cable_helper.rb | 45 ++ lib/action_cable/remote_connections.rb | 82 ++++ lib/action_cable/server/base.rb | 163 +++++++ lib/action_cable/server/broadcasting.rb | 62 +++ lib/action_cable/server/configuration.rb | 75 ++++ lib/action_cable/server/connections.rb | 44 ++ lib/action_cable/server/socket.rb | 180 ++++++++ .../server/socket/client_socket.rb | 159 +++++++ .../server/socket/message_buffer.rb | 56 +++ lib/action_cable/server/socket/stream.rb | 117 +++++ lib/action_cable/server/socket/web_socket.rb | 47 ++ lib/action_cable/server/stream_event_loop.rb | 119 ++++++ .../server/tagged_logger_proxy.rb | 46 ++ lib/action_cable/server/worker.rb | 75 ++++ .../active_record_connection_management.rb | 23 + .../subscription_adapter/async.rb | 14 + lib/action_cable/subscription_adapter/base.rb | 39 ++ .../subscription_adapter/channel_prefix.rb | 30 ++ .../subscription_adapter/inline.rb | 40 ++ .../subscription_adapter/postgresql.rb | 130 ++++++ .../subscription_adapter/redis.rb | 257 +++++++++++ .../subscription_adapter/subscriber_map.rb | 80 ++++ lib/action_cable/subscription_adapter/test.rb | 41 ++ lib/action_cable/test_case.rb | 13 + lib/action_cable/test_helper.rb | 163 +++++++ lib/action_cable/version.rb | 12 + lib/actioncable-next.rb | 5 + lib/rails/generators/channel/USAGE | 19 + .../generators/channel/channel_generator.rb | 127 ++++++ .../templates/application_cable/channel.rb.tt | 4 + .../application_cable/connection.rb.tt | 4 + .../channel/templates/channel.rb.tt | 16 + .../templates/javascript/channel.js.tt | 20 + .../templates/javascript/consumer.js.tt | 6 + .../channel/templates/javascript/index.js.tt | 1 + .../generators/test_unit/channel_generator.rb | 22 + .../test_unit/templates/channel_test.rb.tt | 8 + test/channel/base_test.rb | 285 ++++++++++++ test/channel/broadcasting_test.rb | 48 +++ test/channel/naming_test.rb | 12 + test/channel/periodic_timers_test.rb | 85 ++++ test/channel/rejection_test.rb | 56 +++ test/channel/stream_test.rb | 335 +++++++++++++++ test/channel/test_case_test.rb | 274 ++++++++++++ test/client_test.rb | 383 +++++++++++++++++ test/connection/authorization_test.rb | 34 ++ test/connection/base_test.rb | 76 ++++ test/connection/callbacks_test.rb | 101 +++++ test/connection/identifier_test.rb | 61 +++ test/connection/multiple_identifiers_test.rb | 31 ++ test/connection/string_identifier_test.rb | 29 ++ test/connection/subscriptions_test.rb | 160 +++++++ test/connection/test_case_test.rb | 213 +++++++++ test/server/base_test.rb | 38 ++ test/server/broadcasting_test.rb | 54 +++ test/server/cross_site_forgery_test.rb | 90 ++++ test/server/health_check_test.rb | 57 +++ test/server/socket/client_socket_test.rb | 100 +++++ test/server/socket/stream_test.rb | 76 ++++ test/server/socket_test.rb | 153 +++++++ test/stubs/global_id.rb | 10 + test/stubs/room.rb | 18 + test/stubs/test_adapter.rb | 22 + test/stubs/test_server.rb | 48 +++ test/stubs/test_socket.rb | 39 ++ test/stubs/user.rb | 17 + test/subscription_adapter/async_test.rb | 19 + test/subscription_adapter/base_test.rb | 68 +++ test/subscription_adapter/channel_prefix.rb | 29 ++ test/subscription_adapter/common.rb | 139 ++++++ test/subscription_adapter/inline_test.rb | 19 + test/subscription_adapter/postgresql_test.rb | 85 ++++ test/subscription_adapter/redis_test.rb | 150 +++++++ .../subscriber_map_test.rb | 19 + .../subscription_adapter/test_adapter_test.rb | 47 ++ test/test_helper.rb | 43 ++ test/test_helper_test.rb | 141 ++++++ test/worker_test.rb | 46 ++ 106 files changed, 8819 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 MIT-LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 actioncable-next.gemspec create mode 100644 lib/action_cable.rb create mode 100644 lib/action_cable/channel/base.rb create mode 100644 lib/action_cable/channel/broadcasting.rb create mode 100644 lib/action_cable/channel/callbacks.rb create mode 100644 lib/action_cable/channel/naming.rb create mode 100644 lib/action_cable/channel/periodic_timers.rb create mode 100644 lib/action_cable/channel/streams.rb create mode 100644 lib/action_cable/channel/test_case.rb create mode 100644 lib/action_cable/connection/authorization.rb create mode 100644 lib/action_cable/connection/base.rb create mode 100644 lib/action_cable/connection/callbacks.rb create mode 100644 lib/action_cable/connection/identification.rb create mode 100644 lib/action_cable/connection/internal_channel.rb create mode 100644 lib/action_cable/connection/subscriptions.rb create mode 100644 lib/action_cable/connection/test_case.rb create mode 100644 lib/action_cable/deprecator.rb create mode 100644 lib/action_cable/engine.rb create mode 100644 lib/action_cable/gem_version.rb create mode 100644 lib/action_cable/helpers/action_cable_helper.rb create mode 100644 lib/action_cable/remote_connections.rb create mode 100644 lib/action_cable/server/base.rb create mode 100644 lib/action_cable/server/broadcasting.rb create mode 100644 lib/action_cable/server/configuration.rb create mode 100644 lib/action_cable/server/connections.rb create mode 100644 lib/action_cable/server/socket.rb create mode 100644 lib/action_cable/server/socket/client_socket.rb create mode 100644 lib/action_cable/server/socket/message_buffer.rb create mode 100644 lib/action_cable/server/socket/stream.rb create mode 100644 lib/action_cable/server/socket/web_socket.rb create mode 100644 lib/action_cable/server/stream_event_loop.rb create mode 100644 lib/action_cable/server/tagged_logger_proxy.rb create mode 100644 lib/action_cable/server/worker.rb create mode 100644 lib/action_cable/server/worker/active_record_connection_management.rb create mode 100644 lib/action_cable/subscription_adapter/async.rb create mode 100644 lib/action_cable/subscription_adapter/base.rb create mode 100644 lib/action_cable/subscription_adapter/channel_prefix.rb create mode 100644 lib/action_cable/subscription_adapter/inline.rb create mode 100644 lib/action_cable/subscription_adapter/postgresql.rb create mode 100644 lib/action_cable/subscription_adapter/redis.rb create mode 100644 lib/action_cable/subscription_adapter/subscriber_map.rb create mode 100644 lib/action_cable/subscription_adapter/test.rb create mode 100644 lib/action_cable/test_case.rb create mode 100644 lib/action_cable/test_helper.rb create mode 100644 lib/action_cable/version.rb create mode 100644 lib/actioncable-next.rb create mode 100644 lib/rails/generators/channel/USAGE create mode 100644 lib/rails/generators/channel/channel_generator.rb create mode 100644 lib/rails/generators/channel/templates/application_cable/channel.rb.tt create mode 100644 lib/rails/generators/channel/templates/application_cable/connection.rb.tt create mode 100644 lib/rails/generators/channel/templates/channel.rb.tt create mode 100644 lib/rails/generators/channel/templates/javascript/channel.js.tt create mode 100644 lib/rails/generators/channel/templates/javascript/consumer.js.tt create mode 100644 lib/rails/generators/channel/templates/javascript/index.js.tt create mode 100644 lib/rails/generators/test_unit/channel_generator.rb create mode 100644 lib/rails/generators/test_unit/templates/channel_test.rb.tt create mode 100644 test/channel/base_test.rb create mode 100644 test/channel/broadcasting_test.rb create mode 100644 test/channel/naming_test.rb create mode 100644 test/channel/periodic_timers_test.rb create mode 100644 test/channel/rejection_test.rb create mode 100644 test/channel/stream_test.rb create mode 100644 test/channel/test_case_test.rb create mode 100644 test/client_test.rb create mode 100644 test/connection/authorization_test.rb create mode 100644 test/connection/base_test.rb create mode 100644 test/connection/callbacks_test.rb create mode 100644 test/connection/identifier_test.rb create mode 100644 test/connection/multiple_identifiers_test.rb create mode 100644 test/connection/string_identifier_test.rb create mode 100644 test/connection/subscriptions_test.rb create mode 100644 test/connection/test_case_test.rb create mode 100644 test/server/base_test.rb create mode 100644 test/server/broadcasting_test.rb create mode 100644 test/server/cross_site_forgery_test.rb create mode 100644 test/server/health_check_test.rb create mode 100644 test/server/socket/client_socket_test.rb create mode 100644 test/server/socket/stream_test.rb create mode 100644 test/server/socket_test.rb create mode 100644 test/stubs/global_id.rb create mode 100644 test/stubs/room.rb create mode 100644 test/stubs/test_adapter.rb create mode 100644 test/stubs/test_server.rb create mode 100644 test/stubs/test_socket.rb create mode 100644 test/stubs/user.rb create mode 100644 test/subscription_adapter/async_test.rb create mode 100644 test/subscription_adapter/base_test.rb create mode 100644 test/subscription_adapter/channel_prefix.rb create mode 100644 test/subscription_adapter/common.rb create mode 100644 test/subscription_adapter/inline_test.rb create mode 100644 test/subscription_adapter/postgresql_test.rb create mode 100644 test/subscription_adapter/redis_test.rb create mode 100644 test/subscription_adapter/subscriber_map_test.rb create mode 100644 test/subscription_adapter/test_adapter_test.rb create mode 100644 test/test_helper.rb create mode 100644 test/test_helper_test.rb create mode 100644 test/worker_test.rb diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8c44847 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Lint Ruby + +on: + push: + branches: + - main + paths: + - "gemfiles/*" + - "Gemfile" + - "**/*.rb" + - "**/*.gemspec" + - ".github/workflows/lint.yml" + pull_request: + paths: + - "gemfiles/*" + - "Gemfile" + - "**/*.rb" + - "**/*.gemspec" + - ".github/workflows/lint.yml" + +jobs: + rubocop: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3 + bundler-cache: true + - name: Lint Ruby code with RuboCop + run: | + bundle exec rubocop diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..30d90e9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + test: + if: ${{ !contains(github.event.head_commit.message, '[ci skip]') }} + runs-on: ubuntu-latest + env: + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + CI: true + RAILS_VERSION: ${{ matrix.rails_version }} + DATABASE_URL: postgresql://postgres:postgres@localhost:5432 + services: + redis: + image: redis:7.0-alpine + ports: ["6379:6379"] + options: --health-cmd="redis-cli ping" --health-interval 1s --health-timeout 3s --health-retries 30 + postgres: + image: postgres:16 + ports: ["5432:5432"] + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + fail-fast: false + matrix: + rails_version: ["~> 7.0.0", "~> 7.0", "~> 8.0.0.beta1"] + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3 + bundler-cache: true + - name: Create a PostgreSQL database + run: | + createdb -h localhost -U postgres activerecord_unittest + - name: Run tests + run: | + bundle exec rake test + - name: Run tests in isolation + run: | + bundle exec rake test:isolated diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94b3148 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Numerous always-ignore extensions +*.diff +*.err +*.orig +*.log +*.rej +*.swo +*.swp +*.vi +*~ +*.sass-cache +*.iml +.idea/ + +# Sublime +*.sublime-project +*.sublime-workspace + +# OS or Editor folders +.DS_Store +.cache +.project +.settings +.tmproj +Thumbs.db + +.bundle/ +log/*.log +*.gz +pkg/ +spec/dummy/db/*.sqlite3 +spec/dummy/db/*.sqlite3-journal +spec/dummy/tmp/ + +Gemfile.lock +Gemfile.local +.rspec +*.gem +tmp/ +coverage/ + +/src +/test/javascript/compiled/ +/tmp/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..b1c5925 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,404 @@ +require: + - rubocop-minitest + - rubocop-packaging + - rubocop-performance + - rubocop-rails + - rubocop-md + +AllCops: + # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop + # to ignore them, so only the ones explicitly set in this file are enabled. + DisabledByDefault: true + SuggestExtensions: false + Exclude: + - '**/tmp/**/*' + - '**/templates/**/*' + - '**/vendor/**/*' + - 'actionmailbox/test/dummy/**/*' + - 'activestorage/test/dummy/**/*' + - 'actiontext/test/dummy/**/*' + - 'tools/rail_inspector/test/fixtures/*' + - guides/source/debugging_rails_applications.md + - guides/source/active_support_instrumentation.md + - '**/node_modules/**/*' + - '**/CHANGELOG.md' + - '**/2_*_release_notes.md' + - '**/3_*_release_notes.md' + - '**/4_*_release_notes.md' + - '**/5_*_release_notes.md' + - '**/6_*_release_notes.md' + + +Performance: + Exclude: + - '**/test/**/*' + +# Prefer assert_not over assert ! +Rails/AssertNot: + Include: + - '**/test/**/*' + +# Prefer assert_not_x over refute_x +Rails/RefuteMethods: + Include: + - '**/test/**/*' + +Rails/IndexBy: + Enabled: true + +Rails/IndexWith: + Enabled: true + +# Prefer &&/|| over and/or. +Style/AndOr: + Enabled: true + +Layout/ClosingHeredocIndentation: + Enabled: true + +Layout/ClosingParenthesisIndentation: + Enabled: true + +# Align comments with method definitions. +Layout/CommentIndentation: + Enabled: true + +Layout/DefEndAlignment: + Enabled: true + +Layout/ElseAlignment: + Enabled: true + +# Align `end` with the matching keyword or starting expression except for +# assignments, where it should be aligned with the LHS. +Layout/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: variable + AutoCorrect: true + +Layout/EndOfLine: + Enabled: true + +Layout/EmptyLineAfterMagicComment: + Enabled: true + +Layout/EmptyLinesAroundAccessModifier: + Enabled: true + EnforcedStyle: only_before + +Layout/EmptyLinesAroundBlockBody: + Enabled: true + +# In a regular class definition, no empty lines around the body. +Layout/EmptyLinesAroundClassBody: + Enabled: true + +# In a regular method definition, no empty lines around the body. +Layout/EmptyLinesAroundMethodBody: + Enabled: true + +# In a regular module definition, no empty lines around the body. +Layout/EmptyLinesAroundModuleBody: + Enabled: true + +# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. +Style/HashSyntax: + Enabled: true + EnforcedShorthandSyntax: either + +# Method definitions after `private` or `protected` isolated calls need one +# extra level of indentation. +Layout/IndentationConsistency: + Enabled: true + EnforcedStyle: indented_internal_methods + Exclude: + - '**/*.md' + +# Two spaces, no tabs (for indentation). +Layout/IndentationWidth: + Enabled: true + +Layout/LeadingCommentSpace: + Enabled: true + +Layout/SpaceAfterColon: + Enabled: true + +Layout/SpaceAfterComma: + Enabled: true + +Layout/SpaceAfterSemicolon: + Enabled: true + +Layout/SpaceAroundEqualsInParameterDefault: + Enabled: true + +Layout/SpaceAroundKeyword: + Enabled: true + +Layout/SpaceAroundOperators: + Enabled: true + +Layout/SpaceBeforeComma: + Enabled: true + +Layout/SpaceBeforeComment: + Enabled: true + +Layout/SpaceBeforeFirstArg: + Enabled: true + +Style/DefWithParentheses: + Enabled: true + +# Defining a method with parameters needs parentheses. +Style/MethodDefParentheses: + Enabled: true + +Style/ExplicitBlockArgument: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + Exclude: + - 'actionview/test/**/*.builder' + - 'actionview/test/**/*.ruby' + - 'actionpack/test/**/*.builder' + - 'actionpack/test/**/*.ruby' + - 'activestorage/db/migrate/**/*.rb' + - 'activestorage/db/update_migrate/**/*.rb' + - 'actionmailbox/db/migrate/**/*.rb' + - 'actiontext/db/migrate/**/*.rb' + - '**/*.md' + +Style/MapToHash: + Enabled: true + +Style/RedundantFreeze: + Enabled: true + +# Use `foo {}` not `foo{}`. +Layout/SpaceBeforeBlockBraces: + Enabled: true + +# Use `foo { bar }` not `foo {bar}`. +Layout/SpaceInsideBlockBraces: + Enabled: true + EnforcedStyleForEmptyBraces: space + +# Use `{ a: 1 }` not `{a:1}`. +Layout/SpaceInsideHashLiteralBraces: + Enabled: true + +Layout/SpaceInsideParens: + Enabled: true + +# Check quotes usage according to lint rule below. +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +# Detect hard tabs, no hard tabs. +Layout/IndentationStyle: + Enabled: true + +# Empty lines should not have any spaces. +Layout/TrailingEmptyLines: + Enabled: true + +# No trailing whitespace. +Layout/TrailingWhitespace: + Enabled: true + +# Use quotes for string literals when they are enough. +Style/RedundantPercentQ: + Enabled: true + +Lint/AmbiguousOperator: + Enabled: true + +Lint/AmbiguousRegexpLiteral: + Enabled: true + +Lint/Debugger: + Enabled: true + DebuggerRequires: + - debug + +Lint/DuplicateRequire: + Enabled: true + +Lint/DuplicateMagicComment: + Enabled: true + +Lint/DuplicateMethods: + Enabled: true + +Lint/ErbNewArguments: + Enabled: true + +Lint/EnsureReturn: + Enabled: true + +Lint/MissingCopEnableDirective: + Enabled: true + +# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. +Lint/RequireParentheses: + Enabled: true + +Lint/RedundantCopDisableDirective: + Enabled: true + +Lint/RedundantCopEnableDirective: + Enabled: true + +Lint/RedundantRequireStatement: + Enabled: true + +Lint/RedundantStringCoercion: + Enabled: true + +Lint/RedundantSafeNavigation: + Enabled: true + +Lint/UriEscapeUnescape: + Enabled: true + +Lint/UselessAssignment: + Enabled: true + +Lint/DeprecatedClassMethods: + Enabled: true + +Lint/InterpolationCheck: + Enabled: true + Exclude: + - '**/test/**/*' + +Lint/SafeNavigationChain: + Enabled: true + +Style/EvalWithLocation: + Enabled: true + Exclude: + - '**/test/**/*' + +Style/ParenthesesAroundCondition: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +Style/RedundantBegin: + Enabled: true + +Style/RedundantReturn: + Enabled: true + AllowMultipleReturnValues: true + +Style/RedundantRegexpEscape: + Enabled: true + +Style/Semicolon: + Enabled: true + AllowAsExpressionSeparator: true + +# Prefer Foo.method over Foo::method +Style/ColonMethodCall: + Enabled: true + +Style/TrivialAccessors: + Enabled: true + +# Prefer a = b || c over a = b ? b : c +Style/RedundantCondition: + Enabled: true + +Style/RedundantDoubleSplatHashBraces: + Enabled: true + +Style/OpenStructUse: + Enabled: true + +Style/ArrayIntersect: + Enabled: true + +Performance/BindCall: + Enabled: true + +Performance/FlatMap: + Enabled: true + +Performance/MapCompact: + Enabled: true + +Performance/SelectMap: + Enabled: true + +Performance/RedundantMerge: + Enabled: true + +Performance/StartWith: + Enabled: true + +Performance/EndWith: + Enabled: true + +Performance/RegexpMatch: + Enabled: true + +Performance/ReverseEach: + Enabled: true + +Performance/StringReplacement: + Enabled: true + +Performance/DeletePrefix: + Enabled: true + +Performance/DeleteSuffix: + Enabled: true + +Performance/InefficientHashSearch: + Enabled: true + +Performance/ConstantRegexp: + Enabled: true + +Performance/RedundantStringChars: + Enabled: true + +Performance/StringInclude: + Enabled: true + +Minitest/AssertPredicate: + Enabled: true + +Minitest/AssertRaisesWithRegexpArgument: + Enabled: true + +Minitest/AssertWithExpectedArgument: + Enabled: true + +Minitest/LiteralAsActualArgument: + Enabled: true + +Minitest/NonExecutableTestMethod: + Enabled: true + +Minitest/SkipEnsure: + Enabled: true + +Minitest/UnreachableAssertion: + Enabled: true + +Markdown: + # Whether to run RuboCop against non-valid snippets + WarnInvalid: true + # Whether to lint codeblocks without code attributes + Autodetect: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..629d7a9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Change log + +## main + +- Initial extraction. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..29fbd7c --- /dev/null +++ b/Gemfile @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +gem "minitest" + +# We need a newish Rake since Active Job sets its test tasks' descriptions. +gem "rake", ">= 13" + +# Explicitly avoid 1.x that doesn't support Ruby 2.4+ +gem "json", ">= 2.0.0", "!=2.7.0" + +# Workaround until Ruby ships with cgi version 0.3.6 or higher. +gem "cgi", ">= 0.3.6", require: false + +# Workaround until all supported Ruby versions ship with uri version 0.13.1 or higher. +gem "uri", ">= 0.13.1", require: false + +group :rubocop do + gem "rubocop", ">= 1.25.1", require: false + gem "rubocop-minitest", require: false + gem "rubocop-packaging", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-md", require: false +end + +gem "puma", ">= 5.0.3", require: false + +gem "redis", ">= 4.0.1", require: false + +gem "redis-namespace" + +gem "pg", require: false if ENV["CI"] || ENV["DATABASE_URL"] + +gem "websocket-client-simple", github: "matthewd/websocket-client-simple", branch: "close-race", require: false + +rails_version = ENV.fetch("RAILS_VERSION", "~> 7.0") + +gem "activerecord", rails_version +gem "actionpack", rails_version +gem "activesupport", rails_version + +platforms :mri do + gem "debug", ">= 1.1.0", require: false +end diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..5f10529 --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright 2024 Vladimir Dementyev + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8671d5f --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Action Cable Next + +This gem provides the functionality of the _server adapterization_ PR: [rails/rails#50979](https://github.com/rails/rails/pull/50979). + +See the PR description for more information on the purpose of this refactoring. + +## Usage + +Add this line to your application's Gemfile **before Rails or Action Cable**: + +```ruby +gem "actioncable-next" + +gem "rails", "~> 7.0" +``` + +Then, you can use Action Cable as before. Under the hood, the new implementation would be used. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4d63615 --- /dev/null +++ b/Rakefile @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "base64" +require "rake/testtask" +require "pathname" +require "open3" +require "action_cable" + +task default: :test + +task :package + +ENV["RAILS_MINITEST_PLUGIN"] = "true" + +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList["#{__dir__}/test/**/*_test.rb"] + t.warning = true + t.verbose = true + t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) +end + +namespace :test do + task :isolated do + Dir.glob("test/**/*_test.rb").all? do |file| + sh(Gem.ruby, "-w", "-Ilib:test", file) + end || raise("Failures") + end +end diff --git a/actioncable-next.gemspec b/actioncable-next.gemspec new file mode 100644 index 0000000..b311377 --- /dev/null +++ b/actioncable-next.gemspec @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "lib/actioncable-next" +version = ActionCableNext::VERSION + +Gem::Specification.new do |s| + s.name = "actioncable-next" + s.version = version + s.summary = "Next-gen version of Action Cable" + s.description = "Next-gen version of Action Cable" + + s.required_ruby_version = ">= 3.1.0" + + s.license = "MIT" + + s.author = ["Pratik Naik", "David Heinemeier Hansson", "Vladimir Dementyev"] + s.email = ["pratiknaik@gmail.com", "david@loudthinking.com", "palkan@evilmartians.com"] + s.homepage = "https://github.com/anycable/action_cable-next" + + s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*"] + s.require_path = "lib" + + s.metadata = { + "bug_tracker_uri" => "https://github.com/anycable/actioncable-next", + "changelog_uri" => "https://github.com/anycable/actioncable-next/blob/v#{version}/CHANGELOG.md", + "source_code_uri" => "https://github.com/anycable/actioncable-next", + "rubygems_mfa_required" => "true", + } + + s.add_dependency "activesupport", ">= 7.0", "<= 8.1" + s.add_dependency "actionpack", ">= 7.0", "<= 8.1" + + s.add_dependency "nio4r", "~> 2.0" + s.add_dependency "websocket-driver", ">= 0.6.1" + s.add_dependency "zeitwerk", "~> 2.6" +end diff --git a/lib/action_cable.rb b/lib/action_cable.rb new file mode 100644 index 0000000..b351040 --- /dev/null +++ b/lib/action_cable.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- +# Copyright (c) 37signals LLC +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +#++ + +require "active_support" +require "active_support/rails" +require "zeitwerk" + +# We compute lib this way instead of using __dir__ because __dir__ gives a real +# path, while __FILE__ honors symlinks. If the gem is stored under a symlinked +# directory, this matters. +lib = File.dirname(__FILE__) + +Zeitwerk::Loader.for_gem.tap do |loader| + loader.ignore( + "#{lib}/rails", # Contains generators, templates, docs, etc. + "#{lib}/action_cable/gem_version.rb", + "#{lib}/action_cable/version.rb", + "#{lib}/action_cable/deprecator.rb", + "#{lib}/actioncable-next.rb", + ) + + loader.do_not_eager_load( + "#{lib}/action_cable/subscription_adapter", # Adapters are required and loaded on demand. + "#{lib}/action_cable/test_helper.rb", + Dir["#{lib}/action_cable/**/test_case.rb"] + ) + + loader.inflector.inflect("postgresql" => "PostgreSQL") +end.setup + +# :markup: markdown +# :include: ../README.md +module ActionCable + require_relative "action_cable/version" + require_relative "action_cable/deprecator" + + INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze + } + + # Singleton instance of the server + module_function def server + @server ||= ActionCable::Server::Base.new + end +end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb new file mode 100644 index 0000000..885aeaf --- /dev/null +++ b/lib/action_cable/channel/base.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "set" +require "active_support/rescuable" +require "active_support/parameter_filter" + +module ActionCable + module Channel + # # Action Cable Channel Base + # + # The channel provides the basic structure of grouping behavior into logical + # units when communicating over the WebSocket connection. 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 responding to the subscriber's direct + # requests. + # + # Channel instances are long-lived. A channel object will be instantiated when + # the cable consumer becomes a subscriber, and then lives until the consumer + # disconnects. This may be seconds, minutes, hours, or even days. That means you + # have to take special care not to do anything silly in a channel that would + # balloon its memory footprint or whatever. The references are forever, so they + # won't be released as is normally the case with a controller instance that gets + # thrown away after every request. + # + # Long-lived channels (and connections) also mean you're responsible for + # ensuring that the data is fresh. If you hold a reference to a user record, but + # the name is changed while that reference is held, you may be sending stale + # data if you don't take precautions to avoid it. + # + # The upside of long-lived channel instances is that you can use instance + # variables to keep reference to objects that future subscriber requests can + # interact with. Here's a quick example: + # + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # end + # + # def speak(data) + # @room.speak data, user: current_user + # end + # end + # + # The #speak action simply uses the Chat::Room object that was created when the + # channel was first subscribed to by the consumer when that subscriber wants to + # say something in the room. + # + # ## Action processing + # + # Unlike subclasses of ActionController::Base, channels do not follow a RESTful + # constraint form for their actions. Instead, Action Cable operates through a + # remote-procedure call model. You can declare any public method on the channel + # (optionally taking a `data` argument), and this method is automatically + # exposed as callable to the client. + # + # Example: + # + # class AppearanceChannel < ApplicationCable::Channel + # def subscribed + # @connection_token = generate_connection_token + # end + # + # def unsubscribed + # current_user.disappear @connection_token + # end + # + # def appear(data) + # current_user.appear @connection_token, on: data['appearing_on'] + # end + # + # def away + # current_user.away @connection_token + # end + # + # private + # def generate_connection_token + # SecureRandom.hex(36) + # end + # end + # + # In this example, the subscribed and unsubscribed methods are not callable + # methods, as they were already declared in ActionCable::Channel::Base, but + # `#appear` and `#away` are. `#generate_connection_token` is also not callable, + # since it's a private method. You'll see that appear accepts a data parameter, + # which it then uses as part of its model call. `#away` does not, since it's + # simply a trigger action. + # + # Also note that in this example, `current_user` is available because it was + # marked as an identifying attribute on the connection. All such identifiers + # will automatically create a delegation method of the same name on the channel + # instance. + # + # ## Rejecting subscription requests + # + # A channel can reject a subscription request in the #subscribed callback by + # invoking the #reject method: + # + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # reject unless current_user.can_access?(@room) + # end + # end + # + # In this example, the subscription will be rejected if the `current_user` does + # not have access to the chat room. On the client-side, the `Channel#rejected` + # callback will get invoked when the server rejects the subscription request. + class Base + include Callbacks + include PeriodicTimers + include Streams + include Naming + include Broadcasting + include ActiveSupport::Rescuable + + attr_reader :params, :connection, :identifier + delegate :logger, to: :connection + + class << self + # A list of method names that should be considered actions. This includes all + # public instance methods on a channel, less any internal methods (defined on + # Base), adding back in any methods that are internal, but still exist on the + # class itself. + # + # #### Returns + # * `Set` - A set of all methods that should be considered actions. + def action_methods + @action_methods ||= begin + # All public instance methods of this class, including ancestors + methods = (public_instance_methods(true) - + # Except for public instance methods of Base and its ancestors + ActionCable::Channel::Base.public_instance_methods(true) + + # Be sure to include shadowed public instance methods of this class + public_instance_methods(false)).uniq.map(&:to_s) + methods.to_set + end + end + + private + # action_methods are cached and there is sometimes need to refresh them. + # ::clear_action_methods! allows you to do that, so next time you run + # action_methods, they will be recalculated. + def clear_action_methods! # :doc: + @action_methods = nil + end + + # Refresh the cached action_methods when a new action_method is added. + def method_added(name) # :doc: + super + clear_action_methods! + end + end + + def initialize(connection, identifier, params = {}) + @connection = connection + @identifier = identifier + @params = params + + # When a channel is streaming via pubsub, we want to delay the confirmation + # transmission until pubsub subscription is confirmed. + # + # The counter starts at 1 because it's awaiting a call to #subscribe_to_channel + @defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1) + + @reject_subscription = nil + @subscription_confirmation_sent = nil + + delegate_connection_identifiers + end + + # Extract the action name from the passed data and process it via the channel. + # The process will ensure that the action requested is a public method on the + # channel declared by the user (so not one of the callbacks like #subscribed). + def perform_action(data) + action = extract_action(data) + + if processable_action?(action) + payload = { channel_class: self.class.name, action: action, data: data } + ActiveSupport::Notifications.instrument("perform_action.action_cable", payload) do + dispatch_action(action, data) + end + else + logger.error "Unable to process #{action_signature(action, data)}" + end + end + + # This method is called after subscription has been added to the connection and + # confirms or rejects the subscription. + def subscribe_to_channel + run_callbacks :subscribe do + subscribed + end + + reject_subscription if subscription_rejected? + ensure_confirmation_sent + end + + # Called by the cable connection when it's cut, so the channel has a chance to + # cleanup with callbacks. This method is not intended to be called directly by + # the user. Instead, override the #unsubscribed callback. + def unsubscribe_from_channel # :nodoc: + run_callbacks :unsubscribe do + unsubscribed + end + end + + private + # Called once a consumer has become a subscriber of the channel. Usually the + # place to set up any streams you want this channel to be sending to the + # subscriber. + def subscribed # :doc: + # Override in subclasses + end + + # Called once a consumer has cut its cable connection. Can be used for cleaning + # up connections or marking users as offline or the like. + def unsubscribed # :doc: + # Override in subclasses + end + + # Transmit a hash of data to the subscriber. The hash will automatically be + # wrapped in a JSON envelope with the proper channel identifier marked as the + # recipient. + def transmit(data, via: nil) # :doc: + logger.debug do + status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}" + status += " (via #{via})" if via + status + end + + payload = { channel_class: self.class.name, data: data, via: via } + ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do + connection.transmit identifier: @identifier, message: data + end + end + + def ensure_confirmation_sent # :doc: + return if subscription_rejected? + @defer_subscription_confirmation_counter.decrement + transmit_subscription_confirmation unless defer_subscription_confirmation? + end + + def defer_subscription_confirmation! # :doc: + @defer_subscription_confirmation_counter.increment + end + + def defer_subscription_confirmation? # :doc: + @defer_subscription_confirmation_counter.value > 0 + end + + def subscription_confirmation_sent? # :doc: + @subscription_confirmation_sent + end + + def reject # :doc: + @reject_subscription = true + end + + def subscription_rejected? # :doc: + @reject_subscription + end + + def delegate_connection_identifiers + connection.identifiers.each do |identifier| + define_singleton_method(identifier) do + connection.send(identifier) + end + end + end + + def extract_action(data) + (data["action"].presence || :receive).to_sym + end + + def processable_action?(action) + self.class.action_methods.include?(action.to_s) unless subscription_rejected? + end + + def dispatch_action(action, data) + logger.debug action_signature(action, data) + + if method(action).arity == 1 + public_send action, data + else + public_send action + end + rescue Exception => exception + rescue_with_handler(exception) || raise + end + + def action_signature(action, data) + (+"#{self.class.name}##{action}").tap do |signature| + arguments = data.except("action") + + if arguments.any? + arguments = parameter_filter.filter(arguments) + signature << "(#{arguments.inspect})" + end + end + end + + def parameter_filter + @parameter_filter ||= ActiveSupport::ParameterFilter.new(connection.config.filter_parameters) + end + + def transmit_subscription_confirmation + unless subscription_confirmation_sent? + logger.debug "#{self.class.name} is transmitting the subscription confirmation" + + ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name, identifier: @identifier) do + connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation] + @subscription_confirmation_sent = true + end + end + end + + def reject_subscription + connection.subscriptions.remove_subscription self + transmit_subscription_rejection + end + + def transmit_subscription_rejection + logger.debug "#{self.class.name} is transmitting the subscription rejection" + + ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name, identifier: @identifier) do + connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection] + end + end + end + end +end + +ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base) diff --git a/lib/action_cable/channel/broadcasting.rb b/lib/action_cable/channel/broadcasting.rb new file mode 100644 index 0000000..1a22e1f --- /dev/null +++ b/lib/action_cable/channel/broadcasting.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/to_param" + +module ActionCable + module Channel + module Broadcasting + extend ActiveSupport::Concern + + module ClassMethods + # Broadcast a hash to a unique broadcasting for this `model` in this channel. + def broadcast_to(model, message) + ActionCable.server.broadcast(broadcasting_for(model), message) + end + + # Returns a unique broadcasting identifier for this `model` in this channel: + # + # CommentsChannel.broadcasting_for("all") # => "comments:all" + # + # You can pass any object as a target (e.g. Active Record model), and it would + # be serialized into a string under the hood. + def broadcasting_for(model) + serialize_broadcasting([ channel_name, model ]) + end + + private + def serialize_broadcasting(object) # :nodoc: + case + when object.is_a?(Array) + object.map { |m| serialize_broadcasting(m) }.join(":") + when object.respond_to?(:to_gid_param) + object.to_gid_param + else + object.to_param + end + end + end + + def broadcasting_for(model) + self.class.broadcasting_for(model) + end + + def broadcast_to(model, message) + self.class.broadcast_to(model, message) + end + end + end +end diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb new file mode 100644 index 0000000..5df7bc1 --- /dev/null +++ b/lib/action_cable/channel/callbacks.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" + +module ActionCable + module Channel + # # Action Cable Channel Callbacks + # + # Action Cable Channel provides callback hooks that are invoked during the life + # cycle of a channel: + # + # * [before_subscribe](rdoc-ref:ClassMethods#before_subscribe) + # * [after_subscribe](rdoc-ref:ClassMethods#after_subscribe) (aliased as + # [on_subscribe](rdoc-ref:ClassMethods#on_subscribe)) + # * [before_unsubscribe](rdoc-ref:ClassMethods#before_unsubscribe) + # * [after_unsubscribe](rdoc-ref:ClassMethods#after_unsubscribe) (aliased as + # [on_unsubscribe](rdoc-ref:ClassMethods#on_unsubscribe)) + # + # + # #### Example + # + # class ChatChannel < ApplicationCable::Channel + # after_subscribe :send_welcome_message, unless: :subscription_rejected? + # after_subscribe :track_subscription + # + # private + # def send_welcome_message + # broadcast_to(...) + # end + # + # def track_subscription + # # ... + # end + # end + # + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :subscribe + define_callbacks :unsubscribe + end + + module ClassMethods + def before_subscribe(*methods, &block) + set_callback(:subscribe, :before, *methods, &block) + end + + # This callback will be triggered after the Base#subscribed method is called, + # even if the subscription was rejected with the Base#reject method. + # + # To trigger the callback only on successful subscriptions, use the + # Base#subscription_rejected? method: + # + # after_subscribe :my_method, unless: :subscription_rejected? + # + def after_subscribe(*methods, &block) + set_callback(:subscribe, :after, *methods, &block) + end + alias_method :on_subscribe, :after_subscribe + + def before_unsubscribe(*methods, &block) + set_callback(:unsubscribe, :before, *methods, &block) + end + + def after_unsubscribe(*methods, &block) + set_callback(:unsubscribe, :after, *methods, &block) + end + alias_method :on_unsubscribe, :after_unsubscribe + end + end + end +end diff --git a/lib/action_cable/channel/naming.rb b/lib/action_cable/channel/naming.rb new file mode 100644 index 0000000..9a17fc5 --- /dev/null +++ b/lib/action_cable/channel/naming.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Channel + module Naming + extend ActiveSupport::Concern + + module ClassMethods + # Returns the name of the channel, underscored, without the `Channel` ending. If + # the channel is in a namespace, then the namespaces are represented by single + # colon separators in the channel name. + # + # ChatChannel.channel_name # => 'chat' + # Chats::AppearancesChannel.channel_name # => 'chats:appearances' + # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances' + def channel_name + @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore + end + end + + def channel_name + self.class.channel_name + end + end + end +end diff --git a/lib/action_cable/channel/periodic_timers.rb b/lib/action_cable/channel/periodic_timers.rb new file mode 100644 index 0000000..294306a --- /dev/null +++ b/lib/action_cable/channel/periodic_timers.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Channel + module PeriodicTimers + extend ActiveSupport::Concern + + included do + class_attribute :periodic_timers, instance_reader: false, default: [] + + after_subscribe :start_periodic_timers + after_unsubscribe :stop_periodic_timers + end + + module ClassMethods + # Periodically performs a task on the channel, like updating an online user + # counter, polling a backend for new status messages, sending regular + # "heartbeat" messages, or doing some internal work and giving progress updates. + # + # Pass a method name or lambda argument or provide a block to call. Specify the + # calling period in seconds using the `every:` keyword argument. + # + # periodically :transmit_progress, every: 5.seconds + # + # periodically every: 3.minutes do + # transmit action: :update_count, count: current_count + # end + # + def periodically(callback_or_method_name = nil, every:, &block) + callback = + if block_given? + raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name + block + else + case callback_or_method_name + when Proc + callback_or_method_name + when Symbol + -> { __send__ callback_or_method_name } + else + raise ArgumentError, "Expected a Symbol method name or a Proc, got #{callback_or_method_name.inspect}" + end + end + + unless every.kind_of?(Numeric) && every > 0 + raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}" + end + + self.periodic_timers += [[ callback, every: every ]] + end + end + + private + def active_periodic_timers + @active_periodic_timers ||= [] + end + + def start_periodic_timers + self.class.periodic_timers.each do |callback, options| + active_periodic_timers << start_periodic_timer(callback, every: options.fetch(:every)) + end + end + + def start_periodic_timer(timer_callback, every:) + # A callback must be executed within the channel context + callback = -> { instance_exec(&timer_callback) } + + connection.executor.timer(every) do + connection.perform_work callback, :call + end + end + + def stop_periodic_timers + active_periodic_timers.each { |timer| timer.shutdown } + active_periodic_timers.clear + end + end + end +end diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb new file mode 100644 index 0000000..8a15559 --- /dev/null +++ b/lib/action_cable/channel/streams.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Channel + # # Action Cable Channel Streams + # + # Streams allow channels to route broadcastings to the subscriber. A + # broadcasting is, as discussed elsewhere, a pubsub queue where any data placed + # into it is automatically sent to the clients that are connected at that time. + # It's purely an online queue, though. If you're not streaming a broadcasting at + # the very moment it sends out an update, you will not get that update, even if + # you connect after it has been sent. + # + # Most commonly, the streamed broadcast is sent straight to the subscriber on + # the client-side. The channel just acts as a connector between the two parties + # (the broadcaster and the channel subscriber). Here's an example of a channel + # that allows subscribers to get all new comments on a given page: + # + # class CommentsChannel < ApplicationCable::Channel + # def follow(data) + # stream_from "comments_for_#{data['recording_id']}" + # end + # + # def unfollow + # stop_all_streams + # end + # end + # + # Based on the above example, the subscribers of this channel will get whatever + # data is put into the, let's say, `comments_for_45` broadcasting as soon as + # it's put there. + # + # An example broadcasting for this channel looks like so: + # + # ActionCable.server.broadcast "comments_for_45", { author: 'DHH', content: 'Rails is just swell' } + # + # If you have a stream that is related to a model, then the broadcasting used + # can be generated from the model and channel. The following example would + # subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`. + # + # class CommentsChannel < ApplicationCable::Channel + # def subscribed + # post = Post.find(params[:id]) + # stream_for post + # end + # end + # + # You can then broadcast to this channel using: + # + # CommentsChannel.broadcast_to(@post, @comment) + # + # If you don't just want to parlay the broadcast unfiltered to the subscriber, + # you can also supply a callback that lets you alter what is sent out. The below + # example shows how you can use this to provide performance introspection in the + # process: + # + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # + # stream_for @room, coder: ActiveSupport::JSON do |message| + # if message['originated_at'].present? + # elapsed_time = (Time.now.to_f - message['originated_at']).round(2) + # + # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing + # logger.info "Message took #{elapsed_time}s to arrive" + # end + # + # transmit message + # end + # end + # end + # + # You can stop streaming from all broadcasts by calling #stop_all_streams. + module Streams + extend ActiveSupport::Concern + + included do + on_unsubscribe :stop_all_streams + end + + # Start streaming from the named `broadcasting` pubsub queue. Optionally, you + # can pass a `callback` that'll be used instead of the default of just + # transmitting the updates straight to the subscriber. Pass `coder: + # ActiveSupport::JSON` to decode messages as JSON before passing to the + # callback. Defaults to `coder: nil` which does no decoding, passes raw + # messages. + def stream_from(broadcasting, callback = nil, coder: nil, &block) + broadcasting = String(broadcasting) + + # Don't send the confirmation until pubsub#subscribe is successful + defer_subscription_confirmation! + + # Build a stream handler by wrapping the user-provided callback with a decoder + # or defaulting to a JSON-decoding retransmitter. + handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder) + streams[broadcasting] = handler + + pubsub.subscribe(broadcasting, handler, lambda do + ensure_confirmation_sent + logger.info "#{self.class.name} is streaming from #{broadcasting}" + end) + end + + # Start streaming the pubsub queue for the `model` in this channel. Optionally, + # you can pass a `callback` that'll be used instead of the default of just + # transmitting the updates straight to the subscriber. + # + # Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to + # the callback. Defaults to `coder: nil` which does no decoding, passes raw + # messages. + def stream_for(model, ...) + stream_from(broadcasting_for(model), ...) + end + + # Unsubscribes streams from the named `broadcasting`. + def stop_stream_from(broadcasting) + callback = streams.delete(broadcasting) + if callback + pubsub.unsubscribe(broadcasting, callback) + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" + end + end + + # Unsubscribes streams for the `model`. + def stop_stream_for(model) + stop_stream_from(broadcasting_for(model)) + end + + # Unsubscribes all streams associated with this channel from the pubsub queue. + def stop_all_streams + streams.each do |broadcasting, callback| + pubsub.unsubscribe broadcasting, callback + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" + end.clear + end + + # Calls stream_for with the given `model` if it's present to start streaming, + # otherwise rejects the subscription. + def stream_or_reject_for(model) + if model + stream_for model + else + reject + end + end + + private + delegate :pubsub, to: :connection + + def streams + @_streams ||= {} + end + + # Always wrap the outermost handler to invoke the user handler on the worker + # pool rather than blocking the event loop. + def worker_pool_stream_handler(broadcasting, user_handler, coder: nil) + handler = stream_handler(broadcasting, user_handler, coder: coder) + + -> message do + connection.perform_work handler, :call, message + end + end + + # May be overridden to add instrumentation, logging, specialized error handling, + # or other forms of handler decoration. + # + # TODO: Tests demonstrating this. + def stream_handler(broadcasting, user_handler, coder: nil) + if user_handler + stream_decoder user_handler, coder: coder + else + default_stream_handler broadcasting, coder: coder + end + end + + # May be overridden to change the default stream handling behavior which decodes + # JSON and transmits to the client. + # + # TODO: Tests demonstrating this. + # + # TODO: Room for optimization. Update transmit API to be coder-aware so we can + # no-op when pubsub and connection are both JSON-encoded. Then we can skip + # decode+encode if we're just proxying messages. + def default_stream_handler(broadcasting, coder:) + coder ||= ActiveSupport::JSON + stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting + end + + def stream_decoder(handler = identity_handler, coder:) + if coder + -> message { handler.(coder.decode(message)) } + else + handler + end + end + + def stream_transmitter(handler = identity_handler, broadcasting:) + via = "streamed from #{broadcasting}" + + -> (message) do + transmit handler.(message), via: via + end + end + + def identity_handler + -> message { message } + end + end + end +end diff --git a/lib/action_cable/channel/test_case.rb b/lib/action_cable/channel/test_case.rb new file mode 100644 index 0000000..1a5d3a6 --- /dev/null +++ b/lib/action_cable/channel/test_case.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "json" + +module ActionCable + module Channel + class NonInferrableChannelError < ::StandardError + def initialize(name) + super "Unable to determine the channel to test from #{name}. " + + "You'll need to specify it using `tests YourChannel` in your " + + "test case definition." + end + end + + # # Action Cable Channel extensions for testing + # + # Add public aliases for +subscription_confirmation_sent?+ and + # +subscription_rejected?+ and +stream_names+ to access the list of subscribed streams. + module ChannelExt + def confirmed? = subscription_confirmation_sent? + + def rejected? = subscription_rejected? + + def stream_names = streams.keys + end + + # Superclass for Action Cable channel functional tests. + # + # ## Basic example + # + # Functional tests are written as follows: + # 1. First, one uses the `subscribe` method to simulate subscription creation. + # 2. Then, one asserts whether the current state is as expected. "State" can be + # anything: transmitted messages, subscribed streams, etc. + # + # + # For example: + # + # class ChatChannelTest < ActionCable::Channel::TestCase + # def test_subscribed_with_room_number + # # Simulate a subscription creation + # subscribe room_number: 1 + # + # # Asserts that the subscription was successfully created + # assert subscription.confirmed? + # + # # Asserts that the channel subscribes connection to a stream + # assert_has_stream "chat_1" + # + # # Asserts that the channel subscribes connection to a specific + # # stream created for a model + # assert_has_stream_for Room.find(1) + # end + # + # def test_does_not_stream_with_incorrect_room_number + # subscribe room_number: -1 + # + # # Asserts that not streams was started + # assert_no_streams + # end + # + # def test_does_not_subscribe_without_room_number + # subscribe + # + # # Asserts that the subscription was rejected + # assert subscription.rejected? + # end + # end + # + # You can also perform actions: + # def test_perform_speak + # subscribe room_number: 1 + # + # perform :speak, message: "Hello, Rails!" + # + # assert_equal "Hello, Rails!", transmissions.last["text"] + # end + # + # ## Special methods + # + # ActionCable::Channel::TestCase will also automatically provide the following + # instance methods for use in the tests: + # + # connection + # : An ActionCable::Channel::ConnectionStub, representing the current HTTP + # connection. + # + # subscription + # : An instance of the current channel, created when you call `subscribe`. + # + # transmissions + # : A list of all messages that have been transmitted into the channel. + # + # + # ## Channel is automatically inferred + # + # ActionCable::Channel::TestCase will automatically infer the channel under test + # from the test class name. If the channel cannot be inferred from the test + # class name, you can explicitly set it with `tests`. + # + # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase + # tests SpecialChannel + # end + # + # ## Specifying connection identifiers + # + # You need to set up your connection manually to provide values for the + # identifiers. To do this just use: + # + # stub_connection(user: users(:john)) + # + # ## Testing broadcasting + # + # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions + # (e.g. `assert_broadcasts`) to handle broadcasting to models: + # + # # in your channel + # def speak(data) + # broadcast_to room, text: data["message"] + # end + # + # def test_speak + # subscribe room_id: rooms(:chat).id + # + # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do + # perform :speak, message: "Hello, Rails!" + # end + # end + class TestCase < ActionCable::Connection::TestCase + module Behavior + extend ActiveSupport::Concern + + include ActiveSupport::Testing::ConstantLookup + include ActionCable::TestHelper + + CHANNEL_IDENTIFIER = "test_stub" + + included do + class_attribute :_channel_class + + ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self) + end + + module ClassMethods + def tests(channel) + case channel + when String, Symbol + self._channel_class = channel.to_s.camelize.constantize + when Module + self._channel_class = channel + else + raise NonInferrableChannelError.new(channel) + end + end + + def channel_class + if channel = self._channel_class + channel + else + tests determine_default_channel(name) + end + end + + def tests_connection(connection) + case connection + when String, Symbol + self._connection_class = connection.to_s.camelize.constantize + when Module + self._connection_class = connection + else + raise Connection::NonInferrableConnectionError.new(connection) + end + end + + def connection_class + if connection = self._connection_class + connection + else + tests_connection ActionCable.server.config.connection_class.call + end + end + + def determine_default_channel(name) + channel = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Channel::Base + end + raise NonInferrableChannelError.new(name) if channel.nil? + channel + end + end + + # Use testserver (not test_server) to silence "Test is missing assertions: `test_server`" warnings + attr_reader :subscription, :testserver + + # Set up test connection with the specified identifiers: + # + # class ApplicationCable < ActionCable::Connection::Base + # identified_by :user, :token + # end + # + # stub_connection(user: users[:john], token: 'my-secret-token') + def stub_connection(server: ActionCable.server, **identifiers) + @socket = Connection::TestSocket.new(Connection::TestSocket.build_request(ActionCable.server.config.mount_path || "/cable")) + @testserver = Connection::TestServer.new(server) + @connection = self.class.connection_class.new(testserver, socket).tap do |conn| + identifiers.each do |identifier, val| + conn.public_send("#{identifier}=", val) + end + end + end + + # Subscribe to the channel under test. Optionally pass subscription parameters + # as a Hash. + def subscribe(params = {}) + @connection ||= stub_connection + @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access) + @subscription.singleton_class.include(ChannelExt) + @subscription.subscribe_to_channel + @subscription + end + + # Unsubscribe the subscription under test. + def unsubscribe + check_subscribed! + subscription.unsubscribe_from_channel + end + + # Perform action on a channel. + # + # NOTE: Must be subscribed. + def perform(action, data = {}) + check_subscribed! + subscription.perform_action(data.stringify_keys.merge("action" => action.to_s)) + end + + # Returns messages transmitted into channel + def transmissions + # Return only directly sent message (via #transmit) + socket.transmissions.filter_map { |data| data["message"] } + end + + # Enhance TestHelper assertions to handle non-String broadcastings + def assert_broadcasts(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + def assert_broadcast_on(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + # Asserts that no streams have been started. + # + # def test_assert_no_started_stream + # subscribe + # assert_no_streams + # end + # + def assert_no_streams + check_subscribed! + assert subscription.stream_names.empty?, "No streams started was expected, but #{subscription.stream_names.count} found" + end + + # Asserts that the specified stream has been started. + # + # def test_assert_started_stream + # subscribe + # assert_has_stream 'messages' + # end + # + def assert_has_stream(stream) + check_subscribed! + assert subscription.stream_names.include?(stream), "Stream #{stream} has not been started" + end + + # Asserts that the specified stream for a model has started. + # + # def test_assert_started_stream_for + # subscribe id: 42 + # assert_has_stream_for User.find(42) + # end + # + def assert_has_stream_for(object) + assert_has_stream(broadcasting_for(object)) + end + + # Asserts that the specified stream has not been started. + # + # def test_assert_no_started_stream + # subscribe + # assert_has_no_stream 'messages' + # end + # + def assert_has_no_stream(stream) + check_subscribed! + assert subscription.stream_names.exclude?(stream), "Stream #{stream} has been started" + end + + # Asserts that the specified stream for a model has not started. + # + # def test_assert_no_started_stream_for + # subscribe id: 41 + # assert_has_no_stream_for User.find(42) + # end + # + def assert_has_no_stream_for(object) + assert_has_no_stream(broadcasting_for(object)) + end + + private + def check_subscribed! + raise "Must be subscribed!" if subscription.nil? || subscription.rejected? + end + + def broadcasting_for(stream_or_object) + return stream_or_object if stream_or_object.is_a?(String) + + self.class.channel_class.broadcasting_for(stream_or_object) + end + end + + include Behavior + end + end +end diff --git a/lib/action_cable/connection/authorization.rb b/lib/action_cable/connection/authorization.rb new file mode 100644 index 0000000..de996e3 --- /dev/null +++ b/lib/action_cable/connection/authorization.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + module Authorization + class UnauthorizedError < StandardError; end + + # Closes the WebSocket connection if it is open and returns an "unauthorized" + # reason. + def reject_unauthorized_connection + logger.error "An unauthorized connection attempt was rejected" + raise UnauthorizedError + end + end + end +end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb new file mode 100644 index 0000000..314586b --- /dev/null +++ b/lib/action_cable/connection/base.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/rescuable" + +module ActionCable + module Connection + # # Action Cable Connection Base + # + # For every WebSocket connection the Action Cable server accepts, a Connection + # object will be instantiated. This instance becomes the parent of all of the + # channel subscriptions that are created from there on. Incoming messages are + # then routed to these channel subscriptions based on an identifier sent by the + # Action Cable consumer. The Connection itself does not deal with any specific + # application logic beyond authentication and authorization. + # + # Here's a basic example: + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :current_user + # + # def connect + # self.current_user = find_verified_user + # logger.add_tags current_user.name + # end + # + # def disconnect + # # Any cleanup work needed when the cable connection is cut. + # end + # + # private + # def find_verified_user + # User.find_by_identity(cookies.encrypted[:identity_id]) || + # reject_unauthorized_connection + # end + # end + # end + # + # First, we declare that this connection can be identified by its current_user. + # This allows us to later be able to find all connections established for that + # current_user (and potentially disconnect them). You can declare as many + # identification indexes as you like. Declaring an identification means that an + # attr_accessor is automatically set for that key. + # + # Second, we rely on the fact that the WebSocket connection is established with + # the cookies from the domain being sent along. This makes it easy to use signed + # cookies that were set when logging in via a web interface to authorize the + # WebSocket connection. + # + # Finally, we add a tag to the connection-specific logger with the name of the + # current user to easily distinguish their messages in the log. + # + class Base + include Identification + include InternalChannel + include Authorization + include Callbacks + include ActiveSupport::Rescuable + + attr_reader :subscriptions, :logger + private attr_reader :server, :socket + + delegate :pubsub, :executor, :config, to: :server + delegate :env, :request, :protocol, :perform_work, to: :socket, allow_nil: true + + def initialize(server, socket) + @server = server + @socket = socket + + @logger = socket.logger + @subscriptions = Subscriptions.new(self) + + @_internal_subscriptions = nil + + @started_at = Time.now + end + + # This method is called every time an Action Cable client establishes an underlying connection. + # Override it in your class to define authentication logic and + # populate connection identifiers. + def connect + end + + # This method is called every time an Action Cable client disconnects. + # Override it in your class to cleanup the relevant application state (e.g., presence, online counts, etc.) + def disconnect + end + + def handle_open + connect + subscribe_to_internal_channel + send_welcome_message + rescue ActionCable::Connection::Authorization::UnauthorizedError + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) + end + + def handle_close + subscriptions.unsubscribe_from_all + unsubscribe_from_internal_channel + + disconnect + end + + def handle_channel_command(payload) + run_callbacks :command do + subscriptions.execute_command payload + end + rescue Exception => e + rescue_with_handler(e) || raise + end + + alias_method :handle_incoming, :handle_channel_command + + def transmit(data) # :nodoc: + socket.transmit(data) + end + + # Close the connection. + def close(reason: nil, reconnect: true) + transmit( + type: ActionCable::INTERNAL[:message_types][:disconnect], + reason: reason, + reconnect: reconnect + ) + socket.close + end + + # Return a basic hash of statistics for the connection keyed with `identifier`, + # `started_at`, `subscriptions`, and `request_id`. This can be returned by a + # health check against the connection. + def statistics + { + identifier: connection_identifier, + started_at: @started_at, + subscriptions: subscriptions.identifiers, + request_id: env["action_dispatch.request_id"] + } + end + + def beat + transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i + end + + def inspect # :nodoc: + "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" + end + + private + # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks. + def cookies # :doc: + request.cookie_jar + end + + def send_welcome_message + # Send welcome message to the internal connection monitor channel. This ensures + # the connection monitor state is reset after a successful websocket connection. + transmit type: ActionCable::INTERNAL[:message_types][:welcome] + end + end + end +end + +ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base) diff --git a/lib/action_cable/connection/callbacks.rb b/lib/action_cable/connection/callbacks.rb new file mode 100644 index 0000000..85a27c6 --- /dev/null +++ b/lib/action_cable/connection/callbacks.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" + +module ActionCable + module Connection + # # Action Cable Connection Callbacks + # + # The [before_command](rdoc-ref:ClassMethods#before_command), + # [after_command](rdoc-ref:ClassMethods#after_command), and + # [around_command](rdoc-ref:ClassMethods#around_command) callbacks are invoked + # when sending commands to the client, such as when subscribing, unsubscribing, + # or performing an action. + # + # #### Example + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :user + # + # around_command :set_current_account + # + # private + # + # def set_current_account + # # Now all channels could use Current.account + # Current.set(account: user.account) { yield } + # end + # end + # end + # + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :command + end + + module ClassMethods + def before_command(*methods, &block) + set_callback(:command, :before, *methods, &block) + end + + def after_command(*methods, &block) + set_callback(:command, :after, *methods, &block) + end + + def around_command(*methods, &block) + set_callback(:command, :around, *methods, &block) + end + end + end + end +end diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb new file mode 100644 index 0000000..b358406 --- /dev/null +++ b/lib/action_cable/connection/identification.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "set" + +module ActionCable + module Connection + module Identification + extend ActiveSupport::Concern + + included do + class_attribute :identifiers, default: Set.new + end + + module ClassMethods + # Mark a key as being a connection identifier index that can then be used to + # find the specific connection again later. Common identifiers are current_user + # and current_account, but could be anything, really. + # + # Note that anything marked as an identifier will automatically create a + # delegate by the same name on any channel instances created off the connection. + def identified_by(*identifiers) + Array(identifiers).each { |identifier| attr_accessor identifier } + self.identifiers += identifiers + end + end + + # Return a single connection identifier that combines the value of all the + # registered identifiers into a single gid. + def connection_identifier + unless defined? @connection_identifier + @connection_identifier = connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") } + end + + @connection_identifier + end + + private + def connection_gid(ids) + ids.map do |o| + if o.respond_to? :to_gid_param + o.to_gid_param + else + o.to_s + end + end.sort.join(":") + end + end + end +end diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb new file mode 100644 index 0000000..2c0c2b4 --- /dev/null +++ b/lib/action_cable/connection/internal_channel.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + # # Action Cable InternalChannel + # + # Makes it possible for the RemoteConnection to disconnect a specific + # connection. + module InternalChannel + extend ActiveSupport::Concern + + private + def internal_channel + "action_cable/#{connection_identifier}" + end + + def subscribe_to_internal_channel + if connection_identifier.present? + callback = -> (message) { process_internal_message ActiveSupport::JSON.decode(message) } + @_internal_subscriptions ||= [] + @_internal_subscriptions << [ internal_channel, callback ] + + pubsub.subscribe(internal_channel, callback) + logger.info "Registered connection (#{connection_identifier})" + end + end + + def unsubscribe_from_internal_channel + if @_internal_subscriptions.present? + @_internal_subscriptions.each { |channel, callback| pubsub.unsubscribe(channel, callback) } + end + end + + def process_internal_message(message) + case message["type"] + when "disconnect" + logger.info "Removing connection (#{connection_identifier})" + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:remote], reconnect: message.fetch("reconnect", true)) + end + rescue Exception => e + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + close + end + end + end +end diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb new file mode 100644 index 0000000..80c4c46 --- /dev/null +++ b/lib/action_cable/connection/subscriptions.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/hash/indifferent_access" + +module ActionCable + module Connection + # # Action Cable Connection Subscriptions + # + # Collection class for all the channel subscriptions established on a given + # connection. Responsible for routing incoming commands that arrive on the + # connection to the proper channel. + class Subscriptions # :nodoc: + class Error < StandardError; end + + class AlreadySubscribedError < Error + def initialize(identifier) + super "Already subscribed to #{identifier}" + end + end + + class ChannelNotFound < Error + def initialize(channel_id) + super "Channel not found: #{channel_id}" + end + end + + class MalformedCommandError < Error + def initialize(data) + super "Malformed command: #{data.inspect}" + end + end + + class UnknownCommandError < Error + def initialize(command) + super "Received unrecognized command: #{command}" + end + end + + class UnknownSubscription < Error + def initialize(identifier) + "Unable to find subscription with identifier: #{identifier}" + end + end + + def initialize(connection) + @connection = connection + @subscriptions = {} + end + + def execute_command(data) + case data["command"] + when "subscribe" then add data + when "unsubscribe" then remove data + when "message" then perform_action data + else + raise UnknownCommandError, data["command"] + end + end + + def add(data) + id_key = data["identifier"] + + raise MalformedCommandError, data unless id_key.present? + + raise AlreadySubscribedError, id_key if subscriptions.key?(id_key) + + subscription = subscription_from_identifier(id_key) + + if subscription + subscriptions[id_key] = subscription + subscription.subscribe_to_channel + else + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + raise ChannelNotFound, id_options[:channel] + end + end + + def remove(data) + logger.info "Unsubscribing from channel: #{data['identifier']}" + remove_subscription find(data) + end + + def remove_subscription(subscription) + subscription.unsubscribe_from_channel + subscriptions.delete(subscription.identifier) + end + + def perform_action(data) + find(data).perform_action ActiveSupport::JSON.decode(data["data"]) + end + + def identifiers + subscriptions.keys + end + + def unsubscribe_from_all + subscriptions.each { |id, channel| remove_subscription(channel) } + end + + private + attr_reader :connection, :subscriptions + delegate :logger, to: :connection + + def find(data) + if subscription = subscriptions[data["identifier"]] + subscription + else + raise UnknownSubscription, data["identifier"] + end + end + + def subscription_from_identifier(id_key) + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + subscription_klass = id_options[:channel].safe_constantize + + if subscription_klass && ActionCable::Channel::Base > subscription_klass + subscription_klass.new(connection, id_key, id_options) + end + end + end + end +end diff --git a/lib/action_cable/connection/test_case.rb b/lib/action_cable/connection/test_case.rb new file mode 100644 index 0000000..5b26a35 --- /dev/null +++ b/lib/action_cable/connection/test_case.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "action_dispatch" +require "action_dispatch/http/headers" +require "action_dispatch/testing/test_request" + +module ActionCable + module Connection + class NonInferrableConnectionError < ::StandardError + def initialize(name) + super "Unable to determine the connection to test from #{name}. " + + "You'll need to specify it using `tests YourConnection` in your " + + "test case definition." + end + end + + module Assertions + # Asserts that the connection is rejected (via + # `reject_unauthorized_connection`). + # + # # Asserts that connection without user_id fails + # assert_reject_connection { connect params: { user_id: '' } } + def assert_reject_connection(&block) + assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block) + end + end + + class TestCookies < ActiveSupport::HashWithIndifferentAccess # :nodoc: + def []=(name, options) + value = options.is_a?(Hash) ? options.symbolize_keys[:value] : options + super(name, value) + end + end + + # We don't want to use the whole "encryption stack" for connection unit-tests, + # but we want to make sure that users test against the correct types of cookies + # (i.e. signed or encrypted or plain) + class TestCookieJar < TestCookies + def signed + @signed ||= TestCookies.new + end + + def encrypted + @encrypted ||= TestCookies.new + end + end + + class TestSocket + # Make session and cookies available to the connection + class Request < ActionDispatch::TestRequest + attr_accessor :session, :cookie_jar + end + + attr_reader :logger, :request, :transmissions, :closed, :env + + class << self + def build_request(path, params: nil, headers: {}, session: {}, env: {}, cookies: nil) + wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers) + + uri = URI.parse(path) + + query_string = params.nil? ? uri.query : params.to_query + + request_env = { + "QUERY_STRING" => query_string, + "PATH_INFO" => uri.path + }.merge(env) + + if wrapped_headers.present? + ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers) + end + + Request.create(request_env).tap do |request| + request.session = session.with_indifferent_access + request.cookie_jar = cookies + end + end + end + + def initialize(request) + inner_logger = ActiveSupport::Logger.new(StringIO.new) + tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger) + @logger = ActionCable::Server::TaggedLoggerProxy.new(tagged_logging, tags: []) + @request = request + @env = request.env + @connection = nil + @closed = false + @transmissions = [] + end + + def transmit(data) + @transmissions << data.with_indifferent_access + end + + def close + @closed = true + end + end + + # TestServer provides test pub/sub and executor implementations + class TestServer + attr_reader :streams, :config + + def initialize(server) + @streams = Hash.new { |h, k| h[k] = [] } + @config = server.config + end + + alias_method :pubsub, :itself + alias_method :executor, :itself + + #== Executor interface == + + # Inline async calls + def post(&work) = work.call + # We don't support timers in unit tests yet + def timer(_every) = nil + + #== Pub/sub interface == + def subscribe(stream, callback, success_callback = nil) + @streams[stream] << callback + success_callback&.call + end + + def unsubscribe(stream, callback) + @streams[stream].delete(callback) + @streams.delete(stream) if @streams[stream].empty? + end + end + + # # Action Cable Connection TestCase + # + # Unit test Action Cable connections. + # + # Useful to check whether a connection's `identified_by` gets assigned properly + # and that any improper connection requests are rejected. + # + # ## Basic example + # + # Unit tests are written as follows: + # + # 1. Simulate a connection attempt by calling `connect`. + # 2. Assert state, e.g. identifiers, has been assigned. + # + # + # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + # def test_connects_with_proper_cookie + # # Simulate the connection request with a cookie. + # cookies["user_id"] = users(:john).id + # + # connect + # + # # Assert the connection identifier matches the fixture. + # assert_equal users(:john).id, connection.user.id + # end + # + # def test_rejects_connection_without_proper_cookie + # assert_reject_connection { connect } + # end + # end + # + # `connect` accepts additional information about the HTTP request with the + # `params`, `headers`, `session`, and Rack `env` options. + # + # def test_connect_with_headers_and_query_string + # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" } + # + # assert_equal "1", connection.user.id + # assert_equal "secret-my", connection.token + # end + # + # def test_connect_with_params + # connect params: { user_id: 1 } + # + # assert_equal "1", connection.user.id + # end + # + # You can also set up the correct cookies before the connection request: + # + # def test_connect_with_cookies + # # Plain cookies: + # cookies["user_id"] = 1 + # + # # Or signed/encrypted: + # # cookies.signed["user_id"] = 1 + # # cookies.encrypted["user_id"] = 1 + # + # connect + # + # assert_equal "1", connection.user_id + # end + # + # ## Connection is automatically inferred + # + # ActionCable::Connection::TestCase will automatically infer the connection + # under test from the test class name. If the channel cannot be inferred from + # the test class name, you can explicitly set it with `tests`. + # + # class ConnectionTest < ActionCable::Connection::TestCase + # tests ApplicationCable::Connection + # end + # + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + DEFAULT_PATH = "/cable" + + include ActiveSupport::Testing::ConstantLookup + include Assertions + + included do + class_attribute :_connection_class + + ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self) + end + + module ClassMethods + def tests(connection) + case connection + when String, Symbol + self._connection_class = connection.to_s.camelize.constantize + when Module + self._connection_class = connection + else + raise NonInferrableConnectionError.new(connection) + end + end + + def connection_class + if connection = self._connection_class + connection + else + tests determine_default_connection(name) + end + end + + def determine_default_connection(name) + connection = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Connection::Base + end + raise NonInferrableConnectionError.new(name) if connection.nil? + connection + end + end + + attr_reader :connection, :socket, :testserver + + # Performs connection attempt to exert #connect on the connection under test. + # + # Accepts request path as the first argument and the following request options: + # + # * params – URL parameters (Hash) + # * headers – request headers (Hash) + # * session – session data (Hash) + # * env – additional Rack env configuration (Hash) + def connect(path = ActionCable.server.config.mount_path, server: ActionCable.server, **request_params) + path ||= DEFAULT_PATH + + @socket = TestSocket.new(TestSocket.build_request(path, **request_params, cookies: cookies)) + @testserver = Connection::TestServer.new(server) + connection = self.class.connection_class.new(@testserver, socket) + connection.connect if connection.respond_to?(:connect) + + # Only set instance variable if connected successfully + @connection = connection + end + + # Exert #disconnect on the connection under test. + def disconnect + raise "Must be connected!" if connection.nil? + + connection.disconnect if connection.respond_to?(:disconnect) + @connection = nil + end + + def cookies + @cookie_jar ||= TestCookieJar.new + end + + def transmissions + socket&.transmissions || [] + end + end + + include Behavior + end + end +end diff --git a/lib/action_cable/deprecator.rb b/lib/action_cable/deprecator.rb new file mode 100644 index 0000000..b2e74e8 --- /dev/null +++ b/lib/action_cable/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/lib/action_cable/engine.rb b/lib/action_cable/engine.rb new file mode 100644 index 0000000..d8a92d2 --- /dev/null +++ b/lib/action_cable/engine.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rails" +require "action_cable" +require "active_support/core_ext/hash/indifferent_access" + +module ActionCable + class Engine < Rails::Engine # :nodoc: + config.action_cable = ActiveSupport::OrderedOptions.new + config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path] + config.action_cable.precompile_assets = true + + initializer "action_cable.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_cable] = ActionCable.deprecator + end + + initializer "action_cable.helpers" do + ActiveSupport.on_load(:action_view) do + include ActionCable::Helpers::ActionCableHelper + end + end + + initializer "action_cable.logger" do + ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } + end + + initializer "action_cable.health_check_application" do + ActiveSupport.on_load(:action_cable) { + self.health_check_application = ->(env) { Rails::HealthController.action(:show).call(env) } + } + end + + initializer "action_cable.asset" do + config.after_initialize do |app| + if app.config.respond_to?(:assets) && app.config.action_cable.precompile_assets + app.config.assets.precompile += %w( actioncable.js actioncable.esm.js ) + end + end + end + + initializer "action_cable.set_configs" do |app| + options = app.config.action_cable + options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development? + + app.paths.add "config/cable", with: "config/cable.yml" + + ActiveSupport.on_load(:action_cable) do + if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist? + self.cable = app.config_for(config_path).to_h.with_indifferent_access + end + + previous_connection_class = connection_class + self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call } + self.filter_parameters += app.config.filter_parameters + + options.each { |k, v| send("#{k}=", v) } + end + end + + initializer "action_cable.routes" do + config.after_initialize do |app| + config = app.config + unless config.action_cable.mount_path.nil? + app.routes.prepend do + mount ActionCable.server => config.action_cable.mount_path, internal: true, anchor: true + end + end + end + end + + initializer "action_cable.set_work_hooks" do |app| + ActiveSupport.on_load(:action_cable) do + ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner| + app.executor.wrap(source: "application.action_cable") do + # If we took a while to get the lock, we may have been halted in the meantime. + # As we haven't started doing any real work yet, we should pretend that we never + # made it off the queue. + unless stopping? + inner.call + end + end + end + + wrap = lambda do |_, inner| + app.executor.wrap(source: "application.action_cable", &inner) + end + ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap + ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap + + app.reloader.before_class_unload do + ActionCable.server.restart + end + end + end + end +end diff --git a/lib/action_cable/gem_version.rb b/lib/action_cable/gem_version.rb new file mode 100644 index 0000000..5fd6614 --- /dev/null +++ b/lib/action_cable/gem_version.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + # Returns the currently loaded version of Action Cable as a `Gem::Version`. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 8 + MINOR = 0 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/lib/action_cable/helpers/action_cable_helper.rb b/lib/action_cable/helpers/action_cable_helper.rb new file mode 100644 index 0000000..93d21a9 --- /dev/null +++ b/lib/action_cable/helpers/action_cable_helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Helpers + module ActionCableHelper + # Returns an "action-cable-url" meta tag with the value of the URL specified in + # your configuration. Ensure this is above your JavaScript tag: + # + # + # <%= action_cable_meta_tag %> + # <%= javascript_include_tag 'application', 'data-turbo-track' => 'reload' %> + # + # + # This is then used by Action Cable to determine the URL of your WebSocket + # server. Your JavaScript can then connect to the server without needing to + # specify the URL directly: + # + # import Cable from "@rails/actioncable" + # window.Cable = Cable + # window.App = {} + # App.cable = Cable.createConsumer() + # + # Make sure to specify the correct server location in each of your environment + # config files: + # + # config.action_cable.mount_path = "/cable123" + # <%= action_cable_meta_tag %> would render: + # => + # + # config.action_cable.url = "ws://actioncable.com" + # <%= action_cable_meta_tag %> would render: + # => + # + def action_cable_meta_tag + tag "meta", name: "action-cable-url", content: ( + ActionCable.server.config.url || + ActionCable.server.config.mount_path || + raise("No Action Cable URL configured -- please configure this at config.action_cable.url") + ) + end + end + end +end diff --git a/lib/action_cable/remote_connections.rb b/lib/action_cable/remote_connections.rb new file mode 100644 index 0000000..e167a1c --- /dev/null +++ b/lib/action_cable/remote_connections.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/module/redefine_method" + +module ActionCable + # # Action Cable Remote Connections + # + # If you need to disconnect a given connection, you can go through the + # RemoteConnections. You can find the connections you're looking for by + # searching for the identifier declared on the connection. For example: + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :current_user + # .... + # end + # end + # + # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect + # + # This will disconnect all the connections established for `User.find(1)`, + # across all servers running on all machines, because it uses the internal + # channel that all of these servers are subscribed to. + # + # By default, server sends a "disconnect" message with "reconnect" flag set to + # true. You can override it by specifying the `reconnect` option: + # + # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false) + class RemoteConnections + attr_reader :server + + def initialize(server) + @server = server + end + + def where(identifier) + RemoteConnection.new(server, identifier) + end + + # # Action Cable Remote Connection + # + # Represents a single remote connection found via + # `ActionCable.server.remote_connections.where(*)`. Exists solely for the + # purpose of calling #disconnect on that connection. + class RemoteConnection + class InvalidIdentifiersError < StandardError; end + + include Connection::Identification, Connection::InternalChannel + + def initialize(server, ids) + @server = server + set_identifier_instance_vars(ids) + end + + # Uses the internal channel to disconnect the connection. + def disconnect(reconnect: true) + server.broadcast internal_channel, { type: "disconnect", reconnect: reconnect } + end + + # Returns all the identifiers that were applied to this connection. + redefine_method :identifiers do + server.connection_identifiers + end + + protected + attr_reader :server + + private + def set_identifier_instance_vars(ids) + raise InvalidIdentifiersError unless valid_identifiers?(ids) + ids.each { |k, v| instance_variable_set("@#{k}", v) } + end + + def valid_identifiers?(ids) + keys = ids.keys + identifiers.all? { |id| keys.include?(id) } + end + end + end +end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb new file mode 100644 index 0000000..e739abb --- /dev/null +++ b/lib/action_cable/server/base.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "monitor" + +module ActionCable + module Server + # A wrapper over ConcurrentRuby::ThreadPoolExecutor and Concurrent::TimerTask + class ThreadedExecutor # :nodoc: + def initialize(max_size: 10) + @executor = Concurrent::ThreadPoolExecutor.new( + name: "ActionCable server", + min_threads: 1, + max_threads: max_size, + max_queue: 0, + ) + end + + def post(task = nil, &block) + task ||= block + @executor << task + end + + def timer(interval, &block) + Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute) + end + + def shutdown = @executor.shutdown + end + + # # Action Cable Server Base + # + # A singleton ActionCable::Server instance is available via ActionCable.server. + # It's used by the Rack process that starts the Action Cable server, but is also + # used by the user to reach the RemoteConnections object, which is used for + # finding and disconnecting connections across all servers. + # + # Also, this is the server instance used for broadcasting. See Broadcasting for + # more information. + class Base + include ActionCable::Server::Broadcasting + include ActionCable::Server::Connections + + cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new + + attr_reader :config + + def self.logger; config.logger; end + delegate :logger, to: :config + + attr_reader :mutex + + def initialize(config: self.class.config) + @config = config + @mutex = Monitor.new + @remote_connections = @event_loop = @worker_pool = @executor = @pubsub = nil + end + + # Called by Rack to set up the server. + def call(env) + return config.health_check_application.call(env) if env["PATH_INFO"] == config.health_check_path + setup_heartbeat_timer + Socket.new(self, env).process + end + + # Disconnect all the connections identified by `identifiers` on this server or + # any others via RemoteConnections. + def disconnect(identifiers) + remote_connections.where(identifiers).disconnect + end + + def restart + connections.each do |connection| + connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart]) + end + + @mutex.synchronize do + # Shutdown the worker pool + @worker_pool.halt if @worker_pool + @worker_pool = nil + + # Shutdown the executor + @executor.shutdown if @executor + @executor = nil + + # Shutdown the pub/sub adapter + @pubsub.shutdown if @pubsub + @pubsub = nil + end + end + + # Gateway to RemoteConnections. See that class for details. + def remote_connections + @remote_connections || @mutex.synchronize { @remote_connections ||= RemoteConnections.new(self) } + end + + def event_loop + @event_loop || @mutex.synchronize { @event_loop ||= StreamEventLoop.new } + end + + # The worker pool is where we run connection callbacks and channel actions. We + # do as little as possible on the server's main thread. The worker pool is an + # executor service that's backed by a pool of threads working from a task queue. + # The thread pool size maxes out at 4 worker threads by default. Tune the size + # yourself with `config.action_cable.worker_pool_size`. + # + # Using Active Record, Redis, etc within your channel actions means you'll get a + # separate connection from each thread in the worker pool. Plan your deployment + # accordingly: 5 servers each running 5 Puma workers each running an 8-thread + # worker pool means at least 200 database connections. + # + # Also, ensure that your database connection pool size is as least as large as + # your worker pool size. Otherwise, workers may oversubscribe the database + # connection pool and block while they wait for other workers to release their + # connections. Use a smaller worker pool or a larger database connection pool + # instead. + def worker_pool + @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) } + end + + # Executor is used by various actions within Action Cable (e.g., pub/sub operations) to run code asynchronously. + def executor + @executor || @mutex.synchronize { @executor ||= ThreadedExecutor.new(max_size: config.executor_pool_size) } + end + + # Adapter used for all streams/broadcasting. + def pubsub + @pubsub || (executor && @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) }) + end + + # All of the identifiers applied to the connection class associated with this + # server. + def connection_identifiers + config.connection_class.call.identifiers + end + + # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. + # You can pass request object either directly or via block to lazily evaluate it. + def new_tagged_logger(request = nil, &block) + TaggedLoggerProxy.new logger, + tags: config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request ||= block.call) : tag.to_s.camelize } + end + + # Check if the request origin is allowed to connect to the Action Cable server. + def allow_request_origin?(env) + return true if config.disable_request_forgery_protection + + proto = Rack::Request.new(env).ssl? ? "https" : "http" + if config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}" + true + elsif Array(config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] } + true + else + logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}") + false + end + end + end + + ActiveSupport.run_load_hooks(:action_cable, Base.config) + end +end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb new file mode 100644 index 0000000..34ef509 --- /dev/null +++ b/lib/action_cable/server/broadcasting.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + # # Action Cable Server Broadcasting + # + # Broadcasting is how other parts of your application can send messages to a + # channel's subscribers. As explained in Channel, most of the time, these + # broadcastings are streamed directly to the clients subscribed to the named + # broadcasting. Let's explain with a full-stack example: + # + # class WebNotificationsChannel < ApplicationCable::Channel + # def subscribed + # stream_from "web_notifications_#{current_user.id}" + # end + # end + # + # # Somewhere in your app this is called, perhaps from a NewCommentJob: + # ActionCable.server.broadcast \ + # "web_notifications_1", { title: "New things!", body: "All that's fit for print" } + # + # # Client-side CoffeeScript, which assumes you've already requested the right to send web notifications: + # App.cable.subscriptions.create "WebNotificationsChannel", + # received: (data) -> + # new Notification data['title'], body: data['body'] + module Broadcasting + # Broadcast a hash directly to a named `broadcasting`. This will later be JSON + # encoded. + def broadcast(broadcasting, message, coder: ActiveSupport::JSON) + broadcaster_for(broadcasting, coder: coder).broadcast(message) + end + + # Returns a broadcaster for a named `broadcasting` that can be reused. Useful + # when you have an object that may need multiple spots to transmit to a specific + # broadcasting over and over. + def broadcaster_for(broadcasting, coder: ActiveSupport::JSON) + Broadcaster.new(self, String(broadcasting), coder: coder) + end + + private + class Broadcaster + attr_reader :server, :broadcasting, :coder + + def initialize(server, broadcasting, coder:) + @server, @broadcasting, @coder = server, broadcasting, coder + end + + def broadcast(message) + server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect.truncate(300)}" } + + payload = { broadcasting: broadcasting, message: message, coder: coder } + ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do + encoded = coder ? coder.encode(message) : message + server.pubsub.broadcast broadcasting, encoded + end + end + end + end + end +end diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb new file mode 100644 index 0000000..b5bf431 --- /dev/null +++ b/lib/action_cable/server/configuration.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rack" + +module ActionCable + module Server + # # Action Cable Server Configuration + # + # An instance of this configuration object is available via + # ActionCable.server.config, which allows you to tweak Action Cable + # configuration in a Rails config initializer. + class Configuration + attr_accessor :logger, :log_tags + attr_accessor :connection_class, :worker_pool_size, :executor_pool_size + attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host, :filter_parameters + attr_accessor :cable, :url, :mount_path + attr_accessor :precompile_assets + attr_accessor :health_check_path, :health_check_application + attr_writer :pubsub_adapter + + def initialize + @log_tags = [] + + @connection_class = -> { ActionCable::Connection::Base } + @worker_pool_size = 4 + @executor_pool_size = 10 + + @disable_request_forgery_protection = false + @allow_same_origin_as_host = true + @filter_parameters = [] + + @health_check_application = ->(env) { + [200, { Rack::CONTENT_TYPE => "text/html", "date" => Time.now.httpdate }, []] + } + end + + # Returns constant of subscription adapter specified in config/cable.yml or directly in the configuration. + # If the adapter cannot be found, this will default to the Redis adapter. Also makes + # sure proper dependencies are required. + def pubsub_adapter + # Provided explicitly in the configuration + return @pubsub_adapter.constantize if @pubsub_adapter + + adapter = (cable.fetch("adapter") { "redis" }) + + # Require the adapter itself and give useful feedback about + # 1. Missing adapter gems and + # 2. Adapter gems' missing dependencies. + path_to_adapter = "action_cable/subscription_adapter/#{adapter}" + begin + require path_to_adapter + rescue LoadError => e + # We couldn't require the adapter itself. Raise an exception that points out + # config typos and missing gems. + if e.path == path_to_adapter + # We can assume that a non-builtin adapter was specified, so it's either + # misspelled or missing from Gemfile. + raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace + + # Bubbled up from the adapter require. Prefix the exception message with some + # guidance about how to address it and reraise. + else + raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace + end + end + + adapter = adapter.camelize + adapter = "PostgreSQL" if adapter == "Postgresql" + "ActionCable::SubscriptionAdapter::#{adapter}".constantize + end + end + end +end diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb new file mode 100644 index 0000000..8911224 --- /dev/null +++ b/lib/action_cable/server/connections.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + # # Action Cable Server Connections + # + # Collection class for all the connections that have been established on this + # specific server. Remember, usually you'll run many Action Cable servers, so + # you can't use this collection as a full list of all of the connections + # established against your application. Instead, use RemoteConnections for that. + module Connections # :nodoc: + BEAT_INTERVAL = 3 + + def connections + @connections ||= [] + end + + def add_connection(connection) + connections << connection + end + + def remove_connection(connection) + connections.delete connection + end + + # WebSocket connection implementations differ on when they'll mark a connection + # as stale. We basically never want a connection to go stale, as you then can't + # rely on being able to communicate with the connection. To solve this, a 3 + # second heartbeat runs on all connections. If the beat fails, we automatically + # disconnect. + def setup_heartbeat_timer + @heartbeat_timer ||= executor.timer(BEAT_INTERVAL) do + executor.post { connections.each(&:beat) } + end + end + + def open_connections_statistics + connections.map(&:statistics) + end + end + end +end diff --git a/lib/action_cable/server/socket.rb b/lib/action_cable/server/socket.rb new file mode 100644 index 0000000..5dcbf66 --- /dev/null +++ b/lib/action_cable/server/socket.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "action_dispatch" + +module ActionCable + module Server + # This class encapsulates all the low-level logic of working with the underlying WebSocket conenctions + # and delegate all the business-logic to the user-level connection object (e.g., ApplicationCable::Connection). + # This connection object is also responsible for handling encoding and decoding of messages, so the user-level + # connection object shouldn't know about such details. + class Socket + attr_reader :server, :env, :protocol, :logger, :connection + private attr_reader :worker_pool + + delegate :event_loop, :pubsub, :config, to: :server + + def initialize(server, env, coder: ActiveSupport::JSON) + @server, @env, @coder = server, env, coder + + @worker_pool = server.worker_pool + @logger = server.new_tagged_logger { request } + + @websocket = WebSocket.new(env, self, event_loop) + @message_buffer = MessageBuffer.new(self) + + @protocol = nil + @connection = config.connection_class.call.new(server, self) + end + + # Called by the server when a new WebSocket connection is established. + def process # :nodoc: + logger.info started_request_message + + if websocket.possible? && server.allow_request_origin?(env) + respond_to_successful_request + else + respond_to_invalid_request + end + end + + # Methods used by the delegate (i.e., an application connection) + + # Send a non-serialized message over the WebSocket connection. + def transmit(cable_message) + return unless websocket.alive? + + websocket.transmit encode(cable_message) + end + + # Close the WebSocket connection. + def close(...) + websocket.close(...) if websocket.alive? + end + + # Invoke a method on the connection asynchronously through the pool of thread workers. + def perform_work(receiver, method, *args) + worker_pool.async_invoke(receiver, method, *args, connection: self) + end + + def send_async(method, *arguments) + worker_pool.async_invoke(self, method, *arguments) + end + + # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc. + def request + @request ||= begin + environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + ActionDispatch::Request.new(environment || env) + end + end + + # Decodes WebSocket messages and dispatches them to subscribed channels. + # WebSocket message transfer encoding is always JSON. + def receive(websocket_message) # :nodoc: + send_async :dispatch_websocket_message, websocket_message + end + + def dispatch_websocket_message(websocket_message) # :nodoc: + if websocket.alive? + @connection.handle_incoming decode(websocket_message) + else + logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})" + end + rescue Exception => e + logger.error "Could not handle incoming message: #{websocket_message.inspect} [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" + end + + def on_open # :nodoc: + send_async :handle_open + end + + def on_message(message) # :nodoc: + message_buffer.append message + end + + def on_error(message) # :nodoc: + # log errors to make diagnosing socket errors easier + logger.error "WebSocket error occurred: #{message}" + end + + def on_close(reason, code) # :nodoc: + send_async :handle_close + end + + def inspect # :nodoc: + "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" + end + + private + attr_reader :websocket + attr_reader :message_buffer + + def encode(cable_message) + @coder.encode cable_message + end + + def decode(websocket_message) + @coder.decode websocket_message + end + + def handle_open + @protocol = websocket.protocol + + @connection.handle_open + + message_buffer.process! + server.add_connection(@connection) + end + + def handle_close + logger.info finished_request_message + + server.remove_connection(@connection) + @connection.handle_close + end + + def respond_to_successful_request + logger.info successful_request_message + websocket.rack_response + end + + def respond_to_invalid_request + close if websocket.alive? + + logger.error invalid_request_message + logger.info finished_request_message + [ 404, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, [ "Page not found" ] ] + end + + def started_request_message + 'Started %s "%s"%s for %s at %s' % [ + request.request_method, + request.filtered_path, + websocket.possible? ? " [WebSocket]" : "[non-WebSocket]", + request.ip, + Time.now.to_s ] + end + + def finished_request_message + 'Finished "%s"%s for %s at %s' % [ + request.filtered_path, + websocket.possible? ? " [WebSocket]" : "[non-WebSocket]", + request.ip, + Time.now.to_s ] + end + + def invalid_request_message + "Failed to upgrade to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [ + env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"] + ] + end + + def successful_request_message + "Successfully upgraded to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [ + env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"] + ] + end + end + end +end diff --git a/lib/action_cable/server/socket/client_socket.rb b/lib/action_cable/server/socket/client_socket.rb new file mode 100644 index 0000000..45a079b --- /dev/null +++ b/lib/action_cable/server/socket/client_socket.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "websocket/driver" + +module ActionCable + module Server + class Socket + #-- + # This class is heavily based on faye-websocket-ruby + # + # Copyright (c) 2010-2015 James Coglan + class ClientSocket # :nodoc: + def self.determine_url(env) + scheme = secure_request?(env) ? "wss:" : "ws:" + "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }" + end + + def self.secure_request?(env) + return true if env["HTTPS"] == "on" + return true if env["HTTP_X_FORWARDED_SSL"] == "on" + return true if env["HTTP_X_FORWARDED_SCHEME"] == "https" + return true if env["HTTP_X_FORWARDED_PROTO"] == "https" + return true if env["rack.url_scheme"] == "https" + + false + end + + CONNECTING = 0 + OPEN = 1 + CLOSING = 2 + CLOSED = 3 + + attr_reader :env, :url + + def initialize(env, event_target, event_loop, protocols) + @env = env + @event_target = event_target + @event_loop = event_loop + + @url = ClientSocket.determine_url(@env) + + @driver = @driver_started = nil + @close_params = ["", 1006] + + @ready_state = CONNECTING + + # The driver calls +env+, +url+, and +write+ + @driver = ::WebSocket::Driver.rack(self, protocols: protocols) + + @driver.on(:open) { |e| open } + @driver.on(:message) { |e| receive_message(e.data) } + @driver.on(:close) { |e| begin_close(e.reason, e.code) } + @driver.on(:error) { |e| emit_error(e.message) } + + @stream = Stream.new(@event_loop, self) + end + + def start_driver + return if @driver.nil? || @driver_started + @stream.hijack_rack_socket + + if callback = @env["async.callback"] + callback.call([101, {}, @stream]) + end + + @driver_started = true + @driver.start + end + + def rack_response + start_driver + [ -1, {}, [] ] + end + + def write(data) + @stream.write(data) + rescue => e + emit_error e.message + end + + def transmit(message) + return false if @ready_state > OPEN + case message + when Numeric then @driver.text(message.to_s) + when String then @driver.text(message) + when Array then @driver.binary(message) + else false + end + end + + def close(code = nil, reason = nil) + code ||= 1000 + reason ||= "" + + unless code == 1000 || (code >= 3000 && code <= 4999) + raise ArgumentError, "Failed to execute 'close' on WebSocket: " \ + "The code must be either 1000, or between 3000 and 4999. " \ + "#{code} is neither." + end + + @ready_state = CLOSING unless @ready_state == CLOSED + @driver.close(reason, code) + end + + def parse(data) + @driver.parse(data) + end + + def client_gone + finalize_close + end + + def alive? + @ready_state == OPEN + end + + def protocol + @driver.protocol + end + + private + def open + return unless @ready_state == CONNECTING + @ready_state = OPEN + + @event_target.on_open + end + + def receive_message(data) + return unless @ready_state == OPEN + + @event_target.on_message(data) + end + + def emit_error(message) + return if @ready_state >= CLOSING + + @event_target.on_error(message) + end + + def begin_close(reason, code) + return if @ready_state == CLOSED + @ready_state = CLOSING + @close_params = [reason, code] + + @stream.shutdown if @stream + finalize_close + end + + def finalize_close + return if @ready_state == CLOSED + @ready_state = CLOSED + + @event_target.on_close(*@close_params) + end + end + end + end +end diff --git a/lib/action_cable/server/socket/message_buffer.rb b/lib/action_cable/server/socket/message_buffer.rb new file mode 100644 index 0000000..9932307 --- /dev/null +++ b/lib/action_cable/server/socket/message_buffer.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ActionCable + module Server + class Socket + # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized, and is ready to receive them. + class MessageBuffer # :nodoc: + def initialize(connection) + @connection = connection + @buffered_messages = [] + end + + def append(message) + if valid? message + if processing? + receive message + else + buffer message + end + else + connection.logger.error "Couldn't handle non-string message: #{message.class}" + end + end + + def processing? + @processing + end + + def process! + @processing = true + receive_buffered_messages + end + + private + attr_reader :connection + attr_reader :buffered_messages + + def valid?(message) + message.is_a?(String) + end + + def receive(message) + connection.receive message + end + + def buffer(message) + buffered_messages << message + end + + def receive_buffered_messages + receive buffered_messages.shift until buffered_messages.empty? + end + end + end + end +end diff --git a/lib/action_cable/server/socket/stream.rb b/lib/action_cable/server/socket/stream.rb new file mode 100644 index 0000000..a71e41b --- /dev/null +++ b/lib/action_cable/server/socket/stream.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module ActionCable + module Server + class Socket + #-- + # This class is heavily based on faye-websocket-ruby + # + # Copyright (c) 2010-2015 James Coglan + class Stream # :nodoc: + def initialize(event_loop, socket) + @event_loop = event_loop + @socket_object = socket + @stream_send = socket.env["stream.send"] + + @rack_hijack_io = nil + @write_lock = Mutex.new + + @write_head = nil + @write_buffer = Queue.new + end + + def each(&callback) + @stream_send ||= callback + end + + def close + shutdown + @socket_object.client_gone + end + + def shutdown + clean_rack_hijack + end + + def write(data) + if @stream_send + return @stream_send.call(data) + end + + if @write_lock.try_lock + begin + if @write_head.nil? && @write_buffer.empty? + written = @rack_hijack_io.write_nonblock(data, exception: false) + + case written + when :wait_writable + # proceed below + when data.bytesize + return data.bytesize + else + @write_head = data.byteslice(written, data.bytesize) + @event_loop.writes_pending @rack_hijack_io + + return data.bytesize + end + end + ensure + @write_lock.unlock + end + end + + @write_buffer << data + @event_loop.writes_pending @rack_hijack_io + + data.bytesize + rescue EOFError, Errno::ECONNRESET + @socket_object.client_gone + end + + def flush_write_buffer + @write_lock.synchronize do + loop do + if @write_head.nil? + return true if @write_buffer.empty? + @write_head = @write_buffer.pop + end + + written = @rack_hijack_io.write_nonblock(@write_head, exception: false) + case written + when :wait_writable + return false + when @write_head.bytesize + @write_head = nil + else + @write_head = @write_head.byteslice(written, @write_head.bytesize) + return false + end + end + end + end + + def receive(data) + @socket_object.parse(data) + end + + def hijack_rack_socket + return unless @socket_object.env["rack.hijack"] + + # This should return the underlying io according to the SPEC: + @rack_hijack_io = @socket_object.env["rack.hijack"].call + # Retain existing behavior if required: + @rack_hijack_io ||= @socket_object.env["rack.hijack_io"] + + @event_loop.attach(@rack_hijack_io, self) + end + + private + def clean_rack_hijack + return unless @rack_hijack_io + @event_loop.detach(@rack_hijack_io, self) + @rack_hijack_io = nil + end + end + end + end +end diff --git a/lib/action_cable/server/socket/web_socket.rb b/lib/action_cable/server/socket/web_socket.rb new file mode 100644 index 0000000..8e4f6b9 --- /dev/null +++ b/lib/action_cable/server/socket/web_socket.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "websocket/driver" + +module ActionCable + module Server + class Socket + # # Action Cable Connection WebSocket + # + # Wrap the real socket to minimize the externally-presented API + class WebSocket # :nodoc: + def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols]) + @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil + end + + def possible? + websocket + end + + def alive? + websocket&.alive? + end + + def transmit(...) + websocket&.transmit(...) + end + + def close(...) + websocket&.close(...) + end + + def protocol + websocket&.protocol + end + + def rack_response + websocket&.rack_response + end + + private + attr_reader :websocket + end + end + end +end diff --git a/lib/action_cable/server/stream_event_loop.rb b/lib/action_cable/server/stream_event_loop.rb new file mode 100644 index 0000000..08b55b0 --- /dev/null +++ b/lib/action_cable/server/stream_event_loop.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "nio" + +module ActionCable + module Server + class StreamEventLoop + def initialize + @nio = @thread = nil + @map = {} + @stopping = false + @todo = Queue.new + + @spawn_mutex = Mutex.new + end + + def attach(io, stream) + @todo << lambda do + @map[io] = @nio.register(io, :r) + @map[io].value = stream + end + wakeup + end + + def detach(io, stream) + @todo << lambda do + @nio.deregister io + @map.delete io + io.close + end + wakeup + end + + def writes_pending(io) + @todo << lambda do + if monitor = @map[io] + monitor.interests = :rw + end + end + wakeup + end + + def stop + @stopping = true + wakeup if @nio + end + + private + def spawn + return if @thread && @thread.status + + @spawn_mutex.synchronize do + return if @thread && @thread.status + + @nio ||= NIO::Selector.new + + @thread = Thread.new { run } + + return true + end + end + + def wakeup + spawn || @nio.wakeup + end + + def run + loop do + if @stopping + @nio.close + break + end + + until @todo.empty? + @todo.pop(true).call + end + + next unless monitors = @nio.select + + monitors.each do |monitor| + io = monitor.io + stream = monitor.value + + begin + if monitor.writable? + if stream.flush_write_buffer + monitor.interests = :r + end + next unless monitor.readable? + end + + incoming = io.read_nonblock(4096, exception: false) + case incoming + when :wait_readable + next + when nil + stream.close + else + stream.receive incoming + end + rescue + # We expect one of EOFError or Errno::ECONNRESET in normal operation (when the + # client goes away). But if anything else goes wrong, this is still the best way + # to handle it. + begin + stream.close + rescue + @nio.deregister io + @map.delete io + end + end + end + end + end + end + end +end diff --git a/lib/action_cable/server/tagged_logger_proxy.rb b/lib/action_cable/server/tagged_logger_proxy.rb new file mode 100644 index 0000000..ea8c3d7 --- /dev/null +++ b/lib/action_cable/server/tagged_logger_proxy.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + # # Action Cable Connection TaggedLoggerProxy + # + # Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional + # ActiveSupport::TaggedLogging enhanced Rails.logger, as that logger will reset the tags between requests. + # The connection is long-lived, so it needs its own set of tags for its independent duration. + class TaggedLoggerProxy + attr_reader :tags + + def initialize(logger, tags:) + @logger = logger + @tags = tags.flatten + end + + def add_tags(*tags) + @tags += tags.flatten + @tags = @tags.uniq + end + + def tag(logger, &block) + if logger.respond_to?(:tagged) + current_tags = tags - logger.formatter.current_tags + logger.tagged(*current_tags, &block) + else + yield + end + end + + %i( debug info warn error fatal unknown ).each do |severity| + define_method(severity) do |message = nil, &block| + log severity, message, &block + end + end + + private + def log(type, message, &block) # :doc: + tag(@logger) { @logger.send type, message, &block } + end + end + end +end diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb new file mode 100644 index 0000000..2522317 --- /dev/null +++ b/lib/action_cable/server/worker.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" +require "active_support/core_ext/module/attribute_accessors_per_thread" +require "concurrent" + +module ActionCable + module Server + # Worker used by Server.send_async to do connection work in threads. + class Worker # :nodoc: + include ActiveSupport::Callbacks + + thread_mattr_accessor :connection + define_callbacks :work + include ActiveRecordConnectionManagement + + attr_reader :executor + + def initialize(max_size: 5) + @executor = Concurrent::ThreadPoolExecutor.new( + name: "ActionCable worker", + min_threads: 1, + max_threads: max_size, + max_queue: 0, + ) + end + + # Stop processing work: any work that has not already started running will be + # discarded from the queue + def halt + @executor.shutdown + end + + def stopping? + @executor.shuttingdown? + end + + def work(connection, &block) + self.connection = connection + + run_callbacks :work, &block + ensure + self.connection = nil + end + + def async_exec(receiver, *args, connection:, &block) + async_invoke receiver, :instance_exec, *args, connection: connection, &block + end + + def async_invoke(receiver, method, *args, connection: receiver, &block) + @executor.post do + invoke(receiver, method, *args, connection: connection, &block) + end + end + + def invoke(receiver, method, *args, connection:, &block) + work(connection) do + receiver.send method, *args, &block + rescue Exception => e + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + receiver.handle_exception if receiver.respond_to?(:handle_exception) + end + end + + private + def logger + ActionCable.server.logger + end + end + end +end diff --git a/lib/action_cable/server/worker/active_record_connection_management.rb b/lib/action_cable/server/worker/active_record_connection_management.rb new file mode 100644 index 0000000..2512c50 --- /dev/null +++ b/lib/action_cable/server/worker/active_record_connection_management.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + class Worker + module ActiveRecordConnectionManagement + extend ActiveSupport::Concern + + included do + if defined?(ActiveRecord::Base) + set_callback :work, :around, :with_database_connections + end + end + + def with_database_connections(&block) + connection.logger.tag(ActiveRecord::Base.logger, &block) + end + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/async.rb b/lib/action_cable/subscription_adapter/async.rb new file mode 100644 index 0000000..16a3651 --- /dev/null +++ b/lib/action_cable/subscription_adapter/async.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class Async < Inline # :nodoc: + private + def new_subscriber_map + SubscriberMap::Async.new(executor) + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/base.rb b/lib/action_cable/subscription_adapter/base.rb new file mode 100644 index 0000000..f325545 --- /dev/null +++ b/lib/action_cable/subscription_adapter/base.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class Base + private attr_reader :executor + private attr_reader :config + + delegate :logger, to: :config + + def initialize(server) + @executor = server.executor + @config = server.config + end + + def broadcast(channel, payload) + raise NotImplementedError + end + + def subscribe(channel, message_callback, success_callback = nil) + raise NotImplementedError + end + + def unsubscribe(channel, message_callback) + raise NotImplementedError + end + + def shutdown + raise NotImplementedError + end + + def identifier + config.cable[:id] ||= "ActionCable-PID-#{$$}" + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/channel_prefix.rb b/lib/action_cable/subscription_adapter/channel_prefix.rb new file mode 100644 index 0000000..776d611 --- /dev/null +++ b/lib/action_cable/subscription_adapter/channel_prefix.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + module ChannelPrefix # :nodoc: + def broadcast(channel, payload) + channel = channel_with_prefix(channel) + super + end + + def subscribe(channel, callback, success_callback = nil) + channel = channel_with_prefix(channel) + super + end + + def unsubscribe(channel, callback) + channel = channel_with_prefix(channel) + super + end + + private + # Returns the channel name, including channel_prefix specified in cable.yml + def channel_with_prefix(channel) + [config.cable[:channel_prefix], channel].compact.join(":") + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/inline.rb b/lib/action_cable/subscription_adapter/inline.rb new file mode 100644 index 0000000..dbdd701 --- /dev/null +++ b/lib/action_cable/subscription_adapter/inline.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class Inline < Base # :nodoc: + def initialize(*) + super + @mutex = Mutex.new + @subscriber_map = nil + end + + def broadcast(channel, payload) + subscriber_map.broadcast(channel, payload) + end + + def subscribe(channel, callback, success_callback = nil) + subscriber_map.add_subscriber(channel, callback, success_callback) + end + + def unsubscribe(channel, callback) + subscriber_map.remove_subscriber(channel, callback) + end + + def shutdown + # nothing to do + end + + private + def subscriber_map + @subscriber_map || @mutex.synchronize { @subscriber_map ||= new_subscriber_map } + end + + def new_subscriber_map + SubscriberMap.new + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/postgresql.rb b/lib/action_cable/subscription_adapter/postgresql.rb new file mode 100644 index 0000000..5af986a --- /dev/null +++ b/lib/action_cable/subscription_adapter/postgresql.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# :markup: markdown + +gem "pg", "~> 1.1" +require "pg" +require "openssl" + +module ActionCable + module SubscriptionAdapter + class PostgreSQL < Base # :nodoc: + prepend ChannelPrefix + + def initialize(*) + super + @mutex = Mutex.new + @listener = nil + end + + def broadcast(channel, payload) + with_broadcast_connection do |pg_conn| + pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'") + end + end + + def subscribe(channel, callback, success_callback = nil) + listener.add_subscriber(channel_identifier(channel), callback, success_callback) + end + + def unsubscribe(channel, callback) + listener.remove_subscriber(channel_identifier(channel), callback) + end + + def shutdown + listener.shutdown + end + + def with_subscriptions_connection(&block) # :nodoc: + ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn| + # Action Cable is taking ownership over this database connection, and will + # perform the necessary cleanup tasks + ActiveRecord::Base.connection_pool.remove(conn) + end + pg_conn = ar_conn.raw_connection + + verify!(pg_conn) + pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(identifier)}") + yield pg_conn + ensure + ar_conn.disconnect! + end + + def with_broadcast_connection(&block) # :nodoc: + ActiveRecord::Base.connection_pool.with_connection do |ar_conn| + pg_conn = ar_conn.raw_connection + verify!(pg_conn) + yield pg_conn + end + end + + private + def channel_identifier(channel) + channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel + end + + def listener + @listener || @mutex.synchronize { @listener ||= Listener.new(self, executor) } + end + + def verify!(pg_conn) + unless pg_conn.is_a?(PG::Connection) + raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter" + end + end + + class Listener < SubscriberMap::Async + def initialize(adapter, executor) + super(executor) + + @adapter = adapter + @queue = Queue.new + + @thread = Thread.new do + Thread.current.abort_on_exception = true + listen + end + end + + def listen + @adapter.with_subscriptions_connection do |pg_conn| + catch :shutdown do + loop do + until @queue.empty? + action, channel, callback = @queue.pop(true) + + case action + when :listen + pg_conn.exec("LISTEN #{pg_conn.escape_identifier channel}") + @executor.post(&callback) if callback + when :unlisten + pg_conn.exec("UNLISTEN #{pg_conn.escape_identifier channel}") + when :shutdown + throw :shutdown + end + end + + pg_conn.wait_for_notify(1) do |chan, pid, message| + broadcast(chan, message) + end + end + end + end + end + + def shutdown + @queue.push([:shutdown]) + Thread.pass while @thread.alive? + end + + def add_channel(channel, on_success) + @queue.push([:listen, channel, on_success]) + end + + def remove_channel(channel) + @queue.push([:unlisten, channel]) + end + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/redis.rb b/lib/action_cable/subscription_adapter/redis.rb new file mode 100644 index 0000000..8ddd2bb --- /dev/null +++ b/lib/action_cable/subscription_adapter/redis.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +# :markup: markdown + +gem "redis", ">= 4", "< 6" +require "redis" + +require "active_support/core_ext/hash/except" + +module ActionCable + module SubscriptionAdapter + class Redis < Base # :nodoc: + prepend ChannelPrefix + + # Overwrite this factory method for Redis connections if you want to use a + # different Redis library than the redis gem. This is needed, for example, when + # using Makara proxies for distributed Redis. + cattr_accessor :redis_connector, default: ->(config) do + ::Redis.new(config.except(:adapter, :channel_prefix)) + end + + def initialize(*) + super + @listener = nil + @mutex = Mutex.new + @redis_connection_for_broadcasts = nil + end + + def broadcast(channel, payload) + redis_connection_for_broadcasts.publish(channel, payload) + end + + def subscribe(channel, callback, success_callback = nil) + listener.add_subscriber(channel, callback, success_callback) + end + + def unsubscribe(channel, callback) + listener.remove_subscriber(channel, callback) + end + + def shutdown + @listener.shutdown if @listener + end + + def redis_connection_for_subscriptions + redis_connection + end + + private + def listener + @listener || @mutex.synchronize { @listener ||= Listener.new(self, config_options, executor) } + end + + def redis_connection_for_broadcasts + @redis_connection_for_broadcasts || @mutex.synchronize do + @redis_connection_for_broadcasts ||= redis_connection + end + end + + def redis_connection + self.class.redis_connector.call(config_options) + end + + def config_options + @config_options ||= config.cable.deep_symbolize_keys.merge(id: identifier) + end + + class Listener < SubscriberMap::Async + delegate :logger, to: :@adapter + + def initialize(adapter, config_options, executor) + super(executor) + + @adapter = adapter + + @subscribe_callbacks = Hash.new { |h, k| h[k] = [] } + @subscription_lock = Mutex.new + + @reconnect_attempt = 0 + # Use the same config as used by Redis conn + @reconnect_attempts = config_options.fetch(:reconnect_attempts, 1) + @reconnect_attempts = Array.new(@reconnect_attempts, 0) if @reconnect_attempts.is_a?(Integer) + + @subscribed_client = nil + + @when_connected = [] + + @thread = nil + end + + def listen(conn) + conn.without_reconnect do + original_client = extract_subscribed_client(conn) + + conn.subscribe("_action_cable_internal") do |on| + on.subscribe do |chan, count| + @subscription_lock.synchronize do + if count == 1 + @reconnect_attempt = 0 + @subscribed_client = original_client + + until @when_connected.empty? + @when_connected.shift.call + end + end + + if callbacks = @subscribe_callbacks[chan] + next_callback = callbacks.shift + @executor.post(&next_callback) if next_callback + @subscribe_callbacks.delete(chan) if callbacks.empty? + end + end + end + + on.message do |chan, message| + broadcast(chan, message) + end + + on.unsubscribe do |chan, count| + if count == 0 + @subscription_lock.synchronize do + @subscribed_client = nil + end + end + end + end + end + end + + def shutdown + @subscription_lock.synchronize do + return if @thread.nil? + + when_connected do + @subscribed_client.unsubscribe + @subscribed_client = nil + end + end + + Thread.pass while @thread.alive? + end + + def add_channel(channel, on_success) + @subscription_lock.synchronize do + ensure_listener_running + @subscribe_callbacks[channel] << on_success + when_connected { @subscribed_client.subscribe(channel) } + end + end + + def remove_channel(channel) + @subscription_lock.synchronize do + when_connected { @subscribed_client.unsubscribe(channel) } + end + end + + private + def ensure_listener_running + @thread ||= Thread.new do + Thread.current.abort_on_exception = true + + begin + conn = @adapter.redis_connection_for_subscriptions + listen conn + rescue ConnectionError => e + reset + if retry_connecting? + logger&.warn "Redis connection failed: #{e.message}. Trying to reconnect..." + when_connected { resubscribe } + retry + else + logger&.error "Failed to reconnect to Redis after #{@reconnect_attempt} attempts." + end + end + end + end + + def when_connected(&block) + if @subscribed_client + block.call + else + @when_connected << block + end + end + + def retry_connecting? + @reconnect_attempt += 1 + + return false if @reconnect_attempt > @reconnect_attempts.size + + sleep_t = @reconnect_attempts[@reconnect_attempt - 1] + + sleep(sleep_t) if sleep_t > 0 + + true + end + + def resubscribe + channels = @sync.synchronize do + @subscribers.keys + end + @subscribed_client.subscribe(*channels) unless channels.empty? + end + + def reset + @subscription_lock.synchronize do + @subscribed_client = nil + @subscribe_callbacks.clear + @when_connected.clear + end + end + + if ::Redis::VERSION < "5" + ConnectionError = ::Redis::BaseConnectionError + + class SubscribedClient + def initialize(raw_client) + @raw_client = raw_client + end + + def subscribe(*channel) + send_command("subscribe", *channel) + end + + def unsubscribe(*channel) + send_command("unsubscribe", *channel) + end + + private + def send_command(*command) + @raw_client.write(command) + + very_raw_connection = + @raw_client.connection.instance_variable_defined?(:@connection) && + @raw_client.connection.instance_variable_get(:@connection) + + if very_raw_connection && very_raw_connection.respond_to?(:flush) + very_raw_connection.flush + end + nil + end + end + + def extract_subscribed_client(conn) + SubscribedClient.new(conn._client) + end + else + ConnectionError = RedisClient::ConnectionError + + def extract_subscribed_client(conn) + conn + end + end + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/subscriber_map.rb b/lib/action_cable/subscription_adapter/subscriber_map.rb new file mode 100644 index 0000000..612b3fa --- /dev/null +++ b/lib/action_cable/subscription_adapter/subscriber_map.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class SubscriberMap + def initialize + @subscribers = Hash.new { |h, k| h[k] = [] } + @sync = Mutex.new + end + + def add_subscriber(channel, subscriber, on_success) + @sync.synchronize do + new_channel = !@subscribers.key?(channel) + + @subscribers[channel] << subscriber + + if new_channel + add_channel channel, on_success + elsif on_success + on_success.call + end + end + end + + def remove_subscriber(channel, subscriber) + @sync.synchronize do + @subscribers[channel].delete(subscriber) + + if @subscribers[channel].empty? + @subscribers.delete channel + remove_channel channel + end + end + end + + def broadcast(channel, message) + list = @sync.synchronize do + return if !@subscribers.key?(channel) + @subscribers[channel].dup + end + + list.each do |subscriber| + invoke_callback(subscriber, message) + end + end + + def add_channel(channel, on_success) + on_success.call if on_success + end + + def remove_channel(channel) + end + + def invoke_callback(callback, message) + callback.call message + end + + class Async < self + def initialize(executor) + @executor = executor + super() + end + + def add_subscriber(*) + @executor.post { super } + end + + def remove_subscriber(*) + @executor.post { super } + end + + def invoke_callback(*) + @executor.post { super } + end + end + end + end +end diff --git a/lib/action_cable/subscription_adapter/test.rb b/lib/action_cable/subscription_adapter/test.rb new file mode 100644 index 0000000..d09018a --- /dev/null +++ b/lib/action_cable/subscription_adapter/test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + # ## Test adapter for Action Cable + # + # The test adapter should be used only in testing. Along with + # ActionCable::TestHelper it makes a great tool to test your Rails application. + # + # To use the test adapter set `adapter` value to `test` in your + # `config/cable.yml` file. + # + # NOTE: `Test` adapter extends the `ActionCable::SubscriptionAdapter::Async` + # adapter, so it could be used in system tests too. + class Test < Async + def broadcast(channel, payload) + broadcasts(channel) << payload + super + end + + def broadcasts(channel) + channels_data[channel] ||= [] + end + + def clear_messages(channel) + channels_data[channel] = [] + end + + def clear + @channels_data = nil + end + + private + def channels_data + @channels_data ||= {} + end + end + end +end diff --git a/lib/action_cable/test_case.rb b/lib/action_cable/test_case.rb new file mode 100644 index 0000000..b56f2ea --- /dev/null +++ b/lib/action_cable/test_case.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/test_case" + +module ActionCable + class TestCase < ActiveSupport::TestCase + include ActionCable::TestHelper + + ActiveSupport.run_load_hooks(:action_cable_test_case, self) + end +end diff --git a/lib/action_cable/test_helper.rb b/lib/action_cable/test_helper.rb new file mode 100644 index 0000000..682d8fc --- /dev/null +++ b/lib/action_cable/test_helper.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + # Provides helper methods for testing Action Cable broadcasting + module TestHelper + def before_setup # :nodoc: + server = ActionCable.server + test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) + + @old_pubsub_adapter = server.pubsub + + server.instance_variable_set(:@pubsub, test_adapter) + super + end + + def after_teardown # :nodoc: + super + ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter) + end + + # Asserts that the number of broadcasted messages to the stream matches the + # given number. + # + # def test_broadcasts + # assert_broadcasts 'messages', 0 + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # assert_broadcasts 'messages', 1 + # ActionCable.server.broadcast 'messages', { text: 'world' } + # assert_broadcasts 'messages', 2 + # end + # + # If a block is passed, that block should cause the specified number of messages + # to be broadcasted. + # + # def test_broadcasts_again + # assert_broadcasts('messages', 1) do + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # end + # + # assert_broadcasts('messages', 2) do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # end + # + def assert_broadcasts(stream, number, &block) + if block_given? + new_messages = new_broadcasts_from(broadcasts(stream), stream, "assert_broadcasts", &block) + + actual_count = new_messages.size + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + else + actual_count = broadcasts(stream).size + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + end + end + + # Asserts that no messages have been sent to the stream. + # + # def test_no_broadcasts + # assert_no_broadcasts 'messages' + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # assert_broadcasts 'messages', 1 + # end + # + # If a block is passed, that block should not cause any message to be sent. + # + # def test_broadcasts_again + # assert_no_broadcasts 'messages' do + # # No job messages should be sent from this block + # end + # end + # + # Note: This assertion is simply a shortcut for: + # + # assert_broadcasts 'messages', 0, &block + # + def assert_no_broadcasts(stream, &block) + assert_broadcasts stream, 0, &block + end + + # Returns the messages that are broadcasted in the block. + # + # def test_broadcasts + # messages = capture_broadcasts('messages') do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # assert_equal 2, messages.length + # assert_equal({ text: 'hi' }, messages.first) + # assert_equal({ text: 'how are you?' }, messages.last) + # end + # + def capture_broadcasts(stream, &block) + new_broadcasts_from(broadcasts(stream), stream, "capture_broadcasts", &block).map { |m| ActiveSupport::JSON.decode(m) } + end + + # Asserts that the specified message has been sent to the stream. + # + # def test_assert_transmitted_message + # ActionCable.server.broadcast 'messages', text: 'hello' + # assert_broadcast_on('messages', text: 'hello') + # end + # + # If a block is passed, that block should cause a message with the specified + # data to be sent. + # + # def test_assert_broadcast_on_again + # assert_broadcast_on('messages', text: 'hello') do + # ActionCable.server.broadcast 'messages', text: 'hello' + # end + # end + # + def assert_broadcast_on(stream, data, &block) + # Encode to JSON and back–we want to use this value to compare with decoded + # JSON. Comparing JSON strings doesn't work due to the order if the keys. + serialized_msg = + ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data)) + + new_messages = broadcasts(stream) + if block_given? + new_messages = new_broadcasts_from(new_messages, stream, "assert_broadcast_on", &block) + end + + message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg } + + error_message = "No messages sent with #{data} to #{stream}" + + if new_messages.any? + error_message = new_messages.inject("#{error_message}\nMessage(s) found:\n") do |error_message, new_message| + error_message + "#{ActiveSupport::JSON.decode(new_message)}\n" + end + else + error_message = "#{error_message}\nNo message found for #{stream}" + end + + assert message, error_message + end + + def pubsub_adapter # :nodoc: + ActionCable.server.pubsub + end + + delegate :broadcasts, :clear_messages, to: :pubsub_adapter + + private + def new_broadcasts_from(current_messages, stream, assertion, &block) + old_messages = current_messages + clear_messages(stream) + + _assert_nothing_raised_or_warn(assertion, &block) + new_messages = broadcasts(stream) + clear_messages(stream) + + # Restore all sent messages + (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) } + + new_messages + end + end +end diff --git a/lib/action_cable/version.rb b/lib/action_cable/version.rb new file mode 100644 index 0000000..14423f9 --- /dev/null +++ b/lib/action_cable/version.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# :markup: markdown + +require_relative "gem_version" + +module ActionCable + # Returns the currently loaded version of Action Cable as a `Gem::Version`. + def self.version + gem_version + end +end diff --git a/lib/actioncable-next.rb b/lib/actioncable-next.rb new file mode 100644 index 0000000..961ec00 --- /dev/null +++ b/lib/actioncable-next.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module ActionCableNext + VERSION = "0.1.0" +end diff --git a/lib/rails/generators/channel/USAGE b/lib/rails/generators/channel/USAGE new file mode 100644 index 0000000..1319092 --- /dev/null +++ b/lib/rails/generators/channel/USAGE @@ -0,0 +1,19 @@ +Description: + Generates a new cable channel for the server (in Ruby) and client (in JavaScript). + Pass the channel name, either CamelCased or under_scored, and an optional list of channel actions as arguments. + +Examples: + `bin/rails generate channel notification` + + creates a notification channel class, test and JavaScript asset: + Channel: app/channels/notification_channel.rb + Test: test/channels/notification_channel_test.rb + Assets: $JAVASCRIPT_PATH/channels/notification_channel.js + + `bin/rails generate channel chat speak` + + creates a chat channel with a speak action. + + `bin/rails generate channel comments --no-assets` + + creates a comments channel without JavaScript assets. diff --git a/lib/rails/generators/channel/channel_generator.rb b/lib/rails/generators/channel/channel_generator.rb new file mode 100644 index 0000000..c128dd6 --- /dev/null +++ b/lib/rails/generators/channel/channel_generator.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# :markup: markdown + +module Rails + module Generators + class ChannelGenerator < NamedBase + source_root File.expand_path("templates", __dir__) + + argument :actions, type: :array, default: [], banner: "method method" + + class_option :assets, type: :boolean + + check_class_collision suffix: "Channel" + + hook_for :test_framework + + def create_channel_files + create_shared_channel_files + create_channel_file + + if using_javascript? + if first_setup_required? + create_shared_channel_javascript_files + import_channels_in_javascript_entrypoint + + if using_importmap? + pin_javascript_dependencies + elsif using_js_runtime? + install_javascript_dependencies + end + end + + create_channel_javascript_file + import_channel_in_javascript_entrypoint + end + end + + private + def create_shared_channel_files + return if behavior != :invoke + + copy_file "#{__dir__}/templates/application_cable/channel.rb", + "app/channels/application_cable/channel.rb" + copy_file "#{__dir__}/templates/application_cable/connection.rb", + "app/channels/application_cable/connection.rb" + end + + def create_channel_file + template "channel.rb", + File.join("app/channels", class_path, "#{file_name}_channel.rb") + end + + def create_shared_channel_javascript_files + template "javascript/index.js", "app/javascript/channels/index.js" + template "javascript/consumer.js", "app/javascript/channels/consumer.js" + end + + def create_channel_javascript_file + channel_js_path = File.join("app/javascript/channels", class_path, "#{file_name}_channel") + js_template "javascript/channel", channel_js_path + gsub_file "#{channel_js_path}.js", /\.\/consumer/, "channels/consumer" unless using_js_runtime? + end + + def import_channels_in_javascript_entrypoint + append_to_file "app/javascript/application.js", + using_js_runtime? ? %(import "./channels"\n) : %(import "channels"\n) + end + + def import_channel_in_javascript_entrypoint + append_to_file "app/javascript/channels/index.js", + using_js_runtime? ? %(import "./#{file_name}_channel"\n) : %(import "channels/#{file_name}_channel"\n) + end + + def install_javascript_dependencies + say "Installing JavaScript dependencies", :green + if using_bun? + run "bun add @rails/actioncable" + elsif using_node? + run "yarn add @rails/actioncable" + end + end + + def pin_javascript_dependencies + append_to_file "config/importmap.rb", <<-RUBY +pin "@rails/actioncable", to: "actioncable.esm.js" +pin_all_from "app/javascript/channels", under: "channels" + RUBY + end + + def file_name + @_file_name ||= super.sub(/_channel\z/i, "") + end + + def first_setup_required? + !root.join("app/javascript/channels/index.js").exist? + end + + def using_javascript? + @using_javascript ||= options[:assets] && root.join("app/javascript").exist? + end + + def using_js_runtime? + @using_js_runtime ||= root.join("package.json").exist? + end + + def using_bun? + # Cannot assume bun.lockb has been generated yet so we look for a file known to + # be generated by the jsbundling-rails gem + @using_bun ||= using_js_runtime? && root.join("bun.config.js").exist? + end + + def using_node? + # Bun is the only runtime that _isn't_ node. + @using_node ||= using_js_runtime? && !root.join("bun.config.js").exist? + end + + def using_importmap? + @using_importmap ||= root.join("config/importmap.rb").exist? + end + + def root + @root ||= Pathname(destination_root) + end + end + end +end diff --git a/lib/rails/generators/channel/templates/application_cable/channel.rb.tt b/lib/rails/generators/channel/templates/application_cable/channel.rb.tt new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/lib/rails/generators/channel/templates/application_cable/channel.rb.tt @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/lib/rails/generators/channel/templates/application_cable/connection.rb.tt b/lib/rails/generators/channel/templates/application_cable/connection.rb.tt new file mode 100644 index 0000000..0ff5442 --- /dev/null +++ b/lib/rails/generators/channel/templates/application_cable/connection.rb.tt @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/lib/rails/generators/channel/templates/channel.rb.tt b/lib/rails/generators/channel/templates/channel.rb.tt new file mode 100644 index 0000000..4bcfb2b --- /dev/null +++ b/lib/rails/generators/channel/templates/channel.rb.tt @@ -0,0 +1,16 @@ +<% module_namespacing do -%> +class <%= class_name %>Channel < ApplicationCable::Channel + def subscribed + # stream_from "some_channel" + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +<% actions.each do |action| -%> + + def <%= action %> + end +<% end -%> +end +<% end -%> diff --git a/lib/rails/generators/channel/templates/javascript/channel.js.tt b/lib/rails/generators/channel/templates/javascript/channel.js.tt new file mode 100644 index 0000000..ddf6b2d --- /dev/null +++ b/lib/rails/generators/channel/templates/javascript/channel.js.tt @@ -0,0 +1,20 @@ +import consumer from "./consumer" + +consumer.subscriptions.create("<%= class_name %>Channel", { + connected() { + // Called when the subscription is ready for use on the server + }, + + disconnected() { + // Called when the subscription has been terminated by the server + }, + + received(data) { + // Called when there's incoming data on the websocket for this channel + }<%= actions.any? ? ",\n" : '' %> +<% actions.each do |action| -%> + <%=action %>: function() { + return this.perform('<%= action %>'); + }<%= action == actions[-1] ? '' : ",\n" %> +<% end -%> +}); diff --git a/lib/rails/generators/channel/templates/javascript/consumer.js.tt b/lib/rails/generators/channel/templates/javascript/consumer.js.tt new file mode 100644 index 0000000..8ec3aad --- /dev/null +++ b/lib/rails/generators/channel/templates/javascript/consumer.js.tt @@ -0,0 +1,6 @@ +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. + +import { createConsumer } from "@rails/actioncable" + +export default createConsumer() diff --git a/lib/rails/generators/channel/templates/javascript/index.js.tt b/lib/rails/generators/channel/templates/javascript/index.js.tt new file mode 100644 index 0000000..08dc8af --- /dev/null +++ b/lib/rails/generators/channel/templates/javascript/index.js.tt @@ -0,0 +1 @@ +// Import all the channels to be used by Action Cable diff --git a/lib/rails/generators/test_unit/channel_generator.rb b/lib/rails/generators/test_unit/channel_generator.rb new file mode 100644 index 0000000..6054a3b --- /dev/null +++ b/lib/rails/generators/test_unit/channel_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + +module TestUnit + module Generators + class ChannelGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "ChannelTest" + + def create_test_files + template "channel_test.rb", File.join("test/channels", class_path, "#{file_name}_channel_test.rb") + end + + private + def file_name # :doc: + @_file_name ||= super.sub(/_channel\z/i, "") + end + end + end +end diff --git a/lib/rails/generators/test_unit/templates/channel_test.rb.tt b/lib/rails/generators/test_unit/templates/channel_test.rb.tt new file mode 100644 index 0000000..7307654 --- /dev/null +++ b/lib/rails/generators/test_unit/templates/channel_test.rb.tt @@ -0,0 +1,8 @@ +require "test_helper" + +class <%= class_name %>ChannelTest < ActionCable::Channel::TestCase + # test "subscribes" do + # subscribe + # assert subscription.confirmed? + # end +end diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb new file mode 100644 index 0000000..536a7ec --- /dev/null +++ b/test/channel/base_test.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_socket" +require "stubs/room" + +class ActionCable::Channel::BaseTest < ActionCable::TestCase + class ActionCable::Channel::Base + def kick + @last_action = [ :kick ] + end + + def topic + end + end + + class BasicChannel < ActionCable::Channel::Base + def chatters + @last_action = [ :chatters ] + end + end + + class ChatChannel < BasicChannel + attr_reader :room, :last_action + after_subscribe :toggle_subscribed + after_unsubscribe :toggle_subscribed + + class SomeCustomError < StandardError; end + rescue_from SomeCustomError, with: :error_handler + + def initialize(*) + @subscribed = false + super + end + + def subscribed + @room = Room.new params[:id] + @actions = [] + end + + def unsubscribed + @room = nil + end + + def toggle_subscribed + @subscribed = !@subscribed + end + + def leave + @last_action = [ :leave ] + end + + def speak(data) + @last_action = [ :speak, data ] + end + + def topic(data) + @last_action = [ :topic, data ] + end + + def subscribed? + @subscribed + end + + def get_latest + transmit({ data: "latest" }) + end + + def receive + @last_action = [ :receive ] + end + + def error_action + raise SomeCustomError + end + + private + def rm_rf + @last_action = [ :rm_rf ] + end + + def error_handler + @last_action = [ :error_action ] + end + end + + setup do + @user = User.new "lifo" + @connection = TestSocket.new(@user) + @channel = ChatChannel.new @connection, "{id: 1}", id: 1 + end + + test "should subscribe to a channel" do + @channel.subscribe_to_channel + assert_equal 1, @channel.room.id + end + + test "on subscribe callbacks" do + @channel.subscribe_to_channel + assert @channel.subscribed + end + + test "channel params" do + assert_equal({ id: 1 }, @channel.params) + end + + test "does not log filtered parameters" do + @connection.server.config.filter_parameters << :password + data = { password: "password", foo: "foo" } + + assert_logged(':password=>"[FILTERED]"') do + @channel.perform_action data + end + end + + test "unsubscribing from a channel" do + @channel.subscribe_to_channel + + assert @channel.room + assert_predicate @channel, :subscribed? + + @channel.unsubscribe_from_channel + + assert_not @channel.room + assert_not_predicate @channel, :subscribed? + end + + test "connection identifiers" do + assert_equal @user.name, @channel.current_user.name + end + + test "callable action without any argument" do + @channel.perform_action "action" => :leave + assert_equal [ :leave ], @channel.last_action + end + + test "callable action with arguments" do + data = { "action" => :speak, "content" => "Hello World" } + + @channel.perform_action data + assert_equal [ :speak, data ], @channel.last_action + end + + test "should not dispatch a private method" do + @channel.perform_action "action" => :rm_rf + assert_nil @channel.last_action + end + + test "should not dispatch a public method defined on Base" do + @channel.perform_action "action" => :kick + assert_nil @channel.last_action + end + + test "should dispatch a public method defined on Base and redefined on channel" do + data = { "action" => :topic, "content" => "This is Sparta!" } + + @channel.perform_action data + assert_equal [ :topic, data ], @channel.last_action + end + + test "should dispatch calling a public method defined in an ancestor" do + @channel.perform_action "action" => :chatters + assert_equal [ :chatters ], @channel.last_action + end + + test "should dispatch receive action when perform_action is called with empty action" do + data = { "content" => "hello" } + @channel.perform_action data + assert_equal [ :receive ], @channel.last_action + end + + test "transmitting data" do + @channel.perform_action "action" => :get_latest + + expected = { "identifier" => "{id: 1}", "message" => { "data" => "latest" } } + assert_equal expected, @connection.last_transmission + end + + test "do not send subscription confirmation on initialize" do + assert_nil @connection.last_transmission + end + + test "subscription confirmation on subscribe_to_channel" do + expected = { "identifier" => "{id: 1}", "type" => "confirm_subscription" } + @channel.subscribe_to_channel + assert_equal expected, @connection.last_transmission + end + + test "actions available on Channel" do + available_actions = %w(room last_action subscribed unsubscribed toggle_subscribed leave speak subscribed? get_latest receive chatters topic error_action).to_set + assert_equal available_actions, ChatChannel.action_methods + end + + test "invalid action on Channel" do + assert_logged("Unable to process ActionCable::Channel::BaseTest::ChatChannel#invalid_action") do + @channel.perform_action "action" => :invalid_action + end + end + + test "notification for perform_action" do + events = [] + ActiveSupport::Notifications.subscribe("perform_action.action_cable") { |event| events << event } + + data = { "action" => :speak, "content" => "hello" } + @channel.perform_action data + + assert_equal 1, events.length + assert_equal "perform_action.action_cable", events[0].name + assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] + assert_equal :speak, events[0].payload[:action] + assert_equal data, events[0].payload[:data] + ensure + ActiveSupport::Notifications.unsubscribe "perform_action.action_cable" + end + + test "notification for transmit" do + events = [] + ActiveSupport::Notifications.subscribe("transmit.action_cable") { |event| events << event } + + @channel.perform_action "action" => :get_latest + expected_data = { data: "latest" } + + assert_equal 1, events.length + assert_equal "transmit.action_cable", events[0].name + assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] + assert_equal expected_data, events[0].payload[:data] + assert_nil events[0].payload[:via] + ensure + ActiveSupport::Notifications.unsubscribe "transmit.action_cable" + end + + test "notification for transmit_subscription_confirmation" do + @channel.subscribe_to_channel + + events = [] + ActiveSupport::Notifications.subscribe("transmit_subscription_confirmation.action_cable") { |e| events << e } + + @channel.stub(:subscription_confirmation_sent?, false) do + @channel.send(:transmit_subscription_confirmation) + + assert_equal 1, events.length + assert_equal "transmit_subscription_confirmation.action_cable", events[0].name + assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] + assert_equal "{id: 1}", events[0].payload[:identifier] + end + ensure + ActiveSupport::Notifications.unsubscribe "transmit_subscription_confirmation.action_cable" + end + + test "notification for transmit_subscription_rejection" do + events = [] + ActiveSupport::Notifications.subscribe("transmit_subscription_rejection.action_cable") { |event| events << event } + + @channel.send(:transmit_subscription_rejection) + + assert_equal 1, events.length + assert_equal "transmit_subscription_rejection.action_cable", events[0].name + assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] + assert_equal "{id: 1}", events[0].payload[:identifier] + ensure + ActiveSupport::Notifications.unsubscribe "transmit_subscription_rejection.action_cable" + end + + test "behaves like rescuable" do + @channel.perform_action "action" => :error_action + assert_equal [ :error_action ], @channel.last_action + end + + private + def assert_logged(message) + old_logger = @connection.logger + log = StringIO.new + @connection.instance_variable_set(:@logger, Logger.new(log)) + + begin + yield + + log.rewind + assert_match message, log.read + ensure + @connection.instance_variable_set(:@logger, old_logger) + end + end +end diff --git a/test/channel/broadcasting_test.rb b/test/channel/broadcasting_test.rb new file mode 100644 index 0000000..9310078 --- /dev/null +++ b/test/channel/broadcasting_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_socket" +require "stubs/room" + +class ActionCable::Channel::BroadcastingTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + end + + setup do + @connection = TestSocket.new + end + + test "broadcasts_to" do + assert_called_with( + ActionCable.server, + :broadcast, + [ + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", + "Hello World" + ] + ) do + ChatChannel.broadcast_to(Room.new(1), "Hello World") + end + end + + test "broadcasting_for with an object" do + assert_equal( + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", + ChatChannel.broadcasting_for(Room.new(1)) + ) + end + + test "broadcasting_for with an array" do + assert_equal( + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire:Room#2-Campfire", + ChatChannel.broadcasting_for([ Room.new(1), Room.new(2) ]) + ) + end + + test "broadcasting_for with a string" do + assert_equal( + "action_cable:channel:broadcasting_test:chat:hello", + ChatChannel.broadcasting_for("hello") + ) + end +end diff --git a/test/channel/naming_test.rb b/test/channel/naming_test.rb new file mode 100644 index 0000000..45652d9 --- /dev/null +++ b/test/channel/naming_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionCable::Channel::NamingTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + end + + test "channel_name" do + assert_equal "action_cable:channel:naming_test:chat", ChatChannel.channel_name + end +end diff --git a/test/channel/periodic_timers_test.rb b/test/channel/periodic_timers_test.rb new file mode 100644 index 0000000..88aea07 --- /dev/null +++ b/test/channel/periodic_timers_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_socket" +require "stubs/room" +require "active_support/time" + +class ActionCable::Channel::PeriodicTimersTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + # Method name arg + periodically :send_updates, every: 1 + + # Proc arg + periodically -> { ping }, every: 2 + + # Block arg + periodically every: 3 do + ping + end + + private + def ping + end + end + + setup do + @connection = TestSocket.new + end + + test "periodic timers definition" do + timers = ChatChannel.periodic_timers + + assert_equal 3, timers.size + + timers.each_with_index do |timer, i| + assert_kind_of Proc, timer[0] + assert_equal i + 1, timer[1][:every] + end + end + + test "disallow negative and zero periods" do + [ 0, 0.0, 0.seconds, -1, -1.seconds, "foo", :foo, Object.new ].each do |invalid| + e = assert_raise ArgumentError do + ChatChannel.periodically :send_updates, every: invalid + end + assert_match(/Expected every:/, e.message) + end + end + + test "disallow block and arg together" do + e = assert_raise ArgumentError do + ChatChannel.periodically(:send_updates, every: 1) { ping } + end + assert_match(/not both/, e.message) + end + + test "disallow unknown args" do + [ "send_updates", Object.new, nil ].each do |invalid| + e = assert_raise ArgumentError do + ChatChannel.periodically invalid, every: 1 + end + assert_match(/Expected a Symbol/, e.message) + end + end + + test "timer start and stop" do + mock = Minitest::Mock.new + 3.times { mock.expect(:shutdown, nil) } + + assert_called( + @connection.server.executor, + :timer, + times: 3, + returns: mock + ) do + channel = ChatChannel.new @connection, "{id: 1}", id: 1 + + channel.subscribe_to_channel + channel.unsubscribe_from_channel + assert_equal [], channel.send(:active_periodic_timers) + end + + assert mock.verify + end +end diff --git a/test/channel/rejection_test.rb b/test/channel/rejection_test.rb new file mode 100644 index 0000000..81a1b74 --- /dev/null +++ b/test/channel/rejection_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_socket" +require "stubs/room" + +class ActionCable::Channel::RejectionTest < ActionCable::TestCase + class SecretChannel < ActionCable::Channel::Base + def subscribed + reject if params[:id] > 0 + end + + def secret_action + end + end + + setup do + @user = User.new "lifo" + @connection = TestSocket.new(@user) + end + + test "subscription rejection" do + subscriptions = Minitest::Mock.new + subscriptions.expect(:remove_subscription, SecretChannel, [SecretChannel]) + + @connection.stub(:subscriptions, subscriptions) do + @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel + + expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal expected, @connection.last_transmission + end + + assert subscriptions.verify + end + + test "does not execute action if subscription is rejected" do + subscriptions = Minitest::Mock.new + subscriptions.expect(:remove_subscription, SecretChannel, [SecretChannel]) + + @connection.stub(:subscriptions, subscriptions) do + @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel + + expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal expected, @connection.last_transmission + assert_equal 1, @connection.transmissions.size + + @channel.perform_action("action" => :secret_action) + assert_equal 1, @connection.transmissions.size + end + + assert subscriptions.verify + end +end diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb new file mode 100644 index 0000000..86409c3 --- /dev/null +++ b/test/channel/stream_test.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_socket" +require "stubs/room" + +module ActionCable::StreamTests + class Connection < ActionCable::Connection::Base + end + + class ChatChannel < ActionCable::Channel::Base + def subscribed + if params[:id] + @room = Room.new params[:id] + stream_from "test_room_#{@room.id}", coder: pick_coder(params[:coder]) + end + end + + def send_confirmation + transmit_subscription_confirmation + end + + private + def pick_coder(coder) + case coder + when nil, "json" + ActiveSupport::JSON + when "custom" + DummyEncoder + when "none" + nil + end + end + end + + module DummyEncoder + extend self + def encode(*) '{ "foo": "encoded" }' end + def decode(*) { foo: "decoded" } end + end + + class SymbolChannel < ActionCable::Channel::Base + def subscribed + stream_from :channel + end + end + + class StreamTest < ActionCable::TestCase + setup do + @server = TestServer.new(subscription_adapter: SuccessAdapter) + @pubsub = @server.pubsub + @socket = TestSocket.new + end + + attr_reader :socket, :server + + test "streaming start and stop" do + connection = Connection.new(server, socket) + pubsub = Minitest::Mock.new server.pubsub + + pubsub.expect(:subscribe, nil, ["test_room_1", Proc, Proc]) + pubsub.expect(:unsubscribe, nil, ["test_room_1", Proc]) + + server.stub(:pubsub, pubsub) do + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + + channel.unsubscribe_from_channel + end + + assert pubsub.verify + end + + test "stream from non-string channel" do + connection = Connection.new(server, socket) + pubsub = Minitest::Mock.new server.pubsub + + pubsub.expect(:subscribe, nil, ["channel", Proc, Proc]) + pubsub.expect(:unsubscribe, nil, ["channel", Proc]) + + server.stub(:pubsub, pubsub) do + channel = SymbolChannel.new connection, "" + channel.subscribe_to_channel + + channel.unsubscribe_from_channel + end + + assert pubsub.verify + end + + test "stream_for" do + connection = Connection.new(server, socket) + pubsub = Minitest::Mock.new server.pubsub + + pubsub.expect(:subscribe, nil, ["action_cable:stream_tests:chat:Room#1-Campfire", Proc, Proc]) + + channel = ChatChannel.new connection, "" + channel.subscribe_to_channel + + server.stub(:pubsub, pubsub) do + channel.stream_for Room.new(1) + end + + assert pubsub.verify + end + + test "stream_or_reject_for" do + connection = Connection.new(server, socket) + pubsub = Minitest::Mock.new server.pubsub + + pubsub.expect(:subscribe, nil, ["action_cable:stream_tests:chat:Room#1-Campfire", Proc, Proc]) + + channel = ChatChannel.new connection, "" + channel.subscribe_to_channel + + server.stub(:pubsub, pubsub) do + channel.stream_or_reject_for Room.new(1) + end + + assert pubsub.verify + end + + test "reject subscription when nil is passed to stream_or_reject_for" do + connection = Connection.new(server, socket) + + channel = ChatChannel.new connection, "{id: 1}", id: 1 + def channel.subscribed + stream_or_reject_for nil + end + + channel.subscribe_to_channel + + rejection = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal rejection, socket.last_transmission + end + + test "stream_from subscription confirmation" do + connection = Connection.new(server, socket) + + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + + confirmation = { "identifier" => "{id: 1}", "type" => "confirm_subscription" } + assert_equal confirmation, socket.last_transmission, "Did not receive subscription confirmation within 0.1s" + end + + test "subscription confirmation should only be sent out once" do + connection = Connection.new(server, socket) + + channel = ChatChannel.new connection, "test_channel" + channel.send_confirmation + channel.send_confirmation + + expected = { "identifier" => "test_channel", "type" => "confirm_subscription" } + assert_equal expected, socket.last_transmission, "Did not receive subscription confirmation" + + assert_equal 1, socket.transmissions.size + end + + test "stop_all_streams" do + connection = Connection.new(server, socket) + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + assert_equal 0, subscribers_of(connection).size + + channel.stream_from "room_one" + channel.stream_from "room_two" + + assert_equal 2, subscribers_of(connection).size + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_from "room_one" + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 2, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + + channel.stop_all_streams + + subscribers = subscribers_of(connection) + assert_equal 1, subscribers.size + assert_equal 1, subscribers["room_one"].size + end + + test "stop_stream_from" do + connection = Connection.new(server, socket) + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + channel.stream_from "room_one" + channel.stream_from "room_two" + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_from "room_one" + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 2, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + + channel.stop_stream_from "room_one" + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 1, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + end + + test "stop_stream_for" do + connection = Connection.new(server, socket) + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + channel.stream_for Room.new(1) + channel.stream_for Room.new(2) + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_for Room.new(1) + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + + assert_equal 2, subscribers[ChatChannel.broadcasting_for(Room.new(1))].size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(2))].size + + channel.stop_stream_for Room.new(1) + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(1))].size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(2))].size + end + + private + def subscribers_of(_connection) + server + .pubsub + .subscriber_map + end + end + + class UserCallbackChannel < ActionCable::Channel::Base + def subscribed + stream_from :channel do + Thread.current[:ran_callback] = true + end + end + end + + class MultiChatChannel < ActionCable::Channel::Base + def subscribed + stream_from "main_room" + stream_from "test_all_rooms" + end + end + + class StreamFromTest < ActionCable::TestCase + setup do + @server = TestServer.new(subscription_adapter: ActionCable::SubscriptionAdapter::Async) + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + @server.config.connection_class = -> { Connection } + end + + attr_reader :socket, :server, :connection + + test "custom encoder" do + run_in_eventmachine do + open_connection + subscribe_to identifiers: { id: 1 } + + server.broadcast "test_room_1", { foo: "bar" }, coder: DummyEncoder + wait_for_async + + assert_equal({ "foo" => "encoded" }, socket.last_transmission.fetch("message")) + end + end + + test "user supplied callbacks are run through the worker pool" do + run_in_eventmachine do + open_connection + receive(command: "subscribe", channel: UserCallbackChannel.name, identifiers: { id: 1 }) + + server.broadcast "channel", {} + wait_for_async + + assert_not Thread.current[:ran_callback], "User callback was not run through the worker pool" + end + end + + test "subscription confirmation should only be sent out once with multiple stream_from" do + run_in_eventmachine do + open_connection + + expected = { "identifier" => { "channel" => MultiChatChannel.name }.to_json, "type" => "confirm_subscription" } + receive(command: "subscribe", channel: MultiChatChannel.name, identifiers: {}) + wait_for_async + + assert_equal expected, socket.last_transmission, "Did not receive subscription confirmation" + end + end + + private + def subscribe_to(identifiers:) + receive command: "subscribe", identifiers: identifiers + wait_for_async + end + + def open_connection + @socket = TestSocket.new + @connection = Connection.new(@server, @socket) + end + + def receive(command:, identifiers:, channel: "ActionCable::StreamTests::ChatChannel") + identifier = JSON.generate(identifiers.merge(channel: channel)) + connection.handle_incoming({ "command" => command, "identifier" => identifier }) + end + end +end diff --git a/test/channel/test_case_test.rb b/test/channel/test_case_test.rb new file mode 100644 index 0000000..c2fdabb --- /dev/null +++ b/test/channel/test_case_test.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestTestChannel < ActionCable::Channel::Base +end + +class NonInferrableExplicitClassChannelTest < ActionCable::Channel::TestCase + tests TestTestChannel + + def test_set_channel_class_manual + assert_equal TestTestChannel, self.class.channel_class + end +end + +class NonInferrableSymbolNameChannelTest < ActionCable::Channel::TestCase + tests :test_test_channel + + def test_set_channel_class_manual_using_symbol + assert_equal TestTestChannel, self.class.channel_class + end +end + +class NonInferrableStringNameChannelTest < ActionCable::Channel::TestCase + tests "test_test_channel" + + def test_set_channel_class_manual_using_string + assert_equal TestTestChannel, self.class.channel_class + end +end + +class SubscriptionsTestChannel < ActionCable::Channel::Base +end + +class SubscriptionsTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection + end + + def test_no_subscribe + assert_nil subscription + end + + def test_subscribe + subscribe + + assert_predicate subscription, :confirmed? + assert_not subscription.rejected? + assert_equal 1, socket.transmissions.size + assert_equal ActionCable::INTERNAL[:message_types][:confirmation], + socket.transmissions.last["type"] + end +end + +class StubConnectionTest < ActionCable::Channel::TestCase + class Connection < ActionCable::Connection::Base + identified_by :username, :admin + end + + tests_connection Connection + tests SubscriptionsTestChannel + + def test_connection_identifiers + stub_connection username: "John", admin: true + + subscribe + + assert_equal "John", subscription.username + assert subscription.admin + assert_equal "John:true", connection.connection_identifier + end + + def test_unknown_identifiers + assert_raises NoMethodError do + stub_connection non_existing: "John" + end + end +end + +class RejectionTestChannel < ActionCable::Channel::Base + def subscribed + reject + end +end + +class RejectionTestChannelTest < ActionCable::Channel::TestCase + def test_rejection + subscribe + + assert_not subscription.confirmed? + assert_predicate subscription, :rejected? + assert_equal 1, socket.transmissions.size + assert_equal ActionCable::INTERNAL[:message_types][:rejection], + socket.transmissions.last["type"] + end +end + +class StreamsTestChannel < ActionCable::Channel::Base + def subscribed + stream_from "test_#{params[:id] || 0}" + end + + def unsubscribed + stop_stream_from "test_#{params[:id] || 0}" + end +end + +class StreamsTestChannelTest < ActionCable::Channel::TestCase + def test_stream_without_params + subscribe + + assert_has_stream "test_0" + end + + def test_stream_with_params + subscribe id: 42 + + assert_has_stream "test_42" + end + + def test_not_stream_without_params + subscribe + unsubscribe + + assert_has_no_stream "test_0" + end + + def test_not_stream_with_params + subscribe id: 42 + perform :unsubscribed, id: 42 + + assert_has_no_stream "test_42" + end + + def test_unsubscribe_from_stream + subscribe + unsubscribe + + assert_no_streams + end +end + +class StreamsForTestChannel < ActionCable::Channel::Base + def subscribed + stream_for User.new(params[:id]) + end + + def unsubscribed + stop_stream_for User.new(params[:id]) + end +end + +class StreamsForTestChannelTest < ActionCable::Channel::TestCase + def test_stream_with_params + subscribe id: 42 + + assert_has_stream_for User.new(42) + end + + def test_not_stream_with_params + subscribe id: 42 + perform :unsubscribed, id: 42 + + assert_has_no_stream_for User.new(42) + end +end + +class NoStreamsTestChannel < ActionCable::Channel::Base + def subscribed; end # no-op +end + +class NoStreamsTestChannelTest < ActionCable::Channel::TestCase + def test_stream_with_params + subscribe + + assert_no_streams + end +end + +class PerformTestChannel < ActionCable::Channel::Base + def echo(data) + data.delete("action") + transmit data + end + + def ping + transmit({ type: "pong" }) + end +end + +class PerformTestChannelTest < ActionCable::Channel::TestCase + class Connection < ActionCable::Connection::Base + identified_by :user_id + end + + tests_connection Connection + + def setup + stub_connection user_id: 2016 + subscribe id: 5 + end + + def test_perform_with_params + perform :echo, text: "You are man!" + + assert_equal({ "text" => "You are man!" }, transmissions.last) + end + + def test_perform_and_transmit + perform :ping + + assert_equal "pong", transmissions.last["type"] + end +end + +class PerformUnsubscribedTestChannelTest < ActionCable::Channel::TestCase + tests PerformTestChannel + + def test_perform_when_unsubscribed + assert_raises do + perform :echo + end + end +end + +class BroadcastsTestChannel < ActionCable::Channel::Base + def broadcast(data) + ActionCable.server.broadcast( + "broadcast_#{params[:id]}", + { text: data["message"], user_id: user_id } + ) + end + + def broadcast_to_user(data) + user = User.new user_id + + broadcast_to user, text: data["message"] + end +end + +class BroadcastsTestChannelTest < ActionCable::Channel::TestCase + class Connection < ActionCable::Connection::Base + identified_by :user_id + end + + tests_connection Connection + + def setup + stub_connection user_id: 2017 + subscribe id: 5 + end + + def test_broadcast_matchers_included + assert_broadcast_on("broadcast_5", user_id: 2017, text: "SOS") do + perform :broadcast, message: "SOS" + end + end + + def test_broadcast_to_object + user = User.new(2017) + + assert_broadcasts(user, 1) do + perform :broadcast_to_user, text: "SOS" + end + end + + def test_broadcast_to_object_with_data + user = User.new(2017) + + assert_broadcast_on(user, text: "SOS") do + perform :broadcast_to_user, message: "SOS" + end + end +end diff --git a/test/client_test.rb b/test/client_test.rb new file mode 100644 index 0000000..8ae0258 --- /dev/null +++ b/test/client_test.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require "test_helper" +require "concurrent" + +require "websocket-client-simple" +require "json" + +require "active_support/hash_with_indifferent_access" + +#### +# 😷 Warning suppression 😷 +WebSocket::Frame::Handler::Handler03.prepend Module.new { + def initialize(*) + @application_data_buffer = nil + super + end +} + +WebSocket::Frame::Data.prepend Module.new { + def initialize(*) + @masking_key = nil + super + end +} +# +#### + +class ClientTest < ActionCable::TestCase + WAIT_WHEN_EXPECTING_EVENT = 2 + WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5 + + class Connection < ActionCable::Connection::Base + identified_by :id + + def connect + self.id = request.params["id"] || SecureRandom.hex(4) + end + end + + class EchoChannel < ActionCable::Channel::Base + def subscribed + stream_from "global" + end + + def unsubscribed + "Goodbye from EchoChannel!" + end + + def ding(data) + transmit({ dong: data["message"] }) + end + + def delay(data) + sleep 1 + transmit({ dong: data["message"] }) + end + + def bulk(data) + ActionCable.server.broadcast "global", { wide: data["message"] } + end + end + + def setup + ActionCable.instance_variable_set(:@server, nil) + server = ActionCable.server + server.config.logger = + if %w[1 t true].include?(ENV["LOG"]) + Logger.new($stdout).tap { |l| l.level = Logger::DEBUG } + else + Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + end + + server.config.cable = ActiveSupport::HashWithIndifferentAccess.new(adapter: "async") + server.pubsub.define_singleton_method(:wait_subscribers) do |channel, count: nil, timeout: 2| + sync = subscriber_map.instance_variable_get(:@sync) + + loop do + list = sync.synchronize do + (subscriber_map.instance_variable_get(:@subscribers)[channel] || []).dup + end + return if count && list.size == count + return if !count && list.any? + sleep 0.1 + timeout -= 0.1 + raise "Timeout waiting for subscribers" if timeout <= 0 + end + end + + server.config.connection_class = -> { ClientTest::Connection } + + # and now the "real" setup for our test: + server.config.disable_request_forgery_protection = true + end + + def with_puma_server(rack_app = ActionCable.server, port = 3099) + opts = { min_threads: 1, max_threads: 4 } + server = if Puma::Const::PUMA_VERSION >= "6" + opts[:log_writer] = ::Puma::LogWriter.strings + ::Puma::Server.new(rack_app, nil, opts) + else + # Puma >= 5.0.3 + ::Puma::Server.new(rack_app, ::Puma::Events.strings, opts) + end + server.add_tcp_listener "127.0.0.1", port + + thread = server.run + + begin + yield port + + ensure + server.stop + + begin + thread.join + + rescue IOError + # Work around https://bugs.ruby-lang.org/issues/13405 + # + # Puma's sometimes raising while shutting down, when it closes + # its internal pipe. We can safely ignore that, but we do need + # to do the step skipped by the exception: + server.binder.close + + rescue RuntimeError => ex + # Work around https://bugs.ruby-lang.org/issues/13239 + raise unless ex.message.match?(/can't modify frozen IOError/) + + # Handle this as if it were the IOError: do the same as above. + server.binder.close + end + end + end + + class SyncClient + attr_reader :pings + + def initialize(port, path = "/") + messages = @messages = Queue.new + closed = @closed = Concurrent::Event.new + has_messages = @has_messages = Concurrent::Semaphore.new(0) + pings = @pings = Concurrent::AtomicFixnum.new(0) + + open = Concurrent::Promise.new + + @ws = WebSocket::Client::Simple.connect("ws://127.0.0.1:#{port}#{path}") do |ws| + ws.on(:error) do |event| + event = RuntimeError.new(event.message) unless event.is_a?(Exception) + + if open.pending? + open.fail(event) + else + messages << event + has_messages.release + end + end + + ws.on(:open) do |event| + open.set(true) + end + + ws.on(:message) do |event| + if event.type == :close + closed.set + else + message = JSON.parse(event.data) + if message["type"] == "ping" + pings.increment + else + messages << message + has_messages.release + end + end + end + + ws.on(:close) do |_| + closed.set + end + end + + open.wait!(WAIT_WHEN_EXPECTING_EVENT) + end + + def read_message + @has_messages.try_acquire(1, WAIT_WHEN_EXPECTING_EVENT) + + msg = @messages.pop(true) + raise msg if msg.is_a?(Exception) + + msg + end + + def read_messages(expected_size = 0) + list = [] + loop do + if @has_messages.try_acquire(1, list.size < expected_size ? WAIT_WHEN_EXPECTING_EVENT : WAIT_WHEN_NOT_EXPECTING_EVENT) + msg = @messages.pop(true) + raise msg if msg.is_a?(Exception) + + list << msg + else + break + end + end + list + end + + def send_message(message) + @ws.send(JSON.generate(message)) + end + + def close + sleep WAIT_WHEN_NOT_EXPECTING_EVENT + + unless @messages.empty? + raise "#{@messages.size} messages unprocessed" + end + + @ws.close + wait_for_close + end + + def wait_for_close + @closed.wait(WAIT_WHEN_EXPECTING_EVENT) + end + + def closed? + @closed.set? + end + end + + def websocket_client(*args) + SyncClient.new(*args) + end + + def concurrently(enum) + enum.map { |*x| Concurrent::Promises.future { yield(*x) } }.map(&:value!) + end + + def test_single_client + with_puma_server do |port| + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "message" => { "dong" => "hello" } }, c.read_message) + c.close + end + end + + def test_interacting_clients + with_puma_server do |port| + clients = concurrently(10.times) { websocket_client(port) } + + barrier_1 = Concurrent::CyclicBarrier.new(clients.size) + barrier_2 = Concurrent::CyclicBarrier.new(clients.size) + + concurrently(clients) do |c| + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "message" => { "dong" => "hello" } }, c.read_message) + barrier_1.wait WAIT_WHEN_EXPECTING_EVENT + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "bulk", message: "hello") + barrier_2.wait WAIT_WHEN_EXPECTING_EVENT + assert_equal clients.size, c.read_messages(clients.size).size + end + + concurrently(clients, &:close) + end + end + + def test_many_clients + with_puma_server do |port| + clients = concurrently(100.times) { websocket_client(port) } + + concurrently(clients) do |c| + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "message" => { "dong" => "hello" } }, c.read_message) + end + + concurrently(clients, &:close) + end + end + + def test_disappearing_client + with_puma_server do |port| + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "delay", message: "hello") + c.close # disappear before write + + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "message" => { "dong" => "hello" } }, c.read_message) + c.close # disappear before read + end + end + + def test_unsubscribe_client + with_puma_server do |port| + app = ActionCable.server + identifier = JSON.generate(channel: "ClientTest::EchoChannel") + + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) + c.send_message command: "subscribe", identifier: identifier + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + assert_equal(1, app.connections.count) + + subscriptions = app.connections.first.subscriptions.send(:subscriptions) + assert_not_equal 0, subscriptions.size, "Missing EchoChannel subscription" + channel = subscriptions.first[1] + assert_called(channel, :unsubscribed) do + c.close + app.pubsub.wait_subscribers("global", count: 0) + end + + # All data is removed: No more connection or subscription information! + assert_equal(0, app.connections.count) + end + end + + def test_remote_disconnect_client + with_puma_server do |port| + app = ActionCable.server + + c = websocket_client(port, "/?id=1") + assert_equal({ "type" => "welcome" }, c.read_message) + + # Make sure connections is registered + app.pubsub.wait_subscribers("action_cable/1") + + app.remote_connections.where(id: "1").disconnect + + assert_equal({ "type" => "disconnect", "reason" => "remote", "reconnect" => true }, c.read_message) + + c.wait_for_close + assert_predicate(c, :closed?) + end + end + + def test_remote_disconnect_client_with_reconnect + with_puma_server do |port| + app = ActionCable.server + + c = websocket_client(port, "/?id=2") + assert_equal({ "type" => "welcome" }, c.read_message) + + app.pubsub.wait_subscribers("action_cable/2") + app.remote_connections.where(id: "2").disconnect(reconnect: false) + + assert_equal({ "type" => "disconnect", "reason" => "remote", "reconnect" => false }, c.read_message) + + c.wait_for_close + assert_predicate(c, :closed?) + end + end + + def test_server_restart + with_puma_server do |port| + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + + ActionCable.server.restart + c.wait_for_close + assert_predicate c, :closed? + end + end +end diff --git a/test/connection/authorization_test.rb b/test/connection/authorization_test.rb new file mode 100644 index 0000000..a8203cd --- /dev/null +++ b/test/connection/authorization_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::AuthorizationTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :socket + + def connect + reject_unauthorized_connection + end + end + + test "unauthorized connection" do + connection = open_connection + + assert_called_with(connection.socket, :transmit, [{ type: "disconnect", reason: "unauthorized", reconnect: false }]) do + assert_called(connection.socket, :close) do + connection.handle_open + end + end + end + + private + def open_connection + server = TestServer.new + env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" + + socket = ActionCable::Server::Socket.new(server, env) + Connection.new(server, socket) + end +end diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb new file mode 100644 index 0000000..e77712d --- /dev/null +++ b/test/connection/base_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "active_support/core_ext/object/json" + +class ActionCable::Connection::BaseTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :subscriptions, :connected + # Make this method public so we can test it + attr_reader :socket + + def connect + @connected = true + end + + def disconnect + @connected = false + end + end + + test "on connection open" do + connection = open_connection + + assert_called_with(connection.socket, :transmit, [{ type: "welcome" }]) do + connection.handle_open + end + + assert connection.connected + end + + test "on connection close" do + connection = open_connection + + # Set up the connection + connection.handle_open + assert connection.connected + + assert_called(connection.subscriptions, :unsubscribe_from_all) do + connection.handle_close + end + + assert_not connection.connected + end + + test "connection statistics" do + connection = open_connection + connection.handle_open + + statistics = connection.statistics + + assert_predicate statistics[:identifier], :blank? + assert_kind_of Time, statistics[:started_at] + assert_equal [], statistics[:subscriptions] + end + + test "explicitly closing a connection" do + connection = open_connection + + assert_called(connection.socket, :close) do + assert_called(connection.socket, :transmit, [{ type: "disconnect", reason: "testing", reconnect: true }]) do + connection.close(reason: "testing") + end + end + end + + private + def open_connection + server = TestServer.new + env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" + + socket = ActionCable::Server::Socket.new(server, env) + Connection.new(server, socket) + end +end diff --git a/test/connection/callbacks_test.rb b/test/connection/callbacks_test.rb new file mode 100644 index 0000000..0764b0b --- /dev/null +++ b/test/connection/callbacks_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::CallbacksTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :context + + attr_reader :commands_counter + + before_command do + throw :abort unless context.nil? + end + + around_command :set_current_context + after_command :increment_commands_counter + + def initialize(*) + super + @commands_counter = 0 + end + + private + def set_current_context + self.context = request.params["context"] + yield + ensure + self.context = nil + end + + def increment_commands_counter + @commands_counter += 1 + end + end + + class ChatChannel < ActionCable::Channel::Base + class << self + attr_accessor :words_spoken, :subscribed_count + end + + self.words_spoken = [] + self.subscribed_count = 0 + + def subscribed + self.class.subscribed_count += 1 + end + + def speak(data) + self.class.words_spoken << { data: data, context: context } + end + end + + setup do + @server = TestServer.new + @env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + + @socket = ActionCable::Server::Socket.new(@server, @env) + @connection = Connection.new(@server, @socket) + @identifier = { channel: "ActionCable::Connection::CallbacksTest::ChatChannel" }.to_json + end + + attr_reader :server, :env, :connection, :identifier + + test "before and after callbacks" do + result = assert_difference -> { ChatChannel.subscribed_count }, +1 do + assert_difference -> { connection.commands_counter }, +1 do + connection.handle_channel_command({ "identifier" => identifier, "command" => "subscribe" }) + end + end + assert result + end + + test "before callback halts" do + connection.context = "non_null" + result = assert_no_difference -> { ChatChannel.subscribed_count } do + connection.handle_channel_command({ "identifier" => identifier, "command" => "subscribe" }) + end + assert_not result + end + + test "around_command callback" do + @env["QUERY_STRING"] = "context=test" + + assert_difference -> { ChatChannel.words_spoken.size }, +1 do + # We need to add subscriptions first + connection.handle_channel_command({ + "identifier" => identifier, + "command" => "subscribe" + }) + connection.handle_channel_command({ + "identifier" => identifier, + "command" => "message", + "data" => { "action" => "speak", "message" => "hello" }.to_json + }) + end + + message = ChatChannel.words_spoken.last + assert_equal({ data: { "action" => "speak", "message" => "hello" }, context: "test" }, message) + end +end diff --git a/test/connection/identifier_test.rb b/test/connection/identifier_test.rb new file mode 100644 index 0000000..fe363f9 --- /dev/null +++ b/test/connection/identifier_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "stubs/user" + +class ActionCable::Connection::IdentifierTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_user + attr_reader :websocket + + public :process_internal_message + + def connect + self.current_user = User.new "lifo" + end + end + + setup do + @server = TestServer.new + end + + test "connection identifier" do + open_connection + assert_equal "User#lifo", @connection.connection_identifier + end + + test "should subscribe to internal channel on open and unsubscribe on close" do + assert_called(@server.pubsub, :subscribe, [{ channel: "action_cable/User#lifo" }]) do + open_connection + end + + assert_called(@server.pubsub, :unsubscribe, [{ channel: "action_cable/User#lifo" }]) do + @connection.handle_close + end + end + + test "processing disconnect message" do + open_connection + + assert_called(@socket, :close) do + @connection.process_internal_message "type" => "disconnect" + end + end + + test "processing invalid message" do + open_connection + + assert_not_called(@socket, :close) do + @connection.process_internal_message "type" => "unknown" + end + end + + private + def open_connection + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + + @socket = ActionCable::Server::Socket.new(@server, env) + @connection = Connection.new(@server, @socket).tap(&:handle_open) + end +end diff --git a/test/connection/multiple_identifiers_test.rb b/test/connection/multiple_identifiers_test.rb new file mode 100644 index 0000000..d0d27b5 --- /dev/null +++ b/test/connection/multiple_identifiers_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "stubs/user" + +class ActionCable::Connection::MultipleIdentifiersTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_user, :current_room + + def connect + self.current_user = User.new "lifo" + self.current_room = Room.new "my", "room" + end + end + + test "multiple connection identifiers" do + open_connection + + assert_equal "Room#my-room:User#lifo", @connection.connection_identifier + end + + private + def open_connection + server = TestServer.new + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + + @socket = ActionCable::Server::Socket.new(server, env) + @connection = Connection.new(server, @socket).tap(&:handle_open) + end +end diff --git a/test/connection/string_identifier_test.rb b/test/connection/string_identifier_test.rb new file mode 100644 index 0000000..5e01a1f --- /dev/null +++ b/test/connection/string_identifier_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::StringIdentifierTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_token + + def connect + self.current_token = "random-string" + end + end + + test "connection identifier" do + open_connection + + assert_equal "random-string", @connection.connection_identifier + end + + private + def open_connection + server = TestServer.new + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + + @socket = ActionCable::Server::Socket.new(server, env) + @connection = Connection.new(server, @socket).tap(&:handle_open) + end +end diff --git a/test/connection/subscriptions_test.rb b/test/connection/subscriptions_test.rb new file mode 100644 index 0000000..0f3bf41 --- /dev/null +++ b/test/connection/subscriptions_test.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase + class ChatChannelError < Exception; end + + class Connection < ActionCable::Connection::Base + attr_reader :exceptions + + rescue_from ChatChannelError, with: :error_handler + + def initialize(*) + super + @exceptions = [] + end + + def error_handler(e) + @exceptions << e + end + end + + class ChatChannel < ActionCable::Channel::Base + attr_reader :room, :lines + + def subscribed + @room = Room.new params[:id] + @lines = [] + end + + def speak(data) + @lines << data + end + + def throw_exception(_data) + raise ChatChannelError.new("Uh Oh") + end + end + + setup do + @server = TestServer.new + @chat_identifier = ActiveSupport::JSON.encode(id: 1, channel: "ActionCable::Connection::SubscriptionsTest::ChatChannel") + end + + test "subscribe command" do + setup_connection + channel = subscribe_to_chat_channel + + assert_kind_of ChatChannel, channel + assert_equal 1, channel.room.id + end + + test "subscribe command without an identifier" do + setup_connection + + assert_raises ActionCable::Connection::Subscriptions::MalformedCommandError do + @subscriptions.execute_command "command" => "subscribe" + end + + assert_empty @subscriptions.identifiers + end + + test "subscribe command with Base channel" do + setup_connection + + identifier = ActiveSupport::JSON.encode(id: 1, channel: "ActionCable::Channel::Base") + + assert_raises ActionCable::Connection::Subscriptions::ChannelNotFound do + @subscriptions.execute_command "command" => "subscribe", "identifier" => identifier + end + + assert_empty @subscriptions.identifiers + end + + test "double subscribe command" do + setup_connection + + subscribe_to_chat_channel + + assert_raises ActionCable::Connection::Subscriptions::AlreadySubscribedError do + subscribe_to_chat_channel + end + + assert_equal 1, @subscriptions.identifiers.size + end + + test "unsubscribe command" do + setup_connection + + channel = subscribe_to_chat_channel + + assert_called(channel, :unsubscribe_from_channel) do + @subscriptions.execute_command "command" => "unsubscribe", "identifier" => @chat_identifier + end + + assert_empty @subscriptions.identifiers + end + + test "unsubscribe command without an identifier" do + setup_connection + + assert_raises ActionCable::Connection::Subscriptions::UnknownSubscription do + @subscriptions.execute_command "command" => "unsubscribe" + end + + assert_empty @subscriptions.identifiers + end + + test "message command" do + setup_connection + channel = subscribe_to_chat_channel + + data = { "content" => "Hello World!", "action" => "speak" } + @subscriptions.execute_command "command" => "message", "identifier" => @chat_identifier, "data" => ActiveSupport::JSON.encode(data) + + assert_equal [ data ], channel.lines + end + + test "accessing exceptions thrown during command execution" do + setup_connection + subscribe_to_chat_channel + + data = { "content" => "Hello World!", "action" => "throw_exception" } + @connection.handle_channel_command({ "command" => "message", "identifier" => @chat_identifier, "data" => ActiveSupport::JSON.encode(data) }) + + exception = @connection.exceptions.first + assert_kind_of ChatChannelError, exception + end + + test "unsubscribe from all" do + setup_connection + + channel1 = subscribe_to_chat_channel + + channel2_id = ActiveSupport::JSON.encode(id: 2, channel: "ActionCable::Connection::SubscriptionsTest::ChatChannel") + channel2 = subscribe_to_chat_channel(channel2_id) + + assert_called(channel1, :unsubscribe_from_channel) do + assert_called(channel2, :unsubscribe_from_channel) do + @subscriptions.unsubscribe_from_all + end + end + end + + private + def subscribe_to_chat_channel(identifier = @chat_identifier) + @subscriptions.execute_command "command" => "subscribe", "identifier" => identifier + assert_equal identifier, @subscriptions.identifiers.last + + @subscriptions.send :find, "identifier" => identifier + end + + def setup_connection + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + socket = ActionCable::Server::Socket.new(@server, env) + + @connection = Connection.new(@server, socket) + @subscriptions = @connection.subscriptions + end +end diff --git a/test/connection/test_case_test.rb b/test/connection/test_case_test.rb new file mode 100644 index 0000000..7852fde --- /dev/null +++ b/test/connection/test_case_test.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "test_helper" + +class SimpleConnection < ActionCable::Connection::Base + identified_by :user_id + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.user_id = request.params[:user_id] || cookies[:user_id] + end + + def disconnect + self.class.disconnected_user_id = user_id + end +end + +class ConnectionSimpleTest < ActionCable::Connection::TestCase + tests SimpleConnection + + def test_connected + connect + + assert_nil connection.user_id + end + + def test_url_params + connect "/cable?user_id=323" + + assert_equal "323", connection.user_id + end + + def test_params + connect params: { user_id: 323 } + + assert_equal "323", connection.user_id + end + + def test_plain_cookie + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_plain_cookie_with_explicit_value_and_string_key + cookies["user_id"] = { "value" => "456" } + + connect + + assert_equal "456", connection.user_id + end + + def test_disconnect + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + + disconnect + + assert_equal "456", SimpleConnection.disconnected_user_id + end +end + +class Connection < ActionCable::Connection::Base + identified_by :current_user_id + identified_by :token + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.current_user_id = verify_user + self.token = request.headers["X-API-TOKEN"] + logger.add_tags("ActionCable") + end + + private + def verify_user + cookies.signed[:user_id].presence || reject_unauthorized_connection + end +end + +class ConnectionTest < ActionCable::Connection::TestCase + def test_connected_with_signed_cookies_and_headers + cookies.signed["user_id"] = "456" + + connect headers: { "X-API-TOKEN" => "abc" } + + assert_equal "abc", connection.token + assert_equal "456", connection.current_user_id + end + + def test_connected_when_no_signed_cookies_set + cookies["user_id"] = "456" + + assert_reject_connection { connect } + end + + def test_connection_rejected + assert_reject_connection { connect } + end + + def test_connection_rejected_assertion_message + error = assert_raises Minitest::Assertion do + assert_reject_connection { "Intentionally doesn't connect." } + end + + assert_match(/Expected to reject connection/, error.message) + end +end + +class EncryptedCookiesConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + cookies.encrypted[:user_id].presence || reject_unauthorized_connection + end +end + +class EncryptedCookiesConnectionTest < ActionCable::Connection::TestCase + tests EncryptedCookiesConnection + + def test_connected_with_encrypted_cookies + cookies.encrypted["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_connected_with_encrypted_cookies_with_explicit_value_and_symbol_key + cookies.encrypted["user_id"] = { value: "456" } + + connect + + assert_equal "456", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class SessionConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + request.session[:user_id].presence || reject_unauthorized_connection + end +end + +class SessionConnectionTest < ActionCable::Connection::TestCase + tests SessionConnection + + def test_connected_with_encrypted_cookies + connect session: { user_id: "789" } + assert_equal "789", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class EnvConnection < ActionCable::Connection::Base + identified_by :user + + def connect + self.user = verify_user + end + + private + def verify_user + # Warden-like authentication + env["authenticator"]&.user || reject_unauthorized_connection + end +end + +class EnvConnectionTest < ActionCable::Connection::TestCase + tests EnvConnection + + def test_connected_with_env + authenticator = Class.new do + def user; "David"; end + end + + connect env: { "authenticator" => authenticator.new } + + assert_equal "David", connection.user + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end diff --git a/test/server/base_test.rb b/test/server/base_test.rb new file mode 100644 index 0000000..d46debe --- /dev/null +++ b/test/server/base_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "active_support/core_ext/hash/indifferent_access" + +class BaseTest < ActionCable::TestCase + def setup + @server = ActionCable::Server::Base.new + @server.config.cable = { adapter: "async" }.with_indifferent_access + end + + class FakeConnection + def close + end + end + + test "#restart closes all open connections" do + conn = FakeConnection.new + @server.add_connection(conn) + + assert_called(conn, :close) do + @server.restart + end + end + + test "#restart shuts down worker pool" do + assert_called(@server.worker_pool, :halt) do + @server.restart + end + end + + test "#restart shuts down pub/sub adapter" do + assert_called(@server.pubsub, :shutdown) do + @server.restart + end + end +end diff --git a/test/server/broadcasting_test.rb b/test/server/broadcasting_test.rb new file mode 100644 index 0000000..102b3c1 --- /dev/null +++ b/test/server/broadcasting_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class BroadcastingTest < ActionCable::TestCase + test "fetching a broadcaster converts the broadcasting queue to a string" do + broadcasting = :test_queue + server = TestServer.new + broadcaster = server.broadcaster_for(broadcasting) + + assert_equal "test_queue", broadcaster.broadcasting + end + + test "broadcast generates notification" do + server = TestServer.new + + events = [] + ActiveSupport::Notifications.subscribe("broadcast.action_cable") { |event| events << event } + + broadcasting = "test_queue" + message = { body: "test message" } + server.broadcast(broadcasting, message) + + assert_equal 1, events.length + assert_equal "broadcast.action_cable", events[0].name + assert_equal broadcasting, events[0].payload[:broadcasting] + assert_equal message, events[0].payload[:message] + assert_equal ActiveSupport::JSON, events[0].payload[:coder] + ensure + ActiveSupport::Notifications.unsubscribe "broadcast.action_cable" + end + + test "broadcaster from broadcaster_for generates notification" do + server = TestServer.new + + events = [] + ActiveSupport::Notifications.subscribe("broadcast.action_cable") { |event| events << event } + + broadcasting = "test_queue" + message = { body: "test message" } + + broadcaster = server.broadcaster_for(broadcasting) + broadcaster.broadcast(message) + + assert_equal 1, events.length + assert_equal "broadcast.action_cable", events[0].name + assert_equal broadcasting, events[0].payload[:broadcasting] + assert_equal message, events[0].payload[:message] + assert_equal ActiveSupport::JSON, events[0].payload[:coder] + ensure + ActiveSupport::Notifications.unsubscribe "broadcast.action_cable" + end +end diff --git a/test/server/cross_site_forgery_test.rb b/test/server/cross_site_forgery_test.rb new file mode 100644 index 0000000..6d17c9f --- /dev/null +++ b/test/server/cross_site_forgery_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Server::Base::CrossSiteForgeryTest < ActionCable::TestCase + HOST = "rubyonrails.com" + + class Connection < ActionCable::Connection::Base + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + @server.config.allow_same_origin_as_host = false + @server.config.connection_class = -> { Connection } + end + + teardown do + @server.config.disable_request_forgery_protection = false + @server.config.allowed_request_origins = [] + @server.config.allow_same_origin_as_host = true + end + + test "disable forgery protection" do + @server.config.disable_request_forgery_protection = true + assert_origin_allowed "http://rubyonrails.com" + assert_origin_allowed "http://hax.com" + end + + test "explicitly specified a single allowed origin" do + @server.config.allowed_request_origins = "http://hax.com" + assert_origin_not_allowed "http://rubyonrails.com" + assert_origin_allowed "http://hax.com" + end + + test "explicitly specified multiple allowed origins" do + @server.config.allowed_request_origins = %w( http://rubyonrails.com http://www.rubyonrails.com ) + assert_origin_allowed "http://rubyonrails.com" + assert_origin_allowed "http://www.rubyonrails.com" + assert_origin_not_allowed "http://hax.com" + end + + test "explicitly specified a single regexp allowed origin" do + @server.config.allowed_request_origins = /.*ha.*/ + assert_origin_not_allowed "http://rubyonrails.com" + assert_origin_allowed "http://hax.com" + end + + test "explicitly specified multiple regexp allowed origins" do + @server.config.allowed_request_origins = [/http:\/\/ruby.*/, /.*rai.s.*com/, "string" ] + assert_origin_allowed "http://rubyonrails.com" + assert_origin_allowed "http://www.rubyonrails.com" + assert_origin_not_allowed "http://hax.com" + assert_origin_not_allowed "http://rails.co.uk" + end + + test "allow same origin as host" do + @server.config.allow_same_origin_as_host = true + assert_origin_allowed "http://#{HOST}" + assert_origin_not_allowed "http://hax.com" + assert_origin_not_allowed "http://rails.co.uk" + end + + private + def assert_origin_allowed(origin) + response = connect_with_origin origin + assert_equal(-1, response[0]) + end + + def assert_origin_not_allowed(origin) + response = connect_with_origin origin + assert_equal 404, response[0] + end + + def connect_with_origin(origin) + response = nil + + run_in_eventmachine do + response = ActionCable::Server::Socket.new(@server, env_for_origin(origin)).process + end + + response + end + + def env_for_origin(origin) + Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", "SERVER_NAME" => HOST, + "HTTP_HOST" => HOST, "HTTP_ORIGIN" => origin + end +end diff --git a/test/server/health_check_test.rb b/test/server/health_check_test.rb new file mode 100644 index 0000000..4fa3a10 --- /dev/null +++ b/test/server/health_check_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/core_ext/hash/indifferent_access" + +class HealthCheckTest < ActionCable::TestCase + def setup + @config = ActionCable::Server::Configuration.new + @config.logger = Logger.new(nil) + @server = ActionCable::Server::Base.new config: @config + @server.config.cable = { adapter: "async" }.with_indifferent_access + + @app = Rack::Lint.new(@server) + end + + + test "no health check app are mounted by default" do + get "/up" + assert_equal 404, response.first + end + + test "setting health_check_path mount the configured health check application" do + @server.config.health_check_path = "/up" + get "/up" + + assert_equal 200, response.first + assert_equal [], response.last.enum_for.to_a + end + + test "health_check_application_can_be_customized" do + @server.config.health_check_path = "/up" + @server.config.health_check_application = health_check_application + get "/up" + + assert_equal 200, response.first + assert_equal ["Hello world!"], response.last.enum_for.to_a + end + + + private + def get(path) + env = Rack::MockRequest.env_for "/up", "HTTP_HOST" => "localhost" + @response = @app.call env + end + + attr_reader :response + + def health_check_application + ->(env) { + [ + 200, + { Rack::CONTENT_TYPE => "text/html" }, + ["Hello world!"], + ] + } + end +end diff --git a/test/server/socket/client_socket_test.rb b/test/server/socket/client_socket_test.rb new file mode 100644 index 0000000..dd0052a --- /dev/null +++ b/test/server/socket/client_socket_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Server::Socket::ClientSocketTest < ActionCable::TestCase + class TestSocket < ActionCable::Server::Socket + class TestConnection + def initialize(socket) + @socket = socket + end + + def handle_open = @socket.connect + + def handle_close = @socket.disconnect + end + + attr_reader :connected, :websocket, :errors + + def initialize(*) + super + @errors = [] + @connection = TestConnection.new(self) + end + + def connect + @connected = true + end + + def disconnect + @connected = false + end + + def on_error(message) + @errors << message + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "delegate socket errors to on_error handler" do + run_in_eventmachine do + connection = open_connection + + # Internal hax = :( + client = connection.websocket.send(:websocket) + client.instance_variable_get("@stream").stub(:write, proc { raise "foo" }) do + assert_not_called(client, :client_gone) do + client.write("boo") + end + end + assert_equal %w[ foo ], connection.errors + end + end + + test "closes hijacked i/o socket at shutdown" do + run_in_eventmachine do + connection = open_connection + + client = connection.websocket.send(:websocket) + event = Concurrent::Event.new + client.instance_variable_get("@stream") + .instance_variable_get("@rack_hijack_io") + .define_singleton_method(:close) { event.set } + connection.close + event.wait + end + end + + private + def open_connection + env = Rack::MockRequest.env_for "/test", + "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" + io, client_io = \ + begin + Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM, 0) + rescue + StringIO.new + end + env["rack.hijack"] = -> { env["rack.hijack_io"] = io } + + TestSocket.new(@server, env).tap do |socket| + socket.process + if client_io + # Make sure server returns handshake response + Timeout.timeout(1) do + loop do + break if client_io.readline == "\r\n" + end + end + end + socket.send :handle_open + assert socket.connected + end + end +end diff --git a/test/server/socket/stream_test.rb b/test/server/socket/stream_test.rb new file mode 100644 index 0000000..880e23d --- /dev/null +++ b/test/server/socket/stream_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_server" + +class ActionCable::Server::Socket::StreamTest < ActionCable::TestCase + class TestSocket < ActionCable::Server::Socket + class TestConnection + def initialize(socket) + @socket = socket + end + + def handle_open = @socket.connect + + def handle_close = @socket.disconnect + end + + attr_reader :connected, :websocket, :errors + + def initialize(*) + super + @errors = [] + @connection = TestConnection.new(self) + end + + def connect + @connected = true + end + + def disconnect + @connected = false + end + + def on_error(message) + @errors << message + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + [ EOFError, Errno::ECONNRESET ].each do |closed_exception| + test "closes socket on #{closed_exception}" do + run_in_eventmachine do + rack_hijack_io = File.open(File::NULL, "w") + connection = open_connection(rack_hijack_io) + + # Internal hax = :( + client = connection.websocket.send(:websocket) + rack_hijack_io.stub(:write_nonblock, proc { raise(closed_exception, "foo") }) do + assert_called(client, :client_gone) do + client.write("boo") + end + end + assert_equal [], connection.errors + end + end + end + + private + def open_connection(io) + env = Rack::MockRequest.env_for "/test", + "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" + env["rack.hijack"] = -> { env["rack.hijack_io"] = io } + + TestSocket.new(@server, env).tap do |socket| + socket.process + socket.send :handle_open + assert socket.connected + end + end +end diff --git a/test/server/socket_test.rb b/test/server/socket_test.rb new file mode 100644 index 0000000..97a72d8 --- /dev/null +++ b/test/server/socket_test.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "active_support/core_ext/object/json" + +class ActionCable::Server::SocketTest < ActionCable::TestCase + class Connection + attr_reader :last_message, :socket, :connected + + def initialize(_server, socket) + @socket = socket + end + + def handle_open + @connected = true + socket.transmit type: "test" + end + + def handle_close + @connected = false + end + + def handle_incoming(payload) + @last_message = payload + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + @server.config.connection_class = -> { Connection } + end + + test "making a connection with invalid headers" do + run_in_eventmachine do + socket = ActionCable::Server::Socket.new(@server, Rack::MockRequest.env_for("/test")) + response = socket.process + assert_equal 404, response[0] + end + end + + test "websocket connection" do + run_in_eventmachine do + socket = open_socket + socket.process + + ws = socket.send(:websocket) + + assert_predicate ws, :possible? + + wait_for_async + assert_predicate ws, :alive? + end + end + + test "rack response" do + run_in_eventmachine do + socket = open_socket + response = socket.process + + assert_equal [ -1, {}, [] ], response + end + end + + test "on connection open" do + run_in_eventmachine do + socket = open_socket + + ws = socket.send(:websocket) + mb = socket.send(:message_buffer) + + assert_called_with(ws, :transmit, [{ type: "test" }.to_json]) do + assert_called(mb, :process!) do + socket.process + wait_for_async + end + end + + assert_equal [ socket.connection ], @server.connections + assert socket.connection.connected + end + end + + test "on connection receive" do + run_in_eventmachine do + socket = open_socket + socket.process + wait_for_async + + socket.receive({ message: "hello" }.to_json) + wait_for_async + + assert_equal({ "message" => "hello" }, socket.connection.last_message) + end + end + + test "on connection close" do + run_in_eventmachine do + socket = open_socket + socket.process + + socket.send :handle_open + assert socket.connection.connected + + socket.send :handle_close + assert_not socket.connection.connected + + assert_equal [], @server.connections + end + end + + test "explicitly closing a connection" do + run_in_eventmachine do + socket = open_socket + socket.process + + ws = socket.send(:websocket) + + assert_called(ws, :close) do + socket.close + end + end + end + + test "rejecting a connection causes a 404" do + run_in_eventmachine do + class CallMeMaybe + def call(*) + raise "Do not call me!" + end + end + + env = Rack::MockRequest.env_for( + "/test", + "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.org", "rack.hijack" => CallMeMaybe.new + ) + + socket = ActionCable::Server::Socket.new(@server, env) + response = socket.process + assert_equal 404, response[0] + end + end + + private + def open_socket + env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" + + ActionCable::Server::Socket.new(@server, env) + end +end diff --git a/test/stubs/global_id.rb b/test/stubs/global_id.rb new file mode 100644 index 0000000..15fab6b --- /dev/null +++ b/test/stubs/global_id.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class GlobalID + attr_reader :uri + delegate :to_param, :to_s, to: :uri + + def initialize(gid, options = {}) + @uri = gid + end +end diff --git a/test/stubs/room.rb b/test/stubs/room.rb new file mode 100644 index 0000000..df7236f --- /dev/null +++ b/test/stubs/room.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Room + attr_reader :id, :name + + def initialize(id, name = "Campfire") + @id = id + @name = name + end + + def to_global_id + GlobalID.new("Room##{id}-#{name}") + end + + def to_gid_param + to_global_id.to_param + end +end diff --git a/test/stubs/test_adapter.rb b/test/stubs/test_adapter.rb new file mode 100644 index 0000000..9450e98 --- /dev/null +++ b/test/stubs/test_adapter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class SuccessAdapter < ActionCable::SubscriptionAdapter::Base + def broadcast(channel, payload) + end + + def subscribe(channel, callback, success_callback = nil) + subscriber_map[channel] << callback + @@subscribe_called = { channel: channel, callback: callback, success_callback: success_callback } + success_callback&.call + end + + def unsubscribe(channel, callback) + subscriber_map[channel].delete(callback) + subscriber_map.delete(channel) if subscriber_map[channel].empty? + @@unsubscribe_called = { channel: channel, callback: callback } + end + + def subscriber_map + @subscribers ||= Hash.new { |h, k| h[k] = [] } + end +end diff --git a/test/stubs/test_server.rb b/test/stubs/test_server.rb new file mode 100644 index 0000000..c6a7e93 --- /dev/null +++ b/test/stubs/test_server.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class TestServer < ActionCable::Server::Base + class FakeConfiguration < ActionCable::Server::Configuration + attr_accessor :subscription_adapter, :log_tags, :filter_parameters, :connection_class + + def initialize(subscription_adapter:) + @log_tags = [] + @filter_parameters = [] + @subscription_adapter = subscription_adapter + @connection_class = -> { ActionCable::Connection::Base } + end + + def pubsub_adapter + @subscription_adapter + end + end + + attr_reader :logger + + def initialize(subscription_adapter: SuccessAdapter) + @logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(%w[1 t true].include?(ENV["LOG"]) ? STDOUT : StringIO.new)) + @config = FakeConfiguration.new(subscription_adapter: subscription_adapter) + @mutex = Monitor.new + end + + def pubsub + @pubsub ||= @config.subscription_adapter.new(self) + end + + def event_loop + @event_loop ||= ActionCable::Server::StreamEventLoop.new + end + + def executor + @executor ||= ActionCable::Server::ThreadedExecutor.new.tap do |ex| + ex.instance_variable_set(:@executor, Concurrent.global_io_executor) + end + end + + def worker_pool + @worker_pool ||= ActionCable::Server::Worker.new(max_size: 5).tap do |wp| + wp.instance_variable_set(:@executor, Concurrent.global_io_executor) + end + end + + def new_tagged_logger = ActionCable::Server::TaggedLoggerProxy.new(logger, tags: []) +end diff --git a/test/stubs/test_socket.rb b/test/stubs/test_socket.rb new file mode 100644 index 0000000..fc03b89 --- /dev/null +++ b/test/stubs/test_socket.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "stubs/user" + +class TestSocket + attr_reader :identifiers, :logger, :current_user, :server, :subscriptions, :transmissions + + delegate :pubsub, :config, :executor, to: :server + + def initialize(user = User.new("lifo"), coder: ActiveSupport::JSON, subscription_adapter: SuccessAdapter) + @coder = coder + @identifiers = [ :current_user ] + + @current_user = user + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @server = TestServer.new(subscription_adapter: subscription_adapter) + @transmissions = [] + end + + def perform_work(receiver, method, *args) + receiver.send method, *args + end + + def transmit(cable_message) + @transmissions << encode(cable_message) + end + + def last_transmission + decode @transmissions.last if @transmissions.any? + end + + def decode(websocket_message) + @coder.decode websocket_message + end + + def encode(cable_message) + @coder.encode cable_message + end +end diff --git a/test/stubs/user.rb b/test/stubs/user.rb new file mode 100644 index 0000000..7894d1d --- /dev/null +++ b/test/stubs/user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class User + attr_reader :name + + def initialize(name) + @name = name + end + + def to_global_id + GlobalID.new("User##{name}") + end + + def to_gid_param + to_global_id.to_param + end +end diff --git a/test/subscription_adapter/async_test.rb b/test/subscription_adapter/async_test.rb new file mode 100644 index 0000000..6e03825 --- /dev/null +++ b/test/subscription_adapter/async_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class AsyncAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "async" } + end +end diff --git a/test/subscription_adapter/base_test.rb b/test/subscription_adapter/base_test.rb new file mode 100644 index 0000000..821bc9c --- /dev/null +++ b/test/subscription_adapter/base_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::SubscriptionAdapter::BaseTest < ActionCable::TestCase + ## TEST THAT ERRORS ARE RETURNED FOR INHERITORS THAT DON'T OVERRIDE METHODS + + class BrokenAdapter < ActionCable::SubscriptionAdapter::Base + end + + setup do + @server = TestServer.new + @server.config.subscription_adapter = BrokenAdapter + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "#broadcast returns NotImplementedError by default" do + assert_raises NotImplementedError do + BrokenAdapter.new(@server).broadcast("channel", "payload") + end + end + + test "#subscribe returns NotImplementedError by default" do + callback = lambda { puts "callback" } + success_callback = lambda { raise "success shouldn't be called" } + + assert_raises NotImplementedError do + BrokenAdapter.new(@server).subscribe("channel", callback, success_callback) + end + end + + test "#unsubscribe returns NotImplementedError by default" do + callback = lambda { puts "callback" } + + assert_raises NotImplementedError do + BrokenAdapter.new(@server).unsubscribe("channel", callback) + end + end + + # TEST METHODS THAT ARE REQUIRED OF THE ADAPTER'S BACKEND STORAGE OBJECT + + test "#broadcast is implemented" do + assert_nothing_raised do + SuccessAdapter.new(@server).broadcast("channel", "payload") + end + end + + test "#subscribe is implemented" do + callback = lambda { puts "callback" } + success_callbacked = false + success_callback = lambda { success_callbacked = true } + + assert_nothing_raised do + SuccessAdapter.new(@server).subscribe("channel", callback, success_callback) + end + + assert success_callbacked + end + + test "#unsubscribe is implemented" do + callback = lambda { puts "callback" } + + assert_nothing_raised do + SuccessAdapter.new(@server).unsubscribe("channel", callback) + end + end +end diff --git a/test/subscription_adapter/channel_prefix.rb b/test/subscription_adapter/channel_prefix.rb new file mode 100644 index 0000000..c6ac3dd --- /dev/null +++ b/test/subscription_adapter/channel_prefix.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" + +module ChannelPrefixTest + def test_channel_prefix + server2 = ActionCable::Server::Base.new(config: ActionCable::Server::Configuration.new) + server2.config.cable = alt_cable_config.with_indifferent_access + + adapter_klass = server2.config.pubsub_adapter + + rx_adapter2 = adapter_klass.new(server2) + tx_adapter2 = adapter_klass.new(server2) + + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("channel", rx_adapter2) do |queue2| + @tx_adapter.broadcast("channel", "hello world") + tx_adapter2.broadcast("channel", "hello world 2") + + assert_equal "hello world", queue.pop + assert_equal "hello world 2", queue2.pop + end + end + end + + def alt_cable_config + cable_config.merge(channel_prefix: "foo") + end +end diff --git a/test/subscription_adapter/common.rb b/test/subscription_adapter/common.rb new file mode 100644 index 0000000..e7008b8 --- /dev/null +++ b/test/subscription_adapter/common.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "test_helper" +require "concurrent" + +require "active_support/core_ext/hash/indifferent_access" +require "pathname" + +module CommonSubscriptionAdapterTest + WAIT_WHEN_EXPECTING_EVENT = 3 + WAIT_WHEN_NOT_EXPECTING_EVENT = 0.2 + + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.with_indifferent_access + + adapter_klass = server.config.pubsub_adapter + + @rx_adapter = adapter_klass.new(server) + @tx_adapter = adapter_klass.new(server) + end + + def teardown + [@rx_adapter, @tx_adapter].uniq.compact.each(&:shutdown) + end + + def subscribe_as_queue(channel, adapter = @rx_adapter) + queue = Queue.new + + callback = -> data { queue << data } + subscribed = Concurrent::Event.new + adapter.subscribe(channel, callback, Proc.new { subscribed.set }) + subscribed.wait(WAIT_WHEN_EXPECTING_EVENT) + assert_predicate subscribed, :set? + + yield queue + + sleep WAIT_WHEN_NOT_EXPECTING_EVENT + assert_empty queue + ensure + if subscribed.set? + adapter.unsubscribe(channel, callback) + wait_for_pubsub_executor(adapter) + end + end + + def test_subscribe_and_unsubscribe + subscribe_as_queue("channel") do |queue| + end + end + + def test_basic_broadcast + subscribe_as_queue("channel") do |queue| + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + end + end + + def test_broadcast_after_unsubscribe + keep_queue = nil + subscribe_as_queue("channel") do |queue| + keep_queue = queue + + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + end + + @tx_adapter.broadcast("channel", "hello void") + + sleep WAIT_WHEN_NOT_EXPECTING_EVENT + assert_empty keep_queue + end + + def test_multiple_broadcast + subscribe_as_queue("channel") do |queue| + @tx_adapter.broadcast("channel", "bananas") + @tx_adapter.broadcast("channel", "apples") + + received = [] + 2.times { received << queue.pop } + assert_equal ["apples", "bananas"], received.sort + end + end + + def test_identical_subscriptions + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("channel") do |queue_2| + @tx_adapter.broadcast("channel", "hello") + + assert_equal "hello", queue_2.pop + end + + assert_equal "hello", queue.pop + end + end + + def test_simultaneous_subscriptions + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("other channel") do |queue_2| + @tx_adapter.broadcast("channel", "apples") + @tx_adapter.broadcast("other channel", "oranges") + + assert_equal "apples", queue.pop + assert_equal "oranges", queue_2.pop + end + end + end + + def test_channel_filtered_broadcast + subscribe_as_queue("channel") do |queue| + @tx_adapter.broadcast("other channel", "one") + @tx_adapter.broadcast("channel", "two") + + assert_equal "two", queue.pop + end + end + + def test_long_identifiers + channel_1 = "a" * 100 + "1" + channel_2 = "a" * 100 + "2" + subscribe_as_queue(channel_1) do |queue| + subscribe_as_queue(channel_2) do |queue_2| + @tx_adapter.broadcast(channel_1, "apples") + @tx_adapter.broadcast(channel_2, "oranges") + + assert_equal "apples", queue.pop + assert_equal "oranges", queue_2.pop + end + end + end + + def wait_for_pubsub_executor(adapter) + # We use a wrapper over ConcurrentRuby::ThreadPoolExecutor, so we need to dig deeper + executor = adapter.instance_variable_get(:@executor).instance_variable_get(:@executor) + wait_for_executor(executor) + end +end diff --git a/test/subscription_adapter/inline_test.rb b/test/subscription_adapter/inline_test.rb new file mode 100644 index 0000000..6305626 --- /dev/null +++ b/test/subscription_adapter/inline_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class InlineAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "inline" } + end +end diff --git a/test/subscription_adapter/postgresql_test.rb b/test/subscription_adapter/postgresql_test.rb new file mode 100644 index 0000000..4bf1aa4 --- /dev/null +++ b/test/subscription_adapter/postgresql_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" +require_relative "channel_prefix" + +require "active_record" + +class PostgresqlAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + include ChannelPrefixTest + + def setup + database_config = { "adapter" => "postgresql", "database" => "activerecord_unittest" } + ar_tests = File.expand_path("../../../activerecord/test", __dir__) + if Dir.exist?(ar_tests) + require File.join(ar_tests, "config") + require File.join(ar_tests, "support/config") + local_config = ARTest.config["connections"]["postgresql"]["arunit"] + database_config.update local_config if local_config + end + + ActiveRecord::Base.establish_connection database_config + + begin + ActiveRecord::Base.lease_connection.connect! + rescue + @rx_adapter = @tx_adapter = nil + skip "Couldn't connect to PostgreSQL: #{database_config.inspect}" + end + + super + end + + def teardown + super + + ActiveRecord::Base.connection_handler.clear_all_connections! + end + + def cable_config + { adapter: "postgresql" } + end + + def test_clear_active_record_connections_adapter_still_works + server = ActionCable::Server::Base.new + server.config.cable = cable_config.with_indifferent_access + + adapter_klass = Class.new(server.config.pubsub_adapter) do + def active? + !@listener.nil? + end + end + + adapter = adapter_klass.new(server) + + subscribe_as_queue("channel", adapter) do |queue| + adapter.broadcast("channel", "hello world") + assert_equal "hello world", queue.pop + end + + ActiveRecord::Base.connection_handler.clear_reloadable_connections! + + assert_predicate adapter, :active? + end + + def test_default_subscription_connection_identifier + subscribe_as_queue("channel") { } + + identifiers = ActiveRecord::Base.lease_connection.exec_query("SELECT application_name FROM pg_stat_activity").rows + assert_includes identifiers, ["ActionCable-PID-#{$$}"] + end + + def test_custom_subscription_connection_identifier + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(id: "hello-world-42").with_indifferent_access + + adapter = server.config.pubsub_adapter.new(server) + + subscribe_as_queue("channel", adapter) { } + + identifiers = ActiveRecord::Base.lease_connection.exec_query("SELECT application_name FROM pg_stat_activity").rows + assert_includes identifiers, ["hello-world-42"] + end +end diff --git a/test/subscription_adapter/redis_test.rb b/test/subscription_adapter/redis_test.rb new file mode 100644 index 0000000..ce06d73 --- /dev/null +++ b/test/subscription_adapter/redis_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" +require_relative "channel_prefix" + +class RedisAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + include ChannelPrefixTest + + def cable_config + { adapter: "redis", driver: "ruby" }.tap do |x| + if host = ENV["REDIS_URL"] + x[:url] = host + end + end + end + + def test_reconnections + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("other channel") do |queue_2| + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + + drop_pubsub_connections + wait_pubsub_connection(redis_conn, "channel") + + @tx_adapter.broadcast("channel", "hallo welt") + + assert_equal "hallo welt", queue.pop + + drop_pubsub_connections + wait_pubsub_connection(redis_conn, "channel") + wait_pubsub_connection(redis_conn, "other channel") + + @tx_adapter.broadcast("channel", "hola mundo") + @tx_adapter.broadcast("other channel", "other message") + + assert_equal "hola mundo", queue.pop + assert_equal "other message", queue_2.pop + end + end + end + + private + def redis_conn + @redis_conn ||= ::Redis.new(cable_config.except(:adapter)) + end + + def drop_pubsub_connections + # Emulate connection failure by dropping all connections + redis_conn.client("kill", "type", "pubsub") + end + + def wait_pubsub_connection(redis_conn, channel, timeout: 5) + wait = timeout + loop do + break if redis_conn.pubsub("numsub", channel).last > 0 + + sleep 0.1 + wait -= 0.1 + + raise "Timed out to subscribe to #{channel}" if wait <= 0 + end + end +end + +class RedisAdapterTest::AlternateConfiguration < RedisAdapterTest + def cable_config + alt_cable_config = super.dup + alt_cable_config.delete(:url) + url = URI(ENV["REDIS_URL"] || "") + alt_cable_config.merge(host: url.hostname || "127.0.0.1", port: url.port || 6379, db: 12) + end +end + +class RedisAdapterTest::ConnectorDefaultID < ActionCable::TestCase + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(adapter: "redis").with_indifferent_access + + @adapter = server.config.pubsub_adapter.new(server) + end + + def cable_config + { url: 1, host: 2, port: 3, db: 4, password: 5 } + end + + def connection_id + "ActionCable-PID-#{$$}" + end + + def expected_connection + cable_config.merge(id: connection_id) + end + + test "sets connection id for connection" do + assert_called_with ::Redis, :new, [ expected_connection.symbolize_keys ] do + @adapter.send(:redis_connection) + end + end +end + +class RedisAdapterTest::ConnectorCustomID < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(id: connection_id) + end + + def connection_id + "Some custom ID" + end +end + +class RedisAdapterTest::ConnectorWithExcluded < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(adapter: "redis", channel_prefix: "custom") + end + + def expected_connection + super.except(:adapter, :channel_prefix) + end +end + +class RedisAdapterTest::SentinelConfigAsHash < ActionCable::TestCase + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(adapter: "redis").with_indifferent_access + + @adapter = server.config.pubsub_adapter.new(server) + end + + def cable_config + { url: "redis://test", sentinels: [{ "host" => "localhost", "port" => 26379 }] } + end + + def expected_connection + { url: "redis://test", sentinels: [{ host: "localhost", port: 26379 }], id: connection_id } + end + + def connection_id + "ActionCable-PID-#{$$}" + end + + test "sets sentinels as array of hashes with keyword arguments" do + assert_called_with ::Redis, :new, [ expected_connection ] do + @adapter.send(:redis_connection) + end + end +end diff --git a/test/subscription_adapter/subscriber_map_test.rb b/test/subscription_adapter/subscriber_map_test.rb new file mode 100644 index 0000000..ed81099 --- /dev/null +++ b/test/subscription_adapter/subscriber_map_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" + +class SubscriberMapTest < ActionCable::TestCase + test "broadcast should not change subscribers" do + setup_subscription_map + origin = @subscription_map.instance_variable_get(:@subscribers).dup + + @subscription_map.broadcast("not_exist_channel", "") + + assert_equal origin, @subscription_map.instance_variable_get(:@subscribers) + end + + private + def setup_subscription_map + @subscription_map = ActionCable::SubscriptionAdapter::SubscriberMap.new + end +end diff --git a/test/subscription_adapter/test_adapter_test.rb b/test/subscription_adapter/test_adapter_test.rb new file mode 100644 index 0000000..3fe07ad --- /dev/null +++ b/test/subscription_adapter/test_adapter_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class ActionCable::SubscriptionAdapter::TestTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "test" } + end + + test "#broadcast stores messages for streams" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + assert_equal ["payload"], @tx_adapter.broadcasts("channel") + assert_equal ["payload2"], @tx_adapter.broadcasts("channel2") + end + + test "#clear_messages deletes recorded broadcasts for the channel" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + @tx_adapter.clear_messages("channel") + + assert_equal [], @tx_adapter.broadcasts("channel") + assert_equal ["payload2"], @tx_adapter.broadcasts("channel2") + end + + test "#clear deletes all recorded broadcasts" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + @tx_adapter.clear + + assert_equal [], @tx_adapter.broadcasts("channel") + assert_equal [], @tx_adapter.broadcasts("channel2") + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..7ba8a43 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# TODO: Uncomment in rails/rails +# require "active_support/testing/strict_warnings" +require "action_cable" +require "active_support/testing/autorun" +require "active_support/testing/method_call_assertions" + +require "puma" +require "rack/mock" + +# Require all the stubs and models +Dir[File.expand_path("stubs/*.rb", __dir__)].each { |file| require file } + +# Set test adapter and logger +ActionCable.server.config.cable = { "adapter" => "test" } +ActionCable.server.config.logger = Logger.new(nil) + +class ActionCable::TestCase < ActiveSupport::TestCase + include ActiveSupport::Testing::MethodCallAssertions + + def wait_for_async + wait_for_executor Concurrent.global_io_executor + end + + def run_in_eventmachine + yield + wait_for_async + end + + def wait_for_executor(executor) + # do not wait forever, wait 2s + timeout = 2 + until executor.completed_task_count == executor.scheduled_task_count + sleep 0.1 + timeout -= 0.1 + raise "Executor could not complete all tasks in 2 seconds" unless timeout > 0 + end + end +end + +# TODO: Uncomment in rails/rails +# require_relative "../../tools/test_common" diff --git a/test/test_helper_test.rb b/test/test_helper_test.rb new file mode 100644 index 0000000..ef5615f --- /dev/null +++ b/test/test_helper_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "test_helper" + +class BroadcastChannel < ActionCable::Channel::Base +end + +class TransmissionsTest < ActionCable::TestCase + def test_assert_broadcasts + assert_nothing_raised do + assert_broadcasts("test", 1) do + ActionCable.server.broadcast "test", "message" + end + end + end + + def test_capture_broadcasts + messages = capture_broadcasts("test") do + ActionCable.server.broadcast "test", "message" + end + assert_equal "message", messages.first + + messages = capture_broadcasts("test") do + ActionCable.server.broadcast "test", { message: "one" } + ActionCable.server.broadcast "test", { message: "two" } + end + assert_equal 2, messages.length + assert_equal({ "message" => "one" }, messages.first) + assert_equal({ "message" => "two" }, messages.last) + end + + def test_assert_broadcasts_with_no_block + assert_nothing_raised do + ActionCable.server.broadcast "test", "message" + assert_broadcasts "test", 1 + end + + assert_nothing_raised do + ActionCable.server.broadcast "test", "message 2" + ActionCable.server.broadcast "test", "message 3" + assert_broadcasts "test", 3 + end + end + + def test_assert_no_broadcasts_with_no_block + assert_nothing_raised do + assert_no_broadcasts "test" + end + end + + def test_assert_no_broadcasts + assert_nothing_raised do + assert_no_broadcasts("test") do + ActionCable.server.broadcast "test2", "message" + end + end + end + + def test_assert_broadcasts_message_too_few_sent + ActionCable.server.broadcast "test", "hello" + error = assert_raises Minitest::Assertion do + assert_broadcasts("test", 2) do + ActionCable.server.broadcast "test", "world" + end + end + + assert_match(/2 .* but 1/, error.message) + end + + def test_assert_broadcasts_message_too_many_sent + error = assert_raises Minitest::Assertion do + assert_broadcasts("test", 1) do + ActionCable.server.broadcast "test", "hello" + ActionCable.server.broadcast "test", "world" + end + end + + assert_match(/1 .* but 2/, error.message) + end + + def test_assert_no_broadcasts_failure + error = assert_raises Minitest::Assertion do + assert_no_broadcasts "test" do + ActionCable.server.broadcast "test", "hello" + end + end + + assert_match(/0 .* but 1/, error.message) + end +end + +class TransmittedDataTest < ActionCable::TestCase + include ActionCable::TestHelper + + def test_assert_broadcast_on + assert_nothing_raised do + assert_broadcast_on("test", "message") do + ActionCable.server.broadcast "test", "message" + end + end + end + + def test_assert_broadcast_on_with_hash + assert_nothing_raised do + assert_broadcast_on("test", text: "hello") do + ActionCable.server.broadcast "test", { text: "hello" } + end + end + end + + def test_assert_broadcast_on_with_no_block + assert_nothing_raised do + ActionCable.server.broadcast "test", "hello" + assert_broadcast_on "test", "hello" + end + + assert_nothing_raised do + ActionCable.server.broadcast "test", "world" + assert_broadcast_on "test", "world" + end + end + + def test_assert_broadcast_on_message + ActionCable.server.broadcast "test", "hello" + error = assert_raises Minitest::Assertion do + assert_broadcast_on("test", "world") + end + + assert_match(/No messages sent/, error.message) + assert_match(/Message\(s\) found:\nhello/, error.message) + end + + def test_assert_broadcast_on_message_with_empty_channel + error = assert_raises Minitest::Assertion do + assert_broadcast_on("test", "world") + end + + assert_match(/No messages sent/, error.message) + assert_match(/No message found for test/, error.message) + end +end diff --git a/test/worker_test.rb b/test/worker_test.rb new file mode 100644 index 0000000..58f29f2 --- /dev/null +++ b/test/worker_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" + +class WorkerTest < ActionCable::TestCase + class Receiver + attr_accessor :last_action + + def run + @last_action = :run + end + + def process(message) + @last_action = [ :process, message ] + end + + def connection + self + end + + def logger + # Impersonating a connection requires a TaggedLoggerProxy'ied logger. + inner_logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + ActionCable::Server::TaggedLoggerProxy.new(inner_logger, tags: []) + end + end + + setup do + @worker = ActionCable::Server::Worker.new + @receiver = Receiver.new + end + + teardown do + @receiver.last_action = nil + end + + test "invoke" do + @worker.invoke @receiver, :run, connection: @receiver.connection + assert_equal :run, @receiver.last_action + end + + test "invoke with arguments" do + @worker.invoke @receiver, :process, "Hello", connection: @receiver.connection + assert_equal [ :process, "Hello" ], @receiver.last_action + end +end