From db568553d172e6ebdc3bc0aae3839d4a91299b42 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 14 Jan 2015 21:59:31 +0530 Subject: Action Cable take#1 --- Gemfile | 4 ++ README | 3 ++ action_cable.gemspec | 19 +++++++++ lib/action_cable.rb | 12 ++++++ lib/action_cable/channel.rb | 6 +++ lib/action_cable/channel/base.rb | 64 ++++++++++++++++++++++++++++++ lib/action_cable/channel/callbacks.rb | 32 +++++++++++++++ lib/action_cable/server.rb | 73 +++++++++++++++++++++++++++++++++++ 8 files changed, 213 insertions(+) create mode 100644 Gemfile create mode 100644 README create mode 100644 action_cable.gemspec create mode 100644 lib/action_cable.rb create mode 100644 lib/action_cable/channel.rb create mode 100644 lib/action_cable/channel/base.rb create mode 100644 lib/action_cable/channel/callbacks.rb create mode 100644 lib/action_cable/server.rb diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..f4110035ed --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'http://rubygems.org' +gemspec + +gem 'cramp', github: "lifo/cramp" diff --git a/README b/README new file mode 100644 index 0000000000..4f350e625f --- /dev/null +++ b/README @@ -0,0 +1,3 @@ +# ActionCable + +Action Cable is a framework for realtime communication over websockets. \ No newline at end of file diff --git a/action_cable.gemspec b/action_cable.gemspec new file mode 100644 index 0000000000..63ba751e9d --- /dev/null +++ b/action_cable.gemspec @@ -0,0 +1,19 @@ +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = 'action_cable' + s.version = '0.0.1' + s.summary = 'Framework for websockets.' + s.description = 'Action Cable is a framework for realtime communication over websockets.' + + s.author = ['Pratik Naik'] + s.email = ['pratiknaik@gmail.com'] + s.homepage = 'http://basecamp.com' + + s.add_dependency('activesupport', '~> 4.2.0') + s.add_dependency('cramp', '~> 0.15.4') + + s.files = Dir['README', 'lib/**/*'] + s.has_rdoc = false + + s.require_path = 'lib' +end diff --git a/lib/action_cable.rb b/lib/action_cable.rb new file mode 100644 index 0000000000..7df2a8c5eb --- /dev/null +++ b/lib/action_cable.rb @@ -0,0 +1,12 @@ +require 'cramp' +require 'active_support' +require 'active_support/json' +require 'active_support/concern' +require 'active_support/core_ext/hash/indifferent_access' + +module ActionCable + VERSION = '0.0.1' + + autoload :Channel, 'action_cable/channel' + autoload :Server, 'action_cable/server' +end diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb new file mode 100644 index 0000000000..a54302d30f --- /dev/null +++ b/lib/action_cable/channel.rb @@ -0,0 +1,6 @@ +module ActionCable + module Channel + autoload :Callbacks, 'action_cable/channel/callbacks' + autoload :Base, 'action_cable/channel/base' + end +end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb new file mode 100644 index 0000000000..82c1a14b49 --- /dev/null +++ b/lib/action_cable/channel/base.rb @@ -0,0 +1,64 @@ +module ActionCable + module Channel + + class Base + include Callbacks + + on_subscribe :start_periodic_timers + on_unsubscribe :stop_periodic_timers + + attr_reader :params + + class << self + def matches?(identifier) + raise "Please implement #{name}#matches? method" + end + end + + def initialize(connection, channel_identifier, params = {}) + @connection = connection + @channel_identifier = channel_identifier + @_active_periodic_timers = [] + @params = params + + setup + end + + def receive(data) + raise "Not implemented" + end + + def subscribe + self.class.on_subscribe_callbacks.each do |callback| + EM.next_tick { send(callback) } + end + end + + def unsubscribe + self.class.on_unsubscribe.each do |callback| + EM.next_tick { send(callback) } + end + end + + protected + def setup + # Override in subclasses + end + + def publish(data) + @connection.publish(data.merge(identifier: @channel_identifier).to_json) + end + + def start_periodic_timers + self.class.periodic_timers.each do |method, options| + @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) { send(method) } + end + end + + def stop_periodic_timers + @_active_periodic_timers.each {|t| t.cancel } + end + end + + end +end \ No newline at end of file diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb new file mode 100644 index 0000000000..cf0246a386 --- /dev/null +++ b/lib/action_cable/channel/callbacks.rb @@ -0,0 +1,32 @@ +module ActionCable + module Channel + + module Callbacks + extend ActiveSupport::Concern + + included do + class_attribute :on_subscribe_callbacks, :on_unsubscribe_callbacks, :periodic_timers, :instance_reader => false + + self.on_subscribe_callbacks = [] + self.on_unsubscribe_callbacks = [] + self.periodic_timers = [] + end + + module ClassMethods + def on_subscribe(*methods) + self.on_subscribe_callbacks += methods + end + + def on_unsubscribe(*methods) + self.on_unsubscribe_callbacks += methods + end + + def periodic_timer(method, every:) + self.periodic_timers += [ [ method, every: every ] ] + end + end + + end + + end +end \ No newline at end of file diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb new file mode 100644 index 0000000000..2d80e96265 --- /dev/null +++ b/lib/action_cable/server.rb @@ -0,0 +1,73 @@ +require 'set' + +module ActionCable + class Server < Cramp::Websocket + on_start :initialize_subscriptions + on_data :received_data + on_finish :cleanup_subscriptions + + class_attribute :registered_channels + self.registered_channels = Set.new + + class << self + def register_channels(*channel_classes) + registered_channels.merge(channel_classes) + end + end + + def initialize_subscriptions + @subscriptions = {} + end + + def received_data(data) + data = ActiveSupport::JSON.decode data + + case data['action'] + when 'subscribe' + subscribe_channel(data) + when 'unsubscribe' + unsubscribe_channel(data) + when 'message' + process_message(data) + end + end + + def cleanup_subscriptions + @subscriptions.each do |id, channel| + channel.unsubscribe + end + end + + def publish(data) + render data + end + + private + def subscribe_channel(data) + id_key = data['identifier'] + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + + if subscription = registered_channels.detect { |channel_klass| channel_klass.matches?(id_options) } + @subscriptions[id_key] = subscription.new(self, id_key, id_options) + @subscriptions[id_key].subscribe + else + # No channel found + end + end + + def process_message(message) + id_key = message['identifier'] + + if @subscriptions[id_key] + @subscriptions[id_key].receive(ActiveSupport::JSON.decode message['data']) + end + end + + def unsubscribe_channel(data) + id_key = data['identifier'] + @subscriptions[id_key].unsubscribe + @subscriptions.delete(id_key) + end + + end +end -- cgit v1.2.3 From 935b73b4c132b9ef6b71054dd577cab88c0050a3 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 15 Jan 2015 10:24:35 +0530 Subject: Rename publish to broadcast --- lib/action_cable/channel/base.rb | 4 ++-- lib/action_cable/server.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 82c1a14b49..e091d08c9c 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -45,8 +45,8 @@ module ActionCable # Override in subclasses end - def publish(data) - @connection.publish(data.merge(identifier: @channel_identifier).to_json) + def broadcast(data) + @connection.broadcast(data.merge(identifier: @channel_identifier).to_json) end def start_periodic_timers diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 2d80e96265..a55c3be3bc 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -38,7 +38,7 @@ module ActionCable end end - def publish(data) + def broadcast(data) render data end -- cgit v1.2.3 From a5c3a8d3e346acce79899c04133ba6fa3c88f830 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 15 Jan 2015 21:07:31 +0530 Subject: Latest gems and fix a callback bug --- .ruby-version | 1 + Gemfile | 6 ++++++ Gemfile.lock | 54 ++++++++++++++++++++++++++++++++++++++++++++++ lib/action_cable/server.rb | 2 +- 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 .ruby-version create mode 100644 Gemfile.lock diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000000..cd57a8b95d --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.1.5 diff --git a/Gemfile b/Gemfile index f4110035ed..3ef2cb6af8 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,9 @@ source 'http://rubygems.org' gemspec gem 'cramp', github: "lifo/cramp" + +group :test do + gem 'rake' + gem 'puma' +end + diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..1afa86ab40 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,54 @@ +GIT + remote: git://github.com/lifo/cramp.git + revision: 60f6f30fe69fedd076ac7cb64f65ba8f382c8a67 + specs: + cramp (0.15.4) + activesupport (~> 4.2.0) + eventmachine (~> 1.0.3) + faye-websocket (~> 0.9.2) + rack (~> 1.6.0) + thor (~> 0.19.0) + +PATH + remote: . + specs: + action_cable (0.0.1) + activesupport (~> 4.2.0) + cramp (~> 0.15.4) + +GEM + remote: http://rubygems.org/ + specs: + activesupport (4.2.0) + i18n (~> 0.7) + json (~> 1.7, >= 1.7.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + eventmachine (1.0.4) + faye-websocket (0.9.2) + eventmachine (>= 0.12.0) + websocket-driver (>= 0.5.1) + i18n (0.7.0) + json (1.8.2) + minitest (5.5.1) + puma (2.10.2) + rack (>= 1.1, < 2.0) + rack (1.6.0) + rake (10.4.2) + thor (0.19.1) + thread_safe (0.3.4) + tzinfo (1.2.2) + thread_safe (~> 0.1) + websocket-driver (0.5.1) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.1) + +PLATFORMS + ruby + +DEPENDENCIES + action_cable! + cramp! + puma + rake diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index a55c3be3bc..4ee6b15982 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -11,7 +11,7 @@ module ActionCable class << self def register_channels(*channel_classes) - registered_channels.merge(channel_classes) + self.registered_channels += channel_classes end end -- cgit v1.2.3 From 843492ee6c2167115f2bbc9c1b3c82da0ad075f8 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 15 Jan 2015 22:58:02 +0530 Subject: Add some tests. Work in progress. Testing websockets is hard. --- .gitignore | 1 + Rakefile | 11 +++++++++++ test/channel_test.rb | 23 +++++++++++++++++++++++ test/server_test.rb | 36 ++++++++++++++++++++++++++++++++++++ test/test_helper.rb | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 .gitignore create mode 100644 Rakefile create mode 100644 test/channel_test.rb create mode 100644 test/server_test.rb create mode 100644 test/test_helper.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..1918a1b0ee --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test/tests.log \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000000..c2ae16b7d9 --- /dev/null +++ b/Rakefile @@ -0,0 +1,11 @@ +require 'rake' +require 'rake/testtask' + +task :default => :test + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end +Rake::Task['test'].comment = "Run tests" diff --git a/test/channel_test.rb b/test/channel_test.rb new file mode 100644 index 0000000000..ad5fa04356 --- /dev/null +++ b/test/channel_test.rb @@ -0,0 +1,23 @@ +require 'test_helper' + +class ChannelTest < ActionCableTest + + class PingChannel < ActionCable::Channel::Base + def self.matches?(identifier) + identifier[:channel] == 'chat' && identifier[:user_id].to_i.nonzero? + end + end + + class PingServer < ActionCable::Server + register_channels PingChannel + end + + def app + PingServer + end + + test "channel callbacks" do + ws = Faye::WebSocket::Client.new(websocket_url) + end + +end diff --git a/test/server_test.rb b/test/server_test.rb new file mode 100644 index 0000000000..50a95b9d59 --- /dev/null +++ b/test/server_test.rb @@ -0,0 +1,36 @@ +require 'test_helper' + +class ServerTest < ActionCableTest + + class ChatChannel < ActionCable::Channel::Base + def self.matches?(identifier) + identifier[:channel] == 'chat' && identifier[:user_id].to_i.nonzero? + end + end + + class ChatServer < ActionCable::Server + register_channels ChatChannel + end + + def app + ChatServer + end + + test "channel registration" do + assert_equal ChatServer.registered_channels, Set.new([ ChatChannel ]) + end + + test "subscribing to a channel with valid params" do + ws = Faye::WebSocket::Client.new(websocket_url) + + ws.on(:message) do |message| + puts message.inspect + end + + ws.send action: 'subscribe', identifier: { channel: 'chat'}.to_json + end + + test "subscribing to a channel with invalid params" do + end + +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000000..5251e711b7 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,46 @@ +require "rubygems" +require "bundler" + +gem 'minitest' +require "minitest/autorun" + +Bundler.setup +Bundler.require :default, :test + +require 'puma' + +require 'action_cable' +ActiveSupport.test_order = :sorted + +require 'logger' +logger = Logger.new(File.join(File.dirname(__FILE__), "tests.log")) +logger.level = Logger::DEBUG +Cramp.logger = logger + +class ActionCableTest < Cramp::TestCase + PORT = 420420 + + setup :start_puma_server + teardown :stop_puma_server + + def start_puma_server + events = Puma::Events.new(StringIO.new, StringIO.new) + binder = Puma::Binder.new(events) + binder.parse(["tcp://0.0.0.0:#{PORT}"], self) + @server = Puma::Server.new(app, events) + @server.binder = binder + @server.run + end + + def stop_puma_server + @server.stop(true) + end + + def websocket_url + "ws://0.0.0.0:#{PORT}/" + end + + def log(*args) + end + +end -- cgit v1.2.3 From 792fe4b29ce76089672c0741370b10d03a7f076a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 15 Jan 2015 23:49:01 +0530 Subject: Specify channel name as an attribute --- lib/action_cable/channel/base.rb | 2 ++ lib/action_cable/server.rb | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index e091d08c9c..fa284eccbc 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -9,6 +9,8 @@ module ActionCable attr_reader :params + class_attribute :channel_name + class << self def matches?(identifier) raise "Please implement #{name}#matches? method" diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 4ee6b15982..1b444a4e7a 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -47,8 +47,12 @@ module ActionCable id_key = data['identifier'] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - if subscription = registered_channels.detect { |channel_klass| channel_klass.matches?(id_options) } - @subscriptions[id_key] = subscription.new(self, id_key, id_options) + subscription_klass = registered_channels.detect do |channel_klass| + channel_klass.channel_name == id_options[:channel] && channel_klass.matches?(id_options) + end + + if subscription_klass + @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) @subscriptions[id_key].subscribe else # No channel found -- cgit v1.2.3 From 449b3ca7b08c000c05100cc909fc29cbc9f365ee Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 16 Jan 2015 22:09:26 +0530 Subject: Initialize subscriptions as early as possible --- lib/action_cable/server.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 1b444a4e7a..08a6e9030b 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -2,7 +2,6 @@ require 'set' module ActionCable class Server < Cramp::Websocket - on_start :initialize_subscriptions on_data :received_data on_finish :cleanup_subscriptions @@ -15,8 +14,10 @@ module ActionCable end end - def initialize_subscriptions + def initialize(*) @subscriptions = {} + + super end def received_data(data) -- cgit v1.2.3 From 61ea867f295b7872b8cb000e257236f70f1518cd Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 16 Jan 2015 22:19:47 +0530 Subject: Always pass the channel broadcast in message key --- lib/action_cable/channel/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index fa284eccbc..1ede07d9e3 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -48,7 +48,7 @@ module ActionCable end def broadcast(data) - @connection.broadcast(data.merge(identifier: @channel_identifier).to_json) + @connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) end def start_periodic_timers -- cgit v1.2.3 From 955bf93549f336d95862b12079f8b88f99145f8b Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 17 Jan 2015 11:39:28 +0530 Subject: Use underscored class name as the default channel name --- lib/action_cable/channel/base.rb | 4 ++++ lib/action_cable/server.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 1ede07d9e3..6eaade4c8f 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -15,6 +15,10 @@ module ActionCable def matches?(identifier) raise "Please implement #{name}#matches? method" end + + def find_name + @name ||= channel_name || to_s.demodulize.underscore + end end def initialize(connection, channel_identifier, params = {}) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 08a6e9030b..eeeb50f08c 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -49,7 +49,7 @@ module ActionCable id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access subscription_klass = registered_channels.detect do |channel_klass| - channel_klass.channel_name == id_options[:channel] && channel_klass.matches?(id_options) + channel_klass.find_name == id_options[:channel] && channel_klass.matches?(id_options) end if subscription_klass -- cgit v1.2.3 From 6a78da924960039d17b618f775695682bb9940d9 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 17 Jan 2015 12:02:05 +0530 Subject: More API changes --- lib/action_cable/channel/base.rb | 12 ++++++++---- lib/action_cable/channel/callbacks.rb | 4 ++-- lib/action_cable/server.rb | 1 - 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 6eaade4c8f..3eaa0ceaeb 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -27,7 +27,9 @@ module ActionCable @_active_periodic_timers = [] @params = params - setup + connect + + subscribe end def receive(data) @@ -47,7 +49,7 @@ module ActionCable end protected - def setup + def connect # Override in subclasses end @@ -56,8 +58,10 @@ module ActionCable end def start_periodic_timers - self.class.periodic_timers.each do |method, options| - @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) { send(method) } + self.class.periodic_timers.each do |callback, options| + @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do + callback.respond_to?(:call) ? callback.call : send(callback) + end end end diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb index cf0246a386..22c6f2d563 100644 --- a/lib/action_cable/channel/callbacks.rb +++ b/lib/action_cable/channel/callbacks.rb @@ -21,8 +21,8 @@ module ActionCable self.on_unsubscribe_callbacks += methods end - def periodic_timer(method, every:) - self.periodic_timers += [ [ method, every: every ] ] + def periodically(callback, every:) + self.periodic_timers += [ [ callback, every: every ] ] end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index eeeb50f08c..02cf592ad6 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -54,7 +54,6 @@ module ActionCable if subscription_klass @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) - @subscriptions[id_key].subscribe else # No channel found end -- cgit v1.2.3 From 76c230e7d80d72352da76f5c95b786b183cdfa09 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 17 Jan 2015 12:15:20 +0530 Subject: Use instance_exec to invoke the lambda callback --- lib/action_cable/channel/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 3eaa0ceaeb..9f72467f17 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -60,7 +60,7 @@ module ActionCable def start_periodic_timers self.class.periodic_timers.each do |callback, options| @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do - callback.respond_to?(:call) ? callback.call : send(callback) + callback.respond_to?(:call) ? instance_exec(&callback) : send(callback) end end end -- cgit v1.2.3 From 3c4488cd9d0781fe563cfd346f0e6cf2d342ced5 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Jan 2015 14:28:45 +0530 Subject: No need for matching params --- lib/action_cable/server.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 02cf592ad6..cdf8ea0f66 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -48,9 +48,7 @@ module ActionCable id_key = data['identifier'] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - subscription_klass = registered_channels.detect do |channel_klass| - channel_klass.find_name == id_options[:channel] && channel_klass.matches?(id_options) - end + subscription_klass = registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } if subscription_klass @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) -- cgit v1.2.3 From 568599dd206301b8fde9b75f4913de4caed65967 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Jan 2015 15:07:28 +0530 Subject: Fix unsubscribe callbacks --- lib/action_cable/channel/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 9f72467f17..7052945eb1 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -43,7 +43,7 @@ module ActionCable end def unsubscribe - self.class.on_unsubscribe.each do |callback| + self.class.on_unsubscribe_callbacks.each do |callback| EM.next_tick { send(callback) } end end -- cgit v1.2.3 From 4f36bc66e640cdd4e42ab1174cb61cd7e3b17b0d Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Jan 2015 15:40:32 +0530 Subject: Add support for redis channels --- lib/action_cable/channel.rb | 1 + lib/action_cable/channel/base.rb | 1 + lib/action_cable/channel/redis.rb | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 lib/action_cable/channel/redis.rb diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb index a54302d30f..94cdc8d722 100644 --- a/lib/action_cable/channel.rb +++ b/lib/action_cable/channel.rb @@ -1,6 +1,7 @@ module ActionCable module Channel autoload :Callbacks, 'action_cable/channel/callbacks' + autoload :Redis, 'action_cable/channel/redis' autoload :Base, 'action_cable/channel/base' end end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 7052945eb1..40af1462b4 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -3,6 +3,7 @@ module ActionCable class Base include Callbacks + include Redis on_subscribe :start_periodic_timers on_unsubscribe :stop_periodic_timers diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb new file mode 100644 index 0000000000..215cc3c975 --- /dev/null +++ b/lib/action_cable/channel/redis.rb @@ -0,0 +1,32 @@ +module ActionCable + module Channel + + module Redis + extend ActiveSupport::Concern + + included do + on_unsubscribe :unsubscribe_from_redis_channels + end + + def subscribe_to(redis_channel, callback = nil) + @_redis_channels ||= [] + @_redis_channels << redis_channel + + callback ||= -> (message) { broadcast ActiveSupport::JSON.decode(message) } + redis.pubsub.subscribe(redis_channel, &callback) + end + + protected + def unsubscribe_from_redis_channels + if @_redis_channels + @_redis_channels.each { |channel| @connection.pubsub.unsubscribe(channel) } + end + end + + def redis + @connection.redis + end + end + + end +end \ No newline at end of file -- cgit v1.2.3 From 55c956b346dfb26a0ac5a5686f4be7f96b28cff6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 31 Jan 2015 15:05:58 -0800 Subject: Add a disconnect callback --- lib/action_cable/channel/base.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 40af1462b4..ae8822d2a2 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -5,9 +5,11 @@ module ActionCable include Callbacks include Redis - on_subscribe :start_periodic_timers + on_subscribe :start_periodic_timers on_unsubscribe :stop_periodic_timers + on_unsubscribe :disconnect + attr_reader :params class_attribute :channel_name @@ -54,6 +56,10 @@ module ActionCable # Override in subclasses end + def disconnect + # Override in subclasses + end + def broadcast(data) @connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) end -- cgit v1.2.3 From 7fef6b01a3011438d48136e3f95bb9a823e87ec6 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 16:35:11 +0530 Subject: No cramp and use celluloid workers to run callbacks --- Gemfile | 2 -- action_cable.gemspec | 1 - lib/action_cable.rb | 3 ++- lib/action_cable/channel/base.rb | 4 ++-- lib/action_cable/server.rb | 43 ++++++++++++++++++++++++++++++++-------- lib/action_cable/worker.rb | 19 ++++++++++++++++++ test/test_helper.rb | 3 +-- 7 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 lib/action_cable/worker.rb diff --git a/Gemfile b/Gemfile index 3ef2cb6af8..7dfe51bf00 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,6 @@ source 'http://rubygems.org' gemspec -gem 'cramp', github: "lifo/cramp" - group :test do gem 'rake' gem 'puma' diff --git a/action_cable.gemspec b/action_cable.gemspec index 63ba751e9d..f6fcc92fee 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -10,7 +10,6 @@ Gem::Specification.new do |s| s.homepage = 'http://basecamp.com' s.add_dependency('activesupport', '~> 4.2.0') - s.add_dependency('cramp', '~> 0.15.4') s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 7df2a8c5eb..993c260e49 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -1,12 +1,13 @@ -require 'cramp' require 'active_support' require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/callbacks' module ActionCable VERSION = '0.0.1' autoload :Channel, 'action_cable/channel' + autoload :Worker, 'action_cable/worker' autoload :Server, 'action_cable/server' end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index ae8822d2a2..e311cc97e9 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -41,13 +41,13 @@ module ActionCable def subscribe self.class.on_subscribe_callbacks.each do |callback| - EM.next_tick { send(callback) } + send(callback) end end def unsubscribe self.class.on_unsubscribe_callbacks.each do |callback| - EM.next_tick { send(callback) } + send(callback) end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index cdf8ea0f66..ea22f0014e 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,10 +1,11 @@ require 'set' +require 'faye/websocket' +require 'celluloid' -module ActionCable - class Server < Cramp::Websocket - on_data :received_data - on_finish :cleanup_subscriptions +Celluloid::Actor[:worker_pool] = ActionCable::Worker.pool(size: 100) +module ActionCable + class Server class_attribute :registered_channels self.registered_channels = Set.new @@ -12,12 +13,35 @@ module ActionCable def register_channels(*channel_classes) self.registered_channels += channel_classes end + + def call(env) + new(env).process + end end - def initialize(*) - @subscriptions = {} + def initialize(env) + @env = env + end + + def process + if Faye::WebSocket.websocket?(@env) + @subscriptions = {} + + @websocket = Faye::WebSocket.new(@env) - super + @websocket.on(:message) do |event| + message = event.data + Celluloid::Actor[:worker_pool].async.received_data(self, message) if message.is_a?(String) + end + + @websocket.on(:close) do |event| + Celluloid::Actor[:worker_pool].async.cleanup_subscriptions(self) + end + + @websocket.rack_response + else + invalid_request + end end def received_data(data) @@ -40,7 +64,7 @@ module ActionCable end def broadcast(data) - render data + @websocket.send data end private @@ -71,5 +95,8 @@ module ActionCable @subscriptions.delete(id_key) end + def invalid_request + [404, {'Content-Type' => 'text/plain'}, ['Page not found']] + end end end diff --git a/lib/action_cable/worker.rb b/lib/action_cable/worker.rb new file mode 100644 index 0000000000..46b5f7edc0 --- /dev/null +++ b/lib/action_cable/worker.rb @@ -0,0 +1,19 @@ +module ActionCable + class Worker + include ActiveSupport::Callbacks + include Celluloid + + define_callbacks :work + + def received_data(connection, data) + run_callbacks :work do + connection.received_data(data) + end + end + + def cleanup_subscriptions(connection) + connection.cleanup_subscriptions + end + + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 5251e711b7..10a4827281 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,9 +15,8 @@ ActiveSupport.test_order = :sorted require 'logger' logger = Logger.new(File.join(File.dirname(__FILE__), "tests.log")) logger.level = Logger::DEBUG -Cramp.logger = logger -class ActionCableTest < Cramp::TestCase +class ActionCableTest < ActiveSupport::TestCase PORT = 420420 setup :start_puma_server -- cgit v1.2.3 From d9d6ebcac83d767700b4c3b618f7fa3ae5ef69b2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 17:02:01 +0530 Subject: Run periodic timers via the worker pool --- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/worker.rb | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index e311cc97e9..af328cf297 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -67,7 +67,7 @@ module ActionCable def start_periodic_timers self.class.periodic_timers.each do |callback, options| @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do - callback.respond_to?(:call) ? instance_exec(&callback) : send(callback) + Celluloid::Actor[:worker_pool].async.run_periodic_timer(self, callback) end end end diff --git a/lib/action_cable/worker.rb b/lib/action_cable/worker.rb index 46b5f7edc0..1a8bee974b 100644 --- a/lib/action_cable/worker.rb +++ b/lib/action_cable/worker.rb @@ -12,7 +12,15 @@ module ActionCable end def cleanup_subscriptions(connection) - connection.cleanup_subscriptions + run_callbacks :work do + connection.cleanup_subscriptions + end + end + + def run_periodic_timer(channel, callback) + run_callbacks :work do + callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback) + end end end -- cgit v1.2.3 From d014bf93280c13f7959724f1b830bcab639c2b89 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 18:59:27 +0530 Subject: EM epoll --- lib/action_cable.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 993c260e49..f3029c6c86 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -1,3 +1,6 @@ +require 'eventmachine' +EM.epoll + require 'active_support' require 'active_support/json' require 'active_support/concern' -- cgit v1.2.3 From b67b19756418ac0508502a95bd591e75a0d3376a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 19:40:56 +0530 Subject: Update the gemspec --- action_cable.gemspec | 4 +++- lib/action_cable.rb | 5 +++++ lib/action_cable/server.rb | 4 ---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/action_cable.gemspec b/action_cable.gemspec index f6fcc92fee..24553aa6fe 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -9,7 +9,9 @@ Gem::Specification.new do |s| s.email = ['pratiknaik@gmail.com'] s.homepage = 'http://basecamp.com' - s.add_dependency('activesupport', '~> 4.2.0') + s.add_dependency('activesupport', '~> 4.2.0') + s.add_dependency('faye-websocket', '~> 0.9.2') + s.add_dependency('celluloid', '~> 0.16.0') s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false diff --git a/lib/action_cable.rb b/lib/action_cable.rb index f3029c6c86..e7d8f4cbb1 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -1,12 +1,17 @@ require 'eventmachine' EM.epoll +require 'set' + require 'active_support' require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' require 'active_support/callbacks' +require 'faye/websocket' +require 'celluloid' + module ActionCable VERSION = '0.0.1' diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index ea22f0014e..10c94734ef 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,7 +1,3 @@ -require 'set' -require 'faye/websocket' -require 'celluloid' - Celluloid::Actor[:worker_pool] = ActionCable::Worker.pool(size: 100) module ActionCable -- cgit v1.2.3 From 41e4396a5549399eb808d74086cb6f4b9471b882 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 20:43:51 +0530 Subject: Configurable worker pool size --- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/server.rb | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index af328cf297..a3aa4df2ad 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -67,7 +67,7 @@ module ActionCable def start_periodic_timers self.class.periodic_timers.each do |callback, options| @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do - Celluloid::Actor[:worker_pool].async.run_periodic_timer(self, callback) + connection.class.worker_pool.async.run_periodic_timer(self, callback) end end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 10c94734ef..d1b7e14b53 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,10 +1,11 @@ -Celluloid::Actor[:worker_pool] = ActionCable::Worker.pool(size: 100) - module ActionCable class Server class_attribute :registered_channels self.registered_channels = Set.new + class_attribute :worker_pool_size + self.worker_pool_size = 100 + class << self def register_channels(*channel_classes) self.registered_channels += channel_classes @@ -13,6 +14,10 @@ module ActionCable def call(env) new(env).process end + + def worker_pool + @worker_pool ||= ActionCable::Worker.pool(size: worker_pool_size) + end end def initialize(env) @@ -27,11 +32,11 @@ module ActionCable @websocket.on(:message) do |event| message = event.data - Celluloid::Actor[:worker_pool].async.received_data(self, message) if message.is_a?(String) + self.class.worker_pool.async.received_data(self, message) if message.is_a?(String) end @websocket.on(:close) do |event| - Celluloid::Actor[:worker_pool].async.cleanup_subscriptions(self) + self.class.worker_pool.async.cleanup_subscriptions(self) end @websocket.rack_response -- cgit v1.2.3 From 1b090cf6b215e334ba62b476d14283a49fca8cab Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 20:51:32 +0530 Subject: Helper method for worker_pool --- lib/action_cable/channel/base.rb | 7 ++++++- lib/action_cable/server.rb | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index a3aa4df2ad..832c8cc314 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -67,7 +67,7 @@ module ActionCable def start_periodic_timers self.class.periodic_timers.each do |callback, options| @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do - connection.class.worker_pool.async.run_periodic_timer(self, callback) + worker_pool.async.run_periodic_timer(self, callback) end end end @@ -75,6 +75,11 @@ module ActionCable def stop_periodic_timers @_active_periodic_timers.each {|t| t.cancel } end + + def worker_pool + connection.worker_pool + end + end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index d1b7e14b53..b6ea201b52 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -32,11 +32,11 @@ module ActionCable @websocket.on(:message) do |event| message = event.data - self.class.worker_pool.async.received_data(self, message) if message.is_a?(String) + worker_pool.async.received_data(self, message) if message.is_a?(String) end @websocket.on(:close) do |event| - self.class.worker_pool.async.cleanup_subscriptions(self) + worker_pool.async.cleanup_subscriptions(self) end @websocket.rack_response @@ -68,6 +68,10 @@ module ActionCable @websocket.send data end + def worker_pool + self.class.worker_pool + end + private def subscribe_channel(data) id_key = data['identifier'] -- cgit v1.2.3 From df70406ef4ae1c0409bac213ecbf7aaf1bc3b758 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 20:58:38 +0530 Subject: Generic Worker#invoke method --- lib/action_cable/server.rb | 4 ++-- lib/action_cable/worker.rb | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index b6ea201b52..9a30c7d3bf 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -32,11 +32,11 @@ module ActionCable @websocket.on(:message) do |event| message = event.data - worker_pool.async.received_data(self, message) if message.is_a?(String) + worker_pool.async.invoke(self, :received_data, message) if message.is_a?(String) end @websocket.on(:close) do |event| - worker_pool.async.cleanup_subscriptions(self) + worker_pool.async.invoke(self, :cleanup_subscriptions) end @websocket.rack_response diff --git a/lib/action_cable/worker.rb b/lib/action_cable/worker.rb index 1a8bee974b..6687af43a0 100644 --- a/lib/action_cable/worker.rb +++ b/lib/action_cable/worker.rb @@ -5,15 +5,9 @@ module ActionCable define_callbacks :work - def received_data(connection, data) + def invoke(receiver, method, *args) run_callbacks :work do - connection.received_data(data) - end - end - - def cleanup_subscriptions(connection) - run_callbacks :work do - connection.cleanup_subscriptions + receiver.send method, *args end end -- cgit v1.2.3 From 55a088167e1f2e10dadefadb5e2e68e2301dba14 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Feb 2015 21:00:18 +0530 Subject: Add a Server#disconnect callback --- lib/action_cable/server.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 9a30c7d3bf..e657f6d636 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -37,6 +37,7 @@ module ActionCable @websocket.on(:close) do |event| worker_pool.async.invoke(self, :cleanup_subscriptions) + worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) end @websocket.rack_response -- cgit v1.2.3 From c40e52ea68e21ba5b098343c7e1f285800f97008 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 7 Feb 2015 00:47:45 +0530 Subject: Use just one redis connection --- lib/action_cable/channel/redis.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 215cc3c975..2aca785cbe 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -10,21 +10,22 @@ module ActionCable def subscribe_to(redis_channel, callback = nil) @_redis_channels ||= [] - @_redis_channels << redis_channel callback ||= -> (message) { broadcast ActiveSupport::JSON.decode(message) } - redis.pubsub.subscribe(redis_channel, &callback) + @_redis_channels << [ redis_channel, callback ] + + pubsub.subscribe(redis_channel, &callback) end protected def unsubscribe_from_redis_channels if @_redis_channels - @_redis_channels.each { |channel| @connection.pubsub.unsubscribe(channel) } + @_redis_channels.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } end end - def redis - @connection.redis + def pubsub + @connection.pubsub end end -- cgit v1.2.3 From 05309cdf3858d4e65bda3fb31cbf86eed3b21c42 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 7 Feb 2015 00:49:41 +0530 Subject: Use the class pubsub method --- lib/action_cable/channel/redis.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 2aca785cbe..00a8230a74 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -25,7 +25,7 @@ module ActionCable end def pubsub - @connection.pubsub + @connection.class.pubsub end end -- cgit v1.2.3 From 60173338ee0fb1667e226927e0034d50e3dd9d5a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 7 Feb 2015 09:22:00 +0530 Subject: Fix periodic timers --- lib/action_cable/channel/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 832c8cc314..9cfeb4b73a 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -10,7 +10,7 @@ module ActionCable on_unsubscribe :disconnect - attr_reader :params + attr_reader :params, :connection class_attribute :channel_name @@ -61,7 +61,7 @@ module ActionCable end def broadcast(data) - @connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) + connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) end def start_periodic_timers -- cgit v1.2.3 From 85ac703585a3bc413571e23d2e7dc3ca1e4cad2e Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 7 Feb 2015 09:22:22 +0530 Subject: Raise an exception when Server.pubsub class method is not defined --- lib/action_cable/channel/redis.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 00a8230a74..bdbd3c95b1 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -9,9 +9,10 @@ module ActionCable end def subscribe_to(redis_channel, callback = nil) - @_redis_channels ||= [] + raise "`ActionCable::Server.pubsub` class method is not defined" unless connection.class.respond_to?(:pubsub) callback ||= -> (message) { broadcast ActiveSupport::JSON.decode(message) } + @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] pubsub.subscribe(redis_channel, &callback) @@ -25,7 +26,7 @@ module ActionCable end def pubsub - @connection.class.pubsub + connection.class.pubsub end end -- cgit v1.2.3 From 9b8195b4b03e9ee70bde3072e364826f198d9fb2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 9 Feb 2015 21:22:21 -0800 Subject: Inline id_key variable --- lib/action_cable/server.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index e657f6d636..96d21416bf 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -88,17 +88,14 @@ module ActionCable end def process_message(message) - id_key = message['identifier'] - - if @subscriptions[id_key] - @subscriptions[id_key].receive(ActiveSupport::JSON.decode message['data']) + if @subscriptions[message['identifier']] + @subscriptions[message['identifier']].receive(ActiveSupport::JSON.decode message['data']) end end def unsubscribe_channel(data) - id_key = data['identifier'] - @subscriptions[id_key].unsubscribe - @subscriptions.delete(id_key) + @subscriptions[data['identifier']].unsubscribe + @subscriptions.delete(data['identifier']) end def invalid_request -- cgit v1.2.3 From c0936fbf1f332a6fc2c68a1dc3f52b327e63f0b2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 9 Feb 2015 21:22:32 -0800 Subject: Add logging --- lib/action_cable/server.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 96d21416bf..8f72d2ca7b 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -6,6 +6,8 @@ module ActionCable class_attribute :worker_pool_size self.worker_pool_size = 100 + cattr_accessor(:logger, instance_reader: true) { Rails.logger } + class << self def register_channels(*channel_classes) self.registered_channels += channel_classes @@ -66,6 +68,7 @@ module ActionCable end def broadcast(data) + logger.info "Sending data: #{data}" @websocket.send data end @@ -81,19 +84,23 @@ module ActionCable subscription_klass = registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } if subscription_klass + logger.info "Subscribing to channel: #{id_key}" @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) else - # No channel found + logger.error "Unable to subscribe to channel: #{id_key}" end end def process_message(message) if @subscriptions[message['identifier']] @subscriptions[message['identifier']].receive(ActiveSupport::JSON.decode message['data']) + else + logger.error "Unable to process message: #{message}" end end def unsubscribe_channel(data) + logger.info "Unsubscribing from channel: #{data['identifier']}" @subscriptions[data['identifier']].unsubscribe @subscriptions.delete(data['identifier']) end -- cgit v1.2.3 From 00aec9c8e8b30cfb40454ed44693465843b0d4b2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 11 Feb 2015 00:00:51 +0530 Subject: Ping the client every 3 seconds --- lib/action_cable/server.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index e657f6d636..47c9352160 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -6,6 +6,8 @@ module ActionCable class_attribute :worker_pool_size self.worker_pool_size = 100 + PING_INTERVAL = 3 + class << self def register_channels(*channel_classes) self.registered_channels += channel_classes @@ -30,6 +32,11 @@ module ActionCable @websocket = Faye::WebSocket.new(@env) + @websocket.on(:open) do |event| + broadcast_ping_timestamp + @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } + end + @websocket.on(:message) do |event| message = event.data worker_pool.async.invoke(self, :received_data, message) if message.is_a?(String) @@ -38,6 +45,8 @@ module ActionCable @websocket.on(:close) do |event| worker_pool.async.invoke(self, :cleanup_subscriptions) worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) + + EventMachine.cancel_timer(@ping_timer) if @ping_timer end @websocket.rack_response @@ -74,6 +83,10 @@ module ActionCable end private + def broadcast_ping_timestamp + broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) + end + def subscribe_channel(data) id_key = data['identifier'] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access @@ -104,5 +117,6 @@ module ActionCable def invalid_request [404, {'Content-Type' => 'text/plain'}, ['Page not found']] end + end end -- cgit v1.2.3 From 2c0c9a17d07832e8a4a3a50b95e1f8f15ab22d5a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 12 Feb 2015 20:47:26 +0530 Subject: Move assets to the gem --- lib/action_cable.rb | 2 + lib/action_cable/engine.rb | 4 ++ lib/assets/javascripts/cable.js.coffee | 107 +++++++++++++++++++++++++++++++ lib/assets/javascripts/channel.js.coffee | 27 ++++++++ 4 files changed, 140 insertions(+) create mode 100644 lib/action_cable/engine.rb create mode 100644 lib/assets/javascripts/cable.js.coffee create mode 100644 lib/assets/javascripts/channel.js.coffee diff --git a/lib/action_cable.rb b/lib/action_cable.rb index e7d8f4cbb1..0681b8bdde 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -12,6 +12,8 @@ require 'active_support/callbacks' require 'faye/websocket' require 'celluloid' +require 'action_cable/engine' if defined?(Rails) + module ActionCable VERSION = '0.0.1' diff --git a/lib/action_cable/engine.rb b/lib/action_cable/engine.rb new file mode 100644 index 0000000000..6c943c7971 --- /dev/null +++ b/lib/action_cable/engine.rb @@ -0,0 +1,4 @@ +module ActionCable + class Engine < ::Rails::Engine + end +end diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee new file mode 100644 index 0000000000..2a70693bf0 --- /dev/null +++ b/lib/assets/javascripts/cable.js.coffee @@ -0,0 +1,107 @@ +#= require_self +#= require_tree . + +class @Cable + MAX_CONNECTION_ATTEMPTS: 10 + MAX_CONNECTION_INTERVAL: 5 * 1000 + MAX_PING_INTERVAL: 6 + + constructor: (@cableUrl) -> + @subscribers = {} + @resetPingTime() + @resetConnectionAttemptsCount() + @connect() + + connect: -> + @connection = @createConnection() + + createConnection: -> + connection = new WebSocket(@cableUrl) + connection.onmessage = @receiveData + connection.onopen = @connected + connection.onclose = @reconnect + + connection.onerror = @reconnect + connection + + isConnected: => + @connection?.readyState is 1 + + sendData: (identifier, data) => + if @isConnected() + @connection.send JSON.stringify { action: 'message', identifier: identifier, data: data } + + receiveData: (message) => + data = JSON.parse message.data + + if data.identifier is '_ping' + @pingReceived(data.message) + else + @subscribers[data.identifier]?.onReceiveData(data.message) + + connected: => + @resetConnectionAttemptsCount() + + for identifier, callbacks of @subscribers + @subscribeOnServer(identifier) + callbacks['onConnect']?() + + reconnect: => + @resetPingTime() + @disconnected() + + setTimeout => + if @isMaxConnectionAttemptsReached() + @giveUp() + else + @incrementConnectionAttemptsCount() + @connect() + , @generateReconnectInterval() + + resetConnectionAttemptsCount: => + @connectionAttempts = 1 + + incrementConnectionAttemptsCount: => + @connectionAttempts += 1 + + isMaxConnectionAttemptsReached: => + @connectionAttempts > @MAX_CONNECTION_ATTEMPTS + + generateReconnectInterval: () -> + interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 + if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval + + resetPingTime: () => + @lastPingTime = null + + disconnected: => + callbacks['onDisconnect']?() for identifier, callbacks of @subscribers + + giveUp: => + # Show an error message + + subscribe: (identifier, callbacks) => + @subscribers[identifier] = callbacks + + if @isConnected() + @subscribeOnServer(identifier) + @subscribers[identifier]['onConnect']?() + + unsubscribe: (identifier) => + @unsubscribeOnServer(identifier, 'unsubscribe') + delete @subscribers[identifier] + + subscribeOnServer: (identifier) => + if @isConnected() + @connection.send JSON.stringify { action: 'subscribe', identifier: identifier } + + unsubscribeOnServer: (identifier) => + if @isConnected() + @connection.send JSON.stringify { action: 'unsubscribe', identifier: identifier } + + pingReceived: (timestamp) => + if @lastPingTime? and (timestamp - @lastPingTime) > @MAX_PING_INTERVAL + console.log "Websocket connection is stale. Reconnecting.." + @connection.close() + else + @lastPingTime = timestamp diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee new file mode 100644 index 0000000000..058bcc03aa --- /dev/null +++ b/lib/assets/javascripts/channel.js.coffee @@ -0,0 +1,27 @@ +class @Cable.Channel + constructor: (params = {}) -> + @channelName ?= @underscore @constructor.name + + params['channel'] = @channelName + @channelIdentifier = JSON.stringify params + + cable.subscribe(@channelIdentifier, { + onConnect: @connected + onDisconnect: @disconnected + onReceiveData: @received + }) + + connected: => + # Override in the subclass + + disconnected: => + # Override in the subclass + + received: (data) => + # Override in the subclass + + send: (data) -> + cable.sendData @channelIdentifier, JSON.stringify data + + underscore: (value) -> + value.replace(/[A-Z]/g, (match) => "_#{match.toLowerCase()}").substr(1) \ No newline at end of file -- cgit v1.2.3 From 06397077e4eb5b5926944e85694fd77fc16734bc Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 13 Feb 2015 17:29:17 +0530 Subject: New Gemfile.lock --- Gemfile.lock | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1afa86ab40..be94db18d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,20 +1,10 @@ -GIT - remote: git://github.com/lifo/cramp.git - revision: 60f6f30fe69fedd076ac7cb64f65ba8f382c8a67 - specs: - cramp (0.15.4) - activesupport (~> 4.2.0) - eventmachine (~> 1.0.3) - faye-websocket (~> 0.9.2) - rack (~> 1.6.0) - thor (~> 0.19.0) - PATH remote: . specs: action_cable (0.0.1) activesupport (~> 4.2.0) - cramp (~> 0.15.4) + celluloid (~> 0.16.0) + faye-websocket (~> 0.9.2) GEM remote: http://rubygems.org/ @@ -25,10 +15,13 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) + celluloid (0.16.0) + timers (~> 4.0.0) eventmachine (1.0.4) faye-websocket (0.9.2) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) + hitimes (1.2.2) i18n (0.7.0) json (1.8.2) minitest (5.5.1) @@ -36,8 +29,9 @@ GEM rack (>= 1.1, < 2.0) rack (1.6.0) rake (10.4.2) - thor (0.19.1) thread_safe (0.3.4) + timers (4.0.1) + hitimes tzinfo (1.2.2) thread_safe (~> 0.1) websocket-driver (0.5.1) @@ -49,6 +43,5 @@ PLATFORMS DEPENDENCIES action_cable! - cramp! puma rake -- cgit v1.2.3 From 6e1b5877de2a5f7476278b6c1a97cddfe933eb07 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 13 Feb 2015 17:29:26 +0530 Subject: Dont create the test log file --- test/test_helper.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 10a4827281..2b1ddb237f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -12,10 +12,6 @@ require 'puma' require 'action_cable' ActiveSupport.test_order = :sorted -require 'logger' -logger = Logger.new(File.join(File.dirname(__FILE__), "tests.log")) -logger.level = Logger::DEBUG - class ActionCableTest < ActiveSupport::TestCase PORT = 420420 -- cgit v1.2.3 From 144a381c5ed9bc2a6f4a4dc1ad392299daca79f2 Mon Sep 17 00:00:00 2001 From: Jeremy Kemper Date: Mon, 16 Feb 2015 08:15:22 -0800 Subject: Support Rails 5 --- Gemfile.lock | 6 +++--- action_cable.gemspec | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index be94db18d2..e767e58784 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - action_cable (0.0.1) - activesupport (~> 4.2.0) + action_cable (0.0.2) + activesupport (>= 4.2.0) celluloid (~> 0.16.0) faye-websocket (~> 0.9.2) @@ -17,7 +17,7 @@ GEM tzinfo (~> 1.1) celluloid (0.16.0) timers (~> 4.0.0) - eventmachine (1.0.4) + eventmachine (1.0.7) faye-websocket (0.9.2) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) diff --git a/action_cable.gemspec b/action_cable.gemspec index 24553aa6fe..1dade2a394 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -1,7 +1,7 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = 'action_cable' - s.version = '0.0.1' + s.version = '0.0.2' s.summary = 'Framework for websockets.' s.description = 'Action Cable is a framework for realtime communication over websockets.' @@ -9,7 +9,7 @@ Gem::Specification.new do |s| s.email = ['pratiknaik@gmail.com'] s.homepage = 'http://basecamp.com' - s.add_dependency('activesupport', '~> 4.2.0') + s.add_dependency('activesupport', '>= 4.2.0') s.add_dependency('faye-websocket', '~> 0.9.2') s.add_dependency('celluloid', '~> 0.16.0') -- cgit v1.2.3 From 359f006bac5318fa3f61b2c20f45bd7ec90b0de1 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 26 Feb 2015 14:25:34 -0600 Subject: Reconnect the websocket if the server doesnt send a ping every 6 seconds --- lib/assets/javascripts/cable.js.coffee | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 2a70693bf0..2b34c1b447 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -4,7 +4,7 @@ class @Cable MAX_CONNECTION_ATTEMPTS: 10 MAX_CONNECTION_INTERVAL: 5 * 1000 - MAX_PING_INTERVAL: 6 + PING_STALE_INTERVAL: 6 constructor: (@cableUrl) -> @subscribers = {} @@ -40,6 +40,7 @@ class @Cable @subscribers[data.identifier]?.onReceiveData(data.message) connected: => + @startWaitingForPing() @resetConnectionAttemptsCount() for identifier, callbacks of @subscribers @@ -47,6 +48,7 @@ class @Cable callbacks['onConnect']?() reconnect: => + @clearPingWaitTimeout() @resetPingTime() @disconnected() @@ -71,7 +73,18 @@ class @Cable interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval - resetPingTime: () => + startWaitingForPing: => + @clearPingWaitTimeout() + + @waitForPingTimeout = setTimeout => + console.log "Ping took too long to arrive. Reconnecting.." + @connection?.close() + , @PING_STALE_INTERVAL * 1000 + + clearPingWaitTimeout: => + clearTimeout(@waitForPingTimeout) + + resetPingTime: => @lastPingTime = null disconnected: => @@ -100,8 +113,9 @@ class @Cable @connection.send JSON.stringify { action: 'unsubscribe', identifier: identifier } pingReceived: (timestamp) => - if @lastPingTime? and (timestamp - @lastPingTime) > @MAX_PING_INTERVAL + if @lastPingTime? and (timestamp - @lastPingTime) > @PING_STALE_INTERVAL console.log "Websocket connection is stale. Reconnecting.." - @connection.close() + @connection?.close() else + @startWaitingForPing() @lastPingTime = timestamp -- cgit v1.2.3 From d96ea7c5e4481b5d6b08a98a2c2c9fc7a2a5078a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 26 Feb 2015 15:52:45 -0600 Subject: Never stop attempting to reconnect --- lib/assets/javascripts/cable.js.coffee | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 2b34c1b447..07f6ba2284 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -2,7 +2,6 @@ #= require_tree . class @Cable - MAX_CONNECTION_ATTEMPTS: 10 MAX_CONNECTION_INTERVAL: 5 * 1000 PING_STALE_INTERVAL: 6 @@ -53,11 +52,8 @@ class @Cable @disconnected() setTimeout => - if @isMaxConnectionAttemptsReached() - @giveUp() - else - @incrementConnectionAttemptsCount() - @connect() + @incrementConnectionAttemptsCount() + @connect() , @generateReconnectInterval() resetConnectionAttemptsCount: => @@ -66,9 +62,6 @@ class @Cable incrementConnectionAttemptsCount: => @connectionAttempts += 1 - isMaxConnectionAttemptsReached: => - @connectionAttempts > @MAX_CONNECTION_ATTEMPTS - generateReconnectInterval: () -> interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval -- cgit v1.2.3 From 89f3fb71c739aee76be619806ab9fe6e513d5a36 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 26 Feb 2015 16:33:17 -0600 Subject: Remove existing connection when trying to reconnect to ensure we dont end up with multiple connections --- lib/assets/javascripts/cable.js.coffee | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 07f6ba2284..eb80fd7cbf 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -47,6 +47,8 @@ class @Cable callbacks['onConnect']?() reconnect: => + @removeExistingConnection() + @clearPingWaitTimeout() @resetPingTime() @disconnected() @@ -56,6 +58,13 @@ class @Cable @connect() , @generateReconnectInterval() + removeExistingConnection: => + if @connection? + @connection.onclose = -> # no-op + @connection.onerror = -> # no-op + @connection.close() + @connection = null + resetConnectionAttemptsCount: => @connectionAttempts = 1 -- cgit v1.2.3 From 6451fe14084563412cf0d52b4f6b895ee9b53bfe Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 26 Feb 2015 16:33:41 -0600 Subject: Call reconnect() when a ping doesnt arrive in expected time --- lib/assets/javascripts/cable.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index eb80fd7cbf..fff64cd284 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -117,7 +117,7 @@ class @Cable pingReceived: (timestamp) => if @lastPingTime? and (timestamp - @lastPingTime) > @PING_STALE_INTERVAL console.log "Websocket connection is stale. Reconnecting.." - @connection?.close() + @reconnect() else @startWaitingForPing() @lastPingTime = timestamp -- cgit v1.2.3 From 07269ba550ac0aa043412cb0fbe255a7ac3b826a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Mar 2015 17:38:36 -0600 Subject: Authorize before sending and receiving data --- lib/action_cable/channel/base.rb | 31 ++++++++++++++++++++++++++++--- lib/action_cable/server.rb | 2 +- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 9cfeb4b73a..8ee99649f4 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -35,8 +35,16 @@ module ActionCable subscribe end - def receive(data) - raise "Not implemented" + def receive_data(data) + if authorized? + if respond_to?(:receive) + receive(data) + else + logger.error "[ActionCable] #{self.class.name} received data (#{data}) but #{self.class.name}#receive callback is not defined" + end + else + unauthorized + end end def subscribe @@ -52,6 +60,15 @@ module ActionCable end protected + # Override in subclasses + def authorized? + true + end + + def unauthorized + logger.error "[ActionCable] Unauthorized access to #{self.class.name}" + end + def connect # Override in subclasses end @@ -61,7 +78,11 @@ module ActionCable end def broadcast(data) - connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) + if authorized? + connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) + else + unauthorized + end end def start_periodic_timers @@ -80,6 +101,10 @@ module ActionCable connection.worker_pool end + def logger + connection.logger + end + end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 2449837105..3c78ad5239 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -106,7 +106,7 @@ module ActionCable def process_message(message) if @subscriptions[message['identifier']] - @subscriptions[message['identifier']].receive(ActiveSupport::JSON.decode message['data']) + @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) else logger.error "Unable to process message: #{message}" end -- cgit v1.2.3 From d288fbf22d3dd580a2f5a863169c2896fd5272dd Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 6 Mar 2015 14:39:45 -0600 Subject: Add request and cookies helpers --- lib/action_cable/server.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 3c78ad5239..c7745e6b3c 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -24,6 +24,8 @@ module ActionCable end end + attr_reader :env + def initialize(env) @env = env end @@ -37,6 +39,7 @@ module ActionCable @websocket.on(:open) do |event| broadcast_ping_timestamp @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } + worker_pool.async.invoke(self, :connect) if respond_to?(:connect) end @websocket.on(:message) do |event| @@ -85,6 +88,14 @@ module ActionCable self.class.worker_pool end + def request + @request ||= ActionDispatch::Request.new(env) + end + + def cookies + request.cookie_jar + end + private def broadcast_ping_timestamp broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) -- cgit v1.2.3 From 7191b038d829a1b5ae917ebb9298091c41f065b0 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 6 Mar 2015 17:23:31 -0600 Subject: Remove request and cookies helper methods --- lib/action_cable/server.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index c7745e6b3c..a0c128129d 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -88,14 +88,6 @@ module ActionCable self.class.worker_pool end - def request - @request ||= ActionDispatch::Request.new(env) - end - - def cookies - request.cookie_jar - end - private def broadcast_ping_timestamp broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) -- cgit v1.2.3 From 9264ceb437341d768ec092d04e5e0c727f6f43ad Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 23 Mar 2015 15:31:53 -0500 Subject: Don't call connection#close directly --- lib/assets/javascripts/cable.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index fff64cd284..4909609f52 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -80,7 +80,7 @@ class @Cable @waitForPingTimeout = setTimeout => console.log "Ping took too long to arrive. Reconnecting.." - @connection?.close() + @reconnect() , @PING_STALE_INTERVAL * 1000 clearPingWaitTimeout: => -- cgit v1.2.3 From 63d61051aaf62fdc956d3fe11b380033e09a76fe Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 24 Mar 2015 10:24:43 -0500 Subject: Increase the stale timout --- lib/assets/javascripts/cable.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 4909609f52..46cbf7bff0 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -3,7 +3,7 @@ class @Cable MAX_CONNECTION_INTERVAL: 5 * 1000 - PING_STALE_INTERVAL: 6 + PING_STALE_INTERVAL: 8 constructor: (@cableUrl) -> @subscribers = {} -- cgit v1.2.3 From e570864c1f30c1b3e8b75c0039d981ccfccba08d Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 25 Mar 2015 13:21:34 -0500 Subject: Dont process messages until connect is run --- lib/action_cable/server.rb | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index a0c128129d..b6bceda81b 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -28,6 +28,8 @@ module ActionCable def initialize(env) @env = env + @accept_messages = false + @pending_messages = [] end def process @@ -39,12 +41,19 @@ module ActionCable @websocket.on(:open) do |event| broadcast_ping_timestamp @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } - worker_pool.async.invoke(self, :connect) if respond_to?(:connect) + worker_pool.async.invoke(self, :initialize_client) end @websocket.on(:message) do |event| message = event.data - worker_pool.async.invoke(self, :received_data, message) if message.is_a?(String) + + if message.is_a?(String) + if @accept_messages + worker_pool.async.invoke(self, :received_data, message) + else + @pending_messages << message + end + end end @websocket.on(:close) do |event| @@ -89,6 +98,13 @@ module ActionCable end private + def initialize_client + connect if respond_to?(:connect) + @accept_messages = true + + worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? + end + def broadcast_ping_timestamp broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) end -- cgit v1.2.3 From 432139183e885f69c6a8f576af7a178842a6e2a1 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 25 Mar 2015 13:23:34 -0500 Subject: Dont process messages when the websocket is no longer open --- lib/action_cable/server.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index b6bceda81b..77010071d2 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -70,6 +70,8 @@ module ActionCable end def received_data(data) + return unless websocket_alive? + data = ActiveSupport::JSON.decode data case data['action'] @@ -141,5 +143,9 @@ module ActionCable [404, {'Content-Type' => 'text/plain'}, ['Page not found']] end + def websocket_alive? + @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN + end + end end -- cgit v1.2.3 From f29a14207aa3084cb2f0a73cb5672729aa0c6d62 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 25 Mar 2015 13:35:01 -0500 Subject: Close the websocket on exception --- lib/action_cable/server.rb | 6 ++++++ lib/action_cable/worker.rb | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 77010071d2..ebf98171c1 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -99,6 +99,12 @@ module ActionCable self.class.worker_pool end + def handle_exception + logger.error "[ActionCable] Closing connection" + + @websocket.close + end + private def initialize_client connect if respond_to?(:connect) diff --git a/lib/action_cable/worker.rb b/lib/action_cable/worker.rb index 6687af43a0..6773535afe 100644 --- a/lib/action_cable/worker.rb +++ b/lib/action_cable/worker.rb @@ -9,6 +9,11 @@ module ActionCable run_callbacks :work do receiver.send method, *args end + rescue Exception => e + logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + receiver.handle_exception if receiver.respond_to?(:handle_exception) end def run_periodic_timer(channel, callback) @@ -17,5 +22,9 @@ module ActionCable end end + private + def logger + ActionCable::Server.logger + end end end -- cgit v1.2.3 From bdbbe18f3cc527b121bbb2f402898caf4c2fbb15 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 3 Apr 2015 09:32:22 -0500 Subject: Clear ping wait timeout when removing the connection --- lib/assets/javascripts/cable.js.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 46cbf7bff0..345771dd1f 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -49,7 +49,6 @@ class @Cable reconnect: => @removeExistingConnection() - @clearPingWaitTimeout() @resetPingTime() @disconnected() @@ -60,6 +59,8 @@ class @Cable removeExistingConnection: => if @connection? + @clearPingWaitTimeout() + @connection.onclose = -> # no-op @connection.onerror = -> # no-op @connection.close() -- cgit v1.2.3 From 354018bf9b5f5bf0fbbc6e6efddc719e7523b39d Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 4 Apr 2015 00:26:14 -0500 Subject: Separate connection and server classes --- action_cable.gemspec | 1 + lib/action_cable.rb | 4 + lib/action_cable/channel/redis.rb | 7 +- lib/action_cable/connection.rb | 133 ++++++++++++++++++++++++++++++++ lib/action_cable/connections.rb | 17 +++++ lib/action_cable/server.rb | 154 +++----------------------------------- 6 files changed, 166 insertions(+), 150 deletions(-) create mode 100644 lib/action_cable/connection.rb create mode 100644 lib/action_cable/connections.rb diff --git a/action_cable.gemspec b/action_cable.gemspec index 1dade2a394..ba5c4159d6 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -12,6 +12,7 @@ Gem::Specification.new do |s| s.add_dependency('activesupport', '>= 4.2.0') s.add_dependency('faye-websocket', '~> 0.9.2') s.add_dependency('celluloid', '~> 0.16.0') + s.add_dependency('em-hiredis', '~> 0.3.0') s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 0681b8bdde..62046c7717 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -7,10 +7,12 @@ require 'active_support' require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/module/delegation' require 'active_support/callbacks' require 'faye/websocket' require 'celluloid' +require 'em-hiredis' require 'action_cable/engine' if defined?(Rails) @@ -20,4 +22,6 @@ module ActionCable autoload :Channel, 'action_cable/channel' autoload :Worker, 'action_cable/worker' autoload :Server, 'action_cable/server' + autoload :Connection, 'action_cable/connection' + autoload :Connections, 'action_cable/connections' end diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index bdbd3c95b1..2691a3b145 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -6,11 +6,10 @@ module ActionCable included do on_unsubscribe :unsubscribe_from_redis_channels + delegate :pubsub, to: :connection end def subscribe_to(redis_channel, callback = nil) - raise "`ActionCable::Server.pubsub` class method is not defined" unless connection.class.respond_to?(:pubsub) - callback ||= -> (message) { broadcast ActiveSupport::JSON.decode(message) } @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] @@ -24,10 +23,6 @@ module ActionCable @_redis_channels.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } end end - - def pubsub - connection.class.pubsub - end end end diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb new file mode 100644 index 0000000000..00fb8ca817 --- /dev/null +++ b/lib/action_cable/connection.rb @@ -0,0 +1,133 @@ +module ActionCable + class Connection + PING_INTERVAL = 3 + + attr_reader :env, :server + delegate :worker_pool, :pubsub, :logger, to: :server + + def initialize(server, env) + @server = server + @env = env + @accept_messages = false + @pending_messages = [] + end + + def process + if Faye::WebSocket.websocket?(@env) + @subscriptions = {} + + @websocket = Faye::WebSocket.new(@env) + + @websocket.on(:open) do |event| + broadcast_ping_timestamp + @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } + worker_pool.async.invoke(self, :initialize_client) + end + + @websocket.on(:message) do |event| + message = event.data + + if message.is_a?(String) + if @accept_messages + worker_pool.async.invoke(self, :received_data, message) + else + @pending_messages << message + end + end + end + + @websocket.on(:close) do |event| + worker_pool.async.invoke(self, :cleanup_subscriptions) + worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) + + EventMachine.cancel_timer(@ping_timer) if @ping_timer + end + + @websocket.rack_response + else + invalid_request + end + end + + def received_data(data) + return unless websocket_alive? + + data = ActiveSupport::JSON.decode data + + case data['action'] + when 'subscribe' + subscribe_channel(data) + when 'unsubscribe' + unsubscribe_channel(data) + when 'message' + process_message(data) + end + end + + def cleanup_subscriptions + @subscriptions.each do |id, channel| + channel.unsubscribe + end + end + + def broadcast(data) + logger.info "Sending data: #{data}" + @websocket.send data + end + + def handle_exception + logger.error "[ActionCable] Closing connection" + + @websocket.close + end + + private + def initialize_client + connect if respond_to?(:connect) + @accept_messages = true + + worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? + end + + def broadcast_ping_timestamp + broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) + end + + def subscribe_channel(data) + id_key = data['identifier'] + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + + subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } + + if subscription_klass + logger.info "Subscribing to channel: #{id_key}" + @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) + else + logger.error "Unable to subscribe to channel: #{id_key}" + end + end + + def process_message(message) + if @subscriptions[message['identifier']] + @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) + else + logger.error "Unable to process message: #{message}" + end + end + + def unsubscribe_channel(data) + logger.info "Unsubscribing from channel: #{data['identifier']}" + @subscriptions[data['identifier']].unsubscribe + @subscriptions.delete(data['identifier']) + end + + def invalid_request + [404, {'Content-Type' => 'text/plain'}, ['Page not found']] + end + + def websocket_alive? + @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN + end + + end +end diff --git a/lib/action_cable/connections.rb b/lib/action_cable/connections.rb new file mode 100644 index 0000000000..e68cc6e7a4 --- /dev/null +++ b/lib/action_cable/connections.rb @@ -0,0 +1,17 @@ +module ActionCable + module Connections + class << self + def active + end + + def where(identification) + end + end + + def disconnect + end + + def reconnect + end + end +end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index ebf98171c1..6e9265dc06 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,157 +1,23 @@ module ActionCable class Server - class_attribute :registered_channels - self.registered_channels = Set.new - - class_attribute :worker_pool_size - self.worker_pool_size = 100 - cattr_accessor(:logger, instance_reader: true) { Rails.logger } - PING_INTERVAL = 3 - - class << self - def register_channels(*channel_classes) - self.registered_channels += channel_classes - end - - def call(env) - new(env).process - end - - def worker_pool - @worker_pool ||= ActionCable::Worker.pool(size: worker_pool_size) - end - end - - attr_reader :env - - def initialize(env) - @env = env - @accept_messages = false - @pending_messages = [] - end - - def process - if Faye::WebSocket.websocket?(@env) - @subscriptions = {} - - @websocket = Faye::WebSocket.new(@env) - - @websocket.on(:open) do |event| - broadcast_ping_timestamp - @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } - worker_pool.async.invoke(self, :initialize_client) - end - - @websocket.on(:message) do |event| - message = event.data - - if message.is_a?(String) - if @accept_messages - worker_pool.async.invoke(self, :received_data, message) - else - @pending_messages << message - end - end - end + attr_accessor :registered_channels, :worker_pool - @websocket.on(:close) do |event| - worker_pool.async.invoke(self, :cleanup_subscriptions) - worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) - - EventMachine.cancel_timer(@ping_timer) if @ping_timer - end - - @websocket.rack_response - else - invalid_request - end + def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection) + @redis_config = redis_config + @registered_channels = Set.new(channels) + @worker_pool = ActionCable::Worker.pool(size: worker_pool_size) + @connection_class = connection end - def received_data(data) - return unless websocket_alive? - - data = ActiveSupport::JSON.decode data - - case data['action'] - when 'subscribe' - subscribe_channel(data) - when 'unsubscribe' - unsubscribe_channel(data) - when 'message' - process_message(data) - end + def call(env) + @connection_class.new(self, env).process end - def cleanup_subscriptions - @subscriptions.each do |id, channel| - channel.unsubscribe - end + def pubsub + @pubsub ||= EM::Hiredis.connect(@redis_config['url']).pubsub end - def broadcast(data) - logger.info "Sending data: #{data}" - @websocket.send data - end - - def worker_pool - self.class.worker_pool - end - - def handle_exception - logger.error "[ActionCable] Closing connection" - - @websocket.close - end - - private - def initialize_client - connect if respond_to?(:connect) - @accept_messages = true - - worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? - end - - def broadcast_ping_timestamp - broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) - end - - def subscribe_channel(data) - id_key = data['identifier'] - id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - - subscription_klass = registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } - - if subscription_klass - logger.info "Subscribing to channel: #{id_key}" - @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) - else - logger.error "Unable to subscribe to channel: #{id_key}" - end - end - - def process_message(message) - if @subscriptions[message['identifier']] - @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) - else - logger.error "Unable to process message: #{message}" - end - end - - def unsubscribe_channel(data) - logger.info "Unsubscribing from channel: #{data['identifier']}" - @subscriptions[data['identifier']].unsubscribe - @subscriptions.delete(data['identifier']) - end - - def invalid_request - [404, {'Content-Type' => 'text/plain'}, ['Page not found']] - end - - def websocket_alive? - @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN - end - end end -- cgit v1.2.3 From eec92d0229a7bceb62d49d58c70b5629fe140d7f Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 12:21:22 -0500 Subject: Add connection identifier and an internal redis channel --- Gemfile.lock | 9 ++- lib/action_cable.rb | 2 +- lib/action_cable/connection.rb | 133 +----------------------------- lib/action_cable/connection/base.rb | 139 ++++++++++++++++++++++++++++++++ lib/action_cable/connection/registry.rb | 65 +++++++++++++++ lib/action_cable/connection_proxy.rb | 14 ++++ lib/action_cable/connections.rb | 17 ---- 7 files changed, 229 insertions(+), 150 deletions(-) create mode 100644 lib/action_cable/connection/base.rb create mode 100644 lib/action_cable/connection/registry.rb create mode 100644 lib/action_cable/connection_proxy.rb delete mode 100644 lib/action_cable/connections.rb diff --git a/Gemfile.lock b/Gemfile.lock index e767e58784..6fcf4aa39f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH action_cable (0.0.2) activesupport (>= 4.2.0) celluloid (~> 0.16.0) + em-hiredis (~> 0.3.0) faye-websocket (~> 0.9.2) GEM @@ -17,10 +18,14 @@ GEM tzinfo (~> 1.1) celluloid (0.16.0) timers (~> 4.0.0) + em-hiredis (0.3.0) + eventmachine (~> 1.0) + hiredis (~> 0.5.0) eventmachine (1.0.7) faye-websocket (0.9.2) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) + hiredis (0.5.2) hitimes (1.2.2) i18n (0.7.0) json (1.8.2) @@ -34,9 +39,9 @@ GEM hitimes tzinfo (1.2.2) thread_safe (~> 0.1) - websocket-driver (0.5.1) + websocket-driver (0.5.3) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.1) + websocket-extensions (0.1.2) PLATFORMS ruby diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 62046c7717..fd42dc6cdd 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -23,5 +23,5 @@ module ActionCable autoload :Worker, 'action_cable/worker' autoload :Server, 'action_cable/server' autoload :Connection, 'action_cable/connection' - autoload :Connections, 'action_cable/connections' + autoload :ConnectionProxy, 'action_cable/connection_proxy' end diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 00fb8ca817..102903c6ef 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,133 +1,6 @@ module ActionCable - class Connection - PING_INTERVAL = 3 - - attr_reader :env, :server - delegate :worker_pool, :pubsub, :logger, to: :server - - def initialize(server, env) - @server = server - @env = env - @accept_messages = false - @pending_messages = [] - end - - def process - if Faye::WebSocket.websocket?(@env) - @subscriptions = {} - - @websocket = Faye::WebSocket.new(@env) - - @websocket.on(:open) do |event| - broadcast_ping_timestamp - @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } - worker_pool.async.invoke(self, :initialize_client) - end - - @websocket.on(:message) do |event| - message = event.data - - if message.is_a?(String) - if @accept_messages - worker_pool.async.invoke(self, :received_data, message) - else - @pending_messages << message - end - end - end - - @websocket.on(:close) do |event| - worker_pool.async.invoke(self, :cleanup_subscriptions) - worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) - - EventMachine.cancel_timer(@ping_timer) if @ping_timer - end - - @websocket.rack_response - else - invalid_request - end - end - - def received_data(data) - return unless websocket_alive? - - data = ActiveSupport::JSON.decode data - - case data['action'] - when 'subscribe' - subscribe_channel(data) - when 'unsubscribe' - unsubscribe_channel(data) - when 'message' - process_message(data) - end - end - - def cleanup_subscriptions - @subscriptions.each do |id, channel| - channel.unsubscribe - end - end - - def broadcast(data) - logger.info "Sending data: #{data}" - @websocket.send data - end - - def handle_exception - logger.error "[ActionCable] Closing connection" - - @websocket.close - end - - private - def initialize_client - connect if respond_to?(:connect) - @accept_messages = true - - worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? - end - - def broadcast_ping_timestamp - broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) - end - - def subscribe_channel(data) - id_key = data['identifier'] - id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - - subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } - - if subscription_klass - logger.info "Subscribing to channel: #{id_key}" - @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) - else - logger.error "Unable to subscribe to channel: #{id_key}" - end - end - - def process_message(message) - if @subscriptions[message['identifier']] - @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) - else - logger.error "Unable to process message: #{message}" - end - end - - def unsubscribe_channel(data) - logger.info "Unsubscribing from channel: #{data['identifier']}" - @subscriptions[data['identifier']].unsubscribe - @subscriptions.delete(data['identifier']) - end - - def invalid_request - [404, {'Content-Type' => 'text/plain'}, ['Page not found']] - end - - def websocket_alive? - @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN - end - + module Connection + autoload :Base, 'action_cable/connection/base' + autoload :Registry, 'action_cable/connection/Registry' end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb new file mode 100644 index 0000000000..c6c9899094 --- /dev/null +++ b/lib/action_cable/connection/base.rb @@ -0,0 +1,139 @@ +module ActionCable + module Connection + class Base + include Registry + + PING_INTERVAL = 3 + + attr_reader :env, :server + delegate :worker_pool, :pubsub, :logger, to: :server + + def initialize(server, env) + @server = server + @env = env + @accept_messages = false + @pending_messages = [] + end + + def process + if Faye::WebSocket.websocket?(@env) + @subscriptions = {} + + @websocket = Faye::WebSocket.new(@env) + + @websocket.on(:open) do |event| + broadcast_ping_timestamp + @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } + worker_pool.async.invoke(self, :initialize_client) + end + + @websocket.on(:message) do |event| + message = event.data + + if message.is_a?(String) + if @accept_messages + worker_pool.async.invoke(self, :received_data, message) + else + @pending_messages << message + end + end + end + + @websocket.on(:close) do |event| + worker_pool.async.invoke(self, :cleanup_subscriptions) + worker_pool.async.invoke(self, :cleanup_subscriptions) + worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) + + EventMachine.cancel_timer(@ping_timer) if @ping_timer + end + + @websocket.rack_response + else + invalid_request + end + end + + def received_data(data) + return unless websocket_alive? + + data = ActiveSupport::JSON.decode data + + case data['action'] + when 'subscribe' + subscribe_channel(data) + when 'unsubscribe' + unsubscribe_channel(data) + when 'message' + process_message(data) + end + end + + def cleanup_subscriptions + @subscriptions.each do |id, channel| + channel.unsubscribe + end + end + + def broadcast(data) + logger.info "Sending data: #{data}" + @websocket.send data + end + + def handle_exception + logger.error "[ActionCable] Closing connection" + + @websocket.close + end + + private + def initialize_client + connect if respond_to?(:connect) + register_connection + + @accept_messages = true + worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? + end + + def broadcast_ping_timestamp + broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) + end + + def subscribe_channel(data) + id_key = data['identifier'] + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + + subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } + + if subscription_klass + logger.info "Subscribing to channel: #{id_key}" + @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) + else + logger.error "Unable to subscribe to channel: #{id_key}" + end + end + + def process_message(message) + if @subscriptions[message['identifier']] + @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) + else + logger.error "Unable to process message: #{message}" + end + end + + def unsubscribe_channel(data) + logger.info "Unsubscribing from channel: #{data['identifier']}" + @subscriptions[data['identifier']].unsubscribe + @subscriptions.delete(data['identifier']) + end + + def invalid_request + [404, {'Content-Type' => 'text/plain'}, ['Page not found']] + end + + def websocket_alive? + @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN + end + + end + end +end diff --git a/lib/action_cable/connection/registry.rb b/lib/action_cable/connection/registry.rb new file mode 100644 index 0000000000..121f87f9f0 --- /dev/null +++ b/lib/action_cable/connection/registry.rb @@ -0,0 +1,65 @@ +module ActionCable + module Connection + module Registry + extend ActiveSupport::Concern + + included do + class_attribute :identifiers + self.identifiers = Set.new + end + + module ClassMethods + def identified_by(*identifiers) + self.identifiers += identifiers + end + end + + def register_connection + if connection_identifier.present? + callback = -> (message) { process_registry_message(message) } + @_internal_redis_subscriptions ||= [] + @_internal_redis_subscriptions << [ internal_redis_channel, callback ] + + pubsub.subscribe(internal_redis_channel, &callback) + logger.info "[ActionCable] Registered connection (#{connection_identifier})" + puts "[ActionCable] Registered connection: #{connection_identifier}(#{internal_redis_channel})" + end + end + + def internal_redis_channel + "action_cable/#{connection_identifier}" + end + + def connection_identifier + @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}")} + end + + def connection_gid(ids) + ids.map {|o| o.to_global_id.to_s }.sort.join(":") + end + + def cleanup_internal_redis_subscriptions + if @_internal_redis_subscriptions.present? + @_internal_redis_subscriptions.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } + end + end + + private + def process_registry_message(message) + message = ActiveSupport::JSON.decode(message) + + case message['type'] + when 'disconnect' + logger.info "[ActionCable] Removing connection (#{connection_identifier})" + @websocket.close + end + rescue Exception => e + logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + handle_exception + end + + end + end +end diff --git a/lib/action_cable/connection_proxy.rb b/lib/action_cable/connection_proxy.rb new file mode 100644 index 0000000000..980e037ff3 --- /dev/null +++ b/lib/action_cable/connection_proxy.rb @@ -0,0 +1,14 @@ +module ActionCable + module ConnectionProxy + class << self + def active + end + + def where(identification) + end + end + + def disconnect + end + end +end diff --git a/lib/action_cable/connections.rb b/lib/action_cable/connections.rb deleted file mode 100644 index e68cc6e7a4..0000000000 --- a/lib/action_cable/connections.rb +++ /dev/null @@ -1,17 +0,0 @@ -module ActionCable - module Connections - class << self - def active - end - - def where(identification) - end - end - - def disconnect - end - - def reconnect - end - end -end -- cgit v1.2.3 From 1c9d82dbf0e743534c5fa9be936eaa46c5b07523 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 13:17:02 -0500 Subject: Add remote connection to talk over internal redis channel --- action_cable.gemspec | 1 + lib/action_cable.rb | 2 +- lib/action_cable/connection.rb | 3 ++- lib/action_cable/connection/identifier.rb | 19 ++++++++++++++++ lib/action_cable/connection_proxy.rb | 14 ------------ lib/action_cable/remote_connection.rb | 38 +++++++++++++++++++++++++++++++ lib/action_cable/server.rb | 12 ++++++++-- 7 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 lib/action_cable/connection/identifier.rb delete mode 100644 lib/action_cable/connection_proxy.rb create mode 100644 lib/action_cable/remote_connection.rb diff --git a/action_cable.gemspec b/action_cable.gemspec index ba5c4159d6..4f252d4b1e 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -13,6 +13,7 @@ Gem::Specification.new do |s| s.add_dependency('faye-websocket', '~> 0.9.2') s.add_dependency('celluloid', '~> 0.16.0') s.add_dependency('em-hiredis', '~> 0.3.0') + s.add_dependency('redis', '~> 3.0') s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false diff --git a/lib/action_cable.rb b/lib/action_cable.rb index fd42dc6cdd..159ee2bcc0 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -23,5 +23,5 @@ module ActionCable autoload :Worker, 'action_cable/worker' autoload :Server, 'action_cable/server' autoload :Connection, 'action_cable/connection' - autoload :ConnectionProxy, 'action_cable/connection_proxy' + autoload :RemoteConnection, 'action_cable/remote_connection' end diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 102903c6ef..91fc73713c 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,6 +1,7 @@ module ActionCable module Connection autoload :Base, 'action_cable/connection/base' - autoload :Registry, 'action_cable/connection/Registry' + autoload :Registry, 'action_cable/connection/registry' + autoload :Identifier, 'action_cable/connection/identifier' end end diff --git a/lib/action_cable/connection/identifier.rb b/lib/action_cable/connection/identifier.rb new file mode 100644 index 0000000000..9bfd773ab1 --- /dev/null +++ b/lib/action_cable/connection/identifier.rb @@ -0,0 +1,19 @@ +module ActionCable + module Connection + module Identifier + + def internal_redis_channel + "action_cable/#{connection_identifier}" + end + + def connection_identifier + @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}")} + end + + def connection_gid(ids) + ids.map {|o| o.to_global_id.to_s }.sort.join(":") + end + + end + end +end diff --git a/lib/action_cable/connection_proxy.rb b/lib/action_cable/connection_proxy.rb deleted file mode 100644 index 980e037ff3..0000000000 --- a/lib/action_cable/connection_proxy.rb +++ /dev/null @@ -1,14 +0,0 @@ -module ActionCable - module ConnectionProxy - class << self - def active - end - - def where(identification) - end - end - - def disconnect - end - end -end diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb new file mode 100644 index 0000000000..e2cb2d932c --- /dev/null +++ b/lib/action_cable/remote_connection.rb @@ -0,0 +1,38 @@ +module ActionCable + class RemoteConnection + class InvalidIdentifiersError < StandardError; end + + include Connection::Identifier + + delegate :redis, to: :server + + def initialize(server, ids) + @server = server + set_identifier_instance_vars(ids) + end + + def disconnect + message = { type: 'disconnect' }.to_json + redis.publish(internal_redis_channel, message) + end + + def identifiers + @server.connection_identifiers + end + + def redis + @redis ||= Redis.new(@server.redis_config) + end + + 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 diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 6e9265dc06..51e246c232 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -2,12 +2,12 @@ module ActionCable class Server cattr_accessor(:logger, instance_reader: true) { Rails.logger } - attr_accessor :registered_channels, :worker_pool + attr_accessor :registered_channels, :redis_config def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection) @redis_config = redis_config @registered_channels = Set.new(channels) - @worker_pool = ActionCable::Worker.pool(size: worker_pool_size) + @worker_pool_size = worker_pool_size @connection_class = connection end @@ -15,9 +15,17 @@ module ActionCable @connection_class.new(self, env).process end + def worker_pool + @worker_pool ||= ActionCable::Worker.pool(size: @worker_pool_size) + end + def pubsub @pubsub ||= EM::Hiredis.connect(@redis_config['url']).pubsub end + def connection_identifiers + @connection_class.identifiers + end + end end -- cgit v1.2.3 From 9501dd2f6d2f0ece938e2a15a31429e9b8cf1001 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 13:17:14 -0500 Subject: Be sure to cleanup internal redis subscriptions on close --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index c6c9899094..c5b982acf8 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -41,7 +41,7 @@ module ActionCable @websocket.on(:close) do |event| worker_pool.async.invoke(self, :cleanup_subscriptions) - worker_pool.async.invoke(self, :cleanup_subscriptions) + worker_pool.async.invoke(self, :cleanup_internal_redis_subscriptions) worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) EventMachine.cancel_timer(@ping_timer) if @ping_timer -- cgit v1.2.3 From 0c143c03441cf2a66557ec7ba2f5d3d2f889fb5d Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 13:17:41 -0500 Subject: Remove a puts message --- lib/action_cable/connection/registry.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/action_cable/connection/registry.rb b/lib/action_cable/connection/registry.rb index 121f87f9f0..03a0bf4fe9 100644 --- a/lib/action_cable/connection/registry.rb +++ b/lib/action_cable/connection/registry.rb @@ -22,7 +22,6 @@ module ActionCable pubsub.subscribe(internal_redis_channel, &callback) logger.info "[ActionCable] Registered connection (#{connection_identifier})" - puts "[ActionCable] Registered connection: #{connection_identifier}(#{internal_redis_channel})" end end -- cgit v1.2.3 From 90566fce53a71660c746e23499f8aa134b457335 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 13:31:17 -0500 Subject: Remote connections API for the server --- lib/action_cable.rb | 1 + lib/action_cable/remote_connections.rb | 13 +++++++++++++ lib/action_cable/server.rb | 4 ++++ 3 files changed, 18 insertions(+) create mode 100644 lib/action_cable/remote_connections.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 159ee2bcc0..3352453491 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -24,4 +24,5 @@ module ActionCable autoload :Server, 'action_cable/server' autoload :Connection, 'action_cable/connection' autoload :RemoteConnection, 'action_cable/remote_connection' + autoload :RemoteConnections, 'action_cable/remote_connections' end diff --git a/lib/action_cable/remote_connections.rb b/lib/action_cable/remote_connections.rb new file mode 100644 index 0000000000..f9d7c49a27 --- /dev/null +++ b/lib/action_cable/remote_connections.rb @@ -0,0 +1,13 @@ +module ActionCable + class RemoteConnections + attr_reader :server + + def initialize(server) + @server = server + end + + def where(identifier) + RemoteConnection.new(server, identifier) + end + end +end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 51e246c232..222c77fd51 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -23,6 +23,10 @@ module ActionCable @pubsub ||= EM::Hiredis.connect(@redis_config['url']).pubsub end + def remote_connections + @remote_connections ||= RemoteConnections.new(self) + end + def connection_identifiers @connection_class.identifiers end -- cgit v1.2.3 From 8c4c782c78b45d94e97ee9a26a05c44cae7cd428 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 15:38:22 -0500 Subject: Catch exceptions when subscribing to a channel and processing a message --- lib/action_cable/connection/base.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index c5b982acf8..ea5d52e99c 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -105,23 +105,29 @@ module ActionCable subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } if subscription_klass - logger.info "Subscribing to channel: #{id_key}" + logger.info "[ActionCable] Subscribing to channel: #{id_key}" @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) else - logger.error "Unable to subscribe to channel: #{id_key}" + logger.error "[ActionCable] Subscription class not found (#{data.inspect})" end + rescue Exception => e + logger.error "[ActionCable] Could not subscribe to channel (#{data.inspect})" + logger.error e.backtrace.join("\n") end def process_message(message) if @subscriptions[message['identifier']] @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) else - logger.error "Unable to process message: #{message}" + logger.error "[ActionCable] Unable to process message because no subscription found (#{message.inspect})" end + rescue Exception => e + logger.error "[ActionCable] Could not process message (#{data.inspect})" + logger.error e.backtrace.join("\n") end def unsubscribe_channel(data) - logger.info "Unsubscribing from channel: #{data['identifier']}" + logger.info "[ActionCable] Unsubscribing from channel: #{data['identifier']}" @subscriptions[data['identifier']].unsubscribe @subscriptions.delete(data['identifier']) end -- cgit v1.2.3 From fb797ad1f1c3b0d96968c5feef783a2b8fe07eed Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 6 Apr 2015 15:43:23 -0500 Subject: Fix an error message --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index ea5d52e99c..4ad1e7d065 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -119,7 +119,7 @@ module ActionCable if @subscriptions[message['identifier']] @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) else - logger.error "[ActionCable] Unable to process message because no subscription found (#{message.inspect})" + logger.error "[ActionCable] Unable to process message because no subscription was found (#{message.inspect})" end rescue Exception => e logger.error "[ActionCable] Could not process message (#{data.inspect})" -- cgit v1.2.3 From 265f1f1ebae2422df8ee9fe66184d6cd20c73494 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 7 Apr 2015 16:32:22 -0500 Subject: Bump version --- action_cable.gemspec | 2 +- lib/action_cable.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/action_cable.gemspec b/action_cable.gemspec index 4f252d4b1e..714256a73e 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -1,7 +1,7 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = 'action_cable' - s.version = '0.0.2' + s.version = '0.0.3' s.summary = 'Framework for websockets.' s.description = 'Action Cable is a framework for realtime communication over websockets.' diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 3352453491..1926201f1f 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -17,7 +17,7 @@ require 'em-hiredis' require 'action_cable/engine' if defined?(Rails) module ActionCable - VERSION = '0.0.1' + VERSION = '0.0.3' autoload :Channel, 'action_cable/channel' autoload :Worker, 'action_cable/worker' -- cgit v1.2.3 From de8390959664eaccf3453d69be5fa8ce09f97297 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 7 Apr 2015 16:32:45 -0500 Subject: Log request start and finish --- lib/action_cable/connection/base.rb | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 4ad1e7d065..2e62c78bee 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -16,7 +16,9 @@ module ActionCable end def process - if Faye::WebSocket.websocket?(@env) + logger.info "[ActionCable] #{started_request_message}" + + if websocket? @subscriptions = {} @websocket = Faye::WebSocket.new(@env) @@ -40,6 +42,8 @@ module ActionCable end @websocket.on(:close) do |event| + logger.info "[ActionCable] #{finished_request_message}" + worker_pool.async.invoke(self, :cleanup_subscriptions) worker_pool.async.invoke(self, :cleanup_internal_redis_subscriptions) worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) @@ -75,7 +79,7 @@ module ActionCable end def broadcast(data) - logger.info "Sending data: #{data}" + logger.info "[ActionCable] Sending data: #{data}" @websocket.send data end @@ -133,6 +137,7 @@ module ActionCable end def invalid_request + logger.info "[ActionCable] #{finished_request_message}" [404, {'Content-Type' => 'text/plain'}, ['Page not found']] end @@ -140,6 +145,31 @@ module ActionCable @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN end + def request + @request ||= ActionDispatch::Request.new(env) + end + + def websocket? + @is_websocket ||= Faye::WebSocket.websocket?(@env) + end + + def started_request_message + 'Started %s "%s"%s for %s at %s' % [ + request.request_method, + request.filtered_path, + websocket? ? ' [Websocket]' : '', + request.ip, + Time.now.to_default_s ] + end + + def finished_request_message + 'Finished "%s"%s for %s at %s' % [ + request.filtered_path, + websocket? ? ' [Websocket]' : '', + request.ip, + Time.now.to_default_s ] + end + end end end -- cgit v1.2.3 From 6789dd81bdcfbea8bba2f128f59990829439503f Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 15:57:31 -0500 Subject: Fix the error logging --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2e62c78bee..76d6f5da9e 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -126,7 +126,7 @@ module ActionCable logger.error "[ActionCable] Unable to process message because no subscription was found (#{message.inspect})" end rescue Exception => e - logger.error "[ActionCable] Could not process message (#{data.inspect})" + logger.error "[ActionCable] Could not process message (#{message.inspect})" logger.error e.backtrace.join("\n") end -- cgit v1.2.3 From 58058bdecfcd7cbe24baec2593f333b46960374e Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 16:01:40 -0500 Subject: Print exception class and message in the logs --- lib/action_cable/connection/base.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 76d6f5da9e..8eec9d2a82 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -116,7 +116,7 @@ module ActionCable end rescue Exception => e logger.error "[ActionCable] Could not subscribe to channel (#{data.inspect})" - logger.error e.backtrace.join("\n") + log_exception(e) end def process_message(message) @@ -127,7 +127,7 @@ module ActionCable end rescue Exception => e logger.error "[ActionCable] Could not process message (#{message.inspect})" - logger.error e.backtrace.join("\n") + log_exception(e) end def unsubscribe_channel(data) @@ -170,6 +170,10 @@ module ActionCable Time.now.to_default_s ] end + def log_exception(e) + logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + end end end end -- cgit v1.2.3 From bb2f657e4ab1041b6a33b6f302c64580ceb6aa06 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 17:13:39 -0500 Subject: Log when subscribing to a redis channel --- lib/action_cable/channel/redis.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 2691a3b145..fc25f1dd4c 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -14,6 +14,7 @@ module ActionCable @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] + logger.info "[ActionCable] Subscribing to the redis channel: #{redis_channel}" pubsub.subscribe(redis_channel, &callback) end -- cgit v1.2.3 From b4233cdfb218763a3a2095188070e1ffc98b3829 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 17:26:16 -0500 Subject: Log when unsubscribing from a redis channel --- lib/action_cable/channel/redis.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index fc25f1dd4c..d47e503c51 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -21,7 +21,10 @@ module ActionCable protected def unsubscribe_from_redis_channels if @_redis_channels - @_redis_channels.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } + @_redis_channels.each do |channel, callback| + logger.info "[ActionCable] Unsubscribing from the redis channel: #{channel}" + pubsub.unsubscribe_proc(channel, callback) + end end end end -- cgit v1.2.3 From d26f0fa55c63d3a961bc4fae0daf0e1cbde31b34 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 18:25:10 -0500 Subject: Log server initialization --- lib/action_cable/server.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 222c77fd51..75039b8d5e 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -9,6 +9,8 @@ module ActionCable @registered_channels = Set.new(channels) @worker_pool_size = worker_pool_size @connection_class = connection + + logger.info "[ActionCable] Initialized server (redis_config: #{@redis_config.inspect}, worker_pool_size: #{@worker_pool_size})" end def call(env) -- cgit v1.2.3 From 6d31f64cf65e02fb14f0c1d737ccb90382f91cbe Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 18:29:25 -0500 Subject: Log received redis channel messages --- lib/action_cable/channel/redis.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index d47e503c51..fda55ec45d 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -10,7 +10,7 @@ module ActionCable end def subscribe_to(redis_channel, callback = nil) - callback ||= -> (message) { broadcast ActiveSupport::JSON.decode(message) } + callback ||= default_subscription_callback(redis_channel) @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] @@ -27,6 +27,14 @@ module ActionCable end end end + + def default_subscription_callback(channel) + -> (message) do + logger.info "[ActionCable] Received a message over the redis channel: #{channel} (#{message})" + broadcast ActiveSupport::JSON.decode(message) + end + end + end end -- cgit v1.2.3 From a4f96c331100310c6c42291ff926911d47a38c9a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 8 Apr 2015 19:30:06 -0500 Subject: Redis config with indifferent access --- lib/action_cable/server.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 75039b8d5e..507b154e0d 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -5,7 +5,7 @@ module ActionCable attr_accessor :registered_channels, :redis_config def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection) - @redis_config = redis_config + @redis_config = redis_config.with_indifferent_access @registered_channels = Set.new(channels) @worker_pool_size = worker_pool_size @connection_class = connection @@ -22,7 +22,7 @@ module ActionCable end def pubsub - @pubsub ||= EM::Hiredis.connect(@redis_config['url']).pubsub + @pubsub ||= EM::Hiredis.connect(@redis_config[:url]).pubsub end def remote_connections -- cgit v1.2.3 From 662c064334638cfb783e2588c64342fe753f8c63 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 9 Apr 2015 17:11:58 -0500 Subject: Simplify client connection closing --- lib/action_cable/connection/base.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 8eec9d2a82..65e85ed114 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -44,10 +44,7 @@ module ActionCable @websocket.on(:close) do |event| logger.info "[ActionCable] #{finished_request_message}" - worker_pool.async.invoke(self, :cleanup_subscriptions) - worker_pool.async.invoke(self, :cleanup_internal_redis_subscriptions) - worker_pool.async.invoke(self, :disconnect) if respond_to?(:disconnect) - + worker_pool.async.invoke(self, :close_client_connection) EventMachine.cancel_timer(@ping_timer) if @ping_timer end @@ -98,6 +95,12 @@ module ActionCable worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? end + def close_client_connection + cleanup_subscriptions + cleanup_internal_redis_subscriptions + disconnect if respond_to?(:disconnect) + end + def broadcast_ping_timestamp broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) end -- cgit v1.2.3 From a29034e47c45b6bb4ab4c4c06f11fe440b35f10f Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 9 Apr 2015 17:16:50 -0500 Subject: Better method names --- lib/action_cable/connection/base.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 65e85ed114..b2d758a124 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -26,7 +26,7 @@ module ActionCable @websocket.on(:open) do |event| broadcast_ping_timestamp @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } - worker_pool.async.invoke(self, :initialize_client) + worker_pool.async.invoke(self, :initialize_connection) end @websocket.on(:message) do |event| @@ -44,7 +44,7 @@ module ActionCable @websocket.on(:close) do |event| logger.info "[ActionCable] #{finished_request_message}" - worker_pool.async.invoke(self, :close_client_connection) + worker_pool.async.invoke(self, :on_connection_closed) EventMachine.cancel_timer(@ping_timer) if @ping_timer end @@ -87,7 +87,7 @@ module ActionCable end private - def initialize_client + def initialize_connection connect if respond_to?(:connect) register_connection @@ -95,7 +95,7 @@ module ActionCable worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? end - def close_client_connection + def on_connection_closed cleanup_subscriptions cleanup_internal_redis_subscriptions disconnect if respond_to?(:disconnect) -- cgit v1.2.3 From 005a99ebf687051df12af96e83b677e4abda9b21 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 9 Apr 2015 17:26:18 -0500 Subject: Rename Registry to InternalChannel and remove dup methods --- lib/action_cable/connection.rb | 2 +- lib/action_cable/connection/base.rb | 13 +++-- lib/action_cable/connection/internal_channel.rb | 41 ++++++++++++++++ lib/action_cable/connection/registry.rb | 64 ------------------------- 4 files changed, 52 insertions(+), 68 deletions(-) create mode 100644 lib/action_cable/connection/internal_channel.rb delete mode 100644 lib/action_cable/connection/registry.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 91fc73713c..665a851b11 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,7 +1,7 @@ module ActionCable module Connection autoload :Base, 'action_cable/connection/base' - autoload :Registry, 'action_cable/connection/registry' + autoload :InternalChannel, 'action_cable/connection/internal_channel' autoload :Identifier, 'action_cable/connection/identifier' end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index b2d758a124..0c20f11502 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -1,10 +1,17 @@ module ActionCable module Connection class Base - include Registry + include InternalChannel, Identifier PING_INTERVAL = 3 + class_attribute :identifiers + self.identifiers = Set.new + + def self.identified_by(*identifiers) + self.identifiers += identifiers + end + attr_reader :env, :server delegate :worker_pool, :pubsub, :logger, to: :server @@ -89,7 +96,7 @@ module ActionCable private def initialize_connection connect if respond_to?(:connect) - register_connection + subscribe_to_internal_channel @accept_messages = true worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? @@ -97,7 +104,7 @@ module ActionCable def on_connection_closed cleanup_subscriptions - cleanup_internal_redis_subscriptions + unsubscribe_from_internal_channel disconnect if respond_to?(:disconnect) end diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb new file mode 100644 index 0000000000..745fd99b78 --- /dev/null +++ b/lib/action_cable/connection/internal_channel.rb @@ -0,0 +1,41 @@ +module ActionCable + module Connection + module InternalChannel + extend ActiveSupport::Concern + + def subscribe_to_internal_channel + if connection_identifier.present? + callback = -> (message) { process_internal_message(message) } + @_internal_redis_subscriptions ||= [] + @_internal_redis_subscriptions << [ internal_redis_channel, callback ] + + pubsub.subscribe(internal_redis_channel, &callback) + logger.info "[ActionCable] Registered connection (#{connection_identifier})" + end + end + + def unsubscribe_from_internal_channel + if @_internal_redis_subscriptions.present? + @_internal_redis_subscriptions.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } + end + end + + private + def process_internal_message(message) + message = ActiveSupport::JSON.decode(message) + + case message['type'] + when 'disconnect' + logger.info "[ActionCable] Removing connection (#{connection_identifier})" + @websocket.close + end + rescue Exception => e + logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + handle_exception + end + + end + end +end diff --git a/lib/action_cable/connection/registry.rb b/lib/action_cable/connection/registry.rb deleted file mode 100644 index 03a0bf4fe9..0000000000 --- a/lib/action_cable/connection/registry.rb +++ /dev/null @@ -1,64 +0,0 @@ -module ActionCable - module Connection - module Registry - extend ActiveSupport::Concern - - included do - class_attribute :identifiers - self.identifiers = Set.new - end - - module ClassMethods - def identified_by(*identifiers) - self.identifiers += identifiers - end - end - - def register_connection - if connection_identifier.present? - callback = -> (message) { process_registry_message(message) } - @_internal_redis_subscriptions ||= [] - @_internal_redis_subscriptions << [ internal_redis_channel, callback ] - - pubsub.subscribe(internal_redis_channel, &callback) - logger.info "[ActionCable] Registered connection (#{connection_identifier})" - end - end - - def internal_redis_channel - "action_cable/#{connection_identifier}" - end - - def connection_identifier - @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}")} - end - - def connection_gid(ids) - ids.map {|o| o.to_global_id.to_s }.sort.join(":") - end - - def cleanup_internal_redis_subscriptions - if @_internal_redis_subscriptions.present? - @_internal_redis_subscriptions.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } - end - end - - private - def process_registry_message(message) - message = ActiveSupport::JSON.decode(message) - - case message['type'] - when 'disconnect' - logger.info "[ActionCable] Removing connection (#{connection_identifier})" - @websocket.close - end - rescue Exception => e - logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" - logger.error e.backtrace.join("\n") - - handle_exception - end - - end - end -end -- cgit v1.2.3 From 033de15758bfb57a43e5e903fc4a1f6fa9fac118 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 9 Apr 2015 18:50:22 -0500 Subject: Collect information about all the open connections and a method to fetch that --- lib/action_cable/connection/base.rb | 17 +++++++++++++++-- lib/action_cable/server.rb | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 0c20f11502..655e74ee01 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -16,18 +16,19 @@ module ActionCable delegate :worker_pool, :pubsub, :logger, to: :server def initialize(server, env) + @started_at = Time.now + @server = server @env = env @accept_messages = false @pending_messages = [] + @subscriptions = {} end def process logger.info "[ActionCable] #{started_request_message}" if websocket? - @subscriptions = {} - @websocket = Faye::WebSocket.new(@env) @websocket.on(:open) do |event| @@ -93,8 +94,18 @@ module ActionCable @websocket.close end + def statistics + { + identifier: connection_identifier, + started_at: @started_at, + subscriptions: @subscriptions.keys + } + end + private def initialize_connection + server.add_connection(self) + connect if respond_to?(:connect) subscribe_to_internal_channel @@ -103,6 +114,8 @@ module ActionCable end def on_connection_closed + server.remove_connection(self) + cleanup_subscriptions unsubscribe_from_internal_channel disconnect if respond_to?(:disconnect) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 507b154e0d..a867d8578f 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -10,6 +10,8 @@ module ActionCable @worker_pool_size = worker_pool_size @connection_class = connection + @connections = [] + logger.info "[ActionCable] Initialized server (redis_config: #{@redis_config.inspect}, worker_pool_size: #{@worker_pool_size})" end @@ -33,5 +35,17 @@ module ActionCable @connection_class.identifiers end + def add_connection(connection) + @connections << connection + end + + def remove_connection(connection) + @connections.delete connection + end + + def open_connections_statistics + @connections.map(&:statistics) + end + end end -- cgit v1.2.3 From 1ca045ccd43b3647487714e7441981ff87c51943 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 10 Apr 2015 11:58:31 -0500 Subject: Tag log entries with the request id --- lib/action_cable/channel/base.rb | 9 ++--- lib/action_cable/channel/redis.rb | 6 +-- lib/action_cable/connection/base.rb | 54 ++++++++++++++++--------- lib/action_cable/connection/internal_channel.rb | 8 ++-- lib/action_cable/worker.rb | 2 +- 5 files changed, 45 insertions(+), 34 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 8ee99649f4..37399c8101 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -11,6 +11,7 @@ module ActionCable on_unsubscribe :disconnect attr_reader :params, :connection + delegate :log_info, :log_error, to: :connection class_attribute :channel_name @@ -40,7 +41,7 @@ module ActionCable if respond_to?(:receive) receive(data) else - logger.error "[ActionCable] #{self.class.name} received data (#{data}) but #{self.class.name}#receive callback is not defined" + log_error "#{self.class.name} received data (#{data}) but #{self.class.name}#receive callback is not defined" end else unauthorized @@ -66,7 +67,7 @@ module ActionCable end def unauthorized - logger.error "[ActionCable] Unauthorized access to #{self.class.name}" + log_error "Unauthorized access to #{self.class.name}" end def connect @@ -101,10 +102,6 @@ module ActionCable connection.worker_pool end - def logger - connection.logger - end - end end diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index fda55ec45d..197cf03c8e 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -14,7 +14,7 @@ module ActionCable @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] - logger.info "[ActionCable] Subscribing to the redis channel: #{redis_channel}" + log_info "Subscribing to the redis channel: #{redis_channel}" pubsub.subscribe(redis_channel, &callback) end @@ -22,7 +22,7 @@ module ActionCable def unsubscribe_from_redis_channels if @_redis_channels @_redis_channels.each do |channel, callback| - logger.info "[ActionCable] Unsubscribing from the redis channel: #{channel}" + log_info "Unsubscribing from the redis channel: #{channel}" pubsub.unsubscribe_proc(channel, callback) end end @@ -30,7 +30,7 @@ module ActionCable def default_subscription_callback(channel) -> (message) do - logger.info "[ActionCable] Received a message over the redis channel: #{channel} (#{message})" + log_info "Received a message over the redis channel: #{channel} (#{message})" broadcast ActiveSupport::JSON.decode(message) end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 655e74ee01..2ab62092af 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -13,7 +13,8 @@ module ActionCable end attr_reader :env, :server - delegate :worker_pool, :pubsub, :logger, to: :server + attr_accessor :log_tags + delegate :worker_pool, :pubsub, to: :server def initialize(server, env) @started_at = Time.now @@ -23,10 +24,11 @@ module ActionCable @accept_messages = false @pending_messages = [] @subscriptions = {} + @log_tags = [ 'ActionCable' ] end def process - logger.info "[ActionCable] #{started_request_message}" + log_info started_request_message if websocket? @websocket = Faye::WebSocket.new(@env) @@ -50,7 +52,7 @@ module ActionCable end @websocket.on(:close) do |event| - logger.info "[ActionCable] #{finished_request_message}" + log_info finished_request_message worker_pool.async.invoke(self, :on_connection_closed) EventMachine.cancel_timer(@ping_timer) if @ping_timer @@ -84,16 +86,10 @@ module ActionCable end def broadcast(data) - logger.info "[ActionCable] Sending data: #{data}" + log_info "Sending data: #{data}" @websocket.send data end - def handle_exception - logger.error "[ActionCable] Closing connection" - - @websocket.close - end - def statistics { identifier: connection_identifier, @@ -102,7 +98,21 @@ module ActionCable } end - private + def handle_exception + log_error "Closing connection" + + @websocket.close + end + + def log_info(message) + log :info, message + end + + def log_error(message) + log :error, message + end + + protected def initialize_connection server.add_connection(self) @@ -132,13 +142,13 @@ module ActionCable subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } if subscription_klass - logger.info "[ActionCable] Subscribing to channel: #{id_key}" + log_info "Subscribing to channel: #{id_key}" @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) else - logger.error "[ActionCable] Subscription class not found (#{data.inspect})" + log_error "Subscription class not found (#{data.inspect})" end rescue Exception => e - logger.error "[ActionCable] Could not subscribe to channel (#{data.inspect})" + log_error "Could not subscribe to channel (#{data.inspect})" log_exception(e) end @@ -146,21 +156,21 @@ module ActionCable if @subscriptions[message['identifier']] @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) else - logger.error "[ActionCable] Unable to process message because no subscription was found (#{message.inspect})" + log_exception "Unable to process message because no subscription was found (#{message.inspect})" end rescue Exception => e - logger.error "[ActionCable] Could not process message (#{message.inspect})" + log_error "Could not process message (#{message.inspect})" log_exception(e) end def unsubscribe_channel(data) - logger.info "[ActionCable] Unsubscribing from channel: #{data['identifier']}" + log_info "Unsubscribing from channel: #{data['identifier']}" @subscriptions[data['identifier']].unsubscribe @subscriptions.delete(data['identifier']) end def invalid_request - logger.info "[ActionCable] #{finished_request_message}" + log_info "#{finished_request_message}" [404, {'Content-Type' => 'text/plain'}, ['Page not found']] end @@ -194,8 +204,12 @@ module ActionCable end def log_exception(e) - logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" - logger.error e.backtrace.join("\n") + log_error "There was an exception - #{e.class}(#{e.message})" + log_error e.backtrace.join("\n") + end + + def log(type, message) + server.logger.tagged(*log_tags) { server.logger.send type, message } end end end diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index 745fd99b78..5338fc879e 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -10,7 +10,7 @@ module ActionCable @_internal_redis_subscriptions << [ internal_redis_channel, callback ] pubsub.subscribe(internal_redis_channel, &callback) - logger.info "[ActionCable] Registered connection (#{connection_identifier})" + log_info "Registered connection (#{connection_identifier})" end end @@ -26,12 +26,12 @@ module ActionCable case message['type'] when 'disconnect' - logger.info "[ActionCable] Removing connection (#{connection_identifier})" + log_info "Removing connection (#{connection_identifier})" @websocket.close end rescue Exception => e - logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" - logger.error e.backtrace.join("\n") + log_error "There was an exception - #{e.class}(#{e.message})" + log_error e.backtrace.join("\n") handle_exception end diff --git a/lib/action_cable/worker.rb b/lib/action_cable/worker.rb index 6773535afe..6800a75d1d 100644 --- a/lib/action_cable/worker.rb +++ b/lib/action_cable/worker.rb @@ -10,7 +10,7 @@ module ActionCable receiver.send method, *args end rescue Exception => e - logger.error "[ActionCable] There was an exception - #{e.class}(#{e.message})" + 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) -- cgit v1.2.3 From 426fe543d7b6d6fa42ff18304770a628904f5f4c Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 10 Apr 2015 14:23:44 -0500 Subject: Add a tagged proxy logger to handle per connection tags --- lib/action_cable/channel/base.rb | 6 +-- lib/action_cable/channel/redis.rb | 6 +-- lib/action_cable/connection.rb | 1 + lib/action_cable/connection/base.rb | 43 +++++++++------------- lib/action_cable/connection/internal_channel.rb | 8 ++-- lib/action_cable/connection/tagged_logger_proxy.rb | 30 +++++++++++++++ 6 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 lib/action_cable/connection/tagged_logger_proxy.rb diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 37399c8101..fac988ba52 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -11,7 +11,7 @@ module ActionCable on_unsubscribe :disconnect attr_reader :params, :connection - delegate :log_info, :log_error, to: :connection + delegate :logger, to: :connection class_attribute :channel_name @@ -41,7 +41,7 @@ module ActionCable if respond_to?(:receive) receive(data) else - log_error "#{self.class.name} received data (#{data}) but #{self.class.name}#receive callback is not defined" + logger.error "#{self.class.name} received data (#{data}) but #{self.class.name}#receive callback is not defined" end else unauthorized @@ -67,7 +67,7 @@ module ActionCable end def unauthorized - log_error "Unauthorized access to #{self.class.name}" + logger.error "Unauthorized access to #{self.class.name}" end def connect diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 197cf03c8e..cbc3512072 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -14,7 +14,7 @@ module ActionCable @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] - log_info "Subscribing to the redis channel: #{redis_channel}" + logger.info "Subscribing to the redis channel: #{redis_channel}" pubsub.subscribe(redis_channel, &callback) end @@ -22,7 +22,7 @@ module ActionCable def unsubscribe_from_redis_channels if @_redis_channels @_redis_channels.each do |channel, callback| - log_info "Unsubscribing from the redis channel: #{channel}" + logger.info "Unsubscribing from the redis channel: #{channel}" pubsub.unsubscribe_proc(channel, callback) end end @@ -30,7 +30,7 @@ module ActionCable def default_subscription_callback(channel) -> (message) do - log_info "Received a message over the redis channel: #{channel} (#{message})" + logger.info "Received a message over the redis channel: #{channel} (#{message})" broadcast ActiveSupport::JSON.decode(message) end end diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 665a851b11..a9048926e4 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -3,5 +3,6 @@ module ActionCable autoload :Base, 'action_cable/connection/base' autoload :InternalChannel, 'action_cable/connection/internal_channel' autoload :Identifier, 'action_cable/connection/identifier' + autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2ab62092af..e8126f3d75 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -12,8 +12,7 @@ module ActionCable self.identifiers += identifiers end - attr_reader :env, :server - attr_accessor :log_tags + attr_reader :env, :server, :logger delegate :worker_pool, :pubsub, to: :server def initialize(server, env) @@ -24,11 +23,13 @@ module ActionCable @accept_messages = false @pending_messages = [] @subscriptions = {} - @log_tags = [ 'ActionCable' ] + + @logger = TaggedLoggerProxy.new(server.logger, tags: [ 'ActionCable' ]) + @logger.add_tags(*logger_tags) end def process - log_info started_request_message + logger.info started_request_message if websocket? @websocket = Faye::WebSocket.new(@env) @@ -52,7 +53,7 @@ module ActionCable end @websocket.on(:close) do |event| - log_info finished_request_message + logger.info finished_request_message worker_pool.async.invoke(self, :on_connection_closed) EventMachine.cancel_timer(@ping_timer) if @ping_timer @@ -86,7 +87,7 @@ module ActionCable end def broadcast(data) - log_info "Sending data: #{data}" + logger.info "Sending data: #{data}" @websocket.send data end @@ -99,19 +100,11 @@ module ActionCable end def handle_exception - log_error "Closing connection" + logger.error "Closing connection" @websocket.close end - def log_info(message) - log :info, message - end - - def log_error(message) - log :error, message - end - protected def initialize_connection server.add_connection(self) @@ -142,13 +135,13 @@ module ActionCable subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } if subscription_klass - log_info "Subscribing to channel: #{id_key}" + logger.info "Subscribing to channel: #{id_key}" @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) else - log_error "Subscription class not found (#{data.inspect})" + logger.error "Subscription class not found (#{data.inspect})" end rescue Exception => e - log_error "Could not subscribe to channel (#{data.inspect})" + logger.error "Could not subscribe to channel (#{data.inspect})" log_exception(e) end @@ -159,18 +152,18 @@ module ActionCable log_exception "Unable to process message because no subscription was found (#{message.inspect})" end rescue Exception => e - log_error "Could not process message (#{message.inspect})" + logger.error "Could not process message (#{message.inspect})" log_exception(e) end def unsubscribe_channel(data) - log_info "Unsubscribing from channel: #{data['identifier']}" + logger.info "Unsubscribing from channel: #{data['identifier']}" @subscriptions[data['identifier']].unsubscribe @subscriptions.delete(data['identifier']) end def invalid_request - log_info "#{finished_request_message}" + logger.info "#{finished_request_message}" [404, {'Content-Type' => 'text/plain'}, ['Page not found']] end @@ -204,12 +197,12 @@ module ActionCable end def log_exception(e) - log_error "There was an exception - #{e.class}(#{e.message})" - log_error e.backtrace.join("\n") + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") end - def log(type, message) - server.logger.tagged(*log_tags) { server.logger.send type, message } + def logger_tags + [] end end end diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index 5338fc879e..a7d99f4b14 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -10,7 +10,7 @@ module ActionCable @_internal_redis_subscriptions << [ internal_redis_channel, callback ] pubsub.subscribe(internal_redis_channel, &callback) - log_info "Registered connection (#{connection_identifier})" + logger.info "Registered connection (#{connection_identifier})" end end @@ -26,12 +26,12 @@ module ActionCable case message['type'] when 'disconnect' - log_info "Removing connection (#{connection_identifier})" + logger.info "Removing connection (#{connection_identifier})" @websocket.close end rescue Exception => e - log_error "There was an exception - #{e.class}(#{e.message})" - log_error e.backtrace.join("\n") + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") handle_exception end diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb new file mode 100644 index 0000000000..04ea331bde --- /dev/null +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -0,0 +1,30 @@ +module ActionCable + module Connection + class TaggedLoggerProxy + + def initialize(logger, tags:) + @logger = logger + @tags = tags.flatten + end + + def info(message) + log :info, message + end + + def error(message) + log :error, message + end + + def add_tags(*tags) + @tags += tags.flatten + @tags = @tags.uniq + end + + protected + def log(type, message) + @logger.tagged(*@tags) { @logger.send type, message } + end + + end + end +end -- cgit v1.2.3 From 4ac6cfefbb84a6b6ae095c20e990e5807caa9105 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 13 Apr 2015 16:24:53 -0500 Subject: Remove all the existing connections on redis reconnect --- lib/action_cable/connection/base.rb | 4 ++++ lib/action_cable/server.rb | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index e8126f3d75..93db5b3dba 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -100,6 +100,10 @@ module ActionCable end def handle_exception + close_connection + end + + def close_connection logger.error "Closing connection" @websocket.close diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index a867d8578f..bfadcee229 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -24,7 +24,18 @@ module ActionCable end def pubsub - @pubsub ||= EM::Hiredis.connect(@redis_config[:url]).pubsub + @pubsub ||= redis.pubsub + end + + def redis + @redis ||= begin + redis = EM::Hiredis.connect(@redis_config[:url]) + redis.on(:reconnected) do + logger.info "[ActionCable] Redis reconnected. Closing all the open connections." + @connections.map &:close_connection + end + redis + end end def remote_connections -- cgit v1.2.3 From 07bcf2dcc93e6f391413add86f96f0ed72392a06 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 13 Apr 2015 16:26:30 -0500 Subject: Don't rely on server#redis --- lib/action_cable/remote_connection.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb index e2cb2d932c..9c4cd039c5 100644 --- a/lib/action_cable/remote_connection.rb +++ b/lib/action_cable/remote_connection.rb @@ -4,8 +4,6 @@ module ActionCable include Connection::Identifier - delegate :redis, to: :server - def initialize(server, ids) @server = server set_identifier_instance_vars(ids) -- cgit v1.2.3 From 5743cf30ff7714c7aa83133b350af22840473733 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 13 Apr 2015 21:45:03 -0500 Subject: Add Broadcaster to publish to redis channels --- lib/action_cable.rb | 1 + lib/action_cable/broadcaster.rb | 18 ++++++++++++++++++ lib/action_cable/server.rb | 12 ++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 lib/action_cable/broadcaster.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 1926201f1f..26b3980deb 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -25,4 +25,5 @@ module ActionCable autoload :Connection, 'action_cable/connection' autoload :RemoteConnection, 'action_cable/remote_connection' autoload :RemoteConnections, 'action_cable/remote_connections' + autoload :Broadcaster, 'action_cable/broadcaster' end diff --git a/lib/action_cable/broadcaster.rb b/lib/action_cable/broadcaster.rb new file mode 100644 index 0000000000..b2352876e9 --- /dev/null +++ b/lib/action_cable/broadcaster.rb @@ -0,0 +1,18 @@ +module ActionCable + class Broadcaster + attr_reader :server, :channel, :redis + delegate :logger, to: :server + + def initialize(server, channel) + @server = server + @channel = channel + @redis = @server.threaded_redis + end + + def broadcast(message) + redis.publish channel, message.to_json + logger.info "[ActionCable] Broadcasting to channel (#{channel}): #{message}" + end + + end +end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index bfadcee229..70b0610e92 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -38,10 +38,22 @@ module ActionCable end end + def threaded_redis + @threaded_redis ||= Redis.new(redis_config) + end + def remote_connections @remote_connections ||= RemoteConnections.new(self) end + def broadcaster_for(channel) + Broadcaster.new(self, channel) + end + + def broadcast(channel, message) + broadcaster_for(channel).broadcast(message) + end + def connection_identifiers @connection_class.identifiers end -- cgit v1.2.3 From 155db8120811b89624fb7e657818eb32ed07301a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Apr 2015 13:21:26 -0500 Subject: Revert "Remove all the existing connections on redis reconnect" This reverts commit 4ac6cfefbb84a6b6ae095c20e990e5807caa9105. --- lib/action_cable/connection/base.rb | 4 ---- lib/action_cable/server.rb | 13 +------------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 93db5b3dba..e8126f3d75 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -100,10 +100,6 @@ module ActionCable end def handle_exception - close_connection - end - - def close_connection logger.error "Closing connection" @websocket.close diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 70b0610e92..1780e0c028 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -24,18 +24,7 @@ module ActionCable end def pubsub - @pubsub ||= redis.pubsub - end - - def redis - @redis ||= begin - redis = EM::Hiredis.connect(@redis_config[:url]) - redis.on(:reconnected) do - logger.info "[ActionCable] Redis reconnected. Closing all the open connections." - @connections.map &:close_connection - end - redis - end + @pubsub ||= EM::Hiredis.connect(@redis_config[:url]).pubsub end def threaded_redis -- cgit v1.2.3 From d6dc49456fcd5be6b034ebd2a9299bda96201972 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Apr 2015 13:22:34 -0500 Subject: Revert "Revert "Remove all the existing connections on redis reconnect"" This reverts commit 155db8120811b89624fb7e657818eb32ed07301a. --- lib/action_cable/connection/base.rb | 4 ++++ lib/action_cable/server.rb | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index e8126f3d75..93db5b3dba 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -100,6 +100,10 @@ module ActionCable end def handle_exception + close_connection + end + + def close_connection logger.error "Closing connection" @websocket.close diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 1780e0c028..70b0610e92 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -24,7 +24,18 @@ module ActionCable end def pubsub - @pubsub ||= EM::Hiredis.connect(@redis_config[:url]).pubsub + @pubsub ||= redis.pubsub + end + + def redis + @redis ||= begin + redis = EM::Hiredis.connect(@redis_config[:url]) + redis.on(:reconnected) do + logger.info "[ActionCable] Redis reconnected. Closing all the open connections." + @connections.map &:close_connection + end + redis + end end def threaded_redis -- cgit v1.2.3 From 7e0a7c04291c3fcd75ba2ffd11ebe6ef0aaae9ba Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Apr 2015 13:23:05 -0500 Subject: Dont close the open connections on redis reconnect --- lib/action_cable/server.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 70b0610e92..930deec1d7 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -31,8 +31,9 @@ module ActionCable @redis ||= begin redis = EM::Hiredis.connect(@redis_config[:url]) redis.on(:reconnected) do - logger.info "[ActionCable] Redis reconnected. Closing all the open connections." - @connections.map &:close_connection + logger.info "[ActionCable] Redis reconnected." + # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." + # @connections.map &:close_connection end redis end -- cgit v1.2.3 From 4728d0b1d7fce8cab60c3242e064f8b9265b1c64 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 15 Apr 2015 13:58:22 -0500 Subject: Use Server#threaded_redis instead of creating a new connection --- lib/action_cable/remote_connection.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb index 9c4cd039c5..912fb6eb57 100644 --- a/lib/action_cable/remote_connection.rb +++ b/lib/action_cable/remote_connection.rb @@ -19,7 +19,7 @@ module ActionCable end def redis - @redis ||= Redis.new(@server.redis_config) + @server.threaded_redis end private -- cgit v1.2.3 From 5ae546c822f95798bb64cda093bf1c444c829495 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 15 Apr 2015 15:09:21 -0500 Subject: Latest ruby --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index cd57a8b95d..c043eea776 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.1.5 +2.2.1 -- cgit v1.2.3 From 702c919b27940d5b46d653d31a89daf2efa674d0 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 16 Apr 2015 11:13:15 -0500 Subject: Ping pubsub every 2 minutes --- lib/action_cable/server.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 930deec1d7..f7399666bc 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,5 +1,7 @@ module ActionCable class Server + PUBSUB_PING_TIMEOUT = 120 + cattr_accessor(:logger, instance_reader: true) { Rails.logger } attr_accessor :registered_channels, :redis_config @@ -24,7 +26,7 @@ module ActionCable end def pubsub - @pubsub ||= redis.pubsub + @pubsub ||= redis.pubsub.tap { |pb| add_pubsub_periodic_timer(pb) } end def redis @@ -71,5 +73,12 @@ module ActionCable @connections.map(&:statistics) end + protected + def add_pubsub_periodic_timer(ps) + @pubsub_periodic_timer ||= EventMachine.add_periodic_timer(PUBSUB_PING_TIMEOUT) do + logger.info "[ActionCable] Pubsub ping" + ps.ping + end + end end end -- cgit v1.2.3 From 00ea417d9205146dd957a7b7e8bce596d826a4ac Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 16 Apr 2015 11:13:38 -0500 Subject: Ruby 2.2.2 --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index c043eea776..b1b25a5ffa 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.2.1 +2.2.2 -- cgit v1.2.3 From da0015e062c046bc4bdde1ef2f48cf93ad97cc1a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 16 Apr 2015 11:53:56 -0500 Subject: Revert "Ping pubsub every 2 minutes" This reverts commit 702c919b27940d5b46d653d31a89daf2efa674d0. --- lib/action_cable/server.rb | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index f7399666bc..930deec1d7 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,7 +1,5 @@ module ActionCable class Server - PUBSUB_PING_TIMEOUT = 120 - cattr_accessor(:logger, instance_reader: true) { Rails.logger } attr_accessor :registered_channels, :redis_config @@ -26,7 +24,7 @@ module ActionCable end def pubsub - @pubsub ||= redis.pubsub.tap { |pb| add_pubsub_periodic_timer(pb) } + @pubsub ||= redis.pubsub end def redis @@ -73,12 +71,5 @@ module ActionCable @connections.map(&:statistics) end - protected - def add_pubsub_periodic_timer(ps) - @pubsub_periodic_timer ||= EventMachine.add_periodic_timer(PUBSUB_PING_TIMEOUT) do - logger.info "[ActionCable] Pubsub ping" - ps.ping - end - end end end -- cgit v1.2.3 From 88491402dee70b606328b84d9bb45830554e4dd8 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 16 Apr 2015 11:59:21 -0500 Subject: Log redis reconnect failures --- lib/action_cable/server.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 930deec1d7..347592b6b0 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -30,8 +30,8 @@ module ActionCable def redis @redis ||= begin redis = EM::Hiredis.connect(@redis_config[:url]) - redis.on(:reconnected) do - logger.info "[ActionCable] Redis reconnected." + redis.on(:reconnect_failed) do + logger.info "[ActionCable] Redis reconnect failed." # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." # @connections.map &:close_connection end -- cgit v1.2.3 From 36deb234c5aaad05f6d601fb4ff24590a10fd26d Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 7 May 2015 10:54:33 -0500 Subject: Rails style logger tags --- lib/action_cable/connection/base.rb | 8 ++++---- lib/action_cable/server.rb | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 93db5b3dba..e3d2f42e65 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -24,8 +24,7 @@ module ActionCable @pending_messages = [] @subscriptions = {} - @logger = TaggedLoggerProxy.new(server.logger, tags: [ 'ActionCable' ]) - @logger.add_tags(*logger_tags) + @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) end def process @@ -205,9 +204,10 @@ module ActionCable logger.error e.backtrace.join("\n") end - def logger_tags - [] + def log_tags + server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end + end end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 347592b6b0..d922d347ce 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -2,13 +2,14 @@ module ActionCable class Server cattr_accessor(:logger, instance_reader: true) { Rails.logger } - attr_accessor :registered_channels, :redis_config + attr_accessor :registered_channels, :redis_config, :log_tags - def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection) + def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection, log_tags: [ 'ActionCable' ]) @redis_config = redis_config.with_indifferent_access @registered_channels = Set.new(channels) @worker_pool_size = worker_pool_size @connection_class = connection + @log_tags = log_tags @connections = [] -- cgit v1.2.3 From b4a71276f67ca50ece3d553ef9c7224a579b1eb2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 12 May 2015 22:29:15 -0500 Subject: Dont log the messages --- lib/action_cable/channel/redis.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index cbc3512072..6fc6ac179f 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -30,7 +30,7 @@ module ActionCable def default_subscription_callback(channel) -> (message) do - logger.info "Received a message over the redis channel: #{channel} (#{message})" + logger.info "Received a message over the redis channel: #{channel}" broadcast ActiveSupport::JSON.decode(message) end end -- cgit v1.2.3 From cd165923aecfeecae81ee65329c77572c6a8363e Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 1 Jun 2015 10:48:34 -0500 Subject: Remove nil values from connection identifier --- lib/action_cable/connection/identifier.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/identifier.rb b/lib/action_cable/connection/identifier.rb index 9bfd773ab1..103d326b7f 100644 --- a/lib/action_cable/connection/identifier.rb +++ b/lib/action_cable/connection/identifier.rb @@ -7,7 +7,7 @@ module ActionCable end def connection_identifier - @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}")} + @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}")}.compact end def connection_gid(ids) -- cgit v1.2.3 From 8bd5a80ec5b6c11b3d477fa3af36cbe6e860bb4b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 18 Jun 2015 17:01:04 +0200 Subject: Allow unsubscribing from all current redis channels --- lib/action_cable/channel/redis.rb | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 6fc6ac179f..6d114eee7e 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -1,11 +1,10 @@ module ActionCable module Channel - module Redis extend ActiveSupport::Concern included do - on_unsubscribe :unsubscribe_from_redis_channels + on_unsubscribe :unsubscribe_from_all_channels delegate :pubsub, to: :connection end @@ -18,24 +17,22 @@ module ActionCable pubsub.subscribe(redis_channel, &callback) end - protected - def unsubscribe_from_redis_channels - if @_redis_channels - @_redis_channels.each do |channel, callback| - logger.info "Unsubscribing from the redis channel: #{channel}" - pubsub.unsubscribe_proc(channel, callback) - end + def unsubscribe_from_all_channels + if @_redis_channels + @_redis_channels.each do |channel, callback| + logger.info "Unsubscribing from the redis channel: #{channel}" + pubsub.unsubscribe_proc(channel, callback) end end + end + protected def default_subscription_callback(channel) -> (message) do logger.info "Received a message over the redis channel: #{channel}" broadcast ActiveSupport::JSON.decode(message) end end - end - end -end \ No newline at end of file +end -- cgit v1.2.3 From d5caca91e213b8c82c126ce702dca46ae1fa11ba Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 18 Jun 2015 18:51:06 +0200 Subject: Fix that log_exception only takes an exception, not a string --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index e3d2f42e65..06e1eb2e04 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -152,7 +152,7 @@ module ActionCable if @subscriptions[message['identifier']] @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) else - log_exception "Unable to process message because no subscription was found (#{message.inspect})" + raise "Unable to process message because no subscription was found (#{message.inspect})" end rescue Exception => e logger.error "Could not process message (#{message.inspect})" -- cgit v1.2.3 From 5b1591f2bb2b99cf8cda5325be28ad40892d11d3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 18 Jun 2015 18:52:30 +0200 Subject: Nicer formatting --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 06e1eb2e04..2208009b14 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -200,7 +200,7 @@ module ActionCable end def log_exception(e) - logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error "There was an exception: #{e.class} - #{e.message}" logger.error e.backtrace.join("\n") end -- cgit v1.2.3 From 3a4a11dc91240947d40a6736e1a6a402136ced47 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 17:05:14 +0200 Subject: Spaces --- lib/action_cable/channel/base.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index fac988ba52..ec7290be1e 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -101,8 +101,6 @@ module ActionCable def worker_pool connection.worker_pool end - end - end end \ No newline at end of file -- cgit v1.2.3 From 95dfcce7eedb375088413cd0b1800d9740d923c4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 18:29:56 +0200 Subject: Trailing CR --- lib/action_cable/broadcaster.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/action_cable/broadcaster.rb b/lib/action_cable/broadcaster.rb index b2352876e9..38c56b8262 100644 --- a/lib/action_cable/broadcaster.rb +++ b/lib/action_cable/broadcaster.rb @@ -13,6 +13,5 @@ module ActionCable redis.publish channel, message.to_json logger.info "[ActionCable] Broadcasting to channel (#{channel}): #{message}" end - end end -- cgit v1.2.3 From 9fda96b78c3a5079720e847a538917a7da984905 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 18:32:51 +0200 Subject: More spacing --- lib/action_cable/channel/base.rb | 1 - lib/action_cable/channel/callbacks.rb | 3 --- lib/action_cable/connection/base.rb | 1 - lib/action_cable/connection/identifier.rb | 2 -- lib/action_cable/connection/internal_channel.rb | 1 - lib/action_cable/connection/tagged_logger_proxy.rb | 1 - 6 files changed, 9 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index ec7290be1e..f309709e31 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -1,6 +1,5 @@ module ActionCable module Channel - class Base include Callbacks include Redis diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb index 22c6f2d563..15bfb9a3da 100644 --- a/lib/action_cable/channel/callbacks.rb +++ b/lib/action_cable/channel/callbacks.rb @@ -1,6 +1,5 @@ module ActionCable module Channel - module Callbacks extend ActiveSupport::Concern @@ -25,8 +24,6 @@ module ActionCable self.periodic_timers += [ [ callback, every: every ] ] end end - end - end end \ No newline at end of file diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2208009b14..ee1fe51529 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -207,7 +207,6 @@ module ActionCable def log_tags server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end - end end end diff --git a/lib/action_cable/connection/identifier.rb b/lib/action_cable/connection/identifier.rb index 103d326b7f..a608fc546a 100644 --- a/lib/action_cable/connection/identifier.rb +++ b/lib/action_cable/connection/identifier.rb @@ -1,7 +1,6 @@ module ActionCable module Connection module Identifier - def internal_redis_channel "action_cable/#{connection_identifier}" end @@ -13,7 +12,6 @@ module ActionCable def connection_gid(ids) ids.map {|o| o.to_global_id.to_s }.sort.join(":") end - end end end diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index a7d99f4b14..3a11bcaf7b 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -35,7 +35,6 @@ module ActionCable handle_exception end - end end end diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb index 04ea331bde..d99cc2e9a3 100644 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -24,7 +24,6 @@ module ActionCable def log(type, message) @logger.tagged(*@tags) { @logger.send type, message } end - end end end -- cgit v1.2.3 From 2b41ede6f8af2c69296478777babb5617d33b867 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 18:55:14 +0200 Subject: Latest dependencies --- Gemfile.lock | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6fcf4aa39f..0220e1cf6a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,17 @@ PATH remote: . specs: - action_cable (0.0.2) + action_cable (0.0.3) activesupport (>= 4.2.0) celluloid (~> 0.16.0) em-hiredis (~> 0.3.0) faye-websocket (~> 0.9.2) + redis (~> 3.0) GEM remote: http://rubygems.org/ specs: - activesupport (4.2.0) + activesupport (4.2.2) i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) @@ -28,18 +29,19 @@ GEM hiredis (0.5.2) hitimes (1.2.2) i18n (0.7.0) - json (1.8.2) - minitest (5.5.1) + json (1.8.3) + minitest (5.7.0) puma (2.10.2) rack (>= 1.1, < 2.0) rack (1.6.0) rake (10.4.2) - thread_safe (0.3.4) + redis (3.2.1) + thread_safe (0.3.5) timers (4.0.1) hitimes tzinfo (1.2.2) thread_safe (~> 0.1) - websocket-driver (0.5.3) + websocket-driver (0.5.4) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) -- cgit v1.2.3 From 471ba41fddeff19f04f8d29bfe0df3c3f497407a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 18:55:35 +0200 Subject: Free up subscribe/unsubscribe as action names the user can use in their channels --- lib/action_cable/channel/base.rb | 6 +++--- lib/action_cable/connection/base.rb | 4 ++-- lib/action_cable/server.rb | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index f309709e31..cc9580ec52 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -32,7 +32,7 @@ module ActionCable connect - subscribe + run_subscribe_callbacks end def receive_data(data) @@ -47,13 +47,13 @@ module ActionCable end end - def subscribe + def run_subscribe_callbacks self.class.on_subscribe_callbacks.each do |callback| send(callback) end end - def unsubscribe + def run_unsubscribe_callbacks self.class.on_unsubscribe_callbacks.each do |callback| send(callback) end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index ee1fe51529..fff6fd46a6 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -81,7 +81,7 @@ module ActionCable def cleanup_subscriptions @subscriptions.each do |id, channel| - channel.unsubscribe + channel.run_unsubscribe_callbacks end end @@ -161,7 +161,7 @@ module ActionCable def unsubscribe_channel(data) logger.info "Unsubscribing from channel: #{data['identifier']}" - @subscriptions[data['identifier']].unsubscribe + @subscriptions[data['identifier']].run_unsubscribe_callbacks @subscriptions.delete(data['identifier']) end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index d922d347ce..322fc85519 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -71,6 +71,5 @@ module ActionCable def open_connections_statistics @connections.map(&:statistics) end - end end -- cgit v1.2.3 From 2258344e0fbeb1af74ebe477f392a76fa6d6f4ef Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 19:05:06 +0200 Subject: Switch internal actions to be called commands instead, such that we can use action as the routing word on the user side --- lib/action_cable/connection/base.rb | 2 +- lib/assets/javascripts/cable.js.coffee | 6 +++--- test/server_test.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index fff6fd46a6..5c2ee14258 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -69,7 +69,7 @@ module ActionCable data = ActiveSupport::JSON.decode data - case data['action'] + case data['command'] when 'subscribe' subscribe_channel(data) when 'unsubscribe' diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 345771dd1f..7c033d3b08 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -28,7 +28,7 @@ class @Cable sendData: (identifier, data) => if @isConnected() - @connection.send JSON.stringify { action: 'message', identifier: identifier, data: data } + @connection.send JSON.stringify { command: 'message', identifier: identifier, data: data } receiveData: (message) => data = JSON.parse message.data @@ -109,11 +109,11 @@ class @Cable subscribeOnServer: (identifier) => if @isConnected() - @connection.send JSON.stringify { action: 'subscribe', identifier: identifier } + @connection.send JSON.stringify { command: 'subscribe', identifier: identifier } unsubscribeOnServer: (identifier) => if @isConnected() - @connection.send JSON.stringify { action: 'unsubscribe', identifier: identifier } + @connection.send JSON.stringify { command: 'unsubscribe', identifier: identifier } pingReceived: (timestamp) => if @lastPingTime? and (timestamp - @lastPingTime) > @PING_STALE_INTERVAL diff --git a/test/server_test.rb b/test/server_test.rb index 50a95b9d59..824875bb99 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -27,7 +27,7 @@ class ServerTest < ActionCableTest puts message.inspect end - ws.send action: 'subscribe', identifier: { channel: 'chat'}.to_json + ws.send command: 'subscribe', identifier: { channel: 'chat'}.to_json end test "subscribing to a channel with invalid params" do -- cgit v1.2.3 From a44033e623f15524ce87235c79c9e547a54c8e69 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:29:43 +0200 Subject: Use a perform_action router to handle incoming data Similar to a controller in ActionPack. --- lib/action_cable/channel/base.rb | 12 ++++++++---- lib/action_cable/connection/base.rb | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index cc9580ec52..c18593bf6f 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -35,12 +35,16 @@ module ActionCable run_subscribe_callbacks end - def receive_data(data) + def perform_action(data) if authorized? - if respond_to?(:receive) - receive(data) + action = (data['action'].presence || :receive).to_sym + signature = "#{self.class.name}##{action}: #{data}" + + if self.class.instance_methods(false).include?(action) + logger.info "Processing #{signature}" + public_send action, data else - logger.error "#{self.class.name} received data (#{data}) but #{self.class.name}#receive callback is not defined" + logger.error "Failed to process #{signature}" end else unauthorized diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 5c2ee14258..e5ed07b5cc 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -150,7 +150,7 @@ module ActionCable def process_message(message) if @subscriptions[message['identifier']] - @subscriptions[message['identifier']].receive_data(ActiveSupport::JSON.decode message['data']) + @subscriptions[message['identifier']].perform_action(ActiveSupport::JSON.decode message['data']) else raise "Unable to process message because no subscription was found (#{message.inspect})" end -- cgit v1.2.3 From 735e4d24a9d3674a3059b20260b808056aec6446 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:32:13 +0200 Subject: Add #perform_disconnection to have a place for both callbacks and logging And using an unlikely-to-clash name. --- lib/action_cable/channel/base.rb | 13 ++++++++----- lib/action_cable/connection/base.rb | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index c18593bf6f..8580f9d75b 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -56,11 +56,9 @@ module ActionCable send(callback) end end - - def run_unsubscribe_callbacks - self.class.on_unsubscribe_callbacks.each do |callback| - send(callback) - end + def perform_disconnection + run_unsubscribe_callbacks + logger.info "#{self.class.name} disconnected" end protected @@ -89,6 +87,11 @@ module ActionCable end end + private + def run_unsubscribe_callbacks + self.class.on_unsubscribe_callbacks.each { |callback| send(callback) } + end + def start_periodic_timers self.class.periodic_timers.each do |callback, options| @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index e5ed07b5cc..2ae5c5554c 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -81,7 +81,7 @@ module ActionCable def cleanup_subscriptions @subscriptions.each do |id, channel| - channel.run_unsubscribe_callbacks + channel.perform_disconnection end end @@ -161,7 +161,7 @@ module ActionCable def unsubscribe_channel(data) logger.info "Unsubscribing from channel: #{data['identifier']}" - @subscriptions[data['identifier']].run_unsubscribe_callbacks + @subscriptions[data['identifier']].perform_disconnection @subscriptions.delete(data['identifier']) end -- cgit v1.2.3 From b28ad629d054023583c221c30d631ac72fdf58ad Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:32:41 +0200 Subject: Making running of subscribe callbacks a private matter --- lib/action_cable/channel/base.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 8580f9d75b..2dba3b3ea6 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -51,11 +51,6 @@ module ActionCable end end - def run_subscribe_callbacks - self.class.on_subscribe_callbacks.each do |callback| - send(callback) - end - end def perform_disconnection run_unsubscribe_callbacks logger.info "#{self.class.name} disconnected" @@ -88,6 +83,10 @@ module ActionCable end private + def run_subscribe_callbacks + self.class.on_subscribe_callbacks.each { |callback| send(callback) } + end + def run_unsubscribe_callbacks self.class.on_unsubscribe_callbacks.each { |callback| send(callback) } end -- cgit v1.2.3 From 033ef9f5d0455171d24140e033a91184433dfe9f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:33:17 +0200 Subject: Stop logging all send data, but do log broadcasting We basically want to quiet the pings. --- lib/action_cable/channel/base.rb | 1 + lib/action_cable/connection/base.rb | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 2dba3b3ea6..87f1cfe095 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -76,6 +76,7 @@ module ActionCable def broadcast(data) if authorized? + logger.info "Broadcasting: #{data.inspect}" connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) else unauthorized diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2ae5c5554c..8953d9eee1 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -86,7 +86,6 @@ module ActionCable end def broadcast(data) - logger.info "Sending data: #{data}" @websocket.send data end -- cgit v1.2.3 From 19d747e9747a312ea71e96d2373af9d60ad00697 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:33:25 +0200 Subject: Log connection --- lib/action_cable/channel/base.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 87f1cfe095..3de932858a 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -31,8 +31,9 @@ module ActionCable @params = params connect - run_subscribe_callbacks + + logger.info "#{self.class.name} connected" end def perform_action(data) -- cgit v1.2.3 From a083cf69ca28656991ccc9c718bdd9c054ae00fb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:33:33 +0200 Subject: Nix space --- lib/action_cable/connection/base.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 8953d9eee1..c1efca9484 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -103,7 +103,6 @@ module ActionCable def close_connection logger.error "Closing connection" - @websocket.close end -- cgit v1.2.3 From 51bd331004dd9fe4ae251345bf3a5e8ebf0eff81 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:43:39 +0200 Subject: Better spacing --- lib/action_cable/channel/base.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 3de932858a..ec14e13dfa 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -93,6 +93,7 @@ module ActionCable self.class.on_unsubscribe_callbacks.each { |callback| send(callback) } end + def start_periodic_timers self.class.periodic_timers.each do |callback, options| @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do @@ -105,6 +106,7 @@ module ActionCable @_active_periodic_timers.each {|t| t.cancel } end + def worker_pool connection.worker_pool end -- cgit v1.2.3 From 4ebd8cecb85ffcd2292c1a7d0013a2725448f365 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 22:46:23 +0200 Subject: Refactor perform_action via extract methods And improve logging --- lib/action_cable/channel/base.rb | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index ec14e13dfa..1a69d50885 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -38,14 +38,13 @@ module ActionCable def perform_action(data) if authorized? - action = (data['action'].presence || :receive).to_sym - signature = "#{self.class.name}##{action}: #{data}" + action = extract_action(data) - if self.class.instance_methods(false).include?(action) - logger.info "Processing #{signature}" + if performable_action?(action) + logger.info "Processing #{compose_signature(action, data)}" public_send action, data else - logger.error "Failed to process #{signature}" + logger.error "Failed to process #{compose_signature(action, data)}" end else unauthorized @@ -85,6 +84,23 @@ module ActionCable end private + def extract_action(data) + (data['action'].presence || :receive).to_sym + end + + def performable_action?(action) + self.class.instance_methods(false).include?(action) + end + + def compose_signature(action, data) + "#{self.class.name}##{action}".tap do |signature| + if (arguments = data.except('action')).any? + signature << ": #{arguments.inspect}" + end + end + end + + def run_subscribe_callbacks self.class.on_subscribe_callbacks.each { |callback| send(callback) } end -- cgit v1.2.3 From 3759071420d7bd795f38cf26f0bb381c582009b1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 19 Jun 2015 23:06:36 +0200 Subject: Improve logging --- lib/action_cable/channel/base.rb | 35 ++++++++++++++++++++++------------- lib/action_cable/channel/redis.rb | 8 ++++---- lib/action_cable/connection/base.rb | 1 - 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 1a69d50885..6060ccf681 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -7,8 +7,6 @@ module ActionCable on_subscribe :start_periodic_timers on_unsubscribe :stop_periodic_timers - on_unsubscribe :disconnect - attr_reader :params, :connection delegate :logger, to: :connection @@ -30,10 +28,7 @@ module ActionCable @_active_periodic_timers = [] @params = params - connect - run_subscribe_callbacks - - logger.info "#{self.class.name} connected" + perform_connection end def perform_action(data) @@ -41,10 +36,10 @@ module ActionCable action = extract_action(data) if performable_action?(action) - logger.info "Processing #{compose_signature(action, data)}" + logger.info channel_name + compose_signature(action, data) public_send action, data else - logger.error "Failed to process #{compose_signature(action, data)}" + logger.error "#{channel_name} failed to process #{compose_signature(action, data)}" end else unauthorized @@ -52,8 +47,15 @@ module ActionCable end def perform_disconnection + disconnect run_unsubscribe_callbacks - logger.info "#{self.class.name} disconnected" + logger.info "#{channel_name} disconnected" + end + + def perform_connection + logger.info "#{channel_name} connecting" + connect + run_subscribe_callbacks end protected @@ -63,9 +65,10 @@ module ActionCable end def unauthorized - logger.error "Unauthorized access to #{self.class.name}" + logger.error "#{channel_name}: Unauthorized access" end + def connect # Override in subclasses end @@ -74,15 +77,21 @@ module ActionCable # Override in subclasses end + def broadcast(data) if authorized? - logger.info "Broadcasting: #{data.inspect}" + logger.info "#{channel_name} broadcasting #{data.inspect}" connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) else unauthorized end end + + def channel_name + self.class.name + end + private def extract_action(data) (data['action'].presence || :receive).to_sym @@ -93,9 +102,9 @@ module ActionCable end def compose_signature(action, data) - "#{self.class.name}##{action}".tap do |signature| + "##{action}".tap do |signature| if (arguments = data.except('action')).any? - signature << ": #{arguments.inspect}" + signature << "(#{arguments.inspect})" end end end diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index 6d114eee7e..b5fc812919 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -13,15 +13,15 @@ module ActionCable @_redis_channels ||= [] @_redis_channels << [ redis_channel, callback ] - logger.info "Subscribing to the redis channel: #{redis_channel}" pubsub.subscribe(redis_channel, &callback) + logger.info "#{channel_name} subscribed to incoming actions from #{redis_channel}" end def unsubscribe_from_all_channels if @_redis_channels - @_redis_channels.each do |channel, callback| - logger.info "Unsubscribing from the redis channel: #{channel}" - pubsub.unsubscribe_proc(channel, callback) + @_redis_channels.each do |redis_channel, callback| + pubsub.unsubscribe_proc(redis_channel, callback) + logger.info "#{channel_name} unsubscribed from incoming actions #{redis_channel}" end end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index c1efca9484..cb9267e2f5 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -136,7 +136,6 @@ module ActionCable subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } if subscription_klass - logger.info "Subscribing to channel: #{id_key}" @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) else logger.error "Subscription class not found (#{data.inspect})" -- cgit v1.2.3 From 4aa20012a2cb7bbc364a2ac9adc4fac0b26c3f11 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 11:53:14 +0200 Subject: Log when receiving unrecognized commands --- lib/action_cable/connection/base.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index cb9267e2f5..d6e5bfa2d2 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -76,6 +76,8 @@ module ActionCable unsubscribe_channel(data) when 'message' process_message(data) + else + logger.error "Received unrecognized command in #{data.inspect}" end end -- cgit v1.2.3 From 7c1becfc1a0ebeb5a1fd840afd89af26b3a6f02d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 15:29:34 +0200 Subject: No need to double string it --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index d6e5bfa2d2..c36b7bbc1a 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -165,7 +165,7 @@ module ActionCable end def invalid_request - logger.info "#{finished_request_message}" + logger.info finished_request_message [404, {'Content-Type' => 'text/plain'}, ['Page not found']] end -- cgit v1.2.3 From 0e4c2df1e105871f3afa9503043a22489822110e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 16:01:44 +0200 Subject: Add new convention method for performing channel actions --- lib/assets/javascripts/channel.js.coffee | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee index 058bcc03aa..a324a62a1b 100644 --- a/lib/assets/javascripts/channel.js.coffee +++ b/lib/assets/javascripts/channel.js.coffee @@ -11,6 +11,7 @@ class @Cable.Channel onReceiveData: @received }) + connected: => # Override in the subclass @@ -20,8 +21,14 @@ class @Cable.Channel received: (data) => # Override in the subclass + # Perform a channel action with the optional data passed as an attribute + perform: (action, data = {}) -> + data.action = action + cable.sendData @channelIdentifier, JSON.stringify data + send: (data) -> cable.sendData @channelIdentifier, JSON.stringify data + underscore: (value) -> value.replace(/[A-Z]/g, (match) => "_#{match.toLowerCase()}").substr(1) \ No newline at end of file -- cgit v1.2.3 From 404867d332069d8b87547e1eae5ed1d0a7a1b95c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 16:21:21 +0200 Subject: TOC order --- lib/action_cable/channel/base.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 6060ccf681..c39c2fcf0b 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -31,6 +31,12 @@ module ActionCable perform_connection end + def perform_connection + logger.info "#{channel_name} connecting" + connect + run_subscribe_callbacks + end + def perform_action(data) if authorized? action = extract_action(data) @@ -52,11 +58,6 @@ module ActionCable logger.info "#{channel_name} disconnected" end - def perform_connection - logger.info "#{channel_name} connecting" - connect - run_subscribe_callbacks - end protected # Override in subclasses -- cgit v1.2.3 From 294a277a8409e05c6b98cd8e46a8e0745f68b40d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 16:22:40 +0200 Subject: Rename broadcast to transmit for the connection/channel->subscriber communication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disambiguate it from the broadcast to channel method used by the broadcaster (which actually has several listeners, whereas a connection/channel instance only ever has one listener – hence not much of a BROADcast). --- lib/action_cable/channel/base.rb | 6 +++--- lib/action_cable/channel/redis.rb | 3 +-- lib/action_cable/connection/base.rb | 10 +++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index c39c2fcf0b..4c90849a06 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -79,10 +79,10 @@ module ActionCable end - def broadcast(data) + def transmit(data, via: nil) if authorized? - logger.info "#{channel_name} broadcasting #{data.inspect}" - connection.broadcast({ identifier: @channel_identifier, message: data }.to_json) + logger.info "#{channel_name} transmitting #{data.inspect} #{via}" + connection.transmit({ identifier: @channel_identifier, message: data }.to_json) else unauthorized end diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index b5fc812919..bdd6ab9dcf 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -29,8 +29,7 @@ module ActionCable protected def default_subscription_callback(channel) -> (message) do - logger.info "Received a message over the redis channel: #{channel}" - broadcast ActiveSupport::JSON.decode(message) + transmit ActiveSupport::JSON.decode(message), via: "incoming action from #{channel}" end end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index d6e5bfa2d2..52f94a7ab0 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -34,8 +34,8 @@ module ActionCable @websocket = Faye::WebSocket.new(@env) @websocket.on(:open) do |event| - broadcast_ping_timestamp - @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { broadcast_ping_timestamp } + transmit_ping_timestamp + @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { transmit_ping_timestamp } worker_pool.async.invoke(self, :initialize_connection) end @@ -87,7 +87,7 @@ module ActionCable end end - def broadcast(data) + def transmit(data) @websocket.send data end @@ -127,8 +127,8 @@ module ActionCable disconnect if respond_to?(:disconnect) end - def broadcast_ping_timestamp - broadcast({ identifier: '_ping', message: Time.now.to_i }.to_json) + def transmit_ping_timestamp + transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) end def subscribe_channel(data) -- cgit v1.2.3 From f6daf0ef6d811053301c7e9b2991911caa57e841 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 16:34:27 +0200 Subject: Assume channel names include the _channel extension --- lib/assets/javascripts/channel.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee index a324a62a1b..2f07affb19 100644 --- a/lib/assets/javascripts/channel.js.coffee +++ b/lib/assets/javascripts/channel.js.coffee @@ -1,6 +1,6 @@ class @Cable.Channel constructor: (params = {}) -> - @channelName ?= @underscore @constructor.name + @channelName ?= "#{@underscore(@constructor.name)}_channel" params['channel'] = @channelName @channelIdentifier = JSON.stringify params -- cgit v1.2.3 From 995c101caae06ec38d0106dcd9ee36954a489687 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 16:51:11 +0200 Subject: More logging improvements --- lib/action_cable/broadcaster.rb | 2 +- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/channel/redis.rb | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/broadcaster.rb b/lib/action_cable/broadcaster.rb index 38c56b8262..7d8cc90970 100644 --- a/lib/action_cable/broadcaster.rb +++ b/lib/action_cable/broadcaster.rb @@ -10,8 +10,8 @@ module ActionCable end def broadcast(message) + logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" redis.publish channel, message.to_json - logger.info "[ActionCable] Broadcasting to channel (#{channel}): #{message}" end end end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 4c90849a06..e6ca45ddcc 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -81,7 +81,7 @@ module ActionCable def transmit(data, via: nil) if authorized? - logger.info "#{channel_name} transmitting #{data.inspect} #{via}" + logger.info "#{channel_name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } connection.transmit({ identifier: @channel_identifier, message: data }.to_json) else unauthorized diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb index bdd6ab9dcf..0f77dc0418 100644 --- a/lib/action_cable/channel/redis.rb +++ b/lib/action_cable/channel/redis.rb @@ -14,14 +14,14 @@ module ActionCable @_redis_channels << [ redis_channel, callback ] pubsub.subscribe(redis_channel, &callback) - logger.info "#{channel_name} subscribed to incoming actions from #{redis_channel}" + logger.info "#{channel_name} subscribed to broadcasts from #{redis_channel}" end def unsubscribe_from_all_channels if @_redis_channels @_redis_channels.each do |redis_channel, callback| pubsub.unsubscribe_proc(redis_channel, callback) - logger.info "#{channel_name} unsubscribed from incoming actions #{redis_channel}" + logger.info "#{channel_name} unsubscribed to broadcasts from #{redis_channel}" end end end @@ -29,7 +29,7 @@ module ActionCable protected def default_subscription_callback(channel) -> (message) do - transmit ActiveSupport::JSON.decode(message), via: "incoming action from #{channel}" + transmit ActiveSupport::JSON.decode(message), via: "broadcast from #{channel}" end end end -- cgit v1.2.3 From 082c6317f0b680f4bcd3b1643e6481bed4606139 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 17:03:43 +0200 Subject: Update request to do the env_config merge and add cookies helper --- lib/action_cable/connection/base.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 52f94a7ab0..295b71ecb3 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -109,6 +109,14 @@ module ActionCable end protected + def request + @request ||= ActionDispatch::Request.new(Rails.application.env_config.merge(env)) + end + + def cookies + request.cookie_jar + end + def initialize_connection server.add_connection(self) @@ -173,10 +181,6 @@ module ActionCable @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN end - def request - @request ||= ActionDispatch::Request.new(env) - end - def websocket? @is_websocket ||= Faye::WebSocket.websocket?(@env) end -- cgit v1.2.3 From fb18f809e0e38b43d29ca6ce786a80448abe2fe2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 18:07:11 +0200 Subject: Style --- lib/action_cable/channel/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index e6ca45ddcc..83ba2cb3d2 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -129,7 +129,7 @@ module ActionCable end def stop_periodic_timers - @_active_periodic_timers.each {|t| t.cancel } + @_active_periodic_timers.each { |timer| timer.cancel } end -- cgit v1.2.3 From 9886a995f5f0b32d0d400074c48221cb0f6b911e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 20 Jun 2015 18:09:58 +0200 Subject: Better logging --- lib/action_cable/channel/base.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 83ba2cb3d2..12a5789bdc 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -42,10 +42,10 @@ module ActionCable action = extract_action(data) if performable_action?(action) - logger.info channel_name + compose_signature(action, data) + logger.info action_signature(action, data) public_send action, data else - logger.error "#{channel_name} failed to process #{compose_signature(action, data)}" + logger.error "Unable to process #{action_signature(action, data)}" end else unauthorized @@ -102,8 +102,8 @@ module ActionCable self.class.instance_methods(false).include?(action) end - def compose_signature(action, data) - "##{action}".tap do |signature| + def action_signature(action, data) + "#{channel_name}##{action}".tap do |signature| if (arguments = data.except('action')).any? signature << "(#{arguments.inspect})" end -- cgit v1.2.3 From f9ef9486f157da6573a7c4867f905d4e57023e12 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 15:06:15 +0200 Subject: Space --- lib/action_cable/connection/tagged_logger_proxy.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb index d99cc2e9a3..e9e12e2672 100644 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -1,7 +1,6 @@ module ActionCable module Connection class TaggedLoggerProxy - def initialize(logger, tags:) @logger = logger @tags = tags.flatten -- cgit v1.2.3 From 829ae0b2e2cbd580a89a933ce032dae30aa34629 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 19:39:43 +0200 Subject: Spacing --- lib/action_cable/connection/identifier.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/identifier.rb b/lib/action_cable/connection/identifier.rb index a608fc546a..62524263bd 100644 --- a/lib/action_cable/connection/identifier.rb +++ b/lib/action_cable/connection/identifier.rb @@ -6,11 +6,11 @@ module ActionCable end def connection_identifier - @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}")}.compact + @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact end def connection_gid(ids) - ids.map {|o| o.to_global_id.to_s }.sort.join(":") + ids.map { |o| o.to_global_id.to_s }.sort.join(":") end end end -- cgit v1.2.3 From e2a5a323fd1764c8a2b8d34ebfde65e527a1aedd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 19:47:36 +0200 Subject: Homogenize lifecycle method names Active, present voice. --- lib/action_cable/connection/base.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 89d0844031..2ca284b62f 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -44,7 +44,7 @@ module ActionCable if message.is_a?(String) if @accept_messages - worker_pool.async.invoke(self, :received_data, message) + worker_pool.async.invoke(self, :receive_data, message) else @pending_messages << message end @@ -54,7 +54,7 @@ module ActionCable @websocket.on(:close) do |event| logger.info finished_request_message - worker_pool.async.invoke(self, :on_connection_closed) + worker_pool.async.invoke(self, :close_connection) EventMachine.cancel_timer(@ping_timer) if @ping_timer end @@ -64,7 +64,6 @@ module ActionCable end end - def received_data(data) return unless websocket_alive? data = ActiveSupport::JSON.decode data @@ -76,6 +75,7 @@ module ActionCable unsubscribe_channel(data) when 'message' process_message(data) + def receive_data(data) else logger.error "Received unrecognized command in #{data.inspect}" end @@ -124,10 +124,10 @@ module ActionCable subscribe_to_internal_channel @accept_messages = true - worker_pool.async.invoke(self, :received_data, @pending_messages.shift) until @pending_messages.empty? + worker_pool.async.invoke(self, :receive_data, @pending_messages.shift) until @pending_messages.empty? end - def on_connection_closed + def close_connection server.remove_connection(self) cleanup_subscriptions -- cgit v1.2.3 From b7ce9b652e2de0f724941b076078370e4c7590bc Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 19:48:25 +0200 Subject: Add logging for when data is received without a live web socket And make cleanup_subscriptions private --- lib/action_cable/connection/base.rb | 39 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2ca284b62f..32f6d0ae91 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -64,26 +64,22 @@ module ActionCable end end - return unless websocket_alive? - - data = ActiveSupport::JSON.decode data - - case data['command'] - when 'subscribe' - subscribe_channel(data) - when 'unsubscribe' - unsubscribe_channel(data) - when 'message' - process_message(data) def receive_data(data) + if websocket_alive? + data = ActiveSupport::JSON.decode data + + case data['command'] + when 'subscribe' + subscribe_channel(data) + when 'unsubscribe' + unsubscribe_channel(data) + when 'message' + process_message(data) + else + logger.error "Received unrecognized command in #{data.inspect}" + end else - logger.error "Received unrecognized command in #{data.inspect}" - end - end - - def cleanup_subscriptions - @subscriptions.each do |id, channel| - channel.perform_disconnection + logger.error "Received data without a live websocket (#{data.inspect})" end end @@ -135,6 +131,13 @@ module ActionCable disconnect if respond_to?(:disconnect) end + def cleanup_subscriptions + @subscriptions.each do |id, channel| + channel.perform_disconnection + end + end + + def transmit_ping_timestamp transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) end -- cgit v1.2.3 From 890bee58f31ffe65e0127a8795c3432a18633013 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 19:49:31 +0200 Subject: Clarify that the incoming data is JSON --- lib/action_cable/connection/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 32f6d0ae91..321a57fe44 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -64,9 +64,9 @@ module ActionCable end end - def receive_data(data) + def receive_data(data_in_json) if websocket_alive? - data = ActiveSupport::JSON.decode data + data = ActiveSupport::JSON.decode data_in_json case data['command'] when 'subscribe' -- cgit v1.2.3 From 6ae798fc842c65011528175136fcda85d95ab16c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 19:50:17 +0200 Subject: Styling --- lib/action_cable/connection/base.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 321a57fe44..6973848589 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -69,12 +69,9 @@ module ActionCable data = ActiveSupport::JSON.decode data_in_json case data['command'] - when 'subscribe' - subscribe_channel(data) - when 'unsubscribe' - unsubscribe_channel(data) - when 'message' - process_message(data) + when 'subscribe' then subscribe_channel data + when 'unsubscribe' then unsubscribe_channel data + when 'message' then process_message data else logger.error "Received unrecognized command in #{data.inspect}" end -- cgit v1.2.3 From a4a68c2aff6b9aa114b0309d008e7f4180169bd4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 20:25:53 +0200 Subject: Match transmit. No need to qualify _data --- lib/action_cable/connection/base.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 6973848589..4a67167bac 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -44,7 +44,7 @@ module ActionCable if message.is_a?(String) if @accept_messages - worker_pool.async.invoke(self, :receive_data, message) + worker_pool.async.invoke(self, :receive, message) else @pending_messages << message end @@ -64,7 +64,7 @@ module ActionCable end end - def receive_data(data_in_json) + def receive(data_in_json) if websocket_alive? data = ActiveSupport::JSON.decode data_in_json @@ -117,7 +117,7 @@ module ActionCable subscribe_to_internal_channel @accept_messages = true - worker_pool.async.invoke(self, :receive_data, @pending_messages.shift) until @pending_messages.empty? + worker_pool.async.invoke(self, :receive, @pending_messages.shift) until @pending_messages.empty? end def close_connection -- cgit v1.2.3 From cc5ad6a65729a7c2c922e230c68b137762b8b127 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 20:55:16 +0200 Subject: Order of appearance --- lib/action_cable/connection/base.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 4a67167bac..4b73a90dc1 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -84,13 +84,6 @@ module ActionCable @websocket.send data end - def statistics - { - identifier: connection_identifier, - started_at: @started_at, - subscriptions: @subscriptions.keys - } - end def handle_exception close_connection @@ -101,6 +94,16 @@ module ActionCable @websocket.close end + + def statistics + { + identifier: connection_identifier, + started_at: @started_at, + subscriptions: @subscriptions.keys + } + end + + protected def request @request ||= ActionDispatch::Request.new(Rails.application.env_config.merge(env)) -- cgit v1.2.3 From 81ae9ee32162ececbd70664b2821e7c636eaed8b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 21:08:20 +0200 Subject: Consolidate all identification logic in a single concern --- lib/action_cable/connection.rb | 2 +- lib/action_cable/connection/base.rb | 10 ++-------- lib/action_cable/connection/identification.rb | 26 +++++++++++++++++++++++++ lib/action_cable/connection/identifier.rb | 17 ---------------- lib/action_cable/connection/internal_channel.rb | 4 ++++ 5 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 lib/action_cable/connection/identification.rb delete mode 100644 lib/action_cable/connection/identifier.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index a9048926e4..8a695a3d0d 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,8 +1,8 @@ module ActionCable module Connection autoload :Base, 'action_cable/connection/base' + autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' - autoload :Identifier, 'action_cable/connection/identifier' autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 4b73a90dc1..0d666713d2 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -1,17 +1,11 @@ module ActionCable module Connection class Base - include InternalChannel, Identifier + include Identification + include InternalChannel PING_INTERVAL = 3 - class_attribute :identifiers - self.identifiers = Set.new - - def self.identified_by(*identifiers) - self.identifiers += identifiers - end - attr_reader :env, :server, :logger delegate :worker_pool, :pubsub, to: :server diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb new file mode 100644 index 0000000000..246636198b --- /dev/null +++ b/lib/action_cable/connection/identification.rb @@ -0,0 +1,26 @@ +module ActionCable + module Connection + module Identification + extend ActiveSupport::Concern + + included do + class_attribute :identifiers + self.identifiers = Set.new + end + + class_methods do + def identified_by(*identifiers) + self.identifiers += identifiers + end + end + + def connection_identifier + @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact + end + + def connection_gid(ids) + ids.map { |o| o.to_global_id.to_s }.sort.join(":") + end + end + end +end diff --git a/lib/action_cable/connection/identifier.rb b/lib/action_cable/connection/identifier.rb deleted file mode 100644 index 62524263bd..0000000000 --- a/lib/action_cable/connection/identifier.rb +++ /dev/null @@ -1,17 +0,0 @@ -module ActionCable - module Connection - module Identifier - def internal_redis_channel - "action_cable/#{connection_identifier}" - end - - def connection_identifier - @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact - end - - def connection_gid(ids) - ids.map { |o| o.to_global_id.to_s }.sort.join(":") - end - end - end -end diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index 3a11bcaf7b..55dfc72777 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -3,6 +3,10 @@ module ActionCable module InternalChannel extend ActiveSupport::Concern + def internal_redis_channel + "action_cable/#{connection_identifier}" + end + def subscribe_to_internal_channel if connection_identifier.present? callback = -> (message) { process_internal_message(message) } -- cgit v1.2.3 From 22b9882ea6ab621ac5deceb700ec503f796812e6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 21:34:04 +0200 Subject: Styling --- lib/action_cable/connection/base.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 0d666713d2..175b596241 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -5,17 +5,20 @@ module ActionCable include InternalChannel PING_INTERVAL = 3 - - attr_reader :env, :server, :logger + + attr_reader :server, :env delegate :worker_pool, :pubsub, to: :server + attr_reader :logger + def initialize(server, env) @started_at = Time.now - @server = server - @env = env - @accept_messages = false + @server, @env = server, env + + @accept_messages = false @pending_messages = [] + @subscriptions = {} @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) -- cgit v1.2.3 From f8638f789a1cbd33205cdce0dd24f2aee3d69a25 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 21:41:43 +0200 Subject: Rename callback hooks to match setup And make it all private --- lib/action_cable/connection/base.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 175b596241..f6beda1c57 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -33,7 +33,7 @@ module ActionCable @websocket.on(:open) do |event| transmit_ping_timestamp @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { transmit_ping_timestamp } - worker_pool.async.invoke(self, :initialize_connection) + worker_pool.async.invoke(self, :on_open) end @websocket.on(:message) do |event| @@ -51,7 +51,7 @@ module ActionCable @websocket.on(:close) do |event| logger.info finished_request_message - worker_pool.async.invoke(self, :close_connection) + worker_pool.async.invoke(self, :on_close) EventMachine.cancel_timer(@ping_timer) if @ping_timer end @@ -110,7 +110,9 @@ module ActionCable request.cookie_jar end - def initialize_connection + + private + def on_open server.add_connection(self) connect if respond_to?(:connect) @@ -120,7 +122,7 @@ module ActionCable worker_pool.async.invoke(self, :receive, @pending_messages.shift) until @pending_messages.empty? end - def close_connection + def on_close server.remove_connection(self) cleanup_subscriptions -- cgit v1.2.3 From 125a8445f304348d3e530e85b30991c2346155fd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 21:44:22 +0200 Subject: Don't namespace methods with the class we are already in --- lib/action_cable/connection/base.rb | 4 ++-- lib/action_cable/server.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index f6beda1c57..d96216edc2 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -83,10 +83,10 @@ module ActionCable def handle_exception - close_connection + close end - def close_connection + def close logger.error "Closing connection" @websocket.close end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 322fc85519..3a16f51757 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -34,7 +34,7 @@ module ActionCable redis.on(:reconnect_failed) do logger.info "[ActionCable] Redis reconnect failed." # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." - # @connections.map &:close_connection + # @connections.map &:close end redis end -- cgit v1.2.3 From 1029c49c32cf3b8c7a013753783ab37f364ad65d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 21:45:37 +0200 Subject: Remove anemic indirection --- lib/action_cable/connection/base.rb | 5 ----- lib/action_cable/connection/internal_channel.rb | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index d96216edc2..ffef85ead2 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -81,11 +81,6 @@ module ActionCable @websocket.send data end - - def handle_exception - close - end - def close logger.error "Closing connection" @websocket.close diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index 55dfc72777..885457d8c2 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -37,7 +37,7 @@ module ActionCable logger.error "There was an exception - #{e.class}(#{e.message})" logger.error e.backtrace.join("\n") - handle_exception + close end end end -- cgit v1.2.3 From e7b1ced7a4fab45e3fc5851e5500426022fa0c47 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 22:35:07 +0200 Subject: Extracted Subscriptions class --- lib/action_cable/connection.rb | 1 + lib/action_cable/connection/base.rb | 46 +++++------------------- lib/action_cable/connection/subscriptions.rb | 54 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 lib/action_cable/connection/subscriptions.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 8a695a3d0d..31480f220f 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -3,6 +3,7 @@ module ActionCable autoload :Base, 'action_cable/connection/base' autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' + autoload :Subscriptions, 'action_cable/connection/subscriptions' autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index ffef85ead2..df07c567fb 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -9,6 +9,8 @@ module ActionCable attr_reader :server, :env delegate :worker_pool, :pubsub, to: :server + attr_reader :subscriptions + attr_reader :logger def initialize(server, env) @@ -19,9 +21,9 @@ module ActionCable @accept_messages = false @pending_messages = [] - @subscriptions = {} - @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) + + @subscriptions = ActionCable::Connection::Subscriptions.new(self) end def process @@ -66,8 +68,8 @@ module ActionCable data = ActiveSupport::JSON.decode data_in_json case data['command'] - when 'subscribe' then subscribe_channel data - when 'unsubscribe' then unsubscribe_channel data + when 'subscribe' then subscriptions.add data + when 'unsubscribe' then subscriptions.remove data when 'message' then process_message data else logger.error "Received unrecognized command in #{data.inspect}" @@ -91,7 +93,7 @@ module ActionCable { identifier: connection_identifier, started_at: @started_at, - subscriptions: @subscriptions.keys + subscriptions: subscriptions.identifiers } end @@ -120,54 +122,24 @@ module ActionCable def on_close server.remove_connection(self) - cleanup_subscriptions + subscriptions.cleanup unsubscribe_from_internal_channel disconnect if respond_to?(:disconnect) end - def cleanup_subscriptions - @subscriptions.each do |id, channel| - channel.perform_disconnection - end - end - def transmit_ping_timestamp transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) end - def subscribe_channel(data) - id_key = data['identifier'] - id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - - subscription_klass = server.registered_channels.detect { |channel_klass| channel_klass.find_name == id_options[:channel] } - - if subscription_klass - @subscriptions[id_key] = subscription_klass.new(self, id_key, id_options) - else - logger.error "Subscription class not found (#{data.inspect})" - end - rescue Exception => e - logger.error "Could not subscribe to channel (#{data.inspect})" - log_exception(e) - end def process_message(message) - if @subscriptions[message['identifier']] - @subscriptions[message['identifier']].perform_action(ActiveSupport::JSON.decode message['data']) - else - raise "Unable to process message because no subscription was found (#{message.inspect})" - end + subscriptions.find(message['identifier']).perform_action(ActiveSupport::JSON.decode(message['data'])) rescue Exception => e logger.error "Could not process message (#{message.inspect})" log_exception(e) end - def unsubscribe_channel(data) - logger.info "Unsubscribing from channel: #{data['identifier']}" - @subscriptions[data['identifier']].perform_disconnection - @subscriptions.delete(data['identifier']) - end def invalid_request logger.info finished_request_message diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb new file mode 100644 index 0000000000..888b93a652 --- /dev/null +++ b/lib/action_cable/connection/subscriptions.rb @@ -0,0 +1,54 @@ +module ActionCable + module Connection + class Subscriptions + def initialize(connection) + @connection = connection + @subscriptions = {} + end + + def add(data) + id_key = data['identifier'] + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + + subscription_klass = connection.server.registered_channels.detect do |channel_klass| + channel_klass.find_name == id_options[:channel] + end + + if subscription_klass + subscriptions[id_key] = subscription_klass.new(connection, id_key, id_options) + else + connection.logger.error "Subscription class not found (#{data.inspect})" + end + rescue Exception => e + connection.logger.error "Could not subscribe to channel (#{data.inspect}) due to '#{e}': #{e.backtrace.join(' - ')}" + end + + def remove(data) + connection.logger.info "Unsubscribing from channel: #{data['identifier']}" + subscriptions[data['identifier']].perform_disconnection + subscriptions.delete(data['identifier']) + end + + def find(identifier) + if subscription = subscriptions[identifier] + subscription + else + raise "Unable to find subscription with identifier: #{identifier}" + end + end + + def identifiers + subscriptions.keys + end + + def cleanup + subscriptions.each do |id, channel| + channel.perform_disconnection + end + end + + private + attr_reader :connection, :subscriptions + end + end +end \ No newline at end of file -- cgit v1.2.3 From 786bbbb0ee1de0f2c8c9be517b8d5c93f95421d4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 22:49:29 +0200 Subject: Extract Heartbeat class to perform periodical ping --- lib/action_cable/connection.rb | 1 + lib/action_cable/connection/base.rb | 17 +++++------------ lib/action_cable/connection/heartbeat.rb | 27 +++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 lib/action_cable/connection/heartbeat.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 31480f220f..991dd85c57 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,6 +1,7 @@ module ActionCable module Connection autoload :Base, 'action_cable/connection/base' + autoload :Heartbeat, 'action_cable/connection/heartbeat' autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' autoload :Subscriptions, 'action_cable/connection/subscriptions' diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index df07c567fb..6fb0a61743 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -4,13 +4,9 @@ module ActionCable include Identification include InternalChannel - PING_INTERVAL = 3 - attr_reader :server, :env delegate :worker_pool, :pubsub, to: :server - attr_reader :subscriptions - attr_reader :logger def initialize(server, env) @@ -23,6 +19,7 @@ module ActionCable @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) + @heartbeat = ActionCable::Connection::Heartbeat.new(self) @subscriptions = ActionCable::Connection::Subscriptions.new(self) end @@ -33,8 +30,7 @@ module ActionCable @websocket = Faye::WebSocket.new(@env) @websocket.on(:open) do |event| - transmit_ping_timestamp - @ping_timer = EventMachine.add_periodic_timer(PING_INTERVAL) { transmit_ping_timestamp } + heartbeat.start worker_pool.async.invoke(self, :on_open) end @@ -53,8 +49,8 @@ module ActionCable @websocket.on(:close) do |event| logger.info finished_request_message + heartbeat.stop worker_pool.async.invoke(self, :on_close) - EventMachine.cancel_timer(@ping_timer) if @ping_timer end @websocket.rack_response @@ -109,6 +105,8 @@ module ActionCable private + attr_reader :heartbeat, :subscriptions + def on_open server.add_connection(self) @@ -128,11 +126,6 @@ module ActionCable end - def transmit_ping_timestamp - transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) - end - - def process_message(message) subscriptions.find(message['identifier']).perform_action(ActiveSupport::JSON.decode(message['data'])) rescue Exception => e diff --git a/lib/action_cable/connection/heartbeat.rb b/lib/action_cable/connection/heartbeat.rb new file mode 100644 index 0000000000..47cd937c25 --- /dev/null +++ b/lib/action_cable/connection/heartbeat.rb @@ -0,0 +1,27 @@ +module ActionCable + module Connection + class Heartbeat + BEAT_INTERVAL = 3 + + def initialize(connection) + @connection = connection + end + + def start + beat + @timer = EventMachine.add_periodic_timer(BEAT_INTERVAL) { beat } + end + + def stop + EventMachine.cancel_timer(@timer) if @timer + end + + private + attr_reader :connection + + def beat + connection.transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From da098df459d4d60efce418ab79121160eaf45d03 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 22:51:55 +0200 Subject: Centralize logging in process and enhance method name --- lib/action_cable/connection/base.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 6fb0a61743..cac127ab45 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -55,7 +55,9 @@ module ActionCable @websocket.rack_response else - invalid_request + logger.info finished_request_message + + respond_to_invalid_request end end @@ -134,9 +136,8 @@ module ActionCable end - def invalid_request - logger.info finished_request_message - [404, {'Content-Type' => 'text/plain'}, ['Page not found']] + def respond_to_invalid_request + [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] end def websocket_alive? -- cgit v1.2.3 From 375b315da62d55b47074ab8cdde60eac4dfaef2a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 22:53:20 +0200 Subject: Add logging for when message isn't a string --- lib/action_cable/connection/base.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index cac127ab45..995b0901ca 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -43,6 +43,8 @@ module ActionCable else @pending_messages << message end + else + logger.error "Couldn't handle non-string message: #{message.class}" end end -- cgit v1.2.3 From e0926038983177f5491e45cc338e5dc091e3a86d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 21 Jun 2015 23:02:46 +0200 Subject: Wrap message queueing in a more welcoming API --- lib/action_cable/connection/base.rb | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 995b0901ca..71e84aed99 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -14,9 +14,6 @@ module ActionCable @server, @env = server, env - @accept_messages = false - @pending_messages = [] - @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) @heartbeat = ActionCable::Connection::Heartbeat.new(self) @@ -38,10 +35,10 @@ module ActionCable message = event.data if message.is_a?(String) - if @accept_messages + if accepting_messages? worker_pool.async.invoke(self, :receive, message) else - @pending_messages << message + queue_message message end else logger.error "Couldn't handle non-string message: #{message.class}" @@ -117,10 +114,29 @@ module ActionCable connect if respond_to?(:connect) subscribe_to_internal_channel + ready_to_accept_messages + process_pending_messages + end + + + def accepting_messages? + @accept_messages + end + + def ready_to_accept_messages @accept_messages = true + end + + def queue_message(message) + @pending_messages ||= [] + @pending_messages << message + end + + def process_pending_messages worker_pool.async.invoke(self, :receive, @pending_messages.shift) until @pending_messages.empty? end + def on_close server.remove_connection(self) -- cgit v1.2.3 From 8115d25033a0af2d29b57e1e5a6afaa70038a3d4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 14:15:28 +0200 Subject: WIP: Extract processor --- lib/action_cable/connection/base.rb | 13 ++------ lib/action_cable/connection/processor.rb | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 lib/action_cable/connection/processor.rb diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 71e84aed99..1fdc6f0fe8 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -18,6 +18,7 @@ module ActionCable @heartbeat = ActionCable::Connection::Heartbeat.new(self) @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @processor = ActionCable::Connection::Processor.new(self) end def process @@ -32,17 +33,7 @@ module ActionCable end @websocket.on(:message) do |event| - message = event.data - - if message.is_a?(String) - if accepting_messages? - worker_pool.async.invoke(self, :receive, message) - else - queue_message message - end - else - logger.error "Couldn't handle non-string message: #{message.class}" - end + processor.handle event.data end @websocket.on(:close) do |event| diff --git a/lib/action_cable/connection/processor.rb b/lib/action_cable/connection/processor.rb new file mode 100644 index 0000000000..2060392478 --- /dev/null +++ b/lib/action_cable/connection/processor.rb @@ -0,0 +1,54 @@ +module ActionCable + module Connection + class Processor + def initialize(connection) + @connection = connection + @pending_messages = [] + end + + def handle(message) + if valid? message + if ready? + process message + else + queue message + end + end + end + + def ready? + @ready + end + + def ready! + @ready = true + handle_pending_messages + end + + private + attr_reader :connection + attr_accessor :pending_messages + + def process(message) + connection.worker_pool.async.invoke(connection, :receive, message) + end + + def queue(message) + pending_messages << message + end + + def valid?(message) + if message.is_a?(String) + true + else + connection.logger.error "Couldn't handle non-string message: #{message.class}" + false + end + end + + def handle_pending_messages + process pending_messages.shift until pending_messages.empty? + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From d796d9a61e1208f0706642ff02f7c8236185e55a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 15:47:32 +0200 Subject: Finish Processor class extraction --- lib/action_cable/connection.rb | 1 + lib/action_cable/connection/base.rb | 31 ++++++++----------------------- lib/action_cable/connection/processor.rb | 2 +- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 991dd85c57..5928a47949 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -4,6 +4,7 @@ module ActionCable autoload :Heartbeat, 'action_cable/connection/heartbeat' autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' + autoload :Processor, 'action_cable/connection/processor' autoload :Subscriptions, 'action_cable/connection/subscriptions' autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 1fdc6f0fe8..c3c99dcec4 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -29,7 +29,7 @@ module ActionCable @websocket.on(:open) do |event| heartbeat.start - worker_pool.async.invoke(self, :on_open) + send_async :on_open end @websocket.on(:message) do |event| @@ -40,7 +40,7 @@ module ActionCable logger.info finished_request_message heartbeat.stop - worker_pool.async.invoke(self, :on_close) + send_async :on_close end @websocket.rack_response @@ -77,6 +77,10 @@ module ActionCable end + def send_async(method, *arguments) + worker_pool.async.invoke(self, method, *arguments) + end + def statistics { identifier: connection_identifier, @@ -97,7 +101,7 @@ module ActionCable private - attr_reader :heartbeat, :subscriptions + attr_reader :heartbeat, :subscriptions, :processor def on_open server.add_connection(self) @@ -105,26 +109,7 @@ module ActionCable connect if respond_to?(:connect) subscribe_to_internal_channel - ready_to_accept_messages - process_pending_messages - end - - - def accepting_messages? - @accept_messages - end - - def ready_to_accept_messages - @accept_messages = true - end - - def queue_message(message) - @pending_messages ||= [] - @pending_messages << message - end - - def process_pending_messages - worker_pool.async.invoke(self, :receive, @pending_messages.shift) until @pending_messages.empty? + processor.ready! end diff --git a/lib/action_cable/connection/processor.rb b/lib/action_cable/connection/processor.rb index 2060392478..3191be4a4c 100644 --- a/lib/action_cable/connection/processor.rb +++ b/lib/action_cable/connection/processor.rb @@ -30,7 +30,7 @@ module ActionCable attr_accessor :pending_messages def process(message) - connection.worker_pool.async.invoke(connection, :receive, message) + connection.send_async :receive, message end def queue(message) -- cgit v1.2.3 From 09974941ccc7f782d163197d1a96440fcc811e85 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:01:41 +0200 Subject: Extract helper method --- lib/action_cable/connection/base.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index c3c99dcec4..4bc6a14aaa 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -53,7 +53,7 @@ module ActionCable def receive(data_in_json) if websocket_alive? - data = ActiveSupport::JSON.decode data_in_json + data = decode_json data_in_json case data['command'] when 'subscribe' then subscriptions.add data @@ -123,13 +123,17 @@ module ActionCable def process_message(message) - subscriptions.find(message['identifier']).perform_action(ActiveSupport::JSON.decode(message['data'])) + subscriptions.find(message['identifier']).perform_action decode_json(message['data']) rescue Exception => e logger.error "Could not process message (#{message.inspect})" log_exception(e) end + def decode_json(json) + ActiveSupport::JSON.decode json + end + def respond_to_invalid_request [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] end -- cgit v1.2.3 From 24609f18f54938988035a97eb09ccfe309cf8710 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:03:34 +0200 Subject: Rename Processor to MessageBuffer --- lib/action_cable/connection.rb | 2 +- lib/action_cable/connection/base.rb | 12 +++--- lib/action_cable/connection/message_buffer.rb | 51 +++++++++++++++++++++++++ lib/action_cable/connection/processor.rb | 54 --------------------------- 4 files changed, 58 insertions(+), 61 deletions(-) create mode 100644 lib/action_cable/connection/message_buffer.rb delete mode 100644 lib/action_cable/connection/processor.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 5928a47949..09ef1699a6 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -4,7 +4,7 @@ module ActionCable autoload :Heartbeat, 'action_cable/connection/heartbeat' autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' - autoload :Processor, 'action_cable/connection/processor' + autoload :MessageBuffer, 'action_cable/connection/message_buffer' autoload :Subscriptions, 'action_cable/connection/subscriptions' autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 4bc6a14aaa..da1fe380e2 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -16,9 +16,9 @@ module ActionCable @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) - @heartbeat = ActionCable::Connection::Heartbeat.new(self) - @subscriptions = ActionCable::Connection::Subscriptions.new(self) - @processor = ActionCable::Connection::Processor.new(self) + @heartbeat = ActionCable::Connection::Heartbeat.new(self) + @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @message_buffer = ActionCable::Connection::MessageBuffer.new(self) end def process @@ -33,7 +33,7 @@ module ActionCable end @websocket.on(:message) do |event| - processor.handle event.data + message_buffer.append event.data end @websocket.on(:close) do |event| @@ -101,7 +101,7 @@ module ActionCable private - attr_reader :heartbeat, :subscriptions, :processor + attr_reader :heartbeat, :subscriptions, :message_buffer def on_open server.add_connection(self) @@ -109,7 +109,7 @@ module ActionCable connect if respond_to?(:connect) subscribe_to_internal_channel - processor.ready! + message_buffer.process! end diff --git a/lib/action_cable/connection/message_buffer.rb b/lib/action_cable/connection/message_buffer.rb new file mode 100644 index 0000000000..615266e0cb --- /dev/null +++ b/lib/action_cable/connection/message_buffer.rb @@ -0,0 +1,51 @@ +module ActionCable + module Connection + class MessageBuffer + 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_accessor :buffered_messages + + def valid?(message) + message.is_a?(String) + end + + def receive(message) + connection.send_async :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 \ No newline at end of file diff --git a/lib/action_cable/connection/processor.rb b/lib/action_cable/connection/processor.rb deleted file mode 100644 index 3191be4a4c..0000000000 --- a/lib/action_cable/connection/processor.rb +++ /dev/null @@ -1,54 +0,0 @@ -module ActionCable - module Connection - class Processor - def initialize(connection) - @connection = connection - @pending_messages = [] - end - - def handle(message) - if valid? message - if ready? - process message - else - queue message - end - end - end - - def ready? - @ready - end - - def ready! - @ready = true - handle_pending_messages - end - - private - attr_reader :connection - attr_accessor :pending_messages - - def process(message) - connection.send_async :receive, message - end - - def queue(message) - pending_messages << message - end - - def valid?(message) - if message.is_a?(String) - true - else - connection.logger.error "Couldn't handle non-string message: #{message.class}" - false - end - end - - def handle_pending_messages - process pending_messages.shift until pending_messages.empty? - end - end - end -end \ No newline at end of file -- cgit v1.2.3 From aaad3ea707a7ed28bbf4591f1b7b1bdde62714c4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:09:37 +0200 Subject: Slim down the web socket respond blocks Move heartbeat into on_open/close and add a similarly named on_message to handle that callback. --- lib/action_cable/connection/base.rb | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index da1fe380e2..e97d40c941 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -27,26 +27,12 @@ module ActionCable if websocket? @websocket = Faye::WebSocket.new(@env) - @websocket.on(:open) do |event| - heartbeat.start - send_async :on_open - end - - @websocket.on(:message) do |event| - message_buffer.append event.data - end - - @websocket.on(:close) do |event| - logger.info finished_request_message - - heartbeat.stop - send_async :on_close - end + @websocket.on(:open) { |event| send_async :on_open } + @websocket.on(:message) { |event| on_message event.data } + @websocket.on(:close) { |event| send_async :on_close } @websocket.rack_response else - logger.info finished_request_message - respond_to_invalid_request end end @@ -108,16 +94,24 @@ module ActionCable connect if respond_to?(:connect) subscribe_to_internal_channel + heartbeat.start message_buffer.process! end + def on_message(message) + message_buffer.append event.data + end def on_close + logger.info finished_request_message + server.remove_connection(self) subscriptions.cleanup unsubscribe_from_internal_channel + heartbeat.stop + disconnect if respond_to?(:disconnect) end @@ -135,6 +129,7 @@ module ActionCable end def respond_to_invalid_request + logger.info finished_request_message [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] end -- cgit v1.2.3 From a80c8c0e3943001e039e49f9aa91f55eeeb65f5a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:11:15 +0200 Subject: Fix reference --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index e97d40c941..1548447d74 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -100,7 +100,7 @@ module ActionCable end def on_message(message) - message_buffer.append event.data + message_buffer.append message end def on_close -- cgit v1.2.3 From 05c3ba113c752c1aebc09260bd0ce36f9e3b722b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:13:09 +0200 Subject: Use private accessor --- lib/action_cable/connection/base.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 1548447d74..69102aeaa3 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -27,11 +27,11 @@ module ActionCable if websocket? @websocket = Faye::WebSocket.new(@env) - @websocket.on(:open) { |event| send_async :on_open } - @websocket.on(:message) { |event| on_message event.data } - @websocket.on(:close) { |event| send_async :on_close } - - @websocket.rack_response + websocket.on(:open) { |event| send_async :on_open } + websocket.on(:message) { |event| on_message event.data } + websocket.on(:close) { |event| send_async :on_close } + + websocket.rack_response else respond_to_invalid_request end @@ -54,12 +54,12 @@ module ActionCable end def transmit(data) - @websocket.send data + websocket.send data end def close logger.error "Closing connection" - @websocket.close + websocket.close end @@ -87,6 +87,7 @@ module ActionCable private + attr_reader :websocket attr_reader :heartbeat, :subscriptions, :message_buffer def on_open @@ -134,7 +135,7 @@ module ActionCable end def websocket_alive? - @websocket && @websocket.ready_state == Faye::WebSocket::API::OPEN + websocket && websocket.ready_state == Faye::WebSocket::API::OPEN end def websocket? -- cgit v1.2.3 From a7607928e341eea7740b27f3ae507c26c7a68c56 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:19:19 +0200 Subject: Style --- lib/action_cable/connection/base.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 69102aeaa3..7db554294a 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -68,11 +68,7 @@ module ActionCable end def statistics - { - identifier: connection_identifier, - started_at: @started_at, - subscriptions: subscriptions.identifiers - } + { identifier: connection_identifier, started_at: @started_at, subscriptions: subscriptions.identifiers } end -- cgit v1.2.3 From 82f13443a508600a94319ce0e636d04f0ed4673e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:19:30 +0200 Subject: Spacing --- lib/action_cable/connection/base.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 7db554294a..5951198f36 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -120,16 +120,17 @@ module ActionCable log_exception(e) end - def decode_json(json) ActiveSupport::JSON.decode json end + def respond_to_invalid_request logger.info finished_request_message [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] end + def websocket_alive? websocket && websocket.ready_state == Faye::WebSocket::API::OPEN end @@ -138,6 +139,7 @@ module ActionCable @is_websocket ||= Faye::WebSocket.websocket?(@env) end + def started_request_message 'Started %s "%s"%s for %s at %s' % [ request.request_method, @@ -155,6 +157,7 @@ module ActionCable Time.now.to_default_s ] end + def log_exception(e) logger.error "There was an exception: #{e.class} - #{e.message}" logger.error e.backtrace.join("\n") -- cgit v1.2.3 From 72c16340bff9a79eecc2dd5e9291b199f5ae32ea Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:20:06 +0200 Subject: Extract execute_command method and centralize exception handling --- lib/action_cable/connection/base.rb | 22 ++++++++++------------ lib/action_cable/connection/subscriptions.rb | 2 -- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 5951198f36..aa3eb6472d 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -39,15 +39,7 @@ module ActionCable def receive(data_in_json) if websocket_alive? - data = decode_json data_in_json - - case data['command'] - when 'subscribe' then subscriptions.add data - when 'unsubscribe' then subscriptions.remove data - when 'message' then process_message data - else - logger.error "Received unrecognized command in #{data.inspect}" - end + execute_command decode_json(data_in_json) else logger.error "Received data without a live websocket (#{data.inspect})" end @@ -113,10 +105,16 @@ module ActionCable end - def process_message(message) - subscriptions.find(message['identifier']).perform_action decode_json(message['data']) + def execute_command(data) + case data['command'] + when 'subscribe' then subscriptions.add data + when 'unsubscribe' then subscriptions.remove data + when 'message' then subscriptions.find(message['identifier']).perform_action decode_json(message['data']) + else + logger.error "Received unrecognized command in #{data.inspect}" + end rescue Exception => e - logger.error "Could not process message (#{message.inspect})" + logger.error "Could not execute command from #{data.inspect})" log_exception(e) end diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 888b93a652..dbba8eca1d 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -19,8 +19,6 @@ module ActionCable else connection.logger.error "Subscription class not found (#{data.inspect})" end - rescue Exception => e - connection.logger.error "Could not subscribe to channel (#{data.inspect}) due to '#{e}': #{e.backtrace.join(' - ')}" end def remove(data) -- cgit v1.2.3 From 71ebc3aca6be37faf7bdd775667e23b9d759e4a6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:25:23 +0200 Subject: Style --- lib/action_cable/connection/subscriptions.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index dbba8eca1d..a37708d85f 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -40,9 +40,7 @@ module ActionCable end def cleanup - subscriptions.each do |id, channel| - channel.perform_disconnection - end + subscriptions.each { |id, channel| channel.perform_disconnection } end private -- cgit v1.2.3 From 04aed03c896f661143bce1e4b879cff480963fe6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:25:33 +0200 Subject: Use delegated logger --- lib/action_cable/connection/subscriptions.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index a37708d85f..d6525b61c3 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -17,12 +17,12 @@ module ActionCable if subscription_klass subscriptions[id_key] = subscription_klass.new(connection, id_key, id_options) else - connection.logger.error "Subscription class not found (#{data.inspect})" + logger.error "Subscription class not found (#{data.inspect})" end end def remove(data) - connection.logger.info "Unsubscribing from channel: #{data['identifier']}" + logger.info "Unsubscribing from channel: #{data['identifier']}" subscriptions[data['identifier']].perform_disconnection subscriptions.delete(data['identifier']) end @@ -45,6 +45,7 @@ module ActionCable private attr_reader :connection, :subscriptions + delegate :logger, to: :connection end end end \ No newline at end of file -- cgit v1.2.3 From 82f1e19674b290fcee32a048707055e9b82aa310 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:28:53 +0200 Subject: Feature envy detected, so move execute_command to Subscriptions --- lib/action_cable/connection/base.rb | 23 ++------------------- lib/action_cable/connection/subscriptions.rb | 31 ++++++++++++++++++++++------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index aa3eb6472d..7c79abcb89 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -39,7 +39,7 @@ module ActionCable def receive(data_in_json) if websocket_alive? - execute_command decode_json(data_in_json) + subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) else logger.error "Received data without a live websocket (#{data.inspect})" end @@ -105,24 +105,6 @@ module ActionCable end - def execute_command(data) - case data['command'] - when 'subscribe' then subscriptions.add data - when 'unsubscribe' then subscriptions.remove data - when 'message' then subscriptions.find(message['identifier']).perform_action decode_json(message['data']) - else - logger.error "Received unrecognized command in #{data.inspect}" - end - rescue Exception => e - logger.error "Could not execute command from #{data.inspect})" - log_exception(e) - end - - def decode_json(json) - ActiveSupport::JSON.decode json - end - - def respond_to_invalid_request logger.info finished_request_message [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] @@ -157,8 +139,7 @@ module ActionCable def log_exception(e) - logger.error "There was an exception: #{e.class} - #{e.message}" - logger.error e.backtrace.join("\n") + logger.error "Exception raised #{e.class} - #{e.message}: #{e.backtrace.first(5).join(" | ")}" end def log_tags diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index d6525b61c3..e0a3a133c5 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -6,6 +6,19 @@ module ActionCable @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 + logger.error "Received unrecognized command in #{data.inspect}" + end + rescue Exception => e + logger.error "Could not execute command from #{data.inspect})" + connection.log_exception(e) + end + def add(data) id_key = data['identifier'] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access @@ -27,14 +40,11 @@ module ActionCable subscriptions.delete(data['identifier']) end - def find(identifier) - if subscription = subscriptions[identifier] - subscription - else - raise "Unable to find subscription with identifier: #{identifier}" - end + def perform_action(data) + find(data).perform_action ActiveSupport::JSON.decode(data['data']) end + def identifiers subscriptions.keys end @@ -43,9 +53,18 @@ module ActionCable subscriptions.each { |id, channel| channel.perform_disconnection } end + private attr_reader :connection, :subscriptions delegate :logger, to: :connection + + def find(data) + if subscription = subscriptions[data['identifier']] + subscription + else + raise "Unable to find subscription with identifier: #{identifier}" + end + end end end end \ No newline at end of file -- cgit v1.2.3 From f91e39429a4f08e4d78196ddcb12dc2930d07d92 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:52:41 +0200 Subject: Clarify what websocket thing we're talking about --- lib/action_cable/connection/base.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 7c79abcb89..0fb98d7293 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -24,8 +24,8 @@ module ActionCable def process logger.info started_request_message - if websocket? @websocket = Faye::WebSocket.new(@env) + if websocket_request? websocket.on(:open) { |event| send_async :on_open } websocket.on(:message) { |event| on_message event.data } @@ -115,8 +115,8 @@ module ActionCable websocket && websocket.ready_state == Faye::WebSocket::API::OPEN end - def websocket? - @is_websocket ||= Faye::WebSocket.websocket?(@env) + def websocket_request? + @is_websocket ||= Faye::WebSocket.websocket_request?(@env) end @@ -124,7 +124,7 @@ module ActionCable 'Started %s "%s"%s for %s at %s' % [ request.request_method, request.filtered_path, - websocket? ? ' [Websocket]' : '', + websocket_request? ? ' [Websocket]' : '', request.ip, Time.now.to_default_s ] end @@ -132,7 +132,7 @@ module ActionCable def finished_request_message 'Finished "%s"%s for %s at %s' % [ request.filtered_path, - websocket? ? ' [Websocket]' : '', + websocket_request? ? ' [Websocket]' : '', request.ip, Time.now.to_default_s ] end -- cgit v1.2.3 From 6726c11cda7a970ca02fd690ea7b5063fcfba7bc Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:53:00 +0200 Subject: Composed method to same order of abstraction --- lib/action_cable/connection/base.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 0fb98d7293..252b71e847 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -24,8 +24,8 @@ module ActionCable def process logger.info started_request_message - @websocket = Faye::WebSocket.new(@env) if websocket_request? + websocket_initialization websocket.on(:open) { |event| send_async :on_open } websocket.on(:message) { |event| on_message event.data } @@ -111,6 +111,10 @@ module ActionCable end + def websocket_initialization + @websocket = Faye::WebSocket.new(@env) + end + def websocket_alive? websocket && websocket.ready_state == Faye::WebSocket::API::OPEN end -- cgit v1.2.3 From b9fcaa7cbcad9a4ae2e56e1907764b6eae4a94c6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 16:56:58 +0200 Subject: Fix method --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 252b71e847..fe5f058824 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -120,7 +120,7 @@ module ActionCable end def websocket_request? - @is_websocket ||= Faye::WebSocket.websocket_request?(@env) + @is_websocket ||= Faye::WebSocket.websocket?(@env) end -- cgit v1.2.3 From a66c56210c844ba51452fbf7a0aa01175ea3eb6f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 22 Jun 2015 21:34:06 +0200 Subject: Fix RemoteConnection due to refactoring breakage --- lib/action_cable/remote_connection.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb index 912fb6eb57..b6fdf226e3 100644 --- a/lib/action_cable/remote_connection.rb +++ b/lib/action_cable/remote_connection.rb @@ -2,7 +2,7 @@ module ActionCable class RemoteConnection class InvalidIdentifiersError < StandardError; end - include Connection::Identifier + include Connection::Identification, Connection::InternalChannel def initialize(server, ids) @server = server @@ -10,8 +10,7 @@ module ActionCable end def disconnect - message = { type: 'disconnect' }.to_json - redis.publish(internal_redis_channel, message) + redis.publish internal_redis_channel, { type: 'disconnect' }.to_json end def identifiers -- cgit v1.2.3 From b5e0e58fe17b9cf5f53688ee1fac74772e565da1 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Tue, 23 Jun 2015 18:16:31 -0400 Subject: Require Cable.Channel constructors to define their channel name Function.name is not widely supported, and a function's name can be mangled by a minifier making it an unreliable property to infer the channel name from --- lib/assets/javascripts/channel.js.coffee | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee index 2f07affb19..c972334140 100644 --- a/lib/assets/javascripts/channel.js.coffee +++ b/lib/assets/javascripts/channel.js.coffee @@ -1,9 +1,12 @@ class @Cable.Channel constructor: (params = {}) -> - @channelName ?= "#{@underscore(@constructor.name)}_channel" + {channelName} = @constructor - params['channel'] = @channelName - @channelIdentifier = JSON.stringify params + if channelName? + params['channel'] = channelName + @channelIdentifier = JSON.stringify params + else + throw new Error "This channel's constructor is missing the required 'channelName' property" cable.subscribe(@channelIdentifier, { onConnect: @connected @@ -28,7 +31,3 @@ class @Cable.Channel send: (data) -> cable.sendData @channelIdentifier, JSON.stringify data - - - underscore: (value) -> - value.replace(/[A-Z]/g, (match) => "_#{match.toLowerCase()}").substr(1) \ No newline at end of file -- cgit v1.2.3 From 268ee5208ce513eb0b74e2354259e7991d1633c9 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 24 Jun 2015 14:26:26 -0400 Subject: Create JavaScript channels identified by their Ruby class name --- lib/action_cable/connection/subscriptions.rb | 4 +-- lib/assets/javascripts/cable.js.coffee | 5 ++++ lib/assets/javascripts/channel.js.coffee | 43 ++++++++++++---------------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index e0a3a133c5..992def173e 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -24,7 +24,7 @@ module ActionCable id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access subscription_klass = connection.server.registered_channels.detect do |channel_klass| - channel_klass.find_name == id_options[:channel] + channel_klass == id_options[:channel].safe_constantize end if subscription_klass @@ -67,4 +67,4 @@ module ActionCable end end end -end \ No newline at end of file +end diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 7c033d3b08..5d5c3a0a53 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -23,6 +23,11 @@ class @Cable connection.onerror = @reconnect connection + createChannel: (channelName, mixin) -> + channel = channelName + params = if typeof channel is "object" then channel else {channel} + new Cable.Channel this, params, mixin + isConnected: => @connection?.readyState is 1 diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee index c972334140..8bca24bd0e 100644 --- a/lib/assets/javascripts/channel.js.coffee +++ b/lib/assets/javascripts/channel.js.coffee @@ -1,33 +1,26 @@ class @Cable.Channel - constructor: (params = {}) -> - {channelName} = @constructor + constructor: (@cable, params = {}, mixin) -> + @identifier = JSON.stringify(params) + extend(this, mixin) - if channelName? - params['channel'] = channelName - @channelIdentifier = JSON.stringify params - else - throw new Error "This channel's constructor is missing the required 'channelName' property" - - cable.subscribe(@channelIdentifier, { - onConnect: @connected - onDisconnect: @disconnected - onReceiveData: @received - }) - - - connected: => - # Override in the subclass - - disconnected: => - # Override in the subclass - - received: (data) => - # Override in the subclass + @cable.subscribe @identifier, + onConnect: => @connected?() + onDisconnect: => @disconnected?() + onReceiveData: (data) => @receive?(data) # Perform a channel action with the optional data passed as an attribute perform: (action, data = {}) -> data.action = action - cable.sendData @channelIdentifier, JSON.stringify data + @cable.sendData(@identifier, JSON.stringify(data)) send: (data) -> - cable.sendData @channelIdentifier, JSON.stringify data + @cable.sendData(@identifier, JSON.stringify(data)) + + close: -> + @cable.unsubscribe(@identifier) + + extend = (object, properties) -> + if properties? + for key, value of properties + object[key] = value + object -- cgit v1.2.3 From 88585965ec00bbe9fe41bbe468bfbbf6dc0f9d89 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 24 Jun 2015 14:40:51 -0400 Subject: Remove now unused channel_name --- lib/action_cable/channel/base.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 83ba2cb3d2..6c55a8ed65 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -10,16 +10,10 @@ module ActionCable attr_reader :params, :connection delegate :logger, to: :connection - class_attribute :channel_name - class << self def matches?(identifier) raise "Please implement #{name}#matches? method" end - - def find_name - @name ||= channel_name || to_s.demodulize.underscore - end end def initialize(connection, channel_identifier, params = {}) @@ -138,4 +132,4 @@ module ActionCable end end end -end \ No newline at end of file +end -- cgit v1.2.3 From 0f761c0d51b8ccfd0d33562194cc5ef92199dc18 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 24 Jun 2015 18:22:16 -0400 Subject: Update API to camel cased equivalent of WebSocket's API --- lib/assets/javascripts/cable.js.coffee | 47 ++++++++++++++++++-------------- lib/assets/javascripts/channel.js.coffee | 19 ++++++------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 5d5c3a0a53..9fc269f994 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -16,11 +16,10 @@ class @Cable createConnection: -> connection = new WebSocket(@cableUrl) - connection.onmessage = @receiveData - connection.onopen = @connected - connection.onclose = @reconnect - - connection.onerror = @reconnect + connection.onmessage = @onMessage + connection.onopen = @onConnect + connection.onclose = @onClose + connection.onerror = @onError connection createChannel: (channelName, mixin) -> @@ -31,31 +30,40 @@ class @Cable isConnected: => @connection?.readyState is 1 - sendData: (identifier, data) => + sendMessage: (identifier, data) => if @isConnected() @connection.send JSON.stringify { command: 'message', identifier: identifier, data: data } - receiveData: (message) => + onMessage: (message) => data = JSON.parse message.data if data.identifier is '_ping' @pingReceived(data.message) else - @subscribers[data.identifier]?.onReceiveData(data.message) + @subscribers[data.identifier]?.onMessage?(data.message) - connected: => + onConnect: => @startWaitingForPing() @resetConnectionAttemptsCount() - for identifier, callbacks of @subscribers + for identifier, subscriber of @subscribers @subscribeOnServer(identifier) - callbacks['onConnect']?() + subscriber.onConnect?() - reconnect: => - @removeExistingConnection() + onClose: => + @reconnect() + + onError: => + @reconnect() + disconnect: -> + @removeExistingConnection() @resetPingTime() - @disconnected() + for identifier, subscriber of @subscribers + subscriber.onDisconnect?() + + reconnect: -> + @disconnect() setTimeout => @incrementConnectionAttemptsCount() @@ -95,21 +103,18 @@ class @Cable resetPingTime: => @lastPingTime = null - disconnected: => - callbacks['onDisconnect']?() for identifier, callbacks of @subscribers - giveUp: => # Show an error message - subscribe: (identifier, callbacks) => - @subscribers[identifier] = callbacks + subscribe: (identifier, subscriber) => + @subscribers[identifier] = subscriber if @isConnected() @subscribeOnServer(identifier) - @subscribers[identifier]['onConnect']?() + subscriber.onConnect?() unsubscribe: (identifier) => - @unsubscribeOnServer(identifier, 'unsubscribe') + @unsubscribeOnServer(identifier) delete @subscribers[identifier] subscribeOnServer: (identifier) => diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee index 8bca24bd0e..5196d5e03f 100644 --- a/lib/assets/javascripts/channel.js.coffee +++ b/lib/assets/javascripts/channel.js.coffee @@ -2,21 +2,20 @@ class @Cable.Channel constructor: (@cable, params = {}, mixin) -> @identifier = JSON.stringify(params) extend(this, mixin) - - @cable.subscribe @identifier, - onConnect: => @connected?() - onDisconnect: => @disconnected?() - onReceiveData: (data) => @receive?(data) + @subscribe(@identifier, this) # Perform a channel action with the optional data passed as an attribute - perform: (action, data = {}) -> + sendAction: (action, data = {}) -> data.action = action - @cable.sendData(@identifier, JSON.stringify(data)) + @sendMessage(data) + + sendMessage: (data) -> + @cable.sendMessage(@identifier, JSON.stringify(data)) - send: (data) -> - @cable.sendData(@identifier, JSON.stringify(data)) + subscribe: -> + @cable.subscribe(@identifier, this) - close: -> + unsubscribe: -> @cable.unsubscribe(@identifier) extend = (object, properties) -> -- cgit v1.2.3 From c7f00661bf0cc54a73ccdb9d27fa10b0fd806e43 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 10:21:53 -0400 Subject: Move connection and subscriber code into their own classes --- lib/assets/javascripts/cable.js.coffee | 134 ++------------------- lib/assets/javascripts/cable/channel.js.coffee | 22 ++++ lib/assets/javascripts/cable/connection.js.coffee | 102 ++++++++++++++++ .../javascripts/cable/subscriber_manager.js.coffee | 26 ++++ lib/assets/javascripts/channel.js.coffee | 25 ---- 5 files changed, 160 insertions(+), 149 deletions(-) create mode 100644 lib/assets/javascripts/cable/channel.js.coffee create mode 100644 lib/assets/javascripts/cable/connection.js.coffee create mode 100644 lib/assets/javascripts/cable/subscriber_manager.js.coffee delete mode 100644 lib/assets/javascripts/channel.js.coffee diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 9fc269f994..4c93f8f062 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -1,134 +1,20 @@ #= require_self -#= require_tree . +#= require cable/subscriber_manager +#= require cable/connection +#= require cable/channel class @Cable - MAX_CONNECTION_INTERVAL: 5 * 1000 - PING_STALE_INTERVAL: 8 - - constructor: (@cableUrl) -> - @subscribers = {} - @resetPingTime() - @resetConnectionAttemptsCount() - @connect() - - connect: -> - @connection = @createConnection() - - createConnection: -> - connection = new WebSocket(@cableUrl) - connection.onmessage = @onMessage - connection.onopen = @onConnect - connection.onclose = @onClose - connection.onerror = @onError - connection + constructor: (@url) -> + @subscribers = new Cable.SubscriberManager this + @connection = new Cable.Connection this createChannel: (channelName, mixin) -> channel = channelName params = if typeof channel is "object" then channel else {channel} new Cable.Channel this, params, mixin - isConnected: => - @connection?.readyState is 1 - - sendMessage: (identifier, data) => - if @isConnected() - @connection.send JSON.stringify { command: 'message', identifier: identifier, data: data } - - onMessage: (message) => - data = JSON.parse message.data - - if data.identifier is '_ping' - @pingReceived(data.message) - else - @subscribers[data.identifier]?.onMessage?(data.message) - - onConnect: => - @startWaitingForPing() - @resetConnectionAttemptsCount() - - for identifier, subscriber of @subscribers - @subscribeOnServer(identifier) - subscriber.onConnect?() - - onClose: => - @reconnect() - - onError: => - @reconnect() - - disconnect: -> - @removeExistingConnection() - @resetPingTime() - for identifier, subscriber of @subscribers - subscriber.onDisconnect?() - - reconnect: -> - @disconnect() - - setTimeout => - @incrementConnectionAttemptsCount() - @connect() - , @generateReconnectInterval() - - removeExistingConnection: => - if @connection? - @clearPingWaitTimeout() - - @connection.onclose = -> # no-op - @connection.onerror = -> # no-op - @connection.close() - @connection = null - - resetConnectionAttemptsCount: => - @connectionAttempts = 1 - - incrementConnectionAttemptsCount: => - @connectionAttempts += 1 - - generateReconnectInterval: () -> - interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 - if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval - - startWaitingForPing: => - @clearPingWaitTimeout() - - @waitForPingTimeout = setTimeout => - console.log "Ping took too long to arrive. Reconnecting.." - @reconnect() - , @PING_STALE_INTERVAL * 1000 - - clearPingWaitTimeout: => - clearTimeout(@waitForPingTimeout) - - resetPingTime: => - @lastPingTime = null - - giveUp: => - # Show an error message - - subscribe: (identifier, subscriber) => - @subscribers[identifier] = subscriber - - if @isConnected() - @subscribeOnServer(identifier) - subscriber.onConnect?() - - unsubscribe: (identifier) => - @unsubscribeOnServer(identifier) - delete @subscribers[identifier] - - subscribeOnServer: (identifier) => - if @isConnected() - @connection.send JSON.stringify { command: 'subscribe', identifier: identifier } - - unsubscribeOnServer: (identifier) => - if @isConnected() - @connection.send JSON.stringify { command: 'unsubscribe', identifier: identifier } + sendMessage: (identifier, data) -> + @sendCommand(identifier, "message", data) - pingReceived: (timestamp) => - if @lastPingTime? and (timestamp - @lastPingTime) > @PING_STALE_INTERVAL - console.log "Websocket connection is stale. Reconnecting.." - @reconnect() - else - @startWaitingForPing() - @lastPingTime = timestamp + sendCommand: (identifier, command, data) -> + @connection.send({identifier, command, data}) diff --git a/lib/assets/javascripts/cable/channel.js.coffee b/lib/assets/javascripts/cable/channel.js.coffee new file mode 100644 index 0000000000..645a44e140 --- /dev/null +++ b/lib/assets/javascripts/cable/channel.js.coffee @@ -0,0 +1,22 @@ +class Cable.Channel + constructor: (@cable, params = {}, mixin) -> + @identifier = JSON.stringify(params) + extend(this, mixin) + @cable.subscribers.add(@identifier, this) + + # Perform a channel action with the optional data passed as an attribute + sendAction: (action, data = {}) -> + data.action = action + @sendMessage(data) + + sendMessage: (data) -> + @cable.sendMessage(@identifier, JSON.stringify(data)) + + unsubscribe: -> + @cable.subscribers.remove(@identifier) + + extend = (object, properties) -> + if properties? + for key, value of properties + object[key] = value + object diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee new file mode 100644 index 0000000000..a318925b97 --- /dev/null +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -0,0 +1,102 @@ +class Cable.Connection + MAX_CONNECTION_INTERVAL: 5 * 1000 + PING_STALE_INTERVAL: 8 + + constructor: (@cable) -> + @resetPingTime() + @resetConnectionAttemptsCount() + @connect() + + send: (data) -> + if @isConnected() + @websocket.send(JSON.stringify(data)) + true + else + false + + connect: -> + @websocket = @createWebSocket() + + createWebSocket: -> + ws = new WebSocket(@cable.url) + ws.onmessage = @onMessage + ws.onopen = @onConnect + ws.onclose = @onClose + ws.onerror = @onError + ws + + onMessage: (message) => + data = JSON.parse message.data + + if data.identifier is '_ping' + @pingReceived(data.message) + else + @cable.subscribers.notify(data.identifier, "onMessage", data.message) + + onConnect: => + @startWaitingForPing() + @resetConnectionAttemptsCount() + @cable.subscribers.reload() + + onClose: => + @reconnect() + + onError: => + @reconnect() + + isConnected: -> + @websocket?.readyState is 1 + + disconnect: -> + @removeExistingConnection() + @resetPingTime() + @cable.subscribers.notifyAll("onDisconnect") + + reconnect: -> + @disconnect() + + setTimeout => + @incrementConnectionAttemptsCount() + @connect() + , @generateReconnectInterval() + + removeExistingConnection: -> + if @websocket? + @clearPingWaitTimeout() + + @websocket.onclose = -> # no-op + @websocket.onerror = -> # no-op + @websocket.close() + @websocket = null + + resetConnectionAttemptsCount: -> + @connectionAttempts = 1 + + incrementConnectionAttemptsCount: -> + @connectionAttempts += 1 + + generateReconnectInterval: () -> + interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 + if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval + + startWaitingForPing: -> + @clearPingWaitTimeout() + + @waitForPingTimeout = setTimeout => + console.log "Ping took too long to arrive. Reconnecting.." + @reconnect() + , @PING_STALE_INTERVAL * 1000 + + clearPingWaitTimeout: -> + clearTimeout(@waitForPingTimeout) + + resetPingTime: -> + @lastPingTime = null + + pingReceived: (timestamp) -> + if @lastPingTime? and (timestamp - @lastPingTime) > @PING_STALE_INTERVAL + console.log "Websocket connection is stale. Reconnecting.." + @reconnect() + else + @startWaitingForPing() + @lastPingTime = timestamp diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee new file mode 100644 index 0000000000..4f46efe817 --- /dev/null +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -0,0 +1,26 @@ +class Cable.SubscriberManager + constructor: (@cable) -> + @subscribers = {} + + add: (identifier, subscriber) -> + @subscribers[identifier] = subscriber + if @cable.sendCommand(identifier, "subscribe") + @notify(identifier, "onConnect") + + reload: -> + for identifier in Object.keys(@subscribers) + if @cable.sendCommand(identifier, "subscribe") + @notify(identifier, "onConnect") + + remove: (identifier) -> + if subscriber = @subscribers[identifier] + @cable.sendCommand(identifier, "unsubscribe") + delete @subscribers[identifier] + + notifyAll: (event, args...) -> + for identifier in Object.keys(@subscribers) + @notify(identifier, event, args...) + + notify: (identifier, event, args...) -> + if subscriber = @subscribers[identifier] + subscriber[event]?(args...) diff --git a/lib/assets/javascripts/channel.js.coffee b/lib/assets/javascripts/channel.js.coffee deleted file mode 100644 index 5196d5e03f..0000000000 --- a/lib/assets/javascripts/channel.js.coffee +++ /dev/null @@ -1,25 +0,0 @@ -class @Cable.Channel - constructor: (@cable, params = {}, mixin) -> - @identifier = JSON.stringify(params) - extend(this, mixin) - @subscribe(@identifier, this) - - # Perform a channel action with the optional data passed as an attribute - sendAction: (action, data = {}) -> - data.action = action - @sendMessage(data) - - sendMessage: (data) -> - @cable.sendMessage(@identifier, JSON.stringify(data)) - - subscribe: -> - @cable.subscribe(@identifier, this) - - unsubscribe: -> - @cable.unsubscribe(@identifier) - - extend = (object, properties) -> - if properties? - for key, value of properties - object[key] = value - object -- cgit v1.2.3 From 53d0b22aeeba1a585abe47b4a9417462b812d1a4 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 10:32:36 -0400 Subject: Switch back to original API and callback naming --- lib/assets/javascripts/cable/channel.js.coffee | 6 +++--- lib/assets/javascripts/cable/connection.js.coffee | 4 ++-- lib/assets/javascripts/cable/subscriber_manager.js.coffee | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/assets/javascripts/cable/channel.js.coffee b/lib/assets/javascripts/cable/channel.js.coffee index 645a44e140..bbdc9c5589 100644 --- a/lib/assets/javascripts/cable/channel.js.coffee +++ b/lib/assets/javascripts/cable/channel.js.coffee @@ -5,11 +5,11 @@ class Cable.Channel @cable.subscribers.add(@identifier, this) # Perform a channel action with the optional data passed as an attribute - sendAction: (action, data = {}) -> + perform: (action, data = {}) -> data.action = action - @sendMessage(data) + @send(data) - sendMessage: (data) -> + send: (data) -> @cable.sendMessage(@identifier, JSON.stringify(data)) unsubscribe: -> diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index a318925b97..e987c227c6 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -31,7 +31,7 @@ class Cable.Connection if data.identifier is '_ping' @pingReceived(data.message) else - @cable.subscribers.notify(data.identifier, "onMessage", data.message) + @cable.subscribers.notify(data.identifier, "received", data.message) onConnect: => @startWaitingForPing() @@ -50,7 +50,7 @@ class Cable.Connection disconnect: -> @removeExistingConnection() @resetPingTime() - @cable.subscribers.notifyAll("onDisconnect") + @cable.subscribers.notifyAll("disconnected") reconnect: -> @disconnect() diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index 4f46efe817..98223d076c 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -5,12 +5,12 @@ class Cable.SubscriberManager add: (identifier, subscriber) -> @subscribers[identifier] = subscriber if @cable.sendCommand(identifier, "subscribe") - @notify(identifier, "onConnect") + @notify(identifier, "connected") reload: -> for identifier in Object.keys(@subscribers) if @cable.sendCommand(identifier, "subscribe") - @notify(identifier, "onConnect") + @notify(identifier, "connected") remove: (identifier) -> if subscriber = @subscribers[identifier] -- cgit v1.2.3 From d9d7371c568fe99ef460202ebe7217bfed050e88 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 11:36:40 -0400 Subject: Assume subscribers have an identifier --- lib/assets/javascripts/cable.js.coffee | 7 ++--- lib/assets/javascripts/cable/channel.js.coffee | 6 ++-- .../javascripts/cable/subscriber_manager.js.coffee | 35 +++++++++++++--------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 4c93f8f062..86e08e15c0 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -13,8 +13,5 @@ class @Cable params = if typeof channel is "object" then channel else {channel} new Cable.Channel this, params, mixin - sendMessage: (identifier, data) -> - @sendCommand(identifier, "message", data) - - sendCommand: (identifier, command, data) -> - @connection.send({identifier, command, data}) + send: (data) -> + @connection.send(data) diff --git a/lib/assets/javascripts/cable/channel.js.coffee b/lib/assets/javascripts/cable/channel.js.coffee index bbdc9c5589..9168a76d3c 100644 --- a/lib/assets/javascripts/cable/channel.js.coffee +++ b/lib/assets/javascripts/cable/channel.js.coffee @@ -2,7 +2,7 @@ class Cable.Channel constructor: (@cable, params = {}, mixin) -> @identifier = JSON.stringify(params) extend(this, mixin) - @cable.subscribers.add(@identifier, this) + @cable.subscribers.add(this) # Perform a channel action with the optional data passed as an attribute perform: (action, data = {}) -> @@ -10,10 +10,10 @@ class Cable.Channel @send(data) send: (data) -> - @cable.sendMessage(@identifier, JSON.stringify(data)) + @cable.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) unsubscribe: -> - @cable.subscribers.remove(@identifier) + @cable.subscribers.remove(this) extend = (object, properties) -> if properties? diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index 98223d076c..e2e6c9e228 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -2,25 +2,32 @@ class Cable.SubscriberManager constructor: (@cable) -> @subscribers = {} - add: (identifier, subscriber) -> + add: (subscriber) -> + {identifier} = subscriber @subscribers[identifier] = subscriber - if @cable.sendCommand(identifier, "subscribe") - @notify(identifier, "connected") + if @sendCommand("subscribe", identifier) + @notify(subscriber, "connected") reload: -> - for identifier in Object.keys(@subscribers) - if @cable.sendCommand(identifier, "subscribe") - @notify(identifier, "connected") + for identifier, subscriber of @subscribers + if @sendCommand("subscribe", identifier) + @notify(subscriber, "connected") - remove: (identifier) -> - if subscriber = @subscribers[identifier] - @cable.sendCommand(identifier, "unsubscribe") - delete @subscribers[identifier] + remove: (subscriber) -> + {identifier} = subscriber + @sendCommand("unsubscribe", identifier) + delete @subscribers[identifier] notifyAll: (event, args...) -> - for identifier in Object.keys(@subscribers) - @notify(identifier, event, args...) + for identifier, subscriber of @subscribers + @notify(subscriber, event, args...) - notify: (identifier, event, args...) -> - if subscriber = @subscribers[identifier] + notify: (subscriber, event, args...) -> + if typeof subscriber is "string" + subscriber = @subscribers[subscriber] + + if subscriber subscriber[event]?(args...) + + sendCommand: (command, identifier) -> + @cable.send({command, identifier}) -- cgit v1.2.3 From c846f43d46908750020710b4e9437f9395fb9594 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 13:52:47 -0400 Subject: Extract connection monitoring and rewrite as a subscriber --- lib/assets/javascripts/cable.js.coffee | 2 + lib/assets/javascripts/cable/connection.js.coffee | 94 +++++----------------- .../javascripts/cable/connection_monitor.js.coffee | 41 ++++++++++ .../javascripts/cable/subscriber_manager.js.coffee | 1 + 4 files changed, 66 insertions(+), 72 deletions(-) create mode 100644 lib/assets/javascripts/cable/connection_monitor.js.coffee diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 86e08e15c0..0f1d7c8773 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -4,6 +4,8 @@ #= require cable/channel class @Cable + @PING_IDENTIFIER: "_ping" + constructor: (@url) -> @subscribers = new Cable.SubscriberManager this @connection = new Cable.Connection this diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index e987c227c6..096dd519f7 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -1,10 +1,8 @@ -class Cable.Connection - MAX_CONNECTION_INTERVAL: 5 * 1000 - PING_STALE_INTERVAL: 8 +#= require cable/connection_monitor +class Cable.Connection constructor: (@cable) -> - @resetPingTime() - @resetConnectionAttemptsCount() + new Cable.ConnectionMonitor @cable @connect() send: (data) -> @@ -15,88 +13,40 @@ class Cable.Connection false connect: -> - @websocket = @createWebSocket() + @removeWebsocket() + @createWebSocket() createWebSocket: -> - ws = new WebSocket(@cable.url) - ws.onmessage = @onMessage - ws.onopen = @onConnect - ws.onclose = @onClose - ws.onerror = @onError - ws + @websocket = new WebSocket(@cable.url) + @websocket.onmessage = @onMessage + @websocket.onopen = @onConnect + @websocket.onclose = @onClose + @websocket.onerror = @onError + @websocket + + removeWebsocket: -> + if @websocket? + @websocket.onclose = -> # no-op + @websocket.onerror = -> # no-op + @websocket.close() + @websocket = null onMessage: (message) => data = JSON.parse message.data - - if data.identifier is '_ping' - @pingReceived(data.message) - else - @cable.subscribers.notify(data.identifier, "received", data.message) + @cable.subscribers.notify(data.identifier, "received", data.message) onConnect: => - @startWaitingForPing() - @resetConnectionAttemptsCount() @cable.subscribers.reload() onClose: => - @reconnect() + @disconnect() onError: => - @reconnect() + @disconnect() isConnected: -> @websocket?.readyState is 1 disconnect: -> - @removeExistingConnection() - @resetPingTime() @cable.subscribers.notifyAll("disconnected") - - reconnect: -> - @disconnect() - - setTimeout => - @incrementConnectionAttemptsCount() - @connect() - , @generateReconnectInterval() - - removeExistingConnection: -> - if @websocket? - @clearPingWaitTimeout() - - @websocket.onclose = -> # no-op - @websocket.onerror = -> # no-op - @websocket.close() - @websocket = null - - resetConnectionAttemptsCount: -> - @connectionAttempts = 1 - - incrementConnectionAttemptsCount: -> - @connectionAttempts += 1 - - generateReconnectInterval: () -> - interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 - if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval - - startWaitingForPing: -> - @clearPingWaitTimeout() - - @waitForPingTimeout = setTimeout => - console.log "Ping took too long to arrive. Reconnecting.." - @reconnect() - , @PING_STALE_INTERVAL * 1000 - - clearPingWaitTimeout: -> - clearTimeout(@waitForPingTimeout) - - resetPingTime: -> - @lastPingTime = null - - pingReceived: (timestamp) -> - if @lastPingTime? and (timestamp - @lastPingTime) > @PING_STALE_INTERVAL - console.log "Websocket connection is stale. Reconnecting.." - @reconnect() - else - @startWaitingForPing() - @lastPingTime = timestamp + @removeWebsocket() diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee new file mode 100644 index 0000000000..078e1d3b26 --- /dev/null +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -0,0 +1,41 @@ +class Cable.ConnectionMonitor + MAX_CONNECTION_INTERVAL: 5 * 1000 + PING_STALE_INTERVAL: 8 * 1000 + + identifier: Cable.PING_IDENTIFIER + + constructor: (@cable) -> + @reset() + @cable.subscribers.add(this) + @pollConnection() + + connected: -> + @reset() + @pingedAt = now() + + received: -> + @pingedAt = now() + + reset: -> + @connectionAttempts = 1 + + pollConnection: -> + setTimeout => + @reconnect() if @connectionIsStale() + @pollConnection() + , @getPollTimeout() + + getPollTimeout: -> + interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 + if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval + + connectionIsStale: -> + @pingedAt? and (now() - @pingedAt) > @PING_STALE_INTERVAL + + reconnect: -> + console.log "Ping took too long to arrive. Reconnecting.." + @connectionAttempts += 1 + @cable.connection.connect() + + now = -> + new Date().getTime() diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index e2e6c9e228..d76832a802 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -30,4 +30,5 @@ class Cable.SubscriberManager subscriber[event]?(args...) sendCommand: (command, identifier) -> + return true if identifier is Cable.PING_IDENTIFIER @cable.send({command, identifier}) -- cgit v1.2.3 From 288d289b72b2a558e0c7cc9151692eb18a2def0d Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 14:07:14 -0400 Subject: Add "initialized" notification --- lib/assets/javascripts/cable/subscriber_manager.js.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index d76832a802..da9a03c3b6 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -5,6 +5,7 @@ class Cable.SubscriberManager add: (subscriber) -> {identifier} = subscriber @subscribers[identifier] = subscriber + @notify(subscriber, "initialized") if @sendCommand("subscribe", identifier) @notify(subscriber, "connected") -- cgit v1.2.3 From 3509a1226c3b35aa018befd5f20327845c8c1ee9 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 15:22:39 -0400 Subject: event -> callbackName --- lib/assets/javascripts/cable/subscriber_manager.js.coffee | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index da9a03c3b6..ca79f1146e 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -19,16 +19,16 @@ class Cable.SubscriberManager @sendCommand("unsubscribe", identifier) delete @subscribers[identifier] - notifyAll: (event, args...) -> + notifyAll: (callbackName, args...) -> for identifier, subscriber of @subscribers - @notify(subscriber, event, args...) + @notify(subscriber, callbackName, args...) - notify: (subscriber, event, args...) -> + notify: (subscriber, callbackName, args...) -> if typeof subscriber is "string" subscriber = @subscribers[subscriber] if subscriber - subscriber[event]?(args...) + subscriber[callbackName]?(args...) sendCommand: (command, identifier) -> return true if identifier is Cable.PING_IDENTIFIER -- cgit v1.2.3 From 0e5c9e741ac2da96f6e1b0f2dd1de03f50a19b90 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 16:08:44 -0400 Subject: Manage an array of subscribers since there may be more than one subscription to a channel --- .../javascripts/cable/subscriber_manager.js.coffee | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index ca79f1146e..9ff727aa0c 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -1,35 +1,36 @@ class Cable.SubscriberManager constructor: (@cable) -> - @subscribers = {} + @subscribers = [] add: (subscriber) -> - {identifier} = subscriber - @subscribers[identifier] = subscriber + @subscribers.push(subscriber) @notify(subscriber, "initialized") - if @sendCommand("subscribe", identifier) + if @sendCommand(subscriber, "subscribe") @notify(subscriber, "connected") reload: -> - for identifier, subscriber of @subscribers - if @sendCommand("subscribe", identifier) + for subscriber in @subscribers + if @sendCommand(subscriber, "subscribe") @notify(subscriber, "connected") remove: (subscriber) -> - {identifier} = subscriber - @sendCommand("unsubscribe", identifier) - delete @subscribers[identifier] + @sendCommand(subscriber, "unsubscribe") + @subscibers = (s for s in @subscribers when s isnt subscriber) notifyAll: (callbackName, args...) -> - for identifier, subscriber of @subscribers + for subscriber in @subscribers @notify(subscriber, callbackName, args...) notify: (subscriber, callbackName, args...) -> if typeof subscriber is "string" - subscriber = @subscribers[subscriber] + subscribers = (s for s in @subscribers when s.identifier is subscriber) + else + subscribers = [subscriber] - if subscriber + for subscriber in subscribers subscriber[callbackName]?(args...) - sendCommand: (command, identifier) -> + sendCommand: (subscriber, command) -> + {identifier} = subscriber return true if identifier is Cable.PING_IDENTIFIER @cable.send({command, identifier}) -- cgit v1.2.3 From fa0281aeb122058fa7d353fcd74e08a6702a9061 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 16:12:26 -0400 Subject: Cable.Channel -> Cable.Subscription --- lib/assets/javascripts/cable.js.coffee | 6 +++--- lib/assets/javascripts/cable/channel.js.coffee | 22 ---------------------- .../javascripts/cable/subscription.js.coffee | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 25 deletions(-) delete mode 100644 lib/assets/javascripts/cable/channel.js.coffee create mode 100644 lib/assets/javascripts/cable/subscription.js.coffee diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index 0f1d7c8773..fad5aa05d3 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -1,7 +1,7 @@ #= require_self #= require cable/subscriber_manager #= require cable/connection -#= require cable/channel +#= require cable/subscription class @Cable @PING_IDENTIFIER: "_ping" @@ -10,10 +10,10 @@ class @Cable @subscribers = new Cable.SubscriberManager this @connection = new Cable.Connection this - createChannel: (channelName, mixin) -> + createSubscription: (channelName, mixin) -> channel = channelName params = if typeof channel is "object" then channel else {channel} - new Cable.Channel this, params, mixin + new Cable.Subscription this, params, mixin send: (data) -> @connection.send(data) diff --git a/lib/assets/javascripts/cable/channel.js.coffee b/lib/assets/javascripts/cable/channel.js.coffee deleted file mode 100644 index 9168a76d3c..0000000000 --- a/lib/assets/javascripts/cable/channel.js.coffee +++ /dev/null @@ -1,22 +0,0 @@ -class Cable.Channel - constructor: (@cable, params = {}, mixin) -> - @identifier = JSON.stringify(params) - extend(this, mixin) - @cable.subscribers.add(this) - - # Perform a channel action with the optional data passed as an attribute - perform: (action, data = {}) -> - data.action = action - @send(data) - - send: (data) -> - @cable.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) - - unsubscribe: -> - @cable.subscribers.remove(this) - - extend = (object, properties) -> - if properties? - for key, value of properties - object[key] = value - object diff --git a/lib/assets/javascripts/cable/subscription.js.coffee b/lib/assets/javascripts/cable/subscription.js.coffee new file mode 100644 index 0000000000..8057ff7790 --- /dev/null +++ b/lib/assets/javascripts/cable/subscription.js.coffee @@ -0,0 +1,22 @@ +class Cable.Subscription + constructor: (@cable, params = {}, mixin) -> + @identifier = JSON.stringify(params) + extend(this, mixin) + @cable.subscribers.add(this) + + # Perform a channel action with the optional data passed as an attribute + perform: (action, data = {}) -> + data.action = action + @send(data) + + send: (data) -> + @cable.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) + + unsubscribe: -> + @cable.subscribers.remove(this) + + extend = (object, properties) -> + if properties? + for key, value of properties + object[key] = value + object -- cgit v1.2.3 From 84ed13970003e9ec2f21c35df111370492519cdc Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 16:24:58 -0400 Subject: Cable.Consumer --- lib/assets/javascripts/cable.js.coffee | 21 +++++---------------- lib/assets/javascripts/cable/connection.js.coffee | 12 ++++++------ .../javascripts/cable/connection_monitor.js.coffee | 6 +++--- lib/assets/javascripts/cable/consumer.js.coffee | 16 ++++++++++++++++ .../javascripts/cable/subscriber_manager.js.coffee | 4 ++-- lib/assets/javascripts/cable/subscription.js.coffee | 8 ++++---- 6 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 lib/assets/javascripts/cable/consumer.js.coffee diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee index fad5aa05d3..0bd1757505 100644 --- a/lib/assets/javascripts/cable.js.coffee +++ b/lib/assets/javascripts/cable.js.coffee @@ -1,19 +1,8 @@ #= require_self -#= require cable/subscriber_manager -#= require cable/connection -#= require cable/subscription +#= require cable/consumer -class @Cable - @PING_IDENTIFIER: "_ping" +@Cable = + PING_IDENTIFIER: "_ping" - constructor: (@url) -> - @subscribers = new Cable.SubscriberManager this - @connection = new Cable.Connection this - - createSubscription: (channelName, mixin) -> - channel = channelName - params = if typeof channel is "object" then channel else {channel} - new Cable.Subscription this, params, mixin - - send: (data) -> - @connection.send(data) + createConsumer: (url) -> + new Cable.Consumer url diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 096dd519f7..6ee28fcb75 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -1,8 +1,8 @@ #= require cable/connection_monitor class Cable.Connection - constructor: (@cable) -> - new Cable.ConnectionMonitor @cable + constructor: (@consumer) -> + new Cable.ConnectionMonitor @consumer @connect() send: (data) -> @@ -17,7 +17,7 @@ class Cable.Connection @createWebSocket() createWebSocket: -> - @websocket = new WebSocket(@cable.url) + @websocket = new WebSocket(@consumer.url) @websocket.onmessage = @onMessage @websocket.onopen = @onConnect @websocket.onclose = @onClose @@ -33,10 +33,10 @@ class Cable.Connection onMessage: (message) => data = JSON.parse message.data - @cable.subscribers.notify(data.identifier, "received", data.message) + @consumer.subscribers.notify(data.identifier, "received", data.message) onConnect: => - @cable.subscribers.reload() + @consumer.subscribers.reload() onClose: => @disconnect() @@ -48,5 +48,5 @@ class Cable.Connection @websocket?.readyState is 1 disconnect: -> - @cable.subscribers.notifyAll("disconnected") + @consumer.subscribers.notifyAll("disconnected") @removeWebsocket() diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index 078e1d3b26..cf36b2a457 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -4,9 +4,9 @@ class Cable.ConnectionMonitor identifier: Cable.PING_IDENTIFIER - constructor: (@cable) -> + constructor: (@consumer) -> @reset() - @cable.subscribers.add(this) + @consumer.subscribers.add(this) @pollConnection() connected: -> @@ -35,7 +35,7 @@ class Cable.ConnectionMonitor reconnect: -> console.log "Ping took too long to arrive. Reconnecting.." @connectionAttempts += 1 - @cable.connection.connect() + @consumer.connection.connect() now = -> new Date().getTime() diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee new file mode 100644 index 0000000000..a9abd6256a --- /dev/null +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -0,0 +1,16 @@ +#= require cable/connection +#= require cable/subscription +#= require cable/subscriber_manager + +class Cable.Consumer + constructor: (@url) -> + @subscribers = new Cable.SubscriberManager this + @connection = new Cable.Connection this + + createSubscription: (channelName, mixin) -> + channel = channelName + params = if typeof channel is "object" then channel else {channel} + new Cable.Subscription this, params, mixin + + send: (data) -> + @connection.send(data) diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index 9ff727aa0c..0893d217ac 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -1,5 +1,5 @@ class Cable.SubscriberManager - constructor: (@cable) -> + constructor: (@consumer) -> @subscribers = [] add: (subscriber) -> @@ -33,4 +33,4 @@ class Cable.SubscriberManager sendCommand: (subscriber, command) -> {identifier} = subscriber return true if identifier is Cable.PING_IDENTIFIER - @cable.send({command, identifier}) + @consumer.send({command, identifier}) diff --git a/lib/assets/javascripts/cable/subscription.js.coffee b/lib/assets/javascripts/cable/subscription.js.coffee index 8057ff7790..74cc35a7a7 100644 --- a/lib/assets/javascripts/cable/subscription.js.coffee +++ b/lib/assets/javascripts/cable/subscription.js.coffee @@ -1,8 +1,8 @@ class Cable.Subscription - constructor: (@cable, params = {}, mixin) -> + constructor: (@consumer, params = {}, mixin) -> @identifier = JSON.stringify(params) extend(this, mixin) - @cable.subscribers.add(this) + @consumer.subscribers.add(this) # Perform a channel action with the optional data passed as an attribute perform: (action, data = {}) -> @@ -10,10 +10,10 @@ class Cable.Subscription @send(data) send: (data) -> - @cable.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) + @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) unsubscribe: -> - @cable.subscribers.remove(this) + @consumer.subscribers.remove(this) extend = (object, properties) -> if properties? -- cgit v1.2.3 From 315c0fbf1942429c1bcb4f862dd18fb7a585231c Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 25 Jun 2015 18:46:33 -0400 Subject: Fix misspelled variable --- lib/assets/javascripts/cable/subscriber_manager.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index 0893d217ac..922c74808c 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -15,7 +15,7 @@ class Cable.SubscriberManager remove: (subscriber) -> @sendCommand(subscriber, "unsubscribe") - @subscibers = (s for s in @subscribers when s isnt subscriber) + @subscribers = (s for s in @subscribers when s isnt subscriber) notifyAll: (callbackName, args...) -> for subscriber in @subscribers -- cgit v1.2.3 From 5541b8fcaf9ec40e6f16c50cb45030838a3e3450 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 26 Jun 2015 10:24:29 -0400 Subject: Update connection API with #open, #close, #reopen --- lib/assets/javascripts/cable/connection.js.coffee | 39 +++++++++++----------- .../javascripts/cable/connection_monitor.js.coffee | 2 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 6ee28fcb75..cd9539a6aa 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -3,39 +3,41 @@ class Cable.Connection constructor: (@consumer) -> new Cable.ConnectionMonitor @consumer - @connect() + @open() send: (data) -> - if @isConnected() + if @isOpen() @websocket.send(JSON.stringify(data)) true else false - connect: -> - @removeWebsocket() - @createWebSocket() - - createWebSocket: -> + open: -> @websocket = new WebSocket(@consumer.url) @websocket.onmessage = @onMessage - @websocket.onopen = @onConnect + @websocket.onopen = @onOpen @websocket.onclose = @onClose @websocket.onerror = @onError @websocket - removeWebsocket: -> - if @websocket? - @websocket.onclose = -> # no-op - @websocket.onerror = -> # no-op - @websocket.close() - @websocket = null + close: -> + @websocket.close() unless @isClosed() + + reopen: -> + @close() + @open() + + isOpen: -> + @websocket.readyState is WebSocket.OPEN + + isClosed: -> + @websocket.readyState in [ WebSocket.CLOSED, WebSocket.CLOSING ] onMessage: (message) => data = JSON.parse message.data @consumer.subscribers.notify(data.identifier, "received", data.message) - onConnect: => + onOpen: => @consumer.subscribers.reload() onClose: => @@ -43,10 +45,9 @@ class Cable.Connection onError: => @disconnect() - - isConnected: -> - @websocket?.readyState is 1 + @websocket.onclose = -> # no-op + @websocket.onerror = -> # no-op + try @close() disconnect: -> @consumer.subscribers.notifyAll("disconnected") - @removeWebsocket() diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index cf36b2a457..bb4ee8f7f6 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -35,7 +35,7 @@ class Cable.ConnectionMonitor reconnect: -> console.log "Ping took too long to arrive. Reconnecting.." @connectionAttempts += 1 - @consumer.connection.connect() + @consumer.connection.reopen() now = -> new Date().getTime() -- cgit v1.2.3 From 85272d8a91aa2a08a4f83100e24cb1b0c8c9ccf3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 26 Jun 2015 19:11:21 +0200 Subject: Don't need a log_exception helper, just do it inline --- lib/action_cable/connection/base.rb | 4 ---- lib/action_cable/connection/subscriptions.rb | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index fe5f058824..aac23b2596 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -142,10 +142,6 @@ module ActionCable end - def log_exception(e) - logger.error "Exception raised #{e.class} - #{e.message}: #{e.backtrace.first(5).join(" | ")}" - end - def log_tags server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index e0a3a133c5..9e7a8a5f73 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -15,8 +15,7 @@ module ActionCable logger.error "Received unrecognized command in #{data.inspect}" end rescue Exception => e - logger.error "Could not execute command from #{data.inspect})" - connection.log_exception(e) + logger.error "Could not execute command from #{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" end def add(data) -- cgit v1.2.3 From e3cb3696cfaa766b62d644411fe71e4e64aab85a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 26 Jun 2015 19:11:31 +0200 Subject: TOC refactor --- lib/action_cable/connection/base.rb | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index aac23b2596..4af651a5d9 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -78,6 +78,20 @@ module ActionCable attr_reader :websocket attr_reader :heartbeat, :subscriptions, :message_buffer + + def websocket_initialization + @websocket = Faye::WebSocket.new(@env) + end + + def websocket_alive? + websocket && websocket.ready_state == Faye::WebSocket::API::OPEN + end + + def websocket_request? + @is_websocket ||= Faye::WebSocket.websocket?(@env) + end + + def on_open server.add_connection(self) @@ -111,19 +125,6 @@ module ActionCable end - def websocket_initialization - @websocket = Faye::WebSocket.new(@env) - end - - def websocket_alive? - websocket && websocket.ready_state == Faye::WebSocket::API::OPEN - end - - def websocket_request? - @is_websocket ||= Faye::WebSocket.websocket?(@env) - end - - def started_request_message 'Started %s "%s"%s for %s at %s' % [ request.request_method, -- cgit v1.2.3 From b48b2c506f4bde3863b88a7ef71c83f09a86eb92 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 26 Jun 2015 14:37:51 -0400 Subject: Bump version --- action_cable.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action_cable.gemspec b/action_cable.gemspec index 714256a73e..1d68c2b0a5 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -1,7 +1,7 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = 'action_cable' - s.version = '0.0.3' + s.version = '0.1.0' s.summary = 'Framework for websockets.' s.description = 'Action Cable is a framework for realtime communication over websockets.' -- cgit v1.2.3 From 4cef27aacffb2ce5aada0c7199e9eb8787291baf Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 15:59:12 +0200 Subject: Explain the purpose --- lib/action_cable/connection/tagged_logger_proxy.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb index e9e12e2672..e0c0075adf 100644 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -1,5 +1,8 @@ module ActionCable module Connection + # Allows the use of per-connection tags against the server logger. This wouldn't work using the tradional + # 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 def initialize(logger, tags:) @logger = logger -- cgit v1.2.3 From a2d55dfdc3878521793a8472c00b7a648ff21ae3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:18:26 +0200 Subject: Use an encapsulated factory method --- lib/action_cable/connection/base.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 4af651a5d9..a231288b4b 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -14,7 +14,7 @@ module ActionCable @server, @env = server, env - @logger = TaggedLoggerProxy.new(server.logger, tags: log_tags) + @logger = initialize_tagged_logger @heartbeat = ActionCable::Connection::Heartbeat.new(self) @subscriptions = ActionCable::Connection::Subscriptions.new(self) @@ -143,8 +143,10 @@ module ActionCable end - def log_tags - server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. + def initialize_tagged_logger + TaggedLoggerProxy.new server.logger, + tags: server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end end end -- cgit v1.2.3 From 3dd19d9d3c4bf678d45230485403e7460e75373f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:18:38 +0200 Subject: Better order --- lib/action_cable/connection/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index a231288b4b..ba1a486afb 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -10,8 +10,6 @@ module ActionCable attr_reader :logger def initialize(server, env) - @started_at = Time.now - @server, @env = server, env @logger = initialize_tagged_logger @@ -19,6 +17,8 @@ module ActionCable @heartbeat = ActionCable::Connection::Heartbeat.new(self) @subscriptions = ActionCable::Connection::Subscriptions.new(self) @message_buffer = ActionCable::Connection::MessageBuffer.new(self) + + @started_at = Time.now end def process -- cgit v1.2.3 From 78f3c88d69741ffd9b24da8d362f3f7c4c8454f8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:27:08 +0200 Subject: Better ordering --- lib/action_cable/connection/base.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index ba1a486afb..ae9dd58ab4 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -78,7 +78,6 @@ module ActionCable attr_reader :websocket attr_reader :heartbeat, :subscriptions, :message_buffer - def websocket_initialization @websocket = Faye::WebSocket.new(@env) end @@ -125,6 +124,12 @@ module ActionCable end + # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. + def initialize_tagged_logger + TaggedLoggerProxy.new server.logger, + tags: server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + end + def started_request_message 'Started %s "%s"%s for %s at %s' % [ request.request_method, @@ -141,13 +146,6 @@ module ActionCable request.ip, Time.now.to_default_s ] end - - - # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. - def initialize_tagged_logger - TaggedLoggerProxy.new server.logger, - tags: server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } - end end end end -- cgit v1.2.3 From 321d04ff56e2f17ef7285141252dba8ff5cdecca Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:50:05 +0200 Subject: Add WebSocket decorator --- lib/action_cable/connection.rb | 1 + lib/action_cable/connection/base.rb | 28 +++++++--------------------- lib/action_cable/connection/web_socket.rb | 27 +++++++++++++++++++++++++++ lib/action_cable/server.rb | 2 +- 4 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 lib/action_cable/connection/web_socket.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 09ef1699a6..1b4a6ecc23 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -5,6 +5,7 @@ module ActionCable autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' autoload :MessageBuffer, 'action_cable/connection/message_buffer' + autoload :WebSocket, 'action_cable/connection/web_socket' autoload :Subscriptions, 'action_cable/connection/subscriptions' autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index ae9dd58ab4..efabe40b73 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -14,6 +14,7 @@ module ActionCable @logger = initialize_tagged_logger + @websocket = ActionCable::Connection::WebSocket.new(env) @heartbeat = ActionCable::Connection::Heartbeat.new(self) @subscriptions = ActionCable::Connection::Subscriptions.new(self) @message_buffer = ActionCable::Connection::MessageBuffer.new(self) @@ -21,12 +22,10 @@ module ActionCable @started_at = Time.now end - def process + def response logger.info started_request_message - if websocket_request? - websocket_initialization - + if websocket.possible? websocket.on(:open) { |event| send_async :on_open } websocket.on(:message) { |event| on_message event.data } websocket.on(:close) { |event| send_async :on_close } @@ -38,7 +37,7 @@ module ActionCable end def receive(data_in_json) - if websocket_alive? + if websocket.alive? subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) else logger.error "Received data without a live websocket (#{data.inspect})" @@ -46,7 +45,7 @@ module ActionCable end def transmit(data) - websocket.send data + websocket.transmit data end def close @@ -78,19 +77,6 @@ module ActionCable attr_reader :websocket attr_reader :heartbeat, :subscriptions, :message_buffer - def websocket_initialization - @websocket = Faye::WebSocket.new(@env) - end - - def websocket_alive? - websocket && websocket.ready_state == Faye::WebSocket::API::OPEN - end - - def websocket_request? - @is_websocket ||= Faye::WebSocket.websocket?(@env) - end - - def on_open server.add_connection(self) @@ -134,7 +120,7 @@ module ActionCable 'Started %s "%s"%s for %s at %s' % [ request.request_method, request.filtered_path, - websocket_request? ? ' [Websocket]' : '', + websocket.possible? ? ' [Websocket]' : '', request.ip, Time.now.to_default_s ] end @@ -142,7 +128,7 @@ module ActionCable def finished_request_message 'Finished "%s"%s for %s at %s' % [ request.filtered_path, - websocket_request? ? ' [Websocket]' : '', + websocket.possible? ? ' [Websocket]' : '', request.ip, Time.now.to_default_s ] end diff --git a/lib/action_cable/connection/web_socket.rb b/lib/action_cable/connection/web_socket.rb new file mode 100644 index 0000000000..135a28cfe4 --- /dev/null +++ b/lib/action_cable/connection/web_socket.rb @@ -0,0 +1,27 @@ +module ActionCable + module Connection + # Decorate the Faye::WebSocket with helpers we need. + class WebSocket + delegate :rack_response, :close, :on, to: :websocket + + def initialize(env) + @websocket = Faye::WebSocket.websocket?(env) ? Faye::WebSocket.new(env) : nil + end + + def possible? + websocket + end + + def alive? + websocket && websocket.ready_state == Faye::WebSocket::API::OPEN + end + + def transmit(data) + websocket.send data + end + + private + attr_reader :websocket + end + end +end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 3a16f51757..dbfadaa34c 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -17,7 +17,7 @@ module ActionCable end def call(env) - @connection_class.new(self, env).process + @connection_class.new(self, env).response end def worker_pool -- cgit v1.2.3 From 3c333f1a22c1b4f0ae42161df1ce9b4c4730999d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:54:08 +0200 Subject: Change back, more is happening than just response --- lib/action_cable/connection/base.rb | 2 +- lib/action_cable/server.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index efabe40b73..e5d63abe5b 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -22,7 +22,7 @@ module ActionCable @started_at = Time.now end - def response + def process logger.info started_request_message if websocket.possible? diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index dbfadaa34c..3a16f51757 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -17,7 +17,7 @@ module ActionCable end def call(env) - @connection_class.new(self, env).response + @connection_class.new(self, env).process end def worker_pool -- cgit v1.2.3 From 98c1ce0aec6d996836a1c38dc8ebd1caeb49240d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:54:20 +0200 Subject: Composed method on the response --- lib/action_cable/connection/base.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index e5d63abe5b..2da1b74c76 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -30,7 +30,7 @@ module ActionCable websocket.on(:message) { |event| on_message event.data } websocket.on(:close) { |event| send_async :on_close } - websocket.rack_response + respond_to_successful_request else respond_to_invalid_request end @@ -104,6 +104,10 @@ module ActionCable end + def respond_to_successful_request + websocket.rack_response + end + def respond_to_invalid_request logger.info finished_request_message [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] -- cgit v1.2.3 From 1e4b1ca1bc9769c50f5b3716678bc580562333e1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:54:58 +0200 Subject: initialize -> new --- lib/action_cable/connection/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2da1b74c76..69c0db9167 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -12,7 +12,7 @@ module ActionCable def initialize(server, env) @server, @env = server, env - @logger = initialize_tagged_logger + @logger = new_tagged_logger @websocket = ActionCable::Connection::WebSocket.new(env) @heartbeat = ActionCable::Connection::Heartbeat.new(self) @@ -115,7 +115,7 @@ module ActionCable # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. - def initialize_tagged_logger + def new_tagged_logger TaggedLoggerProxy.new server.logger, tags: server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end -- cgit v1.2.3 From 16849a7e68ed7bd50e47bb429a30d6dcedf1979b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 27 Jun 2015 16:57:32 +0200 Subject: Use accessor --- lib/action_cable/connection/internal_channel.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index 885457d8c2..70e5e58373 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -31,7 +31,7 @@ module ActionCable case message['type'] when 'disconnect' logger.info "Removing connection (#{connection_identifier})" - @websocket.close + websocket.close end rescue Exception => e logger.error "There was an exception - #{e.class}(#{e.message})" -- cgit v1.2.3 From d2c613cd8f422b9cf2dd8a765681066d6045036a Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Sat, 27 Jun 2015 13:13:44 -0400 Subject: Wait for connection to close before reopening it --- lib/assets/javascripts/cable/connection.js.coffee | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index cd9539a6aa..98af9ad8ab 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -12,20 +12,23 @@ class Cable.Connection else false - open: -> + open: => @websocket = new WebSocket(@consumer.url) @websocket.onmessage = @onMessage @websocket.onopen = @onOpen @websocket.onclose = @onClose @websocket.onerror = @onError - @websocket close: -> @websocket.close() unless @isClosed() reopen: -> - @close() - @open() + if @isClosed() + @open() + else + @websocket.onclose = @open + @websocket.onerror = @open + @websocket.close() isOpen: -> @websocket.readyState is WebSocket.OPEN -- cgit v1.2.3 From 336d12f97aba485809005f27d4952705e312251c Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Sat, 27 Jun 2015 16:17:00 -0400 Subject: Rework connection monitor --- lib/assets/javascripts/cable/connection.js.coffee | 3 -- .../javascripts/cable/connection_monitor.js.coffee | 62 +++++++++++++++------- lib/assets/javascripts/cable/consumer.js.coffee | 2 + .../javascripts/cable/subscriber_manager.js.coffee | 6 ++- 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 98af9ad8ab..4f7d2abada 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -1,8 +1,5 @@ -#= require cable/connection_monitor - class Cable.Connection constructor: (@consumer) -> - new Cable.ConnectionMonitor @consumer @open() send: (data) -> diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index bb4ee8f7f6..fc5093c5eb 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -1,13 +1,17 @@ class Cable.ConnectionMonitor - MAX_CONNECTION_INTERVAL: 5 * 1000 - PING_STALE_INTERVAL: 8 * 1000 - identifier: Cable.PING_IDENTIFIER + pollInterval: + min: 2 + max: 30 + + staleThreshold: + startedAt: 4 + pingedAt: 8 + constructor: (@consumer) -> - @reset() @consumer.subscribers.add(this) - @pollConnection() + @start() connected: -> @reset() @@ -17,25 +21,45 @@ class Cable.ConnectionMonitor @pingedAt = now() reset: -> - @connectionAttempts = 1 + @reconnectAttempts = 0 + + start: -> + @reset() + delete @stoppedAt + @startedAt = now() + @poll() - pollConnection: -> + stop: -> + @stoppedAt = now() + + poll: -> setTimeout => - @reconnect() if @connectionIsStale() - @pollConnection() - , @getPollTimeout() + unless @stoppedAt + @reconnectIfStale() + @poll() + , @getInterval() - getPollTimeout: -> - interval = (Math.pow(2, @connectionAttempts) - 1) * 1000 - if interval > @MAX_CONNECTION_INTERVAL then @MAX_CONNECTION_INTERVAL else interval + getInterval: -> + {min, max} = @pollInterval + interval = 4 * Math.log(@reconnectAttempts + 1) + clamp(interval, min, max) * 1000 - connectionIsStale: -> - @pingedAt? and (now() - @pingedAt) > @PING_STALE_INTERVAL + reconnectIfStale: -> + if @connectionIsStale() + @reconnectAttempts += 1 + @consumer.connection.reopen() - reconnect: -> - console.log "Ping took too long to arrive. Reconnecting.." - @connectionAttempts += 1 - @consumer.connection.reopen() + connectionIsStale: -> + if @pingedAt + secondsSince(@pingedAt) > @staleThreshold.pingedAt + else + secondsSince(@startedAt) > @staleThreshold.startedAt now = -> new Date().getTime() + + secondsSince = (time) -> + (now() - time) / 1000 + + clamp = (number, min, max) -> + Math.max(min, Math.min(max, number)) diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee index a9abd6256a..b9c08807f2 100644 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -1,4 +1,5 @@ #= require cable/connection +#= require cable/connection_monitor #= require cable/subscription #= require cable/subscriber_manager @@ -6,6 +7,7 @@ class Cable.Consumer constructor: (@url) -> @subscribers = new Cable.SubscriberManager this @connection = new Cable.Connection this + @connectionMonitor = new Cable.ConnectionMonitor this createSubscription: (channelName, mixin) -> channel = channelName diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index 922c74808c..0b6a16590c 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -32,5 +32,7 @@ class Cable.SubscriberManager sendCommand: (subscriber, command) -> {identifier} = subscriber - return true if identifier is Cable.PING_IDENTIFIER - @consumer.send({command, identifier}) + if identifier is Cable.PING_IDENTIFIER + @consumer.connection.isOpen() + else + @consumer.send({command, identifier}) -- cgit v1.2.3 From f61467ec5b90ebb75987a13f763b6a19548d84b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 20:16:54 +0200 Subject: Move server classes to its own namespace --- lib/action_cable.rb | 4 +- lib/action_cable/server.rb | 75 ++------------------------------------ lib/action_cable/server/base.rb | 77 +++++++++++++++++++++++++++++++++++++++ lib/action_cable/server/worker.rb | 32 ++++++++++++++++ lib/action_cable/worker.rb | 30 --------------- test/channel_test.rb | 2 +- test/server_test.rb | 2 +- 7 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 lib/action_cable/server/base.rb create mode 100644 lib/action_cable/server/worker.rb delete mode 100644 lib/action_cable/worker.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 26b3980deb..aaf48efa4b 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -19,10 +19,10 @@ require 'action_cable/engine' if defined?(Rails) module ActionCable VERSION = '0.0.3' - autoload :Channel, 'action_cable/channel' - autoload :Worker, 'action_cable/worker' autoload :Server, 'action_cable/server' autoload :Connection, 'action_cable/connection' + autoload :Channel, 'action_cable/channel' + autoload :RemoteConnection, 'action_cable/remote_connection' autoload :RemoteConnections, 'action_cable/remote_connections' autoload :Broadcaster, 'action_cable/broadcaster' diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 3a16f51757..e17cf872e0 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,75 +1,6 @@ module ActionCable - class Server - cattr_accessor(:logger, instance_reader: true) { Rails.logger } - - attr_accessor :registered_channels, :redis_config, :log_tags - - def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection, log_tags: [ 'ActionCable' ]) - @redis_config = redis_config.with_indifferent_access - @registered_channels = Set.new(channels) - @worker_pool_size = worker_pool_size - @connection_class = connection - @log_tags = log_tags - - @connections = [] - - logger.info "[ActionCable] Initialized server (redis_config: #{@redis_config.inspect}, worker_pool_size: #{@worker_pool_size})" - end - - def call(env) - @connection_class.new(self, env).process - end - - def worker_pool - @worker_pool ||= ActionCable::Worker.pool(size: @worker_pool_size) - end - - def pubsub - @pubsub ||= redis.pubsub - end - - def redis - @redis ||= begin - redis = EM::Hiredis.connect(@redis_config[:url]) - redis.on(:reconnect_failed) do - logger.info "[ActionCable] Redis reconnect failed." - # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." - # @connections.map &:close - end - redis - end - end - - def threaded_redis - @threaded_redis ||= Redis.new(redis_config) - end - - def remote_connections - @remote_connections ||= RemoteConnections.new(self) - end - - def broadcaster_for(channel) - Broadcaster.new(self, channel) - end - - def broadcast(channel, message) - broadcaster_for(channel).broadcast(message) - end - - def connection_identifiers - @connection_class.identifiers - end - - def add_connection(connection) - @connections << connection - end - - def remove_connection(connection) - @connections.delete connection - end - - def open_connections_statistics - @connections.map(&:statistics) - end + module Server + autoload :Base, 'action_cable/server/base' + autoload :Worker, 'action_cable/server/worker' end end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb new file mode 100644 index 0000000000..6abec92dc1 --- /dev/null +++ b/lib/action_cable/server/base.rb @@ -0,0 +1,77 @@ +module ActionCable + module Server + class Base + cattr_accessor(:logger, instance_reader: true) { Rails.logger } + + attr_accessor :registered_channels, :redis_config, :log_tags + + def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection, log_tags: [ 'ActionCable' ]) + @redis_config = redis_config.with_indifferent_access + @registered_channels = Set.new(channels) + @worker_pool_size = worker_pool_size + @connection_class = connection + @log_tags = log_tags + + @connections = [] + + logger.info "[ActionCable] Initialized server (redis_config: #{@redis_config.inspect}, worker_pool_size: #{@worker_pool_size})" + end + + def call(env) + @connection_class.new(self, env).process + end + + def worker_pool + @worker_pool ||= ActionCable::Server::Worker.pool(size: @worker_pool_size) + end + + def pubsub + @pubsub ||= redis.pubsub + end + + def redis + @redis ||= begin + redis = EM::Hiredis.connect(@redis_config[:url]) + redis.on(:reconnect_failed) do + logger.info "[ActionCable] Redis reconnect failed." + # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." + # @connections.map &:close + end + redis + end + end + + def threaded_redis + @threaded_redis ||= Redis.new(redis_config) + end + + def remote_connections + @remote_connections ||= RemoteConnections.new(self) + end + + def broadcaster_for(channel) + Broadcaster.new(self, channel) + end + + def broadcast(channel, message) + broadcaster_for(channel).broadcast(message) + end + + def connection_identifiers + @connection_class.identifiers + end + + def add_connection(connection) + @connections << connection + end + + def remove_connection(connection) + @connections.delete connection + end + + def open_connections_statistics + @connections.map(&:statistics) + end + end + end +end \ No newline at end of file diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb new file mode 100644 index 0000000000..0491cb9ab0 --- /dev/null +++ b/lib/action_cable/server/worker.rb @@ -0,0 +1,32 @@ +module ActionCable + module Server + class Worker + include ActiveSupport::Callbacks + include Celluloid + + define_callbacks :work + + def invoke(receiver, method, *args) + run_callbacks :work do + receiver.send method, *args + end + 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 + + def run_periodic_timer(channel, callback) + run_callbacks :work do + callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback) + end + end + + private + def logger + ActionCable::Server::Base.logger + end + end + end +end \ No newline at end of file diff --git a/lib/action_cable/worker.rb b/lib/action_cable/worker.rb deleted file mode 100644 index 6800a75d1d..0000000000 --- a/lib/action_cable/worker.rb +++ /dev/null @@ -1,30 +0,0 @@ -module ActionCable - class Worker - include ActiveSupport::Callbacks - include Celluloid - - define_callbacks :work - - def invoke(receiver, method, *args) - run_callbacks :work do - receiver.send method, *args - end - 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 - - def run_periodic_timer(channel, callback) - run_callbacks :work do - callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback) - end - end - - private - def logger - ActionCable::Server.logger - end - end -end diff --git a/test/channel_test.rb b/test/channel_test.rb index ad5fa04356..96987977ea 100644 --- a/test/channel_test.rb +++ b/test/channel_test.rb @@ -8,7 +8,7 @@ class ChannelTest < ActionCableTest end end - class PingServer < ActionCable::Server + class PingServer < ActionCable::Server::Base register_channels PingChannel end diff --git a/test/server_test.rb b/test/server_test.rb index 824875bb99..1e02497f61 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -8,7 +8,7 @@ class ServerTest < ActionCableTest end end - class ChatServer < ActionCable::Server + class ChatServer < ActionCable::Server::Base register_channels ChatChannel end -- cgit v1.2.3 From e1a99a83ca135523ff8513be756f156500999cb8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 20:24:50 +0200 Subject: Make broadcasting a concern --- lib/action_cable/broadcaster.rb | 17 ----------------- lib/action_cable/server.rb | 1 + lib/action_cable/server/base.rb | 10 ++-------- lib/action_cable/server/broadcasting.rb | 28 ++++++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 25 deletions(-) delete mode 100644 lib/action_cable/broadcaster.rb create mode 100644 lib/action_cable/server/broadcasting.rb diff --git a/lib/action_cable/broadcaster.rb b/lib/action_cable/broadcaster.rb deleted file mode 100644 index 7d8cc90970..0000000000 --- a/lib/action_cable/broadcaster.rb +++ /dev/null @@ -1,17 +0,0 @@ -module ActionCable - class Broadcaster - attr_reader :server, :channel, :redis - delegate :logger, to: :server - - def initialize(server, channel) - @server = server - @channel = channel - @redis = @server.threaded_redis - end - - def broadcast(message) - logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" - redis.publish channel, message.to_json - end - end -end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index e17cf872e0..fa7bad4e32 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,6 +1,7 @@ module ActionCable module Server autoload :Base, 'action_cable/server/base' + autoload :Broadcasting, 'action_cable/server/broadcasting' autoload :Worker, 'action_cable/server/worker' end end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 6abec92dc1..6dcd282e4a 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -1,6 +1,8 @@ module ActionCable module Server class Base + include ActionCable::Server::Broadcasting + cattr_accessor(:logger, instance_reader: true) { Rails.logger } attr_accessor :registered_channels, :redis_config, :log_tags @@ -49,14 +51,6 @@ module ActionCable @remote_connections ||= RemoteConnections.new(self) end - def broadcaster_for(channel) - Broadcaster.new(self, channel) - end - - def broadcast(channel, message) - broadcaster_for(channel).broadcast(message) - end - def connection_identifiers @connection_class.identifiers end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb new file mode 100644 index 0000000000..682064571f --- /dev/null +++ b/lib/action_cable/server/broadcasting.rb @@ -0,0 +1,28 @@ +module ActionCable + module Server + module Broadcasting + def broadcaster_for(channel) + Broadcaster.new(self, channel) + end + + def broadcast(channel, message) + broadcaster_for(channel).broadcast(message) + end + + class Broadcaster + attr_reader :server, :channel, :redis + delegate :logger, to: :server + + def initialize(server, channel) + @server, @channel = server, channel + @redis = @server.threaded_redis + end + + def broadcast(message) + logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" + redis.publish channel, message.to_json + end + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From 8a2af53c8e83cd9258380fad4007e53f8721aa93 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 20:36:10 +0200 Subject: More redis used for broadcasting into broadcasting concern --- lib/action_cable/server/base.rb | 4 ---- lib/action_cable/server/broadcasting.rb | 32 ++++++++++++++++++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 6dcd282e4a..e8109b325d 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -43,10 +43,6 @@ module ActionCable end end - def threaded_redis - @threaded_redis ||= Redis.new(redis_config) - end - def remote_connections @remote_connections ||= RemoteConnections.new(self) end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 682064571f..691ec1b486 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -1,28 +1,32 @@ module ActionCable module Server module Broadcasting + def broadcast(channel, message) + broadcaster_for(channel).broadcast(message) + end + def broadcaster_for(channel) Broadcaster.new(self, channel) end - def broadcast(channel, message) - broadcaster_for(channel).broadcast(message) - end + private + def redis_for_threads + @redis_for_threads ||= Redis.new(redis_config) + end - class Broadcaster - attr_reader :server, :channel, :redis - delegate :logger, to: :server + class Broadcaster + def initialize(server, channel) + @server, @channel = server, channel + end - def initialize(server, channel) - @server, @channel = server, channel - @redis = @server.threaded_redis - end + def broadcast(message, log: true) + server.logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" if log + server.redis_for_threads.publish channel, message.to_json + end - def broadcast(message) - logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" - redis.publish channel, message.to_json + private + attr_reader :server, :channel end - end end end end \ No newline at end of file -- cgit v1.2.3 From a5d6bc0eb527f6cfa61300e70fa9010544240cf9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 20:38:05 +0200 Subject: Make the remote connection use the broadcaster as well --- lib/action_cable/remote_connection.rb | 10 ++++------ lib/action_cable/server/broadcasting.rb | 8 ++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb index b6fdf226e3..d7a3f0125d 100644 --- a/lib/action_cable/remote_connection.rb +++ b/lib/action_cable/remote_connection.rb @@ -10,18 +10,16 @@ module ActionCable end def disconnect - redis.publish internal_redis_channel, { type: 'disconnect' }.to_json + server.broadcast_without_logging internal_redis_channel, type: 'disconnect' end def identifiers - @server.connection_identifiers - end - - def redis - @server.threaded_redis + server.connection_identifiers end private + attr_reader :server + def set_identifier_instance_vars(ids) raise InvalidIdentifiersError unless valid_identifiers?(ids) ids.each { |k,v| instance_variable_set("@#{k}", v) } diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 691ec1b486..0d591d03e4 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -19,8 +19,12 @@ module ActionCable @server, @channel = server, channel end - def broadcast(message, log: true) - server.logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" if log + def broadcast(message) + server.logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" + broadcast_without_logging(message) + end + + def broadcast_without_logging(message) server.redis_for_threads.publish channel, message.to_json end -- cgit v1.2.3 From 3e693e19c4ed3ad3fdb861d0c0d4c3abe118479c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 20:42:36 +0200 Subject: Fix reference --- lib/action_cable/connection/subscriptions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index ae191c7795..2672279828 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -61,7 +61,7 @@ module ActionCable if subscription = subscriptions[data['identifier']] subscription else - raise "Unable to find subscription with identifier: #{identifier}" + raise "Unable to find subscription with identifier: #{data['identifier']}" end end end -- cgit v1.2.3 From c2e2a94306e6b77b0a1dce9b453fbaa04a7f7446 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 20:42:49 +0200 Subject: Rejig for what's used --- lib/action_cable/server/broadcasting.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 0d591d03e4..3fbaa05039 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -9,12 +9,14 @@ module ActionCable Broadcaster.new(self, channel) end - private - def redis_for_threads - @redis_for_threads ||= Redis.new(redis_config) - end + def broadcasting_redis + @broadcasting_redis ||= Redis.new(redis_config) + end + private class Broadcaster + attr_reader :server, :channel + def initialize(server, channel) @server, @channel = server, channel end @@ -25,11 +27,8 @@ module ActionCable end def broadcast_without_logging(message) - server.redis_for_threads.publish channel, message.to_json + server.broadcasting_redis.publish channel, message.to_json end - - private - attr_reader :server, :channel end end end -- cgit v1.2.3 From 5c4f07d34e82310e2ce9029ddaafb6603435da73 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 28 Jun 2015 21:17:16 +0200 Subject: Introduce Streams as the domain language for the pubsub channels Channels redeliver messages from --- lib/action_cable/channel.rb | 2 +- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/channel/redis.rb | 37 ------------------------------ lib/action_cable/channel/streams.rb | 40 +++++++++++++++++++++++++++++++++ lib/action_cable/server/broadcasting.rb | 18 +++++++-------- 5 files changed, 51 insertions(+), 48 deletions(-) delete mode 100644 lib/action_cable/channel/redis.rb create mode 100644 lib/action_cable/channel/streams.rb diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb index 94cdc8d722..0432052514 100644 --- a/lib/action_cable/channel.rb +++ b/lib/action_cable/channel.rb @@ -1,7 +1,7 @@ module ActionCable module Channel autoload :Callbacks, 'action_cable/channel/callbacks' - autoload :Redis, 'action_cable/channel/redis' + autoload :Streams, 'action_cable/channel/streams' autoload :Base, 'action_cable/channel/base' end end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 6c55a8ed65..39a5a7e795 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -2,7 +2,7 @@ module ActionCable module Channel class Base include Callbacks - include Redis + include Streams on_subscribe :start_periodic_timers on_unsubscribe :stop_periodic_timers diff --git a/lib/action_cable/channel/redis.rb b/lib/action_cable/channel/redis.rb deleted file mode 100644 index 0f77dc0418..0000000000 --- a/lib/action_cable/channel/redis.rb +++ /dev/null @@ -1,37 +0,0 @@ -module ActionCable - module Channel - module Redis - extend ActiveSupport::Concern - - included do - on_unsubscribe :unsubscribe_from_all_channels - delegate :pubsub, to: :connection - end - - def subscribe_to(redis_channel, callback = nil) - callback ||= default_subscription_callback(redis_channel) - @_redis_channels ||= [] - @_redis_channels << [ redis_channel, callback ] - - pubsub.subscribe(redis_channel, &callback) - logger.info "#{channel_name} subscribed to broadcasts from #{redis_channel}" - end - - def unsubscribe_from_all_channels - if @_redis_channels - @_redis_channels.each do |redis_channel, callback| - pubsub.unsubscribe_proc(redis_channel, callback) - logger.info "#{channel_name} unsubscribed to broadcasts from #{redis_channel}" - end - end - end - - protected - def default_subscription_callback(channel) - -> (message) do - transmit ActiveSupport::JSON.decode(message), via: "broadcast from #{channel}" - end - 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 0000000000..3eac776e61 --- /dev/null +++ b/lib/action_cable/channel/streams.rb @@ -0,0 +1,40 @@ +module ActionCable + module Channel + module Streams + extend ActiveSupport::Concern + + included do + on_unsubscribe :stop_all_streams + end + + def stream_from(broadcasting, callback = nil) + callback ||= default_stream_callback(broadcasting) + + streams << [ broadcasting, callback ] + pubsub.subscribe broadcasting, &callback + + logger.info "#{channel_name} is streaming from #{broadcasting}" + end + + def stop_all_streams + streams.each do |broadcasting, callback| + pubsub.unsubscribe_proc broadcasting, callback + logger.info "#{channel_name} stopped streaming from #{broadcasting}" + end + end + + private + delegate :pubsub, to: :connection + + def streams + @_streams ||= [] + end + + def default_stream_callback(broadcasting) + -> (message) do + transmit ActiveSupport::JSON.decode(message), via: "streamed from #{broadcasting}" + end + end + end + end +end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 3fbaa05039..868d418ece 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -1,12 +1,12 @@ module ActionCable module Server module Broadcasting - def broadcast(channel, message) - broadcaster_for(channel).broadcast(message) + def broadcast(broadcasting, message) + broadcaster_for(broadcasting).broadcast(message) end - def broadcaster_for(channel) - Broadcaster.new(self, channel) + def broadcaster_for(broadcasting) + Broadcaster.new(self, broadcasting) end def broadcasting_redis @@ -15,19 +15,19 @@ module ActionCable private class Broadcaster - attr_reader :server, :channel + attr_reader :server, :broadcasting - def initialize(server, channel) - @server, @channel = server, channel + def initialize(server, broadcasting) + @server, @broadcasting = server, broadcasting end def broadcast(message) - server.logger.info "[ActionCable] Broadcasting to #{channel}: #{message}" + server.logger.info "[ActionCable] Broadcasting to #{broadcasting}: #{message}" broadcast_without_logging(message) end def broadcast_without_logging(message) - server.broadcasting_redis.publish channel, message.to_json + server.broadcasting_redis.publish broadcasting, message.to_json end end end -- cgit v1.2.3 From 10323716a134bb86708f6a65280215f8a7f18a1a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 2 Jul 2015 15:41:50 +0200 Subject: Expose broadcast_without_logging at the top level --- lib/action_cable/server/broadcasting.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 868d418ece..b0e51b8ba8 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -5,6 +5,10 @@ module ActionCable broadcaster_for(broadcasting).broadcast(message) end + def broadcast_without_logging(broadcasting, message) + broadcaster_for(broadcasting).broadcast_without_logging(message) + end + def broadcaster_for(broadcasting) Broadcaster.new(self, broadcasting) end -- cgit v1.2.3 From 5de01033150b70982f23a42670c55348a7371c4b Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 2 Jul 2015 11:52:23 -0400 Subject: Guard against duplicate subscriptions --- lib/action_cable/connection/subscriptions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 2672279828..24ab1bdfbf 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -27,7 +27,7 @@ module ActionCable end if subscription_klass - subscriptions[id_key] = subscription_klass.new(connection, id_key, id_options) + subscriptions[id_key] ||= subscription_klass.new(connection, id_key, id_options) else logger.error "Subscription class not found (#{data.inspect})" end -- cgit v1.2.3 From 417ff2a3e081cb92f4460e1a246433761dd4d964 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 5 Jul 2015 16:22:53 +0200 Subject: No need for this no-logging broadcast --- lib/action_cable/remote_connection.rb | 2 +- lib/action_cable/server/broadcasting.rb | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb index d7a3f0125d..e2e2786dc1 100644 --- a/lib/action_cable/remote_connection.rb +++ b/lib/action_cable/remote_connection.rb @@ -10,7 +10,7 @@ module ActionCable end def disconnect - server.broadcast_without_logging internal_redis_channel, type: 'disconnect' + server.broadcast internal_redis_channel, type: 'disconnect' end def identifiers diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index b0e51b8ba8..6376e88de0 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -5,10 +5,6 @@ module ActionCable broadcaster_for(broadcasting).broadcast(message) end - def broadcast_without_logging(broadcasting, message) - broadcaster_for(broadcasting).broadcast_without_logging(message) - end - def broadcaster_for(broadcasting) Broadcaster.new(self, broadcasting) end @@ -27,10 +23,6 @@ module ActionCable def broadcast(message) server.logger.info "[ActionCable] Broadcasting to #{broadcasting}: #{message}" - broadcast_without_logging(message) - end - - def broadcast_without_logging(message) server.broadcasting_redis.publish broadcasting, message.to_json end end -- cgit v1.2.3 From 44e7cc324df1189531b60de1c6353289c8205a97 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 5 Jul 2015 22:33:14 +0200 Subject: Extract connections methods into a separate concern --- lib/action_cable/server.rb | 1 + lib/action_cable/server/base.rb | 11 +---------- lib/action_cable/server/connections.rb | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 lib/action_cable/server/connections.rb diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index fa7bad4e32..a3d6ce6c31 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -2,6 +2,7 @@ module ActionCable module Server autoload :Base, 'action_cable/server/base' autoload :Broadcasting, 'action_cable/server/broadcasting' + autoload :Connections, 'action_cable/server/connections' autoload :Worker, 'action_cable/server/worker' end end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index e8109b325d..fd3e5e4020 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -2,6 +2,7 @@ module ActionCable module Server class Base include ActionCable::Server::Broadcasting + include ActionCable::Server::Connections cattr_accessor(:logger, instance_reader: true) { Rails.logger } @@ -51,16 +52,6 @@ module ActionCable @connection_class.identifiers end - def add_connection(connection) - @connections << connection - end - - def remove_connection(connection) - @connections.delete connection - end - - def open_connections_statistics - @connections.map(&:statistics) end end end diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb new file mode 100644 index 0000000000..4a3fa3c621 --- /dev/null +++ b/lib/action_cable/server/connections.rb @@ -0,0 +1,21 @@ +module ActionCable + module Server + module Connections + def connections + @connections ||= [] + end + + def add_connection(connection) + connections << connection + end + + def remove_connection(connection) + connections.delete connection + end + + def open_connections_statistics + connections.map(&:statistics) + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From b8b50e6b043a5b1900922629edf250ccb6006085 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 5 Jul 2015 22:34:23 +0200 Subject: Extract Server configuration into a Configuration object --- lib/action_cable/connection/base.rb | 2 +- lib/action_cable/connection/subscriptions.rb | 4 +-- lib/action_cable/server.rb | 1 + lib/action_cable/server/base.rb | 47 ++++++++++++------------- lib/action_cable/server/broadcasting.rb | 2 +- lib/action_cable/server/configuration.rb | 51 ++++++++++++++++++++++++++++ test/server_test.rb | 2 +- 7 files changed, 78 insertions(+), 31 deletions(-) create mode 100644 lib/action_cable/server/configuration.rb diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 69c0db9167..09bbc73e2d 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -117,7 +117,7 @@ module ActionCable # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. def new_tagged_logger TaggedLoggerProxy.new server.logger, - tags: server.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end def started_request_message diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 24ab1bdfbf..800474eee5 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -22,8 +22,8 @@ module ActionCable id_key = data['identifier'] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - subscription_klass = connection.server.registered_channels.detect do |channel_klass| - channel_klass == id_options[:channel].safe_constantize + subscription_klass = connection.server.channel_classes.detect do |channel_class| + channel_class == id_options[:channel].safe_constantize end if subscription_klass diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index a3d6ce6c31..e7cc70b68d 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -3,6 +3,7 @@ module ActionCable autoload :Base, 'action_cable/server/base' autoload :Broadcasting, 'action_cable/server/broadcasting' autoload :Connections, 'action_cable/server/connections' + autoload :Configuration, 'action_cable/server/configuration' autoload :Worker, 'action_cable/server/worker' end end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index fd3e5e4020..3d1d9e71ff 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -4,28 +4,31 @@ module ActionCable include ActionCable::Server::Broadcasting include ActionCable::Server::Connections - cattr_accessor(:logger, instance_reader: true) { Rails.logger } + cattr_accessor(:config, instance_accessor: true) { ActionCable::Server::Configuration.new } + + def self.logger; config.logger; end + delegate :logger, to: :config - attr_accessor :registered_channels, :redis_config, :log_tags - - def initialize(redis_config:, channels:, worker_pool_size: 100, connection: Connection, log_tags: [ 'ActionCable' ]) - @redis_config = redis_config.with_indifferent_access - @registered_channels = Set.new(channels) - @worker_pool_size = worker_pool_size - @connection_class = connection - @log_tags = log_tags - - @connections = [] - - logger.info "[ActionCable] Initialized server (redis_config: #{@redis_config.inspect}, worker_pool_size: #{@worker_pool_size})" + def initialize end def call(env) - @connection_class.new(self, env).process + config.connection_class.new(self, env).process end def worker_pool - @worker_pool ||= ActionCable::Server::Worker.pool(size: @worker_pool_size) + @worker_pool ||= ActionCable::Server::Worker.pool(size: config.worker_pool_size) + end + + def channel_classes + @channel_classes ||= begin + config.channel_paths.each { |channel_path| require channel_path } + config.channel_class_names.collect { |name| name.constantize } + end + end + + def remote_connections + @remote_connections ||= RemoteConnections.new(self) end def pubsub @@ -33,25 +36,17 @@ module ActionCable end def redis - @redis ||= begin - redis = EM::Hiredis.connect(@redis_config[:url]) + @redis ||= EM::Hiredis.connect(config.redis[:url]).tap do |redis| redis.on(:reconnect_failed) do logger.info "[ActionCable] Redis reconnect failed." # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." # @connections.map &:close - end - redis + end end end - def remote_connections - @remote_connections ||= RemoteConnections.new(self) - end - def connection_identifiers - @connection_class.identifiers - end - + config.connection_class.identifiers end end end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 6376e88de0..105ccb2b5c 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -10,7 +10,7 @@ module ActionCable end def broadcasting_redis - @broadcasting_redis ||= Redis.new(redis_config) + @broadcasting_redis ||= Redis.new(config.redis) end private diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb new file mode 100644 index 0000000000..c5783f30e0 --- /dev/null +++ b/lib/action_cable/server/configuration.rb @@ -0,0 +1,51 @@ +module ActionCable + module Server + class Configuration + attr_accessor :logger, :log_tags + attr_accessor :connection_class, :worker_pool_size + attr_accessor :redis_path, :channels_path + + def initialize + @logger = Rails.logger + @log_tags = [] + + @connection_class = ApplicationCable::Connection + @worker_pool_size = 100 + + @redis_path = Rails.root.join('config/redis/cable.yml') + @channels_path = Rails.root.join('app/channels') + end + + def channel_paths + @channels ||= Dir["#{channels_path}/**/*_channel.rb"] + end + + def channel_class_names + @channel_class_names ||= channel_paths.collect do |channel_path| + Pathname.new(channel_path).basename.to_s.split('.').first.camelize + end + end + + def redis + @redis ||= config_for(redis_path).with_indifferent_access + end + + private + # FIXME: Extract this from Rails::Application in a way it can be used here. + def config_for(path) + if path.exist? + require "yaml" + require "erb" + (YAML.load(ERB.new(path.read).result) || {})[Rails.env] || {} + else + raise "Could not load configuration. No such file - #{path}" + end + rescue Psych::SyntaxError => e + raise "YAML syntax error occurred while parsing #{path}. " \ + "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \ + "Error: #{e.message}" + end + end + end +end + diff --git a/test/server_test.rb b/test/server_test.rb index 1e02497f61..636fa37cf7 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -17,7 +17,7 @@ class ServerTest < ActionCableTest end test "channel registration" do - assert_equal ChatServer.registered_channels, Set.new([ ChatChannel ]) + assert_equal ChatServer.channel_classes, Set.new([ ChatChannel ]) end test "subscribing to a channel with valid params" do -- cgit v1.2.3 From c811bed8e1fd67869452acb4818c3264e82d627c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 5 Jul 2015 22:34:34 +0200 Subject: Add ActionCable.server singleton --- lib/action_cable.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index aaf48efa4b..1fd95f76fa 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -26,4 +26,9 @@ module ActionCable autoload :RemoteConnection, 'action_cable/remote_connection' autoload :RemoteConnections, 'action_cable/remote_connections' autoload :Broadcaster, 'action_cable/broadcaster' + + # Singleton instance of the server + module_function def server + ActionCable::Server::Base.new + end end -- cgit v1.2.3 From f2542cd417e8ef753988c073a90a46bade5ef455 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Mon, 6 Jul 2015 21:42:49 -0400 Subject: Guard against opening multiple WebSocket connections --- lib/assets/javascripts/cable/connection.js.coffee | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 4f7d2abada..530a589e87 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -10,6 +10,7 @@ class Cable.Connection false open: => + return if @isState("open", "connecting") @websocket = new WebSocket(@consumer.url) @websocket.onmessage = @onMessage @websocket.onopen = @onOpen @@ -17,21 +18,26 @@ class Cable.Connection @websocket.onerror = @onError close: -> - @websocket.close() unless @isClosed() + return if @isState("closed", "closing") + @websocket?.close() reopen: -> - if @isClosed() - @open() - else + if @isOpen() @websocket.onclose = @open @websocket.onerror = @open @websocket.close() + else + @open() isOpen: -> - @websocket.readyState is WebSocket.OPEN + @isState("open") + + isState: (states...) -> + @getState() in states - isClosed: -> - @websocket.readyState in [ WebSocket.CLOSED, WebSocket.CLOSING ] + getState: -> + return state.toLowerCase() for state, value of WebSocket when value is @websocket?.readyState + null onMessage: (message) => data = JSON.parse message.data -- cgit v1.2.3 From 5da432c45b7b5ea5f99296ab83fb42f1a580600d Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Mon, 6 Jul 2015 21:43:50 -0400 Subject: websocket -> webSocket --- lib/assets/javascripts/cable/connection.js.coffee | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 530a589e87..bb3bc5dd12 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -4,28 +4,28 @@ class Cable.Connection send: (data) -> if @isOpen() - @websocket.send(JSON.stringify(data)) + @webSocket.send(JSON.stringify(data)) true else false open: => return if @isState("open", "connecting") - @websocket = new WebSocket(@consumer.url) - @websocket.onmessage = @onMessage - @websocket.onopen = @onOpen - @websocket.onclose = @onClose - @websocket.onerror = @onError + @webSocket = new WebSocket(@consumer.url) + @webSocket.onmessage = @onMessage + @webSocket.onopen = @onOpen + @webSocket.onclose = @onClose + @webSocket.onerror = @onError close: -> return if @isState("closed", "closing") - @websocket?.close() + @webSocket?.close() reopen: -> if @isOpen() - @websocket.onclose = @open - @websocket.onerror = @open - @websocket.close() + @webSocket.onclose = @open + @webSocket.onerror = @open + @webSocket.close() else @open() @@ -36,7 +36,7 @@ class Cable.Connection @getState() in states getState: -> - return state.toLowerCase() for state, value of WebSocket when value is @websocket?.readyState + return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState null onMessage: (message) => @@ -51,8 +51,8 @@ class Cable.Connection onError: => @disconnect() - @websocket.onclose = -> # no-op - @websocket.onerror = -> # no-op + @webSocket.onclose = -> # no-op + @webSocket.onerror = -> # no-op try @close() disconnect: -> -- cgit v1.2.3 From ee6b6cabfbd7b0a8f48cc6fae1bcd3e65300495c Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Tue, 7 Jul 2015 08:57:57 -0400 Subject: Rework event handlers --- lib/assets/javascripts/cable/connection.js.coffee | 58 ++++++++++++++--------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index bb3bc5dd12..4d2c1018aa 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -9,13 +9,10 @@ class Cable.Connection else false - open: => + open: -> return if @isState("open", "connecting") @webSocket = new WebSocket(@consumer.url) - @webSocket.onmessage = @onMessage - @webSocket.onopen = @onOpen - @webSocket.onclose = @onClose - @webSocket.onerror = @onError + @installEventHandlers() close: -> return if @isState("closed", "closing") @@ -23,15 +20,15 @@ class Cable.Connection reopen: -> if @isOpen() - @webSocket.onclose = @open - @webSocket.onerror = @open - @webSocket.close() + @closeSilently => @open() else @open() isOpen: -> @isState("open") + # Private + isState: (states...) -> @getState() in states @@ -39,21 +36,38 @@ class Cable.Connection return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState null - onMessage: (message) => - data = JSON.parse message.data - @consumer.subscribers.notify(data.identifier, "received", data.message) + closeSilently: (callback = ->) -> + @uninstallEventHandlers() + @installEventHandler("close", callback) + @installEventHandler("error", callback) + try + @webSocket.close() + finally + @uninstallEventHandlers() + + installEventHandlers: -> + for eventName of @events + @installEventHandler(eventName) + + installEventHandler: (eventName, handler) -> + handler ?= @events[eventName].bind(this) + @webSocket.addEventListener(eventName, handler) + + uninstallEventHandlers: -> + for eventName of @events + @webSocket.removeEventListener(eventName) - onOpen: => - @consumer.subscribers.reload() + events: + message: (event) -> + {identifier, message} = JSON.parse(event.data) + @consumer.subscribers.notify(identifier, "received", message) - onClose: => - @disconnect() + open: -> + @consumer.subscribers.reload() - onError: => - @disconnect() - @webSocket.onclose = -> # no-op - @webSocket.onerror = -> # no-op - try @close() + close: -> + @consumer.subscribers.notifyAll("disconnected") - disconnect: -> - @consumer.subscribers.notifyAll("disconnected") + error: -> + @consumer.subscribers.notifyAll("disconnected") + @closeSilently() -- cgit v1.2.3 From 239d79e9eb72c4bc363405f1535b30dd58a8a6b2 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Tue, 7 Jul 2015 09:43:22 -0400 Subject: Add helper to inspect current state --- lib/assets/javascripts/cable/connection.js.coffee | 3 +++ lib/assets/javascripts/cable/connection_monitor.js.coffee | 5 +++++ lib/assets/javascripts/cable/consumer.js.coffee | 6 ++++++ lib/assets/javascripts/cable/subscriber_manager.js.coffee | 3 +++ 4 files changed, 17 insertions(+) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 4d2c1018aa..2e7a9930ec 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -71,3 +71,6 @@ class Cable.Connection error: -> @consumer.subscribers.notifyAll("disconnected") @closeSilently() + + toJSON: -> + state: @getState() diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index fc5093c5eb..ea0c360b75 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -55,6 +55,11 @@ class Cable.ConnectionMonitor else secondsSince(@startedAt) > @staleThreshold.startedAt + toJSON: -> + interval = @getInterval() + connectionIsStale = @connectionIsStale() + {@startedAt, @stoppedAt, @pingedAt, @reconnectAttempts, connectionIsStale, interval} + now = -> new Date().getTime() diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee index b9c08807f2..16c49b559a 100644 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -16,3 +16,9 @@ class Cable.Consumer send: (data) -> @connection.send(data) + + inspect: -> + JSON.stringify(this, null, 2) + + toJSON: -> + {@subscribers, @connection, @connectionMonitor} diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee index 0b6a16590c..1eef98ff0b 100644 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ b/lib/assets/javascripts/cable/subscriber_manager.js.coffee @@ -36,3 +36,6 @@ class Cable.SubscriberManager @consumer.connection.isOpen() else @consumer.send({command, identifier}) + + toJSON: -> + subscriber.identifier for subscriber in @subscribers -- cgit v1.2.3 From 026fd5b946c20e2667d3f6acd959c9afbd2c7deb Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Tue, 7 Jul 2015 09:51:40 -0400 Subject: Add URL to inspector --- lib/assets/javascripts/cable/consumer.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee index 16c49b559a..27314ee508 100644 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -21,4 +21,4 @@ class Cable.Consumer JSON.stringify(this, null, 2) toJSON: -> - {@subscribers, @connection, @connectionMonitor} + {@url, @subscribers, @connection, @connectionMonitor} -- cgit v1.2.3 From 4bb995a4aa21531551066c9ca5be5238fdf2225f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:17:40 +0200 Subject: Use latest Bundler --- Gemfile.lock | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0220e1cf6a..4e2ff79c81 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - action_cable (0.0.3) + action_cable (0.1.0) activesupport (>= 4.2.0) celluloid (~> 0.16.0) em-hiredis (~> 0.3.0) @@ -52,3 +52,6 @@ DEPENDENCIES action_cable! puma rake + +BUNDLED WITH + 1.10.5 -- cgit v1.2.3 From 4c0ece2ff0ee39739aac2cd90b270bc2d40e5217 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:19:18 +0200 Subject: Refer to the proper logger --- lib/action_cable/server/worker.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb index 0491cb9ab0..aa807bdf59 100644 --- a/lib/action_cable/server/worker.rb +++ b/lib/action_cable/server/worker.rb @@ -25,7 +25,7 @@ module ActionCable private def logger - ActionCable::Server::Base.logger + ActionCable.server.logger end end end -- cgit v1.2.3 From 53c4b4160a5298ca19ce6bc9c37acc0770a5d053 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:24:49 +0200 Subject: Bundle connect/disconnect callbacks together with all other subscribe callbacks --- lib/action_cable/channel/base.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 335d2d9d7c..fc229c9f27 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -4,6 +4,9 @@ module ActionCable include Callbacks include Streams + on_subscribe :connect + on_unsubscribe :disconnect + on_subscribe :start_periodic_timers on_unsubscribe :stop_periodic_timers @@ -27,7 +30,6 @@ module ActionCable def perform_connection logger.info "#{channel_name} connecting" - connect run_subscribe_callbacks end @@ -47,7 +49,6 @@ module ActionCable end def perform_disconnection - disconnect run_unsubscribe_callbacks logger.info "#{channel_name} disconnected" end -- cgit v1.2.3 From e04c95e5e6af2765503b90364ef147cc0eb40cd4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:32:02 +0200 Subject: Extract periodic timers concern --- lib/action_cable/channel.rb | 3 ++- lib/action_cable/channel/base.rb | 18 +------------- lib/action_cable/channel/callbacks.rb | 7 +----- lib/action_cable/channel/periodic_timers.rb | 38 +++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 lib/action_cable/channel/periodic_timers.rb diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb index 0432052514..9e4d3d3f93 100644 --- a/lib/action_cable/channel.rb +++ b/lib/action_cable/channel.rb @@ -1,7 +1,8 @@ module ActionCable module Channel + autoload :Base, 'action_cable/channel/base' autoload :Callbacks, 'action_cable/channel/callbacks' + autoload :PeriodicTimers, 'action_cable/channel/periodic_timers' autoload :Streams, 'action_cable/channel/streams' - autoload :Base, 'action_cable/channel/base' end end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index fc229c9f27..ee22db4e09 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -2,14 +2,12 @@ module ActionCable module Channel class Base include Callbacks + include PeriodicTimers include Streams on_subscribe :connect on_unsubscribe :disconnect - on_subscribe :start_periodic_timers - on_unsubscribe :stop_periodic_timers - attr_reader :params, :connection delegate :logger, to: :connection @@ -22,7 +20,6 @@ module ActionCable def initialize(connection, channel_identifier, params = {}) @connection = connection @channel_identifier = channel_identifier - @_active_periodic_timers = [] @params = params perform_connection @@ -115,19 +112,6 @@ module ActionCable end - def start_periodic_timers - self.class.periodic_timers.each do |callback, options| - @_active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do - worker_pool.async.run_periodic_timer(self, callback) - end - end - end - - def stop_periodic_timers - @_active_periodic_timers.each { |timer| timer.cancel } - end - - def worker_pool connection.worker_pool end diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb index 15bfb9a3da..3e61d8eb30 100644 --- a/lib/action_cable/channel/callbacks.rb +++ b/lib/action_cable/channel/callbacks.rb @@ -4,11 +4,10 @@ module ActionCable extend ActiveSupport::Concern included do - class_attribute :on_subscribe_callbacks, :on_unsubscribe_callbacks, :periodic_timers, :instance_reader => false + class_attribute :on_subscribe_callbacks, :on_unsubscribe_callbacks, :instance_reader => false self.on_subscribe_callbacks = [] self.on_unsubscribe_callbacks = [] - self.periodic_timers = [] end module ClassMethods @@ -19,10 +18,6 @@ module ActionCable def on_unsubscribe(*methods) self.on_unsubscribe_callbacks += methods end - - def periodically(callback, every:) - self.periodic_timers += [ [ callback, every: every ] ] - 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 0000000000..d7f6b52e3d --- /dev/null +++ b/lib/action_cable/channel/periodic_timers.rb @@ -0,0 +1,38 @@ +module ActionCable + module Channel + module PeriodicTimers + extend ActiveSupport::Concern + + included do + class_attribute :periodic_timers, instance_reader: false + self.periodic_timers = [] + + on_subscribe :start_periodic_timers + on_unsubscribe :stop_periodic_timers + end + + module ClassMethods + def periodically(callback, every:) + 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 << EventMachine::PeriodicTimer.new(options[:every]) do + worker_pool.async.run_periodic_timer(self, callback) + end + end + end + + def stop_periodic_timers + active_periodic_timers.each { |timer| timer.cancel } + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From 70361c7ddf4581cd55a7117297cf023c7a9f3297 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:32:23 +0200 Subject: Style --- lib/action_cable/channel/callbacks.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb index 3e61d8eb30..9ca8896770 100644 --- a/lib/action_cable/channel/callbacks.rb +++ b/lib/action_cable/channel/callbacks.rb @@ -4,7 +4,7 @@ module ActionCable extend ActiveSupport::Concern included do - class_attribute :on_subscribe_callbacks, :on_unsubscribe_callbacks, :instance_reader => false + class_attribute :on_subscribe_callbacks, :on_unsubscribe_callbacks, instance_reader: false self.on_subscribe_callbacks = [] self.on_unsubscribe_callbacks = [] -- cgit v1.2.3 From e6effb247a8051d21c2e9bdeed6c48a511f16b28 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:35:41 +0200 Subject: Removing unused matches? method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @lifo I couldn’t find any use of documentation for this, so removed it for now. Was it just for testing? --- lib/action_cable/channel/base.rb | 6 ------ test/channel_test.rb | 3 --- test/server_test.rb | 3 --- 3 files changed, 12 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index ee22db4e09..cf5f593a31 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -11,12 +11,6 @@ module ActionCable attr_reader :params, :connection delegate :logger, to: :connection - class << self - def matches?(identifier) - raise "Please implement #{name}#matches? method" - end - end - def initialize(connection, channel_identifier, params = {}) @connection = connection @channel_identifier = channel_identifier diff --git a/test/channel_test.rb b/test/channel_test.rb index 96987977ea..2a33237219 100644 --- a/test/channel_test.rb +++ b/test/channel_test.rb @@ -3,9 +3,6 @@ require 'test_helper' class ChannelTest < ActionCableTest class PingChannel < ActionCable::Channel::Base - def self.matches?(identifier) - identifier[:channel] == 'chat' && identifier[:user_id].to_i.nonzero? - end end class PingServer < ActionCable::Server::Base diff --git a/test/server_test.rb b/test/server_test.rb index 636fa37cf7..2d514091ff 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -3,9 +3,6 @@ require 'test_helper' class ServerTest < ActionCableTest class ChatChannel < ActionCable::Channel::Base - def self.matches?(identifier) - identifier[:channel] == 'chat' && identifier[:user_id].to_i.nonzero? - end end class ChatServer < ActionCable::Server::Base -- cgit v1.2.3 From 6fe8a87ed23fb2de8ad2d950e483f54cb723ac40 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:46:43 +0200 Subject: Don't need a delegator for a single-use case --- lib/action_cable/channel/base.rb | 5 ----- lib/action_cable/channel/periodic_timers.rb | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index cf5f593a31..49d335efea 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -104,11 +104,6 @@ module ActionCable def run_unsubscribe_callbacks self.class.on_unsubscribe_callbacks.each { |callback| send(callback) } end - - - def worker_pool - connection.worker_pool - end end end end diff --git a/lib/action_cable/channel/periodic_timers.rb b/lib/action_cable/channel/periodic_timers.rb index d7f6b52e3d..33b9ff19be 100644 --- a/lib/action_cable/channel/periodic_timers.rb +++ b/lib/action_cable/channel/periodic_timers.rb @@ -25,7 +25,7 @@ module ActionCable def start_periodic_timers self.class.periodic_timers.each do |callback, options| active_periodic_timers << EventMachine::PeriodicTimer.new(options[:every]) do - worker_pool.async.run_periodic_timer(self, callback) + connection.worker_pool.async.run_periodic_timer(self, callback) end end end -- cgit v1.2.3 From ab77cb721d2679eb1045f04e5efe720decd67135 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 6 Jul 2015 19:53:29 +0200 Subject: Spacing --- lib/action_cable/channel/base.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 49d335efea..92730de09c 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -79,6 +79,7 @@ module ActionCable self.class.name end + private def extract_action(data) (data['action'].presence || :receive).to_sym -- cgit v1.2.3 From d2a35981377bbfe3bb298c699fabae5c0b3411b8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 16:39:26 +0200 Subject: Switch domain language from channel connect/disconnect to subscribe/unsubscribe --- lib/action_cable/channel/base.rb | 18 +++++++++--------- lib/action_cable/connection/subscriptions.rb | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 92730de09c..336bb43092 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -5,8 +5,8 @@ module ActionCable include PeriodicTimers include Streams - on_subscribe :connect - on_unsubscribe :disconnect + on_subscribe :subscribed + on_unsubscribe :unsubscribed attr_reader :params, :connection delegate :logger, to: :connection @@ -16,11 +16,11 @@ module ActionCable @channel_identifier = channel_identifier @params = params - perform_connection + subscribe_to_channel end - def perform_connection - logger.info "#{channel_name} connecting" + def subscribe_to_channel + logger.info "#{channel_name} subscribing" run_subscribe_callbacks end @@ -39,9 +39,9 @@ module ActionCable end end - def perform_disconnection + def unsubscribe_from_channel run_unsubscribe_callbacks - logger.info "#{channel_name} disconnected" + logger.info "#{channel_name} unsubscribed" end @@ -56,11 +56,11 @@ module ActionCable end - def connect + def subscribed # Override in subclasses end - def disconnect + def unsubscribed # Override in subclasses end diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 800474eee5..7fd4f510bf 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -35,7 +35,7 @@ module ActionCable def remove(data) logger.info "Unsubscribing from channel: #{data['identifier']}" - subscriptions[data['identifier']].perform_disconnection + subscriptions[data['identifier']].unsubscribe_from_channel subscriptions.delete(data['identifier']) end @@ -49,7 +49,7 @@ module ActionCable end def cleanup - subscriptions.each { |id, channel| channel.perform_disconnection } + subscriptions.each { |id, channel| channel.unsubscribe_from_channel } end -- cgit v1.2.3 From 569b7510d1251240fb85020a5cec5a6be3ea3781 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 16:39:52 +0200 Subject: No need to use a channel_ prefix inside the channel --- lib/action_cable/channel/base.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 336bb43092..366ffb1da8 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -11,10 +11,10 @@ module ActionCable attr_reader :params, :connection delegate :logger, to: :connection - def initialize(connection, channel_identifier, params = {}) + def initialize(connection, identifier, params = {}) @connection = connection - @channel_identifier = channel_identifier - @params = params + @identifier = identifier + @params = params subscribe_to_channel end @@ -68,7 +68,7 @@ module ActionCable def transmit(data, via: nil) if authorized? logger.info "#{channel_name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } - connection.transmit({ identifier: @channel_identifier, message: data }.to_json) + connection.transmit({ identifier: @identifier, message: data }.to_json) else unauthorized end -- cgit v1.2.3 From 982a48894c3f38c8ca4b0be4b6f7870fffaaf9a6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 16:45:56 +0200 Subject: No need for this to be public --- lib/action_cable/channel/base.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 366ffb1da8..1ec4ee5fe9 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -19,11 +19,6 @@ module ActionCable subscribe_to_channel end - def subscribe_to_channel - logger.info "#{channel_name} subscribing" - run_subscribe_callbacks - end - def perform_action(data) if authorized? action = extract_action(data) @@ -81,6 +76,11 @@ module ActionCable private + def subscribe_to_channel + logger.info "#{channel_name} subscribing" + run_subscribe_callbacks + end + def extract_action(data) (data['action'].presence || :receive).to_sym end -- cgit v1.2.3 From a21f6c8761242f2b3dd0463085889f4cf6907269 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 16:46:13 +0200 Subject: Go with process_action to match language from Action Controller --- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/connection/subscriptions.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 1ec4ee5fe9..8e5a4e9cbc 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -19,7 +19,7 @@ module ActionCable subscribe_to_channel end - def perform_action(data) + def process_action(data) if authorized? action = extract_action(data) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 7fd4f510bf..438fb08f38 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -10,7 +10,7 @@ module ActionCable case data['command'] when 'subscribe' then add data when 'unsubscribe' then remove data - when 'message' then perform_action data + when 'message' then process_action data else logger.error "Received unrecognized command in #{data.inspect}" end @@ -39,8 +39,8 @@ module ActionCable subscriptions.delete(data['identifier']) end - def perform_action(data) - find(data).perform_action ActiveSupport::JSON.decode(data['data']) + def process_action(data) + find(data).process_action ActiveSupport::JSON.decode(data['data']) end -- cgit v1.2.3 From 0ca074712cf2388c9249648bf907fc3773f5f98a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 16:46:21 +0200 Subject: Starting the documentation process --- lib/action_cable/channel/base.rb | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 8e5a4e9cbc..fd510b2597 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -1,5 +1,31 @@ module ActionCable module Channel + # 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. + # + # 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 < ApplicationChannel + # 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. + # + # class Base include Callbacks include PeriodicTimers -- cgit v1.2.3 From 35e6de4bfa1e338a0e2bb938191a6428d076cd45 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 19:03:49 +0200 Subject: Expand authors given recent work --- action_cable.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/action_cable.gemspec b/action_cable.gemspec index 1d68c2b0a5..12d102a739 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -5,8 +5,8 @@ Gem::Specification.new do |s| s.summary = 'Framework for websockets.' s.description = 'Action Cable is a framework for realtime communication over websockets.' - s.author = ['Pratik Naik'] - s.email = ['pratiknaik@gmail.com'] + s.author = ['Pratik Naik', 'David Heinemeier Hansson'] + s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] s.homepage = 'http://basecamp.com' s.add_dependency('activesupport', '>= 4.2.0') -- cgit v1.2.3 From 9b254fa61f3b815babd928b6fa28e096ee46acb1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 19:30:00 +0200 Subject: Use process vs perform language --- lib/action_cable/channel/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index fd510b2597..19a03f5ef6 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -49,7 +49,7 @@ module ActionCable if authorized? action = extract_action(data) - if performable_action?(action) + if processable_action?(action) logger.info action_signature(action, data) public_send action, data else @@ -111,7 +111,7 @@ module ActionCable (data['action'].presence || :receive).to_sym end - def performable_action?(action) + def processable_action?(action) self.class.instance_methods(false).include?(action) end -- cgit v1.2.3 From a01317ee9d5e42d847af8c3b1db6b2aa68b19343 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:19:28 +0200 Subject: Add documentation --- lib/action_cable/channel/base.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 19a03f5ef6..ee22b29f4e 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -45,6 +45,9 @@ module ActionCable subscribe_to_channel 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 process_action(data) if authorized? action = extract_action(data) @@ -76,16 +79,21 @@ module ActionCable logger.error "#{channel_name}: Unauthorized access" end - + # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams + # you want this channel to be sending to the subscriber. def subscribed # Override in subclasses end + # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking + # people as offline or the like. def unsubscribed # 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) if authorized? logger.info "#{channel_name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } -- cgit v1.2.3 From 35ffec2c489bead9cc7f9c0525e9c63dc04e3038 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:25:14 +0200 Subject: Remove the authorized check for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have the remote connections to immediately cut a connection when someone has been kicked off. Let’s lean on that for now. --- lib/action_cable/channel/base.rb | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index ee22b29f4e..2fcc82d039 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -49,17 +49,13 @@ module ActionCable # that the action requested is a public method on the channel declared by the user (so not one of the callbacks # like #subscribed). def process_action(data) - if authorized? - action = extract_action(data) - - if processable_action?(action) - logger.info action_signature(action, data) - public_send action, data - else - logger.error "Unable to process #{action_signature(action, data)}" - end + action = extract_action(data) + + if processable_action?(action) + logger.info action_signature(action, data) + public_send action, data else - unauthorized + logger.error "Unable to process #{action_signature(action, data)}" end end @@ -70,15 +66,6 @@ module ActionCable protected - # Override in subclasses - def authorized? - true - end - - def unauthorized - logger.error "#{channel_name}: Unauthorized access" - end - # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams # you want this channel to be sending to the subscriber. def subscribed @@ -95,12 +82,8 @@ module ActionCable # 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) - if authorized? - logger.info "#{channel_name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } - connection.transmit({ identifier: @identifier, message: data }.to_json) - else - unauthorized - end + logger.info "#{channel_name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } + connection.transmit({ identifier: @identifier, message: data }.to_json) end -- cgit v1.2.3 From 6f4e9dea93ce306ea1badb839a723c2f4de91ccd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:30:51 +0200 Subject: No need for this delegator --- lib/action_cable/channel/base.rb | 16 ++++++---------- lib/action_cable/channel/streams.rb | 4 ++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 2fcc82d039..f5d7011a72 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -59,9 +59,11 @@ module ActionCable end end + # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. + # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. def unsubscribe_from_channel run_unsubscribe_callbacks - logger.info "#{channel_name} unsubscribed" + logger.info "#{self.class.name} unsubscribed" end @@ -77,24 +79,18 @@ module ActionCable def unsubscribed # 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) - logger.info "#{channel_name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } + logger.info "#{self.class.name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } connection.transmit({ identifier: @identifier, message: data }.to_json) end - def channel_name - self.class.name - end - - private def subscribe_to_channel - logger.info "#{channel_name} subscribing" + logger.info "#{self.class.name} subscribing" run_subscribe_callbacks end @@ -107,7 +103,7 @@ module ActionCable end def action_signature(action, data) - "#{channel_name}##{action}".tap do |signature| + "#{self.class.name}##{action}".tap do |signature| if (arguments = data.except('action')).any? signature << "(#{arguments.inspect})" end diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index 3eac776e61..9ff2f85fa1 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -13,13 +13,13 @@ module ActionCable streams << [ broadcasting, callback ] pubsub.subscribe broadcasting, &callback - logger.info "#{channel_name} is streaming from #{broadcasting}" + logger.info "#{self.class.name} is streaming from #{broadcasting}" end def stop_all_streams streams.each do |broadcasting, callback| pubsub.unsubscribe_proc broadcasting, callback - logger.info "#{channel_name} stopped streaming from #{broadcasting}" + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" end end -- cgit v1.2.3 From 74d764b120fad7fd21781b3c2df4abfac5518e78 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:39:16 +0200 Subject: Allow actions not to accept the data argument --- lib/action_cable/channel/base.rb | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index f5d7011a72..fb29ba1893 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -52,8 +52,7 @@ module ActionCable action = extract_action(data) if processable_action?(action) - logger.info action_signature(action, data) - public_send action, data + dispatch_action(action, data) else logger.error "Unable to process #{action_signature(action, data)}" end @@ -102,6 +101,16 @@ module ActionCable self.class.instance_methods(false).include?(action) end + def dispatch_action(action, data) + logger.info action_signature(action, data) + + if method(action).arity == 1 + public_send action, data + else + public_send action + end + end + def action_signature(action, data) "#{self.class.name}##{action}".tap do |signature| if (arguments = data.except('action')).any? -- cgit v1.2.3 From 5acf45b586eba0abddef6e0efd62f05281618faf Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:39:23 +0200 Subject: Explain action processing --- lib/action_cable/channel/base.rb | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index fb29ba1893..e4f6a8567d 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -25,7 +25,39 @@ module ActionCable # 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 Action Controllers, channels do not follow a REST constraint form for its actions. It's an 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, subscribed/unsubscribed are not callable methods, as they were already declared in ActionCable::Channel::Base, but #appear/away + # are. #generate_connection_token is also not callable as its 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, it's simply a trigger action. class Base include Callbacks include PeriodicTimers -- cgit v1.2.3 From b78e960b8c7bc58ff61af131845f4f98d2d3277b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:39:47 +0200 Subject: Spacing --- lib/action_cable/channel/base.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index e4f6a8567d..2c89ccace4 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -125,6 +125,7 @@ module ActionCable run_subscribe_callbacks end + def extract_action(data) (data['action'].presence || :receive).to_sym end -- cgit v1.2.3 From 81bbf9ecba35e04de4081494941c2f69c9e8784e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 21:52:41 +0200 Subject: Document the remaining parts of the Channel setup. --- lib/action_cable/channel/callbacks.rb | 4 +++ lib/action_cable/channel/periodic_timers.rb | 3 ++ lib/action_cable/channel/streams.rb | 47 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb index 9ca8896770..dcdd27b9a7 100644 --- a/lib/action_cable/channel/callbacks.rb +++ b/lib/action_cable/channel/callbacks.rb @@ -11,10 +11,14 @@ module ActionCable end module ClassMethods + # Name methods that should be called when the channel is subscribed to. + # (These methods should be private, so they're not callable by the user). def on_subscribe(*methods) self.on_subscribe_callbacks += methods end + # Name methods that should be called when the channel is unsubscribed from. + # (These methods should be private, so they're not callable by the user). def on_unsubscribe(*methods) self.on_unsubscribe_callbacks += methods end diff --git a/lib/action_cable/channel/periodic_timers.rb b/lib/action_cable/channel/periodic_timers.rb index 33b9ff19be..fea957563f 100644 --- a/lib/action_cable/channel/periodic_timers.rb +++ b/lib/action_cable/channel/periodic_timers.rb @@ -12,6 +12,9 @@ module ActionCable end module ClassMethods + # Allow you to call a private method every so often seconds. This periodic timer can be useful + # for sending a steady flow of updates to a client based off an object that was configured on subscription. + # It's an alternative to using streams if the channel is able to do the work internally. def periodically(callback, every:) self.periodic_timers += [ [ callback, every: every ] ] end diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index 9ff2f85fa1..6a3dc76c1d 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -1,5 +1,50 @@ module ActionCable module Channel + # Streams allow channels to route broadcastings to the subscriber. A broadcasting is an discussed elsewhere a pub/sub queue where any data + # put 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'll not get that update when connecting later. + # + # 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 + # + # So 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. + # That looks like so from that side of things: + # + # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell' + # + # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that let's you alter what goes out. + # Example below 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_from @room.channel, -> (message) do + # message = ActiveSupport::JSON.decode(m) + # + # 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 + # + # You can stop streaming from all broadcasts by calling #stop_all_streams. module Streams extend ActiveSupport::Concern @@ -7,6 +52,8 @@ module ActionCable 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. def stream_from(broadcasting, callback = nil) callback ||= default_stream_callback(broadcasting) -- cgit v1.2.3 From 049cd824c0452385c55abf093085b760c58fadae Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:27:44 +0200 Subject: Basic authentication helpers --- lib/action_cable/connection.rb | 1 + lib/action_cable/connection/authorization.rb | 13 +++++++++++++ lib/action_cable/connection/base.rb | 4 ++++ 3 files changed, 18 insertions(+) create mode 100644 lib/action_cable/connection/authorization.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 1b4a6ecc23..c63621c519 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,5 +1,6 @@ module ActionCable module Connection + autoload :Authorization, 'action_cable/connection/authorization' autoload :Base, 'action_cable/connection/base' autoload :Heartbeat, 'action_cable/connection/heartbeat' autoload :Identification, 'action_cable/connection/identification' diff --git a/lib/action_cable/connection/authorization.rb b/lib/action_cable/connection/authorization.rb new file mode 100644 index 0000000000..070a70e4e2 --- /dev/null +++ b/lib/action_cable/connection/authorization.rb @@ -0,0 +1,13 @@ +module ActionCable + module Connection + module Authorization + class UnauthorizedError < StandardError; end + + private + def reject_unauthorized_connection + logger.error "An unauthorized connection attempt was rejected" + raise UnauthorizedError + end + end + end +end \ No newline at end of file diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 09bbc73e2d..1a9aac0731 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -3,6 +3,7 @@ module ActionCable class Base include Identification include InternalChannel + include Authorization attr_reader :server, :env delegate :worker_pool, :pubsub, to: :server @@ -85,6 +86,9 @@ module ActionCable heartbeat.start message_buffer.process! + rescue ActionCable::Connection::Authorization::UnauthorizedError + respond_to_invalid_request + close end def on_message(message) -- cgit v1.2.3 From 060284f45e7bea2969686d4d96b399f5f4a3b691 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:28:02 +0200 Subject: Identifiers will add attr_accessor as well for convenience --- lib/action_cable/connection/identification.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 246636198b..6c2af04663 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -10,7 +10,8 @@ module ActionCable class_methods do def identified_by(*identifiers) - self.identifiers += identifiers + Array(identifiers).each { |identifier| attr_accessor identifier } + self.identifiers += identifiers end end -- cgit v1.2.3 From a2c08e78e15705bc4111022fcb0f0ebdc379ce27 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:28:09 +0200 Subject: Document the connection --- lib/action_cable/connection/base.rb | 54 ++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 1a9aac0731..04aba3ecbf 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -1,5 +1,46 @@ module ActionCable module Connection + # For every websocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent + # of all 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 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 + # + # protected + # def find_verified_user + # if current_user = User.find_by_identity cookies.signed[:identity_id] + # current_user + # else + # reject_unauthorized_connection + # end + # end + # end + # end + # + # First, we declare that this connection can be identified by its current_user. This allows us later to be able to find all connections + # established for that current_user (and potentially disconnect them if the user was removed from an account). You can declare as many + # identification indexes as you like. Declaring an identification means that a attr_accessor is automatically set for that key. + # + # Second, we rely on the fact that the websocket connection is established with the cookies from that 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 name of the current user to easily distinguish their messages in the log. + # + # Pretty simple, eh? class Base include Identification include InternalChannel @@ -23,6 +64,8 @@ module ActionCable @started_at = Time.now end + # Called by the server when a new websocket connection is established. This configures the callbacks intended for overwriting by the user. + # This method should now be called directly. Rely on the #connect (and #disconnect) callback instead. def process logger.info started_request_message @@ -37,6 +80,8 @@ module ActionCable end end + # Data received over the cable is handled by this method. It's expected that everything inbound is encoded with JSON. + # The data is routed to the proper channel that the connection has subscribed to. def receive(data_in_json) if websocket.alive? subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) @@ -45,30 +90,37 @@ module ActionCable end end + # Send raw data straight back down the websocket. This is not intended to be called directly. Use the #transmit available on the + # Channel instead, as that'll automatically address the correct subscriber and wrap the message in JSON. def transmit(data) websocket.transmit data end + # Close the websocket connection. def close logger.error "Closing connection" websocket.close end - + # Invoke a method on the connection asynchronously through the pool of thread workers. def send_async(method, *arguments) worker_pool.async.invoke(self, method, *arguments) end + # Return a basic hash of statistics for the connection keyed with `identifier`, `started_at`, and `subscriptions`. + # This can be returned by a health check against the connection. def statistics { identifier: connection_identifier, started_at: @started_at, subscriptions: subscriptions.identifiers } end protected + # The request that initiated the websocket connection is available here. This gives access to the environment, cookies, etc. def request @request ||= ActionDispatch::Request.new(Rails.application.env_config.merge(env)) end + # The cookies of the request that initiated the websocket connection. Useful for performing authorization checks. def cookies request.cookie_jar end -- cgit v1.2.3 From e3bf82625e1572e54dee0f3225512de61f5f2d08 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:33:48 +0200 Subject: Document heartbeat purpose --- lib/action_cable/connection/heartbeat.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/action_cable/connection/heartbeat.rb b/lib/action_cable/connection/heartbeat.rb index 47cd937c25..e0f4a97f53 100644 --- a/lib/action_cable/connection/heartbeat.rb +++ b/lib/action_cable/connection/heartbeat.rb @@ -1,5 +1,8 @@ module ActionCable module Connection + # 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 receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically + # disconnect. class Heartbeat BEAT_INTERVAL = 3 -- cgit v1.2.3 From 7bcc0e48e48e1b70aaac4db59388e102608ae315 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:33:58 +0200 Subject: Document and make private method private --- lib/action_cable/connection/identification.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 6c2af04663..3ea3b77e56 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -9,19 +9,23 @@ module ActionCable end class_methods do + # Mark a key as being a connection identifier index that can then used to find the specific connection again later. + # Common identifiers are current_user and current_account, but could be anything really. 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 @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact end - def connection_gid(ids) - ids.map { |o| o.to_global_id.to_s }.sort.join(":") - end + private + def connection_gid(ids) + ids.map { |o| o.to_global_id.to_s }.sort.join(":") + end end end end -- cgit v1.2.3 From 338e28de15d49b8f49b5694e0e1a5e31d571428c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:36:18 +0200 Subject: Make the entire internal channel private --- lib/action_cable/connection/internal_channel.rb | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index 70e5e58373..baf916dffa 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -3,28 +3,28 @@ module ActionCable module InternalChannel extend ActiveSupport::Concern - def internal_redis_channel - "action_cable/#{connection_identifier}" - end + private + def internal_redis_channel + "action_cable/#{connection_identifier}" + end - def subscribe_to_internal_channel - if connection_identifier.present? - callback = -> (message) { process_internal_message(message) } - @_internal_redis_subscriptions ||= [] - @_internal_redis_subscriptions << [ internal_redis_channel, callback ] + def subscribe_to_internal_channel + if connection_identifier.present? + callback = -> (message) { process_internal_message(message) } + @_internal_redis_subscriptions ||= [] + @_internal_redis_subscriptions << [ internal_redis_channel, callback ] - pubsub.subscribe(internal_redis_channel, &callback) - logger.info "Registered connection (#{connection_identifier})" + pubsub.subscribe(internal_redis_channel, &callback) + logger.info "Registered connection (#{connection_identifier})" + end end - end - def unsubscribe_from_internal_channel - if @_internal_redis_subscriptions.present? - @_internal_redis_subscriptions.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } + def unsubscribe_from_internal_channel + if @_internal_redis_subscriptions.present? + @_internal_redis_subscriptions.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } + end end - end - private def process_internal_message(message) message = ActiveSupport::JSON.decode(message) -- cgit v1.2.3 From 65033ba80883e157d4a8a2dca268ed73469683e9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:41:55 +0200 Subject: More clear method name for what's actually happening --- lib/action_cable/connection/base.rb | 2 +- lib/action_cable/connection/subscriptions.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 04aba3ecbf..cef0bcc2e7 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -152,7 +152,7 @@ module ActionCable server.remove_connection(self) - subscriptions.cleanup + subscriptions.unsubscribe_from_all unsubscribe_from_internal_channel heartbeat.stop diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 438fb08f38..29ca657d5a 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -48,7 +48,7 @@ module ActionCable subscriptions.keys end - def cleanup + def unsubscribe_from_all subscriptions.each { |id, channel| channel.unsubscribe_from_channel } end -- cgit v1.2.3 From 7333febb23bfcf4239536fe4a35d307a931cd44f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:42:02 +0200 Subject: Documentation --- lib/action_cable/connection/internal_channel.rb | 1 + lib/action_cable/connection/message_buffer.rb | 2 ++ lib/action_cable/connection/subscriptions.rb | 2 ++ 3 files changed, 5 insertions(+) diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index baf916dffa..b00e21824c 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -1,5 +1,6 @@ module ActionCable module Connection + # Makes it possible for the RemoteConnection to disconnect a specific connection. module InternalChannel extend ActiveSupport::Concern diff --git a/lib/action_cable/connection/message_buffer.rb b/lib/action_cable/connection/message_buffer.rb index 615266e0cb..d5a8e9eba9 100644 --- a/lib/action_cable/connection/message_buffer.rb +++ b/lib/action_cable/connection/message_buffer.rb @@ -1,5 +1,7 @@ module ActionCable module Connection + # Allows us to buffer messages received from the websocket before the Connection has been fully initialized and is ready to receive them. + # Entirely internal operation and should not be used directly by the user. class MessageBuffer def initialize(connection) @connection = connection diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 29ca657d5a..803894c8f6 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -1,5 +1,7 @@ module ActionCable module Connection + # 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. Should not be used directly by the user. class Subscriptions def initialize(connection) @connection = connection -- cgit v1.2.3 From 0373afba17406bc513e6d6b6e8ab5b1b8f2bff30 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:45:12 +0200 Subject: Complete all the standard severity levels --- lib/action_cable/connection/tagged_logger_proxy.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb index e0c0075adf..854f613f1c 100644 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -9,19 +9,17 @@ module ActionCable @tags = tags.flatten end - def info(message) - log :info, message - end - - def error(message) - log :error, message - end - def add_tags(*tags) @tags += tags.flatten @tags = @tags.uniq end + %i( debug info warn error fatal unknown ).each do |severity| + define_method(severity) do |message| + log severity, message + end + end + protected def log(type, message) @logger.tagged(*@tags) { @logger.send type, message } -- cgit v1.2.3 From 410b504e85d248308a81c2def545e401769aaa81 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 22:53:58 +0200 Subject: Make the RemoteConnection private under RemoteConnections and document the setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You’d never instantiate it on its own. --- lib/action_cable.rb | 1 - lib/action_cable/remote_connection.rb | 33 ---------------------- lib/action_cable/remote_connections.rb | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 34 deletions(-) delete mode 100644 lib/action_cable/remote_connection.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 1fd95f76fa..5f1f3bec35 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -23,7 +23,6 @@ module ActionCable autoload :Connection, 'action_cable/connection' autoload :Channel, 'action_cable/channel' - autoload :RemoteConnection, 'action_cable/remote_connection' autoload :RemoteConnections, 'action_cable/remote_connections' autoload :Broadcaster, 'action_cable/broadcaster' diff --git a/lib/action_cable/remote_connection.rb b/lib/action_cable/remote_connection.rb deleted file mode 100644 index e2e2786dc1..0000000000 --- a/lib/action_cable/remote_connection.rb +++ /dev/null @@ -1,33 +0,0 @@ -module ActionCable - class RemoteConnection - class InvalidIdentifiersError < StandardError; end - - include Connection::Identification, Connection::InternalChannel - - def initialize(server, ids) - @server = server - set_identifier_instance_vars(ids) - end - - def disconnect - server.broadcast internal_redis_channel, type: 'disconnect' - end - - def identifiers - server.connection_identifiers - end - - private - attr_reader :server - - 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 diff --git a/lib/action_cable/remote_connections.rb b/lib/action_cable/remote_connections.rb index f9d7c49a27..df390073de 100644 --- a/lib/action_cable/remote_connections.rb +++ b/lib/action_cable/remote_connections.rb @@ -1,4 +1,18 @@ module ActionCable + # If you need to disconnect a given connection, you go through the RemoteConnections. You find the connections you're looking for by + # searching the identifier declared on the connection. 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 + # + # That 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 these servers are subscribed to). class RemoteConnections attr_reader :server @@ -9,5 +23,41 @@ module ActionCable def where(identifier) RemoteConnection.new(server, identifier) end + + private + # Represents a single remote connection found via ActionCable.server.remote_connections.where(*). + # Exists for the 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 + server.broadcast internal_redis_channel, type: 'disconnect' + end + + def identifiers + server.connection_identifiers + end + + private + attr_reader :server + + 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 -- cgit v1.2.3 From 0103432a9adc32ea007eadf6209c44d6653e58c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 23:13:00 +0200 Subject: Finished class documentation --- lib/action_cable/remote_connections.rb | 1 + lib/action_cable/server/base.rb | 19 +++++++++++++++---- lib/action_cable/server/broadcasting.rb | 21 +++++++++++++++++++++ lib/action_cable/server/configuration.rb | 2 ++ lib/action_cable/server/connections.rb | 3 +++ lib/action_cable/server/worker.rb | 1 + 6 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/remote_connections.rb b/lib/action_cable/remote_connections.rb index df390073de..ae7145891c 100644 --- a/lib/action_cable/remote_connections.rb +++ b/lib/action_cable/remote_connections.rb @@ -42,6 +42,7 @@ module ActionCable server.broadcast internal_redis_channel, type: 'disconnect' end + # Returns all the identifiers that were applied to this connection. def identifiers server.connection_identifiers end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 3d1d9e71ff..23cd8388bd 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -1,5 +1,9 @@ module ActionCable module Server + # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the rack process that starts the cable server, but + # also by the user to reach the RemoteConnections instead for finding and disconnecting connections across all servers. + # + # Also, this is the server instance used for broadcasting. See Broadcasting for details. class Base include ActionCable::Server::Broadcasting include ActionCable::Server::Connections @@ -12,14 +16,22 @@ module ActionCable def initialize end + # Called by rack to setup the server. def call(env) config.connection_class.new(self, env).process end + # Gateway to RemoteConnections. See that class for details. + def remote_connections + @remote_connections ||= RemoteConnections.new(self) + end + + # The thread worker pool for handling all the connection work on this server. Default size is set by config.worker_pool_size. def worker_pool @worker_pool ||= ActionCable::Server::Worker.pool(size: config.worker_pool_size) end + # Requires and returns an array of all the channel class constants in this application. def channel_classes @channel_classes ||= begin config.channel_paths.each { |channel_path| require channel_path } @@ -27,14 +39,12 @@ module ActionCable end end - def remote_connections - @remote_connections ||= RemoteConnections.new(self) - end - + # The redis pubsub adapter used for all streams/broadcasting. def pubsub @pubsub ||= redis.pubsub end + # The EventMachine Redis instance used by the pubsub adapter. def redis @redis ||= EM::Hiredis.connect(config.redis[:url]).tap do |redis| redis.on(:reconnect_failed) do @@ -45,6 +55,7 @@ module ActionCable end end + # All the identifiers applied to the connection class associated with this server. def connection_identifiers config.connection_class.identifiers end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 105ccb2b5c..c082e87e56 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -1,14 +1,35 @@ module ActionCable module Server + # Broadcasting is how other parts of your application can send messages to the channel 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 shit fit for print' } + # + # # Client-side coffescript which assumes you've already requested the right to send web notifications + # BC.cable.createSubscription "WebNotificationsChannel", + # received: (data) -> + # web_notification = new Notification data['title'], body: data['body'] module Broadcasting + # Broadcast a hash directly to a named broadcasting. It'll automatically be JSON encoded. def broadcast(broadcasting, message) broadcaster_for(broadcasting).broadcast(message) end + # Returns a broadcaster for a named broadcasting that can be reused. Useful when you have a object that + # may need multiple spots to transmit to a specific broadcasting over and over. def broadcaster_for(broadcasting) Broadcaster.new(self, broadcasting) end + # The redis instance used for broadcasting. Not intended for direct user use. def broadcasting_redis @broadcasting_redis ||= Redis.new(config.redis) end diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb index c5783f30e0..ac9fa7b085 100644 --- a/lib/action_cable/server/configuration.rb +++ b/lib/action_cable/server/configuration.rb @@ -1,5 +1,7 @@ module ActionCable module Server + # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak the configuration points + # in a Rails config initializer. class Configuration attr_accessor :logger, :log_tags attr_accessor :connection_class, :worker_pool_size diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb index 4a3fa3c621..15d7c3c8c7 100644 --- a/lib/action_cable/server/connections.rb +++ b/lib/action_cable/server/connections.rb @@ -1,5 +1,8 @@ module ActionCable module Server + # Collection class for all the connections that's been established on this specific server. Remember, usually you'll run many cable servers, so + # you can't use this collection as an full list of all the connections established against your application. Use RemoteConnections for that. + # As such, this is primarily for internal use. module Connections def connections @connections ||= [] diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb index aa807bdf59..01d2c25c8a 100644 --- a/lib/action_cable/server/worker.rb +++ b/lib/action_cable/server/worker.rb @@ -1,5 +1,6 @@ module ActionCable module Server + # Worker used by Server.send_async to do connection work in threads. Only for internal use. class Worker include ActiveSupport::Callbacks include Celluloid -- cgit v1.2.3 From 212ba994b3ad1a065538ff67f3af6421ec77f93c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 7 Jul 2015 23:13:09 +0200 Subject: Tests are busted at the moment. Note that. --- test/channel_test.rb | 36 +++++++++++++++--------------- test/server_test.rb | 62 ++++++++++++++++++++++++++-------------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/test/channel_test.rb b/test/channel_test.rb index 2a33237219..145308e3fc 100644 --- a/test/channel_test.rb +++ b/test/channel_test.rb @@ -1,20 +1,20 @@ require 'test_helper' -class ChannelTest < ActionCableTest - - class PingChannel < ActionCable::Channel::Base - end - - class PingServer < ActionCable::Server::Base - register_channels PingChannel - end - - def app - PingServer - end - - test "channel callbacks" do - ws = Faye::WebSocket::Client.new(websocket_url) - end - -end +# FIXME: Currently busted. +# +# class ChannelTest < ActionCableTest +# class PingChannel < ActionCable::Channel::Base +# end +# +# class PingServer < ActionCable::Server::Base +# register_channels PingChannel +# end +# +# def app +# PingServer +# end +# +# test "channel callbacks" do +# ws = Faye::WebSocket::Client.new(websocket_url) +# end +# end \ No newline at end of file diff --git a/test/server_test.rb b/test/server_test.rb index 2d514091ff..bd83953702 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -1,33 +1,33 @@ require 'test_helper' -class ServerTest < ActionCableTest - - class ChatChannel < ActionCable::Channel::Base - end - - class ChatServer < ActionCable::Server::Base - register_channels ChatChannel - end - - def app - ChatServer - end - - test "channel registration" do - assert_equal ChatServer.channel_classes, Set.new([ ChatChannel ]) - end - - test "subscribing to a channel with valid params" do - ws = Faye::WebSocket::Client.new(websocket_url) - - ws.on(:message) do |message| - puts message.inspect - end - - ws.send command: 'subscribe', identifier: { channel: 'chat'}.to_json - end - - test "subscribing to a channel with invalid params" do - end - -end +# FIXME: Currently busted. +# +# class ServerTest < ActionCableTest +# class ChatChannel < ActionCable::Channel::Base +# end +# +# class ChatServer < ActionCable::Server::Base +# register_channels ChatChannel +# end +# +# def app +# ChatServer +# end +# +# test "channel registration" do +# assert_equal ChatServer.channel_classes, Set.new([ ChatChannel ]) +# end +# +# test "subscribing to a channel with valid params" do +# ws = Faye::WebSocket::Client.new(websocket_url) +# +# ws.on(:message) do |message| +# puts message.inspect +# end +# +# ws.send command: 'subscribe', identifier: { channel: 'chat'}.to_json +# end +# +# test "subscribing to a channel with invalid params" do +# end +# end -- cgit v1.2.3 From 7c6a7f28eb882a7e4eca75fc87c42dd5d9e78d8f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 11:00:24 +0200 Subject: Rename SubscriptionManager/Subscriber -> Subscriptions This matches the server-side setup and is more consistent. --- lib/assets/javascripts/cable/connection.js.coffee | 8 ++--- .../javascripts/cable/connection_monitor.js.coffee | 2 +- lib/assets/javascripts/cable/consumer.js.coffee | 18 ++++++++-- .../javascripts/cable/subscriber_manager.js.coffee | 41 ---------------------- .../javascripts/cable/subscription.js.coffee | 4 +-- .../javascripts/cable/subscriptions.js.coffee | 41 ++++++++++++++++++++++ 6 files changed, 63 insertions(+), 51 deletions(-) delete mode 100644 lib/assets/javascripts/cable/subscriber_manager.js.coffee create mode 100644 lib/assets/javascripts/cable/subscriptions.js.coffee diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 2e7a9930ec..87fd038a6f 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -60,16 +60,16 @@ class Cable.Connection events: message: (event) -> {identifier, message} = JSON.parse(event.data) - @consumer.subscribers.notify(identifier, "received", message) + @consumer.subscriptions.notify(identifier, "received", message) open: -> - @consumer.subscribers.reload() + @consumer.subscriptions.reload() close: -> - @consumer.subscribers.notifyAll("disconnected") + @consumer.subscriptions.notifyAll("disconnected") error: -> - @consumer.subscribers.notifyAll("disconnected") + @consumer.subscriptions.notifyAll("disconnected") @closeSilently() toJSON: -> diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index ea0c360b75..1f8ce4eb20 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -10,7 +10,7 @@ class Cable.ConnectionMonitor pingedAt: 8 constructor: (@consumer) -> - @consumer.subscribers.add(this) + @consumer.subscriptions.add(this) @start() connected: -> diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee index 27314ee508..b01ae586aa 100644 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -1,11 +1,23 @@ #= require cable/connection #= require cable/connection_monitor +#= require cable/subscriptions #= require cable/subscription -#= require cable/subscriber_manager +# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, +# the Cable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. +# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription +# method. +# +# The following example shows how this can be setup: +# +# @App = {} +# App.cable = Cable.createConsumer "http://example.com/accounts/1" +# App.appearance = App.cable.createSubscription "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. class Cable.Consumer constructor: (@url) -> - @subscribers = new Cable.SubscriberManager this + @subscriptions = new Cable.Subscriptions this @connection = new Cable.Connection this @connectionMonitor = new Cable.ConnectionMonitor this @@ -21,4 +33,4 @@ class Cable.Consumer JSON.stringify(this, null, 2) toJSON: -> - {@url, @subscribers, @connection, @connectionMonitor} + {@url, @subscriptions, @connection, @connectionMonitor} diff --git a/lib/assets/javascripts/cable/subscriber_manager.js.coffee b/lib/assets/javascripts/cable/subscriber_manager.js.coffee deleted file mode 100644 index 1eef98ff0b..0000000000 --- a/lib/assets/javascripts/cable/subscriber_manager.js.coffee +++ /dev/null @@ -1,41 +0,0 @@ -class Cable.SubscriberManager - constructor: (@consumer) -> - @subscribers = [] - - add: (subscriber) -> - @subscribers.push(subscriber) - @notify(subscriber, "initialized") - if @sendCommand(subscriber, "subscribe") - @notify(subscriber, "connected") - - reload: -> - for subscriber in @subscribers - if @sendCommand(subscriber, "subscribe") - @notify(subscriber, "connected") - - remove: (subscriber) -> - @sendCommand(subscriber, "unsubscribe") - @subscribers = (s for s in @subscribers when s isnt subscriber) - - notifyAll: (callbackName, args...) -> - for subscriber in @subscribers - @notify(subscriber, callbackName, args...) - - notify: (subscriber, callbackName, args...) -> - if typeof subscriber is "string" - subscribers = (s for s in @subscribers when s.identifier is subscriber) - else - subscribers = [subscriber] - - for subscriber in subscribers - subscriber[callbackName]?(args...) - - sendCommand: (subscriber, command) -> - {identifier} = subscriber - if identifier is Cable.PING_IDENTIFIER - @consumer.connection.isOpen() - else - @consumer.send({command, identifier}) - - toJSON: -> - subscriber.identifier for subscriber in @subscribers diff --git a/lib/assets/javascripts/cable/subscription.js.coffee b/lib/assets/javascripts/cable/subscription.js.coffee index 74cc35a7a7..17f5a10868 100644 --- a/lib/assets/javascripts/cable/subscription.js.coffee +++ b/lib/assets/javascripts/cable/subscription.js.coffee @@ -2,7 +2,7 @@ class Cable.Subscription constructor: (@consumer, params = {}, mixin) -> @identifier = JSON.stringify(params) extend(this, mixin) - @consumer.subscribers.add(this) + @consumer.subscriptions.add(this) # Perform a channel action with the optional data passed as an attribute perform: (action, data = {}) -> @@ -13,7 +13,7 @@ class Cable.Subscription @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) unsubscribe: -> - @consumer.subscribers.remove(this) + @consumer.subscriptions.remove(this) extend = (object, properties) -> if properties? diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee new file mode 100644 index 0000000000..7cb008ca67 --- /dev/null +++ b/lib/assets/javascripts/cable/subscriptions.js.coffee @@ -0,0 +1,41 @@ +class Cable.Subscriptions + constructor: (@consumer) -> + @subscriptions = [] + + add: (subscription) -> + @subscriptions.push(subscription) + @notify(subscription, "initialized") + if @sendCommand(subscription, "subscribe") + @notify(subscription, "connected") + + reload: -> + for subscription in @subscriptions + if @sendCommand(subscription, "subscribe") + @notify(subscription, "connected") + + remove: (subscription) -> + @sendCommand(subscription, "unsubscribe") + @subscriptions = (s for s in @subscriptions when s isnt subscription) + + notifyAll: (callbackName, args...) -> + for subscription in @subscriptions + @notify(subscription, callbackName, args...) + + notify: (subscription, callbackName, args...) -> + if typeof subscription is "string" + subscriptions = (s for s in @subscriptions when s.identifier is subscription) + else + subscriptions = [subscription] + + for subscription in subscriptions + subscription[callbackName]?(args...) + + sendCommand: (subscription, command) -> + {identifier} = subscription + if identifier is Cable.PING_IDENTIFIER + @consumer.connection.isOpen() + else + @consumer.send({command, identifier}) + + toJSON: -> + subscription.identifier for subscription in @subscriptions -- cgit v1.2.3 From fdd5c925f5ee668ecf7a21b90b90a5f01b535d13 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 11:06:07 +0200 Subject: Move the subscription factory method from the consumer to the subscriptions collection --- lib/assets/javascripts/cable/consumer.js.coffee | 7 +------ lib/assets/javascripts/cable/subscription.js.coffee | 7 ++++--- lib/assets/javascripts/cable/subscriptions.js.coffee | 5 +++++ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee index b01ae586aa..1df6536831 100644 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -12,7 +12,7 @@ # # @App = {} # App.cable = Cable.createConsumer "http://example.com/accounts/1" -# App.appearance = App.cable.createSubscription "AppearanceChannel" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" # # For more details on how you'd configure an actual channel subscription, see Cable.Subscription. class Cable.Consumer @@ -21,11 +21,6 @@ class Cable.Consumer @connection = new Cable.Connection this @connectionMonitor = new Cable.ConnectionMonitor this - createSubscription: (channelName, mixin) -> - channel = channelName - params = if typeof channel is "object" then channel else {channel} - new Cable.Subscription this, params, mixin - send: (data) -> @connection.send(data) diff --git a/lib/assets/javascripts/cable/subscription.js.coffee b/lib/assets/javascripts/cable/subscription.js.coffee index 17f5a10868..b60033098a 100644 --- a/lib/assets/javascripts/cable/subscription.js.coffee +++ b/lib/assets/javascripts/cable/subscription.js.coffee @@ -1,8 +1,9 @@ class Cable.Subscription - constructor: (@consumer, params = {}, mixin) -> + constructor: (@subscriptions, params = {}, mixin) -> @identifier = JSON.stringify(params) extend(this, mixin) - @consumer.subscriptions.add(this) + @subscriptions.add(this) + @consumer = @subscriptions.consumer # Perform a channel action with the optional data passed as an attribute perform: (action, data = {}) -> @@ -13,7 +14,7 @@ class Cable.Subscription @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) unsubscribe: -> - @consumer.subscriptions.remove(this) + @subscriptions.remove(this) extend = (object, properties) -> if properties? diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee index 7cb008ca67..884257a12d 100644 --- a/lib/assets/javascripts/cable/subscriptions.js.coffee +++ b/lib/assets/javascripts/cable/subscriptions.js.coffee @@ -2,6 +2,11 @@ class Cable.Subscriptions constructor: (@consumer) -> @subscriptions = [] + create: (channelName, mixin) -> + channel = channelName + params = if typeof channel is "object" then channel else {channel} + new Cable.Subscription this, params, mixin + add: (subscription) -> @subscriptions.push(subscription) @notify(subscription, "initialized") -- cgit v1.2.3 From c70892827698bea48ceb723b6920ebf5b607d069 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 14:42:04 +0200 Subject: Document the JavaScript classes --- lib/assets/javascripts/cable/connection.js.coffee | 1 + .../javascripts/cable/connection_monitor.js.coffee | 2 + .../javascripts/cable/subscription.js.coffee | 45 ++++++++++++++++++++++ .../javascripts/cable/subscriptions.js.coffee | 10 +++++ 4 files changed, 58 insertions(+) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 87fd038a6f..464f0c1ff7 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -1,3 +1,4 @@ +# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. class Cable.Connection constructor: (@consumer) -> @open() diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index 1f8ce4eb20..cac65d9043 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -1,3 +1,5 @@ +# Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting +# revival reconnections if things go astray. Internal class, not intended for direct user manipulation. class Cable.ConnectionMonitor identifier: Cable.PING_IDENTIFIER diff --git a/lib/assets/javascripts/cable/subscription.js.coffee b/lib/assets/javascripts/cable/subscription.js.coffee index b60033098a..5b024d4e15 100644 --- a/lib/assets/javascripts/cable/subscription.js.coffee +++ b/lib/assets/javascripts/cable/subscription.js.coffee @@ -1,3 +1,48 @@ +# A new subscription is created through the Cable.Subscriptions instance available on the consumer. +# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding +# Channel instance on the server side. +# +# An example demonstrates the basic functionality: +# +# App.appearance = App.cable.subscriptions.create "AppearanceChannel", +# connected: -> +# # Called once the subscription has been successfully completed +# +# appear: -> +# @perform 'appear', appearing_on: @appearingOn() +# +# away: -> +# @perform 'away' +# +# appearingOn: -> +# $('main').data 'appearing-on' +# +# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server +# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). +# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. +# +# This is how the server component would look: +# +# class AppearanceChannel < ApplicationCable::Channel +# def subscribed +# current_user.appear +# end +# +# def unsubscribed +# current_user.disappear +# end +# +# def appear(data) +# current_user.appear on: data['appearing_on'] +# end +# +# def away +# current_user.away +# end +# end +# +# The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. +# The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method. class Cable.Subscription constructor: (@subscriptions, params = {}, mixin) -> @identifier = JSON.stringify(params) diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee index 884257a12d..e1dfff7511 100644 --- a/lib/assets/javascripts/cable/subscriptions.js.coffee +++ b/lib/assets/javascripts/cable/subscriptions.js.coffee @@ -1,3 +1,11 @@ +# Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user +# us Cable.Subscriptions#create, and it should be called through the consumer like so: +# +# @App = {} +# App.cable = Cable.createConsumer "http://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. class Cable.Subscriptions constructor: (@consumer) -> @subscriptions = [] @@ -7,6 +15,8 @@ class Cable.Subscriptions params = if typeof channel is "object" then channel else {channel} new Cable.Subscription this, params, mixin + # Private + add: (subscription) -> @subscriptions.push(subscription) @notify(subscription, "initialized") -- cgit v1.2.3 From a9c3fd59ca298f854e55faba505d9540709aef10 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:10:28 +0200 Subject: Flush out the README overview of Action Cable --- README | 3 - README.md | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 3 deletions(-) delete mode 100644 README create mode 100644 README.md diff --git a/README b/README deleted file mode 100644 index 4f350e625f..0000000000 --- a/README +++ /dev/null @@ -1,3 +0,0 @@ -# ActionCable - -Action Cable is a framework for realtime communication over websockets. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000..37cb1a642f --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# Action Cable -- Integrated websockets for Rails + +Action Cable seamlessly integrates websockets with the rest of your Rails application. +It allows for real-time features to be written in Ruby in the same style +and form as the rest of your Rails application, while still being performant +and scalable. It's a full-stack offering that provides both a client-side +JavaScript framework and a server-side Ruby framework. You have access to your full +domain model written with ActiveRecord or whatever. + + +## Terminology + +A single Action Cable server can handle multiple connection instances. It goes one +connection instance per websocket connection. A single user may well have multiple +websockets open to your application if they use multiple browser tabs or devices. +The client of a websocket connection is called the consumer. + +Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates +a logical unit of work, similar to what a controller does in a regular MVC setup. So +you may have a ChatChannel and a AppearancesChannel. The consumer can be subscribed to either +or to both. At the very least, a consumer should be subscribed to one channel. + +When the consumer is subscribed to a channel, they act as a subscriber. The connection between +the subscriber and the channel is, surprise-surprise, called a subscription. A consumer +can act as a subscriber to a given channel via a subscription only once. (But remember that +a physical user may have multiple consumers, one per tab/device open to your connection). + +Each channel can then again be streaming zero or more broadcastings. A broadcasting is a +pubsub link where anything transmitted by the broadcaster is sent directly to the channel +subscribers who are streaming that named broadcasting. + +As you can see, this is a fairly deep architectural stack. There's a lot of new terminology +to identify the new pieces, and on top of that, you're dealing with both client and server side +reflections of each unit. + + +## A full-stack example + +The first thing you must do is defined your ApplicationCable::Connection class in Ruby. This +is the place where you do authorization of the incoming connection, and proceed to establish it +if all is well. Here's the simplest example starting with the server-side connection class: + + # app/channels/application_cable/connection.rb + module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + protected + def find_verified_user + if current_user = User.find cookies.signed[:user_id] + current_user + else + reject_unauthorized_connection + end + end + end + end + +This relies on the fact that you will already have handled authentication of the user, and +that a successful authentication sets a signed cookie with the user_id. This cookie is then +automatically sent to the connection instance when a new connection is attempted, and you +use that to set the current_user. By identifying the connection by this same current_user, +you're also ensuring that you can later retrieve all open connections by a given user (and +potentially disconnect them all if the user is deleted or deauthorized). + +The client-side needs to setup a consumer instance of this connection. That's done like so: + + # app/assets/javascripts/cable.coffee + @App = {} + App.cable = Cable.createConsumer "http://cable.example.com" + +The http://cable.example.com address must point to your set of Action Cable servers, and it +must share a cookie namespace with the rest of the application (which may live under http://example.com). +This ensures that the signed cookie will be correctly sent. + +That's all you need to establish the connection! But of course, this isn't very useful in +itself. This just gives you the plumbing. To make stuff happen, you need content. That content +is defined by declaring channels on the server and allowing the consumer to subscribe to them. + + +## Channel example 1: User appearances + +Here's a simple example of a channel that tracks whether a user is online or not and what page they're on. +(That's useful for creating presence features like showing a green dot next to a user name if they're online). + +First you declare the server-side channel: + + # app/channels/appearance_channel.rb + class AppearanceChannel < ApplicationCable::Channel + def subscribed + current_user.appear + end + + def unsubscribed + current_user.disappear + end + + def appear(data) + current_user.appear on: data['appearing_on'] + end + + def away + current_user.away + end + end + +The #subscribed callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, +we take that opportunity to say "the current user has indeed appeared". That appear/disappear API could be backed by +Redis or a database or whatever else. Here's what the client-side of that looks like: + + # app/assets/javascripts/cable/subscriptions/appearance.coffee + App.appearance = App.cable.subscriptions.create "AppearanceChannel", + connected: -> + # Called once the subscription has been successfully completed + + appear: -> + @perform 'appear', appearing_on: @appearingOn() + + away: -> + @perform 'away' + + appearingOn: -> + $('main').data 'appearing-on' + + $(document).on 'page:change', -> + App.appearance.appear() + + $(document).on 'click', '[data-behavior~=appear_away]', -> + App.appearance.away() + false + +Simply calling App.cable.subscriptions.create will setup the subscription, which will call AppearanceChannel#subscribed, +which in turn is linked to original App.consumer -> ApplicationCable::Connection instances. + +We then link App.appearance#appear to AppearanceChannel#appear(data). This is possible because the server-side +channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these +can be reached as remote procedure calls via App.appearance#perform. + +Finally, we expose App.appearance to the machinations of the application itself by hooking the #appear call into the +Turbolinks page:change callback and allowing the user to click a data-behavior link that triggers the #away call. + + +## Channel example 2: Receiving new web notifications + +The appearance example was all about exposing server functionality to client-side invocation over the websocket connection. +But the great thing about websockets is that it's a two-way street. So now let's show an example where the server invokes +action on the client. + +This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right +streams: + + # app/channels/web_notifications.rb + 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 shit fit for print' } + + # Client-side which assumes you've already requested the right to send web notifications + App.cable.subscriptions.create "WebNotificationsChannel", + received: (data) -> + web_notification = new Notification data['title'], body: data['body'] + +The ActionCable.server.broadcast call places a message in the Redis' pubsub queue under the broadcasting name of "web_notifications_1". +The channel has been instructed to stream everything that arrives at "web_notifications_1" directly to the client by invoking the +#received(data) callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip +across the wire, and unpacked for the data argument arriving to #received. + + +## Dependencies + +Action Cable is currently tied to Redis through its use of the pubsub feature to route +messages back and forth over the websocket cable connection. This dependency may well +be alleviated in the future, but for the moment that's what it is. So be sure to have +Redis installed and running. + + +## Deployment + +Action Cable is powered by a combination of EventMachine and threads. The +framework plumbing needed for connection handling is handled in the +EventMachine loop, but the actual channel, user-specified, work is handled +in a normal Ruby thread. This means you can use all your regular Rails models +with no problem, as long as you haven't committed any thread-safety sins. + +But this also means that Action Cable needs to run in its own server process. +So you'll have one set of server processes for your normal web work, and another +set of server processes for the Action Cable. The former can be single-threaded, +like Unicorn, but the latter must be multi-threaded, like Puma. + + +## Alpha disclaimer + +Action Cable is currently considered alpha software. The API is almost guaranteed to change between +now and its first production release as part of Rails 5.0. Real applications using the framework +are all well underway, but as of July 8th, 2015, there are no deployments in the wild yet. + +So this current release, which resides in rails/actioncable, is primarily intended for +the adventurous kind, who do not mind reading the full source code of the framework. And it +serves as an invitation for all those crafty folks to contribute to and test what we have so far, +in advance of that general production release. + +Action Cable will move from rails/actioncable to rails/rails and become a full-fledged default +framework alongside Action Pack, Active Record, and the like once we cross the bridge from alpha +to beta software (which will happen once the API and missing pieces have solidified). + + +## Download and installation + +The latest version of Action Pack can be installed with RubyGems: + + % gem install actionpack + +Source code can be downloaded as part of the Rails project on GitHub + +* https://github.com/rails/rails/tree/master/actionpack + + +## License + +Action Pack is released under the MIT license: + +* http://www.opensource.org/licenses/MIT + + +## Support + +Bug reports can be filed for the alpha development project here: + +* https://github.com/rails/actioncable/issues -- cgit v1.2.3 From 152831ad3ff17f5e8b8f06e28ba2896cc038f322 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:10:33 +0200 Subject: Update for latest API --- lib/action_cable/server/broadcasting.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index c082e87e56..de13e26511 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -14,7 +14,7 @@ module ActionCable # "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' } # # # Client-side coffescript which assumes you've already requested the right to send web notifications - # BC.cable.createSubscription "WebNotificationsChannel", + # App.cable.subscriptions.create "WebNotificationsChannel", # received: (data) -> # web_notification = new Notification data['title'], body: data['body'] module Broadcasting -- cgit v1.2.3 From 102f40e9f6c08651c022ee71d6c1725be9fbf5db Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:13:44 +0200 Subject: Note lack of tests --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 37cb1a642f..e260c15b2f 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,10 @@ Action Cable will move from rails/actioncable to rails/rails and become a full-f framework alongside Action Pack, Active Record, and the like once we cross the bridge from alpha to beta software (which will happen once the API and missing pieces have solidified). +Finally, note that testing is a unfinished, hell unstarted, area of this framework. The framework +has been developed in-app up until this point. We need to find a good way to test both the framework +itself and allow the user to test their connection and channel logic. + ## Download and installation -- cgit v1.2.3 From 36e56fdf1073fe6a80d9cdb4c436261b9c38ab7a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:16:57 +0200 Subject: This is will be a Ruby on Rails framework shortly --- action_cable.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action_cable.gemspec b/action_cable.gemspec index 12d102a739..53ac701a20 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -7,7 +7,7 @@ Gem::Specification.new do |s| s.author = ['Pratik Naik', 'David Heinemeier Hansson'] s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] - s.homepage = 'http://basecamp.com' + s.homepage = 'http://rubyonrails.org' s.add_dependency('activesupport', '>= 4.2.0') s.add_dependency('faye-websocket', '~> 0.9.2') -- cgit v1.2.3 From 9d67374fd0d1455b22bc73fe7f3728c71ed6975c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:20:39 +0200 Subject: Cleanup gemspec --- action_cable.gemspec | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/action_cable.gemspec b/action_cable.gemspec index 53ac701a20..19969378c5 100644 --- a/action_cable.gemspec +++ b/action_cable.gemspec @@ -1,19 +1,21 @@ Gem::Specification.new do |s| - s.platform = Gem::Platform::RUBY - s.name = 'action_cable' - s.version = '0.1.0' - s.summary = 'Framework for websockets.' - s.description = 'Action Cable is a framework for realtime communication over websockets.' + s.name = 'action_cable' + s.version = '0.1.0' + s.summary = 'Websockets framework for Rails.' + s.description = 'Structure many real-time application concerns into channels over a single websockets connection.' + s.license = 'MIT' - s.author = ['Pratik Naik', 'David Heinemeier Hansson'] - s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] + s.author = ['Pratik Naik', 'David Heinemeier Hansson'] + s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] s.homepage = 'http://rubyonrails.org' - s.add_dependency('activesupport', '>= 4.2.0') - s.add_dependency('faye-websocket', '~> 0.9.2') - s.add_dependency('celluloid', '~> 0.16.0') - s.add_dependency('em-hiredis', '~> 0.3.0') - s.add_dependency('redis', '~> 3.0') + s.platform = Gem::Platform::RUBY + + s.add_dependency 'activesupport', '>= 4.2.0' + s.add_dependency 'faye-websocket', '~> 0.9.2' + s.add_dependency 'celluloid', '~> 0.16.0' + s.add_dependency 'em-hiredis', '~> 0.3.0' + s.add_dependency 'redis', '~> 3.0' s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false -- cgit v1.2.3 From e408cc8b246f1ad9bc3b7d0dada97967332631df Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:20:46 +0200 Subject: Note Ruby dependencies --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e260c15b2f..a916b73960 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ messages back and forth over the websocket cable connection. This dependency may be alleviated in the future, but for the moment that's what it is. So be sure to have Redis installed and running. +The Ruby side of things is built on top of Faye-Websocket and Celluiod. ## Deployment -- cgit v1.2.3 From fb902ea2b30eb4cf06f46e7ea0823765ba7944f3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:50:27 +0200 Subject: Update README.md --- README.md | 183 +++++++++++++++++++++++++++++++++----------------------------- 1 file changed, 97 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index a916b73960..f046501825 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Action Cable -- Integrated websockets for Rails +# Action Cable – Integrated websockets for Rails Action Cable seamlessly integrates websockets with the rest of your Rails application. It allows for real-time features to be written in Ruby in the same style @@ -36,29 +36,31 @@ reflections of each unit. ## A full-stack example -The first thing you must do is defined your ApplicationCable::Connection class in Ruby. This +The first thing you must do is defined your `ApplicationCable::Connection` class in Ruby. This is the place where you do authorization of the incoming connection, and proceed to establish it if all is well. Here's the simplest example starting with the server-side connection class: - # app/channels/application_cable/connection.rb - module ApplicationCable - class Connection < ActionCable::Connection::Base - identified_by :current_user - - def connect - self.current_user = find_verified_user - end - - protected - def find_verified_user - if current_user = User.find cookies.signed[:user_id] - current_user - else - reject_unauthorized_connection - end - end +```ruby +# app/channels/application_cable/connection.rb +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user end + + protected + def find_verified_user + if current_user = User.find cookies.signed[:user_id] + current_user + else + reject_unauthorized_connection + end + end end +end +``` This relies on the fact that you will already have handled authentication of the user, and that a successful authentication sets a signed cookie with the user_id. This cookie is then @@ -69,9 +71,11 @@ potentially disconnect them all if the user is deleted or deauthorized). The client-side needs to setup a consumer instance of this connection. That's done like so: - # app/assets/javascripts/cable.coffee - @App = {} - App.cable = Cable.createConsumer "http://cable.example.com" +```coffeescript +# app/assets/javascripts/cable.coffee +@App = {} +App.cable = Cable.createConsumer "http://cable.example.com" +``` The http://cable.example.com address must point to your set of Action Cable servers, and it must share a cookie namespace with the rest of the application (which may live under http://example.com). @@ -89,59 +93,63 @@ Here's a simple example of a channel that tracks whether a user is online or not First you declare the server-side channel: - # app/channels/appearance_channel.rb - class AppearanceChannel < ApplicationCable::Channel - def subscribed - current_user.appear - end - - def unsubscribed - current_user.disappear - end - - def appear(data) - current_user.appear on: data['appearing_on'] - end - - def away - current_user.away - end +```ruby +# app/channels/appearance_channel.rb +class AppearanceChannel < ApplicationCable::Channel + def subscribed + current_user.appear + end + + def unsubscribed + current_user.disappear + end + + def appear(data) + current_user.appear on: data['appearing_on'] + end + + def away + current_user.away end +end +``` -The #subscribed callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, +The `#subscribed` callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, we take that opportunity to say "the current user has indeed appeared". That appear/disappear API could be backed by Redis or a database or whatever else. Here's what the client-side of that looks like: - # app/assets/javascripts/cable/subscriptions/appearance.coffee - App.appearance = App.cable.subscriptions.create "AppearanceChannel", - connected: -> - # Called once the subscription has been successfully completed - - appear: -> - @perform 'appear', appearing_on: @appearingOn() - - away: -> - @perform 'away' - - appearingOn: -> - $('main').data 'appearing-on' - - $(document).on 'page:change', -> - App.appearance.appear() - - $(document).on 'click', '[data-behavior~=appear_away]', -> - App.appearance.away() - false - -Simply calling App.cable.subscriptions.create will setup the subscription, which will call AppearanceChannel#subscribed, -which in turn is linked to original App.consumer -> ApplicationCable::Connection instances. - -We then link App.appearance#appear to AppearanceChannel#appear(data). This is possible because the server-side +```coffeescript +# app/assets/javascripts/cable/subscriptions/appearance.coffee +App.appearance = App.cable.subscriptions.create "AppearanceChannel", + connected: -> + # Called once the subscription has been successfully completed + + appear: -> + @perform 'appear', appearing_on: @appearingOn() + + away: -> + @perform 'away' + + appearingOn: -> + $('main').data 'appearing-on' + +$(document).on 'page:change', -> + App.appearance.appear() + +$(document).on 'click', '[data-behavior~=appear_away]', -> + App.appearance.away() + false +``` + +Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, +which in turn is linked to original `App.consumer` -> `ApplicationCable::Connection` instances. + +We then link `App.appearance#appear` to `AppearanceChannel#appear(data)`. This is possible because the server-side channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these -can be reached as remote procedure calls via App.appearance#perform. +can be reached as remote procedure calls via `App.appearance#perform`. -Finally, we expose App.appearance to the machinations of the application itself by hooking the #appear call into the -Turbolinks page:change callback and allowing the user to click a data-behavior link that triggers the #away call. +Finally, we expose `App.appearance` to the machinations of the application itself by hooking the `#appear` call into the +Turbolinks `page:change` callback and allowing the user to click a data-behavior link that triggers the `#away` call. ## Channel example 2: Receiving new web notifications @@ -153,26 +161,29 @@ action on the client. This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right streams: - # app/channels/web_notifications.rb - class WebNotificationsChannel < ApplicationCable::Channel - def subscribed - stream_from "web_notifications_#{current_user.id}" - end +```ruby +# app/channels/web_notifications.rb +class WebNotificationsChannel < ApplicationCable::Channel + def subscribed + stream_from "web_notifications_#{current_user.id}" end - - # Somewhere in your app this is called, perhaps from a NewCommentJob - ActionCable.server.broadcast \ - "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' } - - # Client-side which assumes you've already requested the right to send web notifications - App.cable.subscriptions.create "WebNotificationsChannel", - received: (data) -> - web_notification = new Notification data['title'], body: data['body'] - -The ActionCable.server.broadcast call places a message in the Redis' pubsub queue under the broadcasting name of "web_notifications_1". + end +``` +```coffeescript +# Somewhere in your app this is called, perhaps from a NewCommentJob +ActionCable.server.broadcast \ + "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' } + +# Client-side which assumes you've already requested the right to send web notifications +App.cable.subscriptions.create "WebNotificationsChannel", + received: (data) -> + web_notification = new Notification data['title'], body: data['body'] +``` + +The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under the broadcasting name of "web_notifications_1". The channel has been instructed to stream everything that arrives at "web_notifications_1" directly to the client by invoking the -#received(data) callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip -across the wire, and unpacked for the data argument arriving to #received. +`#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip +across the wire, and unpacked for the data argument arriving to `#received`. ## Dependencies -- cgit v1.2.3 From cda749243087ca5813e6c0f859ae08fe4fa9596c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:53:37 +0200 Subject: Remove bit about release as it hasn't happened yet --- README.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/README.md b/README.md index f046501825..17527be17a 100644 --- a/README.md +++ b/README.md @@ -229,20 +229,9 @@ has been developed in-app up until this point. We need to find a good way to tes itself and allow the user to test their connection and channel logic. -## Download and installation - -The latest version of Action Pack can be installed with RubyGems: - - % gem install actionpack - -Source code can be downloaded as part of the Rails project on GitHub - -* https://github.com/rails/rails/tree/master/actionpack - - ## License -Action Pack is released under the MIT license: +Action Cable is released under the MIT license: * http://www.opensource.org/licenses/MIT -- cgit v1.2.3 From bd63093304eba598e4a6f2a535624f188fcf1ca5 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 16:29:05 +0200 Subject: Follow the Rails name convention of single word framework names --- action_cable.gemspec | 24 ------------------------ actioncable.gemspec | 24 ++++++++++++++++++++++++ lib/actioncable.rb | 2 ++ 3 files changed, 26 insertions(+), 24 deletions(-) delete mode 100644 action_cable.gemspec create mode 100644 actioncable.gemspec create mode 100644 lib/actioncable.rb diff --git a/action_cable.gemspec b/action_cable.gemspec deleted file mode 100644 index 19969378c5..0000000000 --- a/action_cable.gemspec +++ /dev/null @@ -1,24 +0,0 @@ -Gem::Specification.new do |s| - s.name = 'action_cable' - s.version = '0.1.0' - s.summary = 'Websockets framework for Rails.' - s.description = 'Structure many real-time application concerns into channels over a single websockets connection.' - s.license = 'MIT' - - s.author = ['Pratik Naik', 'David Heinemeier Hansson'] - s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] - s.homepage = 'http://rubyonrails.org' - - s.platform = Gem::Platform::RUBY - - s.add_dependency 'activesupport', '>= 4.2.0' - s.add_dependency 'faye-websocket', '~> 0.9.2' - s.add_dependency 'celluloid', '~> 0.16.0' - s.add_dependency 'em-hiredis', '~> 0.3.0' - s.add_dependency 'redis', '~> 3.0' - - s.files = Dir['README', 'lib/**/*'] - s.has_rdoc = false - - s.require_path = 'lib' -end diff --git a/actioncable.gemspec b/actioncable.gemspec new file mode 100644 index 0000000000..5fdca0b4ff --- /dev/null +++ b/actioncable.gemspec @@ -0,0 +1,24 @@ +Gem::Specification.new do |s| + s.name = 'actioncable' + s.version = '0.1.0' + s.summary = 'Websockets framework for Rails.' + s.description = 'Structure many real-time application concerns into channels over a single websockets connection.' + s.license = 'MIT' + + s.author = ['Pratik Naik', 'David Heinemeier Hansson'] + s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] + s.homepage = 'http://rubyonrails.org' + + s.platform = Gem::Platform::RUBY + + s.add_dependency 'activesupport', '>= 4.2.0' + s.add_dependency 'faye-websocket', '~> 0.9.2' + s.add_dependency 'celluloid', '~> 0.16.0' + s.add_dependency 'em-hiredis', '~> 0.3.0' + s.add_dependency 'redis', '~> 3.0' + + s.files = Dir['README', 'lib/**/*'] + s.has_rdoc = false + + s.require_path = 'lib' +end diff --git a/lib/actioncable.rb b/lib/actioncable.rb new file mode 100644 index 0000000000..f6df6fd063 --- /dev/null +++ b/lib/actioncable.rb @@ -0,0 +1,2 @@ +# Pointer for auto-require +require 'action_cable' \ No newline at end of file -- cgit v1.2.3 From 96405dd9570a5b6debfb1ed1eaeb8004e2428eca Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 17:01:56 +0200 Subject: You can be a subscriber multiple times. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17527be17a..a74ca3f43e 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ or to both. At the very least, a consumer should be subscribed to one channel. When the consumer is subscribed to a channel, they act as a subscriber. The connection between the subscriber and the channel is, surprise-surprise, called a subscription. A consumer -can act as a subscriber to a given channel via a subscription only once. (But remember that -a physical user may have multiple consumers, one per tab/device open to your connection). +can act as a subscriber to a given channel any number of times (like to multiple chat rooms at the same time). +(And remember that a physical user may have multiple consumers, one per tab/device open to your connection). Each channel can then again be streaming zero or more broadcastings. A broadcasting is a pubsub link where anything transmitted by the broadcaster is sent directly to the channel -- cgit v1.2.3 From d2ed66762b6272f49f075b1868eb6642c31f033b Mon Sep 17 00:00:00 2001 From: Nick Quaranto Date: Wed, 8 Jul 2015 12:24:18 -0400 Subject: Some quick documentation edits and add a LICENSE to the repo --- LICENSE | 20 ++++++++++++++++++++ README.md | 30 ++++++++++++++++-------------- 2 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..a4910677eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015 Basecamp, 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. diff --git a/README.md b/README.md index a74ca3f43e..a10276e3a1 100644 --- a/README.md +++ b/README.md @@ -5,25 +5,25 @@ It allows for real-time features to be written in Ruby in the same style and form as the rest of your Rails application, while still being performant and scalable. It's a full-stack offering that provides both a client-side JavaScript framework and a server-side Ruby framework. You have access to your full -domain model written with ActiveRecord or whatever. - +domain model written with ActiveRecord or your ORM of choice. ## Terminology -A single Action Cable server can handle multiple connection instances. It goes one +A single Action Cable server can handle multiple connection instances. It has one connection instance per websocket connection. A single user may well have multiple websockets open to your application if they use multiple browser tabs or devices. The client of a websocket connection is called the consumer. Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates a logical unit of work, similar to what a controller does in a regular MVC setup. So -you may have a ChatChannel and a AppearancesChannel. The consumer can be subscribed to either +you may have a `ChatChannel` and a `AppearancesChannel`. The consumer can be subscribed to either or to both. At the very least, a consumer should be subscribed to one channel. When the consumer is subscribed to a channel, they act as a subscriber. The connection between the subscriber and the channel is, surprise-surprise, called a subscription. A consumer -can act as a subscriber to a given channel any number of times (like to multiple chat rooms at the same time). -(And remember that a physical user may have multiple consumers, one per tab/device open to your connection). +can act as a subscriber to a given channel any number of times. For example, a consumer +could subscribe to multiple chat rooms at the same time. (And remember that a physical user may +have multiple consumers, one per tab/device open to your connection). Each channel can then again be streaming zero or more broadcastings. A broadcasting is a pubsub link where anything transmitted by the broadcaster is sent directly to the channel @@ -36,8 +36,8 @@ reflections of each unit. ## A full-stack example -The first thing you must do is defined your `ApplicationCable::Connection` class in Ruby. This -is the place where you do authorization of the incoming connection, and proceed to establish it +The first thing you must do is define your `ApplicationCable::Connection` class in Ruby. This +is the place where you authorize the incoming connection, and proceed to establish it if all is well. Here's the simplest example starting with the server-side connection class: ```ruby @@ -63,9 +63,9 @@ end ``` This relies on the fact that you will already have handled authentication of the user, and -that a successful authentication sets a signed cookie with the user_id. This cookie is then +that a successful authentication sets a signed cookie with the `user_id`. This cookie is then automatically sent to the connection instance when a new connection is attempted, and you -use that to set the current_user. By identifying the connection by this same current_user, +use that to set the `current_user`. By identifying the connection by this same current_user, you're also ensuring that you can later retrieve all open connections by a given user (and potentially disconnect them all if the user is deleted or deauthorized). @@ -169,6 +169,7 @@ class WebNotificationsChannel < ApplicationCable::Channel end end ``` + ```coffeescript # Somewhere in your app this is called, perhaps from a NewCommentJob ActionCable.server.broadcast \ @@ -180,8 +181,8 @@ App.cable.subscriptions.create "WebNotificationsChannel", web_notification = new Notification data['title'], body: data['body'] ``` -The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under the broadcasting name of "web_notifications_1". -The channel has been instructed to stream everything that arrives at "web_notifications_1" directly to the client by invoking the +The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under the broadcasting name of `web_notifications_1`. +The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the `#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip across the wire, and unpacked for the data argument arriving to `#received`. @@ -193,7 +194,8 @@ messages back and forth over the websocket cable connection. This dependency may be alleviated in the future, but for the moment that's what it is. So be sure to have Redis installed and running. -The Ruby side of things is built on top of Faye-Websocket and Celluiod. +The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluoid](https://github.com/celluloid/celluloid). + ## Deployment @@ -224,7 +226,7 @@ Action Cable will move from rails/actioncable to rails/rails and become a full-f framework alongside Action Pack, Active Record, and the like once we cross the bridge from alpha to beta software (which will happen once the API and missing pieces have solidified). -Finally, note that testing is a unfinished, hell unstarted, area of this framework. The framework +Finally, note that testing is a unfinished/unstarted area of this framework. The framework has been developed in-app up until this point. We need to find a good way to test both the framework itself and allow the user to test their connection and channel logic. -- cgit v1.2.3 From 4c20f1b810595a8eb8b321a24768c7b80be9b98d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 18:30:22 +0200 Subject: Mention the concern about long-lived and stale data --- lib/action_cable/channel/base.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 2c89ccace4..9c23ba382b 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -7,7 +7,10 @@ module ActionCable # 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. + # 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: -- cgit v1.2.3 From a1200616cc22ad266537e31a808a1e90bf4da79f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 22:36:08 +0200 Subject: =?UTF-8?q?Revert=20to=20perform=5Faction=20language=20=E2=80=93?= =?UTF-8?q?=20we're=20already=20using=20process=20for=20the=20connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/connection/subscriptions.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 9c23ba382b..f389e360f6 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -83,7 +83,7 @@ module ActionCable # 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 process_action(data) + def perform_action(data) action = extract_action(data) if processable_action?(action) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 803894c8f6..0411d96413 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -12,7 +12,7 @@ module ActionCable case data['command'] when 'subscribe' then add data when 'unsubscribe' then remove data - when 'message' then process_action data + when 'message' then perform_action data else logger.error "Received unrecognized command in #{data.inspect}" end @@ -41,8 +41,8 @@ module ActionCable subscriptions.delete(data['identifier']) end - def process_action(data) - find(data).process_action ActiveSupport::JSON.decode(data['data']) + def perform_action(data) + find(data).perform_action ActiveSupport::JSON.decode(data['data']) end -- cgit v1.2.3 From e584f49e867de8355fd455e3429d73c3e245dbc7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 22:36:29 +0200 Subject: Include the clearing of database connections configuration by default --- lib/action_cable/server.rb | 2 ++ .../server/worker/clear_database_connections.rb | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 lib/action_cable/server/worker/clear_database_connections.rb diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index e7cc70b68d..919ebd94de 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -4,6 +4,8 @@ module ActionCable autoload :Broadcasting, 'action_cable/server/broadcasting' autoload :Connections, 'action_cable/server/connections' autoload :Configuration, 'action_cable/server/configuration' + autoload :Worker, 'action_cable/server/worker' + autoload :ClearDatabaseConnections, 'action_cable/server/worker/clear_database_connections' end end diff --git a/lib/action_cable/server/worker/clear_database_connections.rb b/lib/action_cable/server/worker/clear_database_connections.rb new file mode 100644 index 0000000000..722d363a41 --- /dev/null +++ b/lib/action_cable/server/worker/clear_database_connections.rb @@ -0,0 +1,22 @@ +module ActionCable + module Server + class Worker + # Clear active connections between units of work so the long-running channel or connection processes do not hoard connections. + module ClearDatabaseConnections + extend ActiveSupport::Concern + + included do + if defined?(ActiveRecord::Base) + set_callback :work, :around, :with_database_connections + end + end + + def with_database_connections + yield + ensure + ActiveRecord::Base.clear_active_connections! + end + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From 0e7175d1e9260834a24bb23d670e4bda44a05795 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 22:47:41 +0200 Subject: Add a process logging file that the config.ru file can require to configure EM and Celluloid logging --- lib/action_cable/process/logging.rb | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 lib/action_cable/process/logging.rb diff --git a/lib/action_cable/process/logging.rb b/lib/action_cable/process/logging.rb new file mode 100644 index 0000000000..bcceff4bec --- /dev/null +++ b/lib/action_cable/process/logging.rb @@ -0,0 +1,6 @@ +EM.error_handler do |e| + puts "Error raised inside the event loop: #{e.message}" + puts e.backtrace.join("\n") +end + +Celluloid.logger = ActionCable.server.logger -- cgit v1.2.3 From 1310e580644f458069161b5b7df6c8c0f7812f7f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 22:47:57 +0200 Subject: Explain the configuration of the framework --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/README.md b/README.md index a10276e3a1..806f2a8273 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,73 @@ The channel has been instructed to stream everything that arrives at `web_notifi across the wire, and unpacked for the data argument arriving to `#received`. +## Configuration + +The only must-configure part of Action Cable is the Redis connection. By default, `ActionCable::Server::Base` will look for a configuration +file in `Rails.root.join('config/redis/cable.yml')`. The file must follow the following format: + +```yaml +production: &production + :url: redis://10.10.3.153:6381 + :host: 10.10.3.153 + :port: 6381 + :timeout: 1 +development: &development + :url: redis://localhost:6379 + :host: localhost + :port: 6379 + :timeout: 1 + :inline: true +test: *development +``` + +This format allows you to specify one configuration per Rails environment. You can also chance the location of the Redis config file in +a Rails initializer with something like: + +```ruby +ActionCable.server.config.redis_path = Rails.root('somewhere/else/cable.yml') +``` + +The other common option to configure is the log tags applied to the per-connection logger. Here's close to what we're using in Basecamp: + +```ruby +ActionCable.server.config.log_tags = [ + -> request { request.env['bc.account_id'] || "no-account" }, + :action_cable, + -> request { request.uuid } +] +``` + +For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. + + +## Starting the cable server + +As mentioned, the cable server(s) is separated from your normal application server. It's still a rack application, but it is its own rack +application. The recommended basic setup is as follows: + +```ruby +# cable/config.ru +require ::File.expand_path('../config/environment', __FILE__) +Rails.application.eager_load! + +require 'action_cable/process/logging' + +run ActionCable.server +``` + +Then you start the server using a binstub in bin/cable ala: +``` +#!/bin/bash +bundle exec puma cable/config.ru -p 28080 +``` + +That'll start a cable server on port 28080. Remember to point your client-side setup against that using something like: +`App.cable.createConsumer('http://basecamp.dev:28080')`. + +Note: We'll get all this abstracted properly when the framework is integrated into Rails. + + ## Dependencies Action Cable is currently tied to Redis through its use of the pubsub feature to route @@ -197,6 +264,7 @@ Redis installed and running. The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluoid](https://github.com/celluloid/celluloid). + ## Deployment Action Cable is powered by a combination of EventMachine and threads. The -- cgit v1.2.3 From f2a02907100f872b5e3dd283c13485ac73065640 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 8 Jul 2015 23:41:44 +0200 Subject: Lock websocket-driver version to 0.5.4 until we can figure out what broke in 0.6.0 --- actioncable.gemspec | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/actioncable.gemspec b/actioncable.gemspec index 5fdca0b4ff..44e435a893 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -11,11 +11,12 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY - s.add_dependency 'activesupport', '>= 4.2.0' - s.add_dependency 'faye-websocket', '~> 0.9.2' - s.add_dependency 'celluloid', '~> 0.16.0' - s.add_dependency 'em-hiredis', '~> 0.3.0' - s.add_dependency 'redis', '~> 3.0' + s.add_dependency 'activesupport', '>= 4.2.0' + s.add_dependency 'faye-websocket', '~> 0.9.2' + s.add_dependency 'websocket-driver', '= 0.5.4' + s.add_dependency 'celluloid', '~> 0.16.0' + s.add_dependency 'em-hiredis', '~> 0.3.0' + s.add_dependency 'redis', '~> 3.0' s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false -- cgit v1.2.3 From 7ea5619e760edc0adc6eb0749113e129221f4a13 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Thu, 9 Jul 2015 10:19:47 +1000 Subject: Fix typo in connection base documentation --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index cef0bcc2e7..ff7a98f777 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -65,7 +65,7 @@ module ActionCable end # Called by the server when a new websocket connection is established. This configures the callbacks intended for overwriting by the user. - # This method should now be called directly. Rely on the #connect (and #disconnect) callback instead. + # This method should not be called directly. Rely on the #connect (and #disconnect) callback instead. def process logger.info started_request_message -- cgit v1.2.3 From 108328facbf0842c15fe9e8098c396d9b9d201ed Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Thu, 9 Jul 2015 11:24:12 +1000 Subject: Update README - fix puma command and requiring environment from cabel/config.ru --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 806f2a8273..fd1e2f8dc5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ websockets open to your application if they use multiple browser tabs or devices The client of a websocket connection is called the consumer. Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates -a logical unit of work, similar to what a controller does in a regular MVC setup. So +a logical unit of work, similar to what a controller does in a regular MVC setup. So you may have a `ChatChannel` and a `AppearancesChannel`. The consumer can be subscribed to either or to both. At the very least, a consumer should be subscribed to one channel. @@ -26,7 +26,7 @@ could subscribe to multiple chat rooms at the same time. (And remember that a ph have multiple consumers, one per tab/device open to your connection). Each channel can then again be streaming zero or more broadcastings. A broadcasting is a -pubsub link where anything transmitted by the broadcaster is sent directly to the channel +pubsub link where anything transmitted by the broadcaster is sent directly to the channel subscribers who are streaming that named broadcasting. As you can see, this is a fairly deep architectural stack. There's a lot of new terminology @@ -148,7 +148,7 @@ We then link `App.appearance#appear` to `AppearanceChannel#appear(data)`. This i channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these can be reached as remote procedure calls via `App.appearance#perform`. -Finally, we expose `App.appearance` to the machinations of the application itself by hooking the `#appear` call into the +Finally, we expose `App.appearance` to the machinations of the application itself by hooking the `#appear` call into the Turbolinks `page:change` callback and allowing the user to click a data-behavior link that triggers the `#away` call. @@ -234,7 +234,7 @@ application. The recommended basic setup is as follows: ```ruby # cable/config.ru -require ::File.expand_path('../config/environment', __FILE__) +require ::File.expand_path('../../config/environment', __FILE__) Rails.application.eager_load! require 'action_cable/process/logging' @@ -245,7 +245,7 @@ run ActionCable.server Then you start the server using a binstub in bin/cable ala: ``` #!/bin/bash -bundle exec puma cable/config.ru -p 28080 +bundle exec puma -p 28080 cable/config.ru ``` That'll start a cable server on port 28080. Remember to point your client-side setup against that using something like: @@ -268,7 +268,7 @@ The Ruby side of things is built on top of [faye-websocket](https://github.com/f ## Deployment Action Cable is powered by a combination of EventMachine and threads. The -framework plumbing needed for connection handling is handled in the +framework plumbing needed for connection handling is handled in the EventMachine loop, but the actual channel, user-specified, work is handled in a normal Ruby thread. This means you can use all your regular Rails models with no problem, as long as you haven't committed any thread-safety sins. -- cgit v1.2.3 From 5f962b7334c1edda1ed4042ae225f62d7e1fe7e0 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Wed, 8 Jul 2015 19:29:54 -0600 Subject: Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 806f2a8273..f3ea218b4c 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ development: &development test: *development ``` -This format allows you to specify one configuration per Rails environment. You can also chance the location of the Redis config file in +This format allows you to specify one configuration per Rails environment. You can also change the location of the Redis config file in a Rails initializer with something like: ```ruby -- cgit v1.2.3 From 9f2345749201e3a39a108ac46dddfbb171bd9441 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Thu, 9 Jul 2015 13:07:54 +1000 Subject: Up[date README sample code so there is no syntax error --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 806f2a8273..836561993c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ websockets open to your application if they use multiple browser tabs or devices The client of a websocket connection is called the consumer. Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates -a logical unit of work, similar to what a controller does in a regular MVC setup. So +a logical unit of work, similar to what a controller does in a regular MVC setup. So you may have a `ChatChannel` and a `AppearancesChannel`. The consumer can be subscribed to either or to both. At the very least, a consumer should be subscribed to one channel. @@ -26,7 +26,7 @@ could subscribe to multiple chat rooms at the same time. (And remember that a ph have multiple consumers, one per tab/device open to your connection). Each channel can then again be streaming zero or more broadcastings. A broadcasting is a -pubsub link where anything transmitted by the broadcaster is sent directly to the channel +pubsub link where anything transmitted by the broadcaster is sent directly to the channel subscribers who are streaming that named broadcasting. As you can see, this is a fairly deep architectural stack. There's a lot of new terminology @@ -52,7 +52,7 @@ module ApplicationCable protected def find_verified_user - if current_user = User.find cookies.signed[:user_id] + if current_user = User.find(cookies.signed[:user_id]) current_user else reject_unauthorized_connection @@ -148,7 +148,7 @@ We then link `App.appearance#appear` to `AppearanceChannel#appear(data)`. This i channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these can be reached as remote procedure calls via `App.appearance#perform`. -Finally, we expose `App.appearance` to the machinations of the application itself by hooking the `#appear` call into the +Finally, we expose `App.appearance` to the machinations of the application itself by hooking the `#appear` call into the Turbolinks `page:change` callback and allowing the user to click a data-behavior link that triggers the `#away` call. @@ -268,7 +268,7 @@ The Ruby side of things is built on top of [faye-websocket](https://github.com/f ## Deployment Action Cable is powered by a combination of EventMachine and threads. The -framework plumbing needed for connection handling is handled in the +framework plumbing needed for connection handling is handled in the EventMachine loop, but the actual channel, user-specified, work is handled in a normal Ruby thread. This means you can use all your regular Rails models with no problem, as long as you haven't committed any thread-safety sins. -- cgit v1.2.3 From 6c0afaf967b2897a6f998da70814cd694a7fe1f1 Mon Sep 17 00:00:00 2001 From: Jeffrey Hardy Date: Wed, 8 Jul 2015 22:45:58 -0400 Subject: ActionCable.server should always return the same instance --- lib/action_cable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 5f1f3bec35..fac83cb4c3 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -28,6 +28,6 @@ module ActionCable # Singleton instance of the server module_function def server - ActionCable::Server::Base.new + @server ||= ActionCable::Server::Base.new end end -- cgit v1.2.3 From 42ac768562a981cbede1d8c4119eb5304a62dd83 Mon Sep 17 00:00:00 2001 From: Jeffrey Hardy Date: Wed, 8 Jul 2015 23:12:57 -0400 Subject: Freshen Gemfile.lock Generated from a fresh `bundle install` * Add websocket-driver * Update activesupport * Rename `actioncable` -> `action_cable` --- Gemfile.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4e2ff79c81..578fc63b02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,17 +1,18 @@ PATH remote: . specs: - action_cable (0.1.0) + actioncable (0.1.0) activesupport (>= 4.2.0) celluloid (~> 0.16.0) em-hiredis (~> 0.3.0) faye-websocket (~> 0.9.2) redis (~> 3.0) + websocket-driver (= 0.5.4) GEM remote: http://rubygems.org/ specs: - activesupport (4.2.2) + activesupport (4.2.3) i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) @@ -49,7 +50,7 @@ PLATFORMS ruby DEPENDENCIES - action_cable! + actioncable! puma rake -- cgit v1.2.3 From 502d47d7f6b5bf9533676c44b9e325dc3feca3c0 Mon Sep 17 00:00:00 2001 From: Jeffrey Hardy Date: Wed, 8 Jul 2015 23:22:56 -0400 Subject: Fix CoffeeScript syntax in code examples --- README.md | 6 +++--- lib/action_cable/server/broadcasting.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 50055fba10..c0d91fdba4 100644 --- a/README.md +++ b/README.md @@ -173,12 +173,12 @@ class WebNotificationsChannel < ApplicationCable::Channel ```coffeescript # Somewhere in your app this is called, perhaps from a NewCommentJob ActionCable.server.broadcast \ - "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' } + "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' } # Client-side which assumes you've already requested the right to send web notifications App.cable.subscriptions.create "WebNotificationsChannel", - received: (data) -> - web_notification = new Notification data['title'], body: data['body'] + received: (data) -> + new Notification data['title'], body: data['body'] ``` The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under the broadcasting name of `web_notifications_1`. diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index de13e26511..4f72ffd96f 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -16,7 +16,7 @@ module ActionCable # # Client-side coffescript which assumes you've already requested the right to send web notifications # App.cable.subscriptions.create "WebNotificationsChannel", # received: (data) -> - # web_notification = new Notification data['title'], body: data['body'] + # new Notification data['title'], body: data['body'] module Broadcasting # Broadcast a hash directly to a named broadcasting. It'll automatically be JSON encoded. def broadcast(broadcasting, message) -- cgit v1.2.3 From 79b05aece11e5a82a648e5a4893ff75e38334d07 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Thu, 9 Jul 2015 17:21:11 +1000 Subject: update documentation to use websocket protocol --- README.md | 6 +++--- lib/assets/javascripts/cable/consumer.js.coffee | 4 ++-- lib/assets/javascripts/cable/subscriptions.js.coffee | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index efa3b52dc0..421b7bedd3 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,10 @@ The client-side needs to setup a consumer instance of this connection. That's do ```coffeescript # app/assets/javascripts/cable.coffee @App = {} -App.cable = Cable.createConsumer "http://cable.example.com" +App.cable = Cable.createConsumer "ws://cable.example.com" ``` -The http://cable.example.com address must point to your set of Action Cable servers, and it +The ws://cable.example.com address must point to your set of Action Cable servers, and it must share a cookie namespace with the rest of the application (which may live under http://example.com). This ensures that the signed cookie will be correctly sent. @@ -249,7 +249,7 @@ bundle exec puma -p 28080 cable/config.ru ``` That'll start a cable server on port 28080. Remember to point your client-side setup against that using something like: -`App.cable.createConsumer('http://basecamp.dev:28080')`. +`App.cable.createConsumer('ws://basecamp.dev:28080')`. Note: We'll get all this abstracted properly when the framework is integrated into Rails. diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee index 1df6536831..05a7398e79 100644 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ b/lib/assets/javascripts/cable/consumer.js.coffee @@ -3,7 +3,7 @@ #= require cable/subscriptions #= require cable/subscription -# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, +# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, # the Cable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. # The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription # method. @@ -11,7 +11,7 @@ # The following example shows how this can be setup: # # @App = {} -# App.cable = Cable.createConsumer "http://example.com/accounts/1" +# App.cable = Cable.createConsumer "ws://example.com/accounts/1" # App.appearance = App.cable.subscriptions.create "AppearanceChannel" # # For more details on how you'd configure an actual channel subscription, see Cable.Subscription. diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee index e1dfff7511..3bc53f2d6a 100644 --- a/lib/assets/javascripts/cable/subscriptions.js.coffee +++ b/lib/assets/javascripts/cable/subscriptions.js.coffee @@ -2,7 +2,7 @@ # us Cable.Subscriptions#create, and it should be called through the consumer like so: # # @App = {} -# App.cable = Cable.createConsumer "http://example.com/accounts/1" +# App.cable = Cable.createConsumer "ws://example.com/accounts/1" # App.appearance = App.cable.subscriptions.create "AppearanceChannel" # # For more details on how you'd configure an actual channel subscription, see Cable.Subscription. -- cgit v1.2.3 From 496bb3883c5e09c06efd2c76246f15cba5d8baf1 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Thu, 9 Jul 2015 17:50:50 +1000 Subject: update README to include creating the ApplicationCabel::Channel --- README.md | 12 ++++++++++++ lib/action_cable/channel/base.rb | 20 ++++++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index efa3b52dc0..f16dfe7377 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,18 @@ module ApplicationCable end ``` +Then you should define your `ApplicationCable::Channel` class in Ruby. This is the place where you put +shared logic between your channels. + +```ruby +# app/channels/application_cable/channel.rb +```ruby +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end +``` + This relies on the fact that you will already have handled authentication of the user, and that a successful authentication sets a signed cookie with the `user_id`. This cookie is then automatically sent to the connection instance when a new connection is attempted, and you diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index f389e360f6..554aca7ffb 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -7,7 +7,7 @@ module ActionCable # 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. + # 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. @@ -15,7 +15,7 @@ module ActionCable # 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 < ApplicationChannel + # class ChatChannel < ApplicationCable::Channel # def subscribed # @room = Chat::Room[params[:room_number]] # end @@ -39,19 +39,19 @@ module ActionCable # 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) @@ -93,7 +93,7 @@ module ActionCable end end - # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. + # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. def unsubscribe_from_channel run_unsubscribe_callbacks @@ -113,8 +113,8 @@ module ActionCable def unsubscribed # Override in subclasses end - - # Transmit a hash of data to the subscriber. The hash will automatically be wrapped in a JSON envelope with + + # 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) logger.info "#{self.class.name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } @@ -139,7 +139,7 @@ module ActionCable def dispatch_action(action, data) logger.info action_signature(action, data) - + if method(action).arity == 1 public_send action, data else -- cgit v1.2.3 From ad5d4b892a584f53a45fc659cdd19b724902a3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Ahlb=C3=A4ck?= Date: Thu, 9 Jul 2015 11:28:23 +0200 Subject: Update README.md Fix double ` ```ruby ` within the same code block. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 43bd89bb32..3fbbfac5e4 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,6 @@ shared logic between your channels. ```ruby # app/channels/application_cable/channel.rb -```ruby module ApplicationCable class Channel < ActionCable::Channel::Base end -- cgit v1.2.3 From 6661d78eb5fed567886e33806223d386b68d43fd Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Fri, 10 Jul 2015 08:39:34 +1000 Subject: Update readme to include requiring the cable js file and rename the example file for creating App.cable not conflict with the action cable js libs --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3fbbfac5e4..a14b382776 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,15 @@ potentially disconnect them all if the user is deleted or deauthorized). The client-side needs to setup a consumer instance of this connection. That's done like so: +```javascript +//app/assets/javascripts/application.js + +//= require cable +``` + ```coffeescript -# app/assets/javascripts/cable.coffee +# app/assets/javascripts/application_cable.coffee + @App = {} App.cable = Cable.createConsumer "ws://cable.example.com" ``` -- cgit v1.2.3 From 795e1e4f3d86ed3350ead6abd90bd9b26775cc7d Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Sat, 11 Jul 2015 02:39:04 -0300 Subject: small typo on README.md celluoid -> celluloid --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a14b382776..3d1831d14f 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ messages back and forth over the websocket cable connection. This dependency may be alleviated in the future, but for the moment that's what it is. So be sure to have Redis installed and running. -The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluoid](https://github.com/celluloid/celluloid). +The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluloid](https://github.com/celluloid/celluloid). -- cgit v1.2.3 From 7f9c8eec2f1f79a97e054c8dcb5353e52c932508 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 11 Jul 2015 11:08:42 +0200 Subject: Note the need for a big DB connection pool to match worker pool size --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3d1831d14f..15d4611c67 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ ActionCable.server.config.log_tags = [ For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. +Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 100, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. ## Starting the cable server -- cgit v1.2.3 From 763d499d9e7e5502c945e3bacfb02f573e9c2fdd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 11 Jul 2015 11:10:27 +0200 Subject: Note that there is no auto-reloading of classes in the cable server --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 15d4611c67..d0acd832c9 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,8 @@ bundle exec puma -p 28080 cable/config.ru That'll start a cable server on port 28080. Remember to point your client-side setup against that using something like: `App.cable.createConsumer('ws://basecamp.dev:28080')`. +Beware that currently the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. + Note: We'll get all this abstracted properly when the framework is integrated into Rails. -- cgit v1.2.3 From cbc73069788abc62fca5cf33ae5a0a4f63937fb1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 11 Jul 2015 11:25:11 +0200 Subject: Add automatic delegations from channel to connection identifiers --- lib/action_cable/channel/base.rb | 13 +++++++++++++ lib/action_cable/connection/identification.rb | 3 +++ 2 files changed, 16 insertions(+) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 554aca7ffb..87ae3a1211 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -61,6 +61,9 @@ module ActionCable # In this example, subscribed/unsubscribed are not callable methods, as they were already declared in ActionCable::Channel::Base, but #appear/away # are. #generate_connection_token is also not callable as its 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, 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. class Base include Callbacks include PeriodicTimers @@ -77,6 +80,7 @@ module ActionCable @identifier = identifier @params = params + delegate_connection_identifiers subscribe_to_channel end @@ -123,6 +127,15 @@ module ActionCable private + def delegate_connection_identifiers + connection.identifiers.each do |identifier| + define_singleton_method(identifier) do + connection.send(identifier) + end + end + end + + def subscribe_to_channel logger.info "#{self.class.name} subscribing" run_subscribe_callbacks diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 3ea3b77e56..113e41ca3f 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -11,6 +11,9 @@ module ActionCable class_methods do # Mark a key as being a connection identifier index that can then 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 -- cgit v1.2.3 From 849278dae77cb3f10823ccd9a0fdc1181380ce17 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 11 Jul 2015 16:14:17 +0200 Subject: Have to require redis in case it wasnt already --- lib/action_cable.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index fac83cb4c3..968adafc25 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -13,6 +13,7 @@ require 'active_support/callbacks' require 'faye/websocket' require 'celluloid' require 'em-hiredis' +require 'redis' require 'action_cable/engine' if defined?(Rails) -- cgit v1.2.3 From fc8bb71872f483e26230fdd1f26368967cbc90f2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 12 Jul 2015 08:31:17 +0200 Subject: Link to example repo --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d0acd832c9..45daac5f46 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,9 @@ The channel has been instructed to stream everything that arrives at `web_notifi `#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip across the wire, and unpacked for the data argument arriving to `#received`. +## More complete examples + +See the [rails/actioncable-examples](http://github.com/rails/actioncable-examples) repository for a full example of how to setup Action Cable in a Rails app and adding channels. ## Configuration -- cgit v1.2.3 From f207245cc76f24c691daf7d223f33c247d0dc66c Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 11 Jul 2015 20:16:18 -0500 Subject: Load mocha for tests --- Gemfile | 1 + Gemfile.lock | 4 ++++ test/test_helper.rb | 2 ++ 3 files changed, 7 insertions(+) diff --git a/Gemfile b/Gemfile index 7dfe51bf00..ba63fae6d0 100644 --- a/Gemfile +++ b/Gemfile @@ -4,5 +4,6 @@ gemspec group :test do gem 'rake' gem 'puma' + gem 'mocha' end diff --git a/Gemfile.lock b/Gemfile.lock index 578fc63b02..c1729aa227 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -31,7 +31,10 @@ GEM hitimes (1.2.2) i18n (0.7.0) json (1.8.3) + metaclass (0.0.4) minitest (5.7.0) + mocha (1.1.0) + metaclass (~> 0.0.1) puma (2.10.2) rack (>= 1.1, < 2.0) rack (1.6.0) @@ -51,6 +54,7 @@ PLATFORMS DEPENDENCIES actioncable! + mocha puma rake diff --git a/test/test_helper.rb b/test/test_helper.rb index 2b1ddb237f..f3b994a9fc 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,8 @@ require "bundler" gem 'minitest' require "minitest/autorun" +require 'mocha/mini_test' + Bundler.setup Bundler.require :default, :test -- cgit v1.2.3 From 8fde483eb02f6d89eaab8590a4ddcd406d74900f Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sat, 11 Jul 2015 20:27:44 -0500 Subject: Tests for the Channel API --- test/channel_test.rb | 138 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 120 insertions(+), 18 deletions(-) diff --git a/test/channel_test.rb b/test/channel_test.rb index 145308e3fc..3f7847a12d 100644 --- a/test/channel_test.rb +++ b/test/channel_test.rb @@ -1,20 +1,122 @@ require 'test_helper' -# FIXME: Currently busted. -# -# class ChannelTest < ActionCableTest -# class PingChannel < ActionCable::Channel::Base -# end -# -# class PingServer < ActionCable::Server::Base -# register_channels PingChannel -# end -# -# def app -# PingServer -# end -# -# test "channel callbacks" do -# ws = Faye::WebSocket::Client.new(websocket_url) -# end -# end \ No newline at end of file +class ChannelTest < ActiveSupport::TestCase + Room = Struct.new(:id) + User = Struct.new(:name) + + class TestConnection + attr_reader :identifiers, :logger, :current_user, :transmissions + + def initialize(user) + @identifiers = [ :current_user ] + + @current_user = user + @logger = Logger.new(StringIO.new) + @transmissions = [] + end + + def transmit(data) + @transmissions << data + end + + def last_transmission + @transmissions.last + end + end + + class ChatChannel < ActionCable::Channel::Base + attr_reader :room, :last_action + on_subscribe :toggle_subscribed + on_unsubscribe :toggle_subscribed + + 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 subscribed? + @subscribed + end + + def get_latest + transmit data: 'latest' + end + + private + def rm_rf + @last_action = [ :rm_rf ] + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + @channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + end + + test "should subscribe to a channel on initialize" do + assert_equal 1, @channel.room.id + end + + test "on subscribe callbacks" do + assert @channel.subscribed + end + + test "channel params" do + assert_equal({ id: 1 }, @channel.params) + end + + test "unsubscribing from a channel" do + assert @channel.room + assert @channel.subscribed? + + @channel.unsubscribe_from_channel + + assert ! @channel.room + assert ! @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 "try calling a private method" do + @channel.perform_action 'action' => :rm_rf + assert_nil @channel.last_action + end + + test "transmitting data" do + @channel.perform_action 'action' => :get_latest + + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "message" => { "data" => "latest" } + assert_equal expected, @connection.last_transmission + end +end -- cgit v1.2.3 From 53c82f6dc036fab537b67b011ae604af646a58a3 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sun, 12 Jul 2015 10:06:56 -0500 Subject: Add actionpack as a dependency --- Gemfile.lock | 33 ++++++++++++++++++++++++++++++++- actioncable.gemspec | 1 + lib/action_cable/connection/base.rb | 7 ++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c1729aa227..46a6e8ec46 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: actioncable (0.1.0) + actionpack (>= 4.2.0) activesupport (>= 4.2.0) celluloid (~> 0.16.0) em-hiredis (~> 0.3.0) @@ -12,17 +13,32 @@ PATH GEM remote: http://rubygems.org/ specs: - activesupport (4.2.3) + actionpack (4.2.1) + actionview (= 4.2.1) + activesupport (= 4.2.1) + rack (~> 1.6) + rack-test (~> 0.6.2) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.1) + actionview (4.2.1) + activesupport (= 4.2.1) + builder (~> 3.1) + erubis (~> 2.7.0) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.1) + activesupport (4.2.1) i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) + builder (3.2.2) celluloid (0.16.0) timers (~> 4.0.0) em-hiredis (0.3.0) eventmachine (~> 1.0) hiredis (~> 0.5.0) + erubis (2.7.0) eventmachine (1.0.7) faye-websocket (0.9.2) eventmachine (>= 0.12.0) @@ -31,13 +47,28 @@ GEM hitimes (1.2.2) i18n (0.7.0) json (1.8.3) + loofah (2.0.2) + nokogiri (>= 1.5.9) metaclass (0.0.4) + mini_portile (0.6.2) minitest (5.7.0) mocha (1.1.0) metaclass (~> 0.0.1) + nokogiri (1.6.6.2) + mini_portile (~> 0.6.0) puma (2.10.2) rack (>= 1.1, < 2.0) rack (1.6.0) + rack-test (0.6.3) + rack (>= 1.0) + rails-deprecated_sanitizer (1.0.3) + activesupport (>= 4.2.0.alpha) + rails-dom-testing (1.0.6) + activesupport (>= 4.2.0.beta, < 5.0) + nokogiri (~> 1.6.0) + rails-deprecated_sanitizer (>= 1.0.1) + rails-html-sanitizer (1.0.2) + loofah (~> 2.0) rake (10.4.2) redis (3.2.1) thread_safe (0.3.5) diff --git a/actioncable.gemspec b/actioncable.gemspec index 44e435a893..4103a51314 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -12,6 +12,7 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.add_dependency 'activesupport', '>= 4.2.0' + s.add_dependency 'actionpack', '>= 4.2.0' s.add_dependency 'faye-websocket', '~> 0.9.2' s.add_dependency 'websocket-driver', '= 0.5.4' s.add_dependency 'celluloid', '~> 0.16.0' diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index ff7a98f777..5671dc1f88 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -1,3 +1,5 @@ +require 'action_dispatch/http/request' + module ActionCable module Connection # For every websocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent @@ -117,7 +119,10 @@ module ActionCable protected # The request that initiated the websocket connection is available here. This gives access to the environment, cookies, etc. def request - @request ||= ActionDispatch::Request.new(Rails.application.env_config.merge(env)) + @request ||= begin + environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + ActionDispatch::Request.new(environment || env) + end end # The cookies of the request that initiated the websocket connection. Useful for performing authorization checks. -- cgit v1.2.3 From 1af531dcf7b8d9ee4237ea5fd392b43875309954 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sun, 12 Jul 2015 10:07:31 -0500 Subject: Add some more tests --- lib/action_cable/channel/periodic_timers.rb | 2 +- test/channel/base_test.rb | 102 +++++++++++++++++++++++ test/channel/periodic_timers_test.rb | 41 ++++++++++ test/channel/stream_test.rb | 25 ++++++ test/channel_test.rb | 122 ---------------------------- test/connection/base_test.rb | 17 ++++ test/stubs/test_connection.rb | 26 ++++++ test/stubs/test_server.rb | 10 +++ test/stubs/user.rb | 7 ++ test/test_helper.rb | 3 +- 10 files changed, 230 insertions(+), 125 deletions(-) create mode 100644 test/channel/base_test.rb create mode 100644 test/channel/periodic_timers_test.rb create mode 100644 test/channel/stream_test.rb delete mode 100644 test/channel_test.rb create mode 100644 test/connection/base_test.rb create mode 100644 test/stubs/test_connection.rb create mode 100644 test/stubs/test_server.rb create mode 100644 test/stubs/user.rb diff --git a/lib/action_cable/channel/periodic_timers.rb b/lib/action_cable/channel/periodic_timers.rb index fea957563f..9bdcc87aa5 100644 --- a/lib/action_cable/channel/periodic_timers.rb +++ b/lib/action_cable/channel/periodic_timers.rb @@ -2,7 +2,7 @@ module ActionCable module Channel module PeriodicTimers extend ActiveSupport::Concern - + included do class_attribute :periodic_timers, instance_reader: false self.periodic_timers = [] diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb new file mode 100644 index 0000000000..e525631642 --- /dev/null +++ b/test/channel/base_test.rb @@ -0,0 +1,102 @@ +require 'test_helper' +require 'stubs/test_connection' + +class ActionCable::Channel::BaseTest < ActiveSupport::TestCase + Room = Struct.new(:id) + + class ChatChannel < ActionCable::Channel::Base + attr_reader :room, :last_action + on_subscribe :toggle_subscribed + on_unsubscribe :toggle_subscribed + + 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 subscribed? + @subscribed + end + + def get_latest + transmit data: 'latest' + end + + private + def rm_rf + @last_action = [ :rm_rf ] + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + @channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + end + + test "should subscribe to a channel on initialize" do + assert_equal 1, @channel.room.id + end + + test "on subscribe callbacks" do + assert @channel.subscribed + end + + test "channel params" do + assert_equal({ id: 1 }, @channel.params) + end + + test "unsubscribing from a channel" do + assert @channel.room + assert @channel.subscribed? + + @channel.unsubscribe_from_channel + + assert ! @channel.room + assert ! @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 "try calling a private method" do + @channel.perform_action 'action' => :rm_rf + assert_nil @channel.last_action + end + + test "transmitting data" do + @channel.perform_action 'action' => :get_latest + + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "message" => { "data" => "latest" } + assert_equal expected, @connection.last_transmission + end +end diff --git a/test/channel/periodic_timers_test.rb b/test/channel/periodic_timers_test.rb new file mode 100644 index 0000000000..96d3e01783 --- /dev/null +++ b/test/channel/periodic_timers_test.rb @@ -0,0 +1,41 @@ +require 'test_helper' +require 'stubs/test_connection' + +class ActionCable::Channel::PeriodicTimersTest < ActiveSupport::TestCase + Room = Struct.new(:id) + + class ChatChannel < ActionCable::Channel::Base + periodically -> { ping }, every: 5 + periodically :send_updates, every: 1 + + private + def ping + end + end + + setup do + @connection = TestConnection.new + end + + test "periodic timers definition" do + timers = ChatChannel.periodic_timers + + assert_equal 2, timers.size + + first_timer = timers[0] + assert_kind_of Proc, first_timer[0] + assert_equal 5, first_timer[1][:every] + + second_timer = timers[1] + assert_equal :send_updates, second_timer[0] + assert_equal 1, second_timer[1][:every] + end + + test "timer start and stop" do + EventMachine::PeriodicTimer.expects(:new).times(2).returns(true) + channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + + channel.expects(:stop_periodic_timers).once + channel.unsubscribe_from_channel + end +end diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb new file mode 100644 index 0000000000..2f4c988adf --- /dev/null +++ b/test/channel/stream_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' +require 'stubs/test_connection' + +class ActionCable::Channel::StreamTest < ActiveSupport::TestCase + Room = Struct.new(:id) + + class ChatChannel < ActionCable::Channel::Base + def subscribed + @room = Room.new params[:id] + stream_from "test_room_#{@room.id}" + end + end + + setup do + @connection = TestConnection.new + end + + test "streaming start and stop" do + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe) } + channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } + channel.unsubscribe_from_channel + end +end diff --git a/test/channel_test.rb b/test/channel_test.rb deleted file mode 100644 index 3f7847a12d..0000000000 --- a/test/channel_test.rb +++ /dev/null @@ -1,122 +0,0 @@ -require 'test_helper' - -class ChannelTest < ActiveSupport::TestCase - Room = Struct.new(:id) - User = Struct.new(:name) - - class TestConnection - attr_reader :identifiers, :logger, :current_user, :transmissions - - def initialize(user) - @identifiers = [ :current_user ] - - @current_user = user - @logger = Logger.new(StringIO.new) - @transmissions = [] - end - - def transmit(data) - @transmissions << data - end - - def last_transmission - @transmissions.last - end - end - - class ChatChannel < ActionCable::Channel::Base - attr_reader :room, :last_action - on_subscribe :toggle_subscribed - on_unsubscribe :toggle_subscribed - - 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 subscribed? - @subscribed - end - - def get_latest - transmit data: 'latest' - end - - private - def rm_rf - @last_action = [ :rm_rf ] - end - end - - setup do - @user = User.new "lifo" - @connection = TestConnection.new(@user) - @channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } - end - - test "should subscribe to a channel on initialize" do - assert_equal 1, @channel.room.id - end - - test "on subscribe callbacks" do - assert @channel.subscribed - end - - test "channel params" do - assert_equal({ id: 1 }, @channel.params) - end - - test "unsubscribing from a channel" do - assert @channel.room - assert @channel.subscribed? - - @channel.unsubscribe_from_channel - - assert ! @channel.room - assert ! @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 "try calling a private method" do - @channel.perform_action 'action' => :rm_rf - assert_nil @channel.last_action - end - - test "transmitting data" do - @channel.perform_action 'action' => :get_latest - - expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "message" => { "data" => "latest" } - assert_equal expected, @connection.last_transmission - end -end diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb new file mode 100644 index 0000000000..66e303a804 --- /dev/null +++ b/test/connection/base_test.rb @@ -0,0 +1,17 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::BaseTest < ActiveSupport::TestCase + setup do + @server = TestServer.new + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = ActionCable::Connection::Base.new(@server, env) + end + + test "making a connection with invalid headers" do + connection = ActionCable::Connection::Base.new(@server, Rack::MockRequest.env_for("/test")) + response = connection.process + assert_equal 404, response[0] + end +end diff --git a/test/stubs/test_connection.rb b/test/stubs/test_connection.rb new file mode 100644 index 0000000000..1c353f9ee3 --- /dev/null +++ b/test/stubs/test_connection.rb @@ -0,0 +1,26 @@ +require 'stubs/user' + +class TestConnection + attr_reader :identifiers, :logger, :current_user, :transmissions + + def initialize(user = User.new("lifo")) + @identifiers = [ :current_user ] + + @current_user = user + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @transmissions = [] + end + + def transmit(data) + @transmissions << data + end + + def last_transmission + @transmissions.last + end + + # Disable async in tests + def send_async(method, *arguments) + send method, *arguments + end +end diff --git a/test/stubs/test_server.rb b/test/stubs/test_server.rb new file mode 100644 index 0000000000..aec80859c3 --- /dev/null +++ b/test/stubs/test_server.rb @@ -0,0 +1,10 @@ +require 'ostruct' + +class TestServer + attr_reader :logger, :config + + def initialize + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @config = OpenStruct.new(log_tags: []) + end +end diff --git a/test/stubs/user.rb b/test/stubs/user.rb new file mode 100644 index 0000000000..af90007af7 --- /dev/null +++ b/test/stubs/user.rb @@ -0,0 +1,7 @@ +class User + attr_reader :name + + def initialize(name) + @name = name + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index f3b994a9fc..5b8ba110db 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,12 +4,11 @@ require "bundler" gem 'minitest' require "minitest/autorun" -require 'mocha/mini_test' - Bundler.setup Bundler.require :default, :test require 'puma' +require 'mocha/mini_test' require 'action_cable' ActiveSupport.test_order = :sorted -- cgit v1.2.3 From edc68d7bf63bb40d440213a449031c1f07a1f95f Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sun, 12 Jul 2015 11:44:46 -0500 Subject: More connection tests --- test/connection/base_test.rb | 64 ++++++++++++++++++++++++++++++++++++++++++- test/stubs/test_connection.rb | 5 ---- test/stubs/test_server.rb | 2 ++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb index 66e303a804..2f008652ee 100644 --- a/test/connection/base_test.rb +++ b/test/connection/base_test.rb @@ -2,11 +2,24 @@ require 'test_helper' require 'stubs/test_server' class ActionCable::Connection::BaseTest < ActiveSupport::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket, :heartbeat, :subscriptions, :message_buffer, :connected + + def connect + @connected = true + end + + def disconnect + @connected = false + end + end + setup do @server = TestServer.new env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = ActionCable::Connection::Base.new(@server, env) + @connection = Connection.new(@server, env) + @response = @connection.process end test "making a connection with invalid headers" do @@ -14,4 +27,53 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase response = connection.process assert_equal 404, response[0] end + + test "websocket connection" do + assert @connection.websocket.possible? + assert @connection.websocket.alive? + end + + test "rack response" do + assert_equal [ -1, {}, [] ], @response + end + + test "on connection open" do + assert ! @connection.connected + + EventMachine.expects(:add_periodic_timer) + @connection.websocket.expects(:transmit).with(regexp_matches(/\_ping/)) + @connection.message_buffer.expects(:process!) + + @connection.send :on_open + + assert_equal [ @connection ], @server.connections + assert @connection.connected + end + + test "on connection close" do + # Setup the connection + EventMachine.stubs(:add_periodic_timer).returns(true) + @connection.send :on_open + assert @connection.connected + + EventMachine.expects(:cancel_timer) + @connection.subscriptions.expects(:unsubscribe_from_all) + @connection.send :on_close + + assert ! @connection.connected + assert_equal [], @server.connections + end + + test "connection statistics" do + statistics = @connection.statistics + + assert statistics[:identifier].blank? + assert_kind_of Time, statistics[:started_at] + assert_equal [], statistics[:subscriptions] + end + + test "explicitly closing a connection" do + @connection.websocket.expects(:close) + @connection.close + end end diff --git a/test/stubs/test_connection.rb b/test/stubs/test_connection.rb index 1c353f9ee3..384abc5e76 100644 --- a/test/stubs/test_connection.rb +++ b/test/stubs/test_connection.rb @@ -18,9 +18,4 @@ class TestConnection def last_transmission @transmissions.last end - - # Disable async in tests - def send_async(method, *arguments) - send method, *arguments - end end diff --git a/test/stubs/test_server.rb b/test/stubs/test_server.rb index aec80859c3..2a7ac3e927 100644 --- a/test/stubs/test_server.rb +++ b/test/stubs/test_server.rb @@ -1,6 +1,8 @@ require 'ostruct' class TestServer + include ActionCable::Server::Connections + attr_reader :logger, :config def initialize -- cgit v1.2.3 From b4448e54d5d47e71f0ec5803f3aa0193e37af4d1 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sun, 12 Jul 2015 11:59:46 -0500 Subject: Test auth failure --- test/connection/authorization_test.rb | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 test/connection/authorization_test.rb diff --git a/test/connection/authorization_test.rb b/test/connection/authorization_test.rb new file mode 100644 index 0000000000..09dfead8c8 --- /dev/null +++ b/test/connection/authorization_test.rb @@ -0,0 +1,26 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::AuthorizationTest < ActiveSupport::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket + + def connect + reject_unauthorized_connection + end + end + + setup do + @server = TestServer.new + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + end + + test "unauthorized connection" do + @connection.websocket.expects(:close) + + @connection.process + @connection.send :on_open + end +end -- cgit v1.2.3 From 13fb9b8c7b16e07e9b484868726ac7145956ced2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sun, 12 Jul 2015 13:25:24 -0500 Subject: Updated Gemfile.lock --- Gemfile.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 46a6e8ec46..63fb266523 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,20 +13,20 @@ PATH GEM remote: http://rubygems.org/ specs: - actionpack (4.2.1) - actionview (= 4.2.1) - activesupport (= 4.2.1) + actionpack (4.2.3) + actionview (= 4.2.3) + activesupport (= 4.2.3) rack (~> 1.6) rack-test (~> 0.6.2) rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.1) - actionview (4.2.1) - activesupport (= 4.2.1) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (4.2.3) + activesupport (= 4.2.3) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.1) - activesupport (4.2.1) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + activesupport (4.2.3) i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) -- cgit v1.2.3 From c7dc339b1dbdb1982d82536d87b6e654e7b7108d Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Sun, 12 Jul 2015 13:36:42 -0500 Subject: Connection identifier tests --- test/connection/identifier_test.rb | 77 ++++++++++++++++++++++++++++++++++++++ test/stubs/user.rb | 4 ++ 2 files changed, 81 insertions(+) create mode 100644 test/connection/identifier_test.rb diff --git a/test/connection/identifier_test.rb b/test/connection/identifier_test.rb new file mode 100644 index 0000000000..745cf308d0 --- /dev/null +++ b/test/connection/identifier_test.rb @@ -0,0 +1,77 @@ +require 'test_helper' +require 'stubs/test_server' +require 'stubs/user' + +class ActionCable::Connection::IdentifierTest < ActiveSupport::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 + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + end + + test "connection identifier" do + open_connection_with_stubbed_pubsub + assert_equal "User#lifo", @connection.connection_identifier + end + + test "should subscribe to internal channel on open" do + pubsub = mock('pubsub') + pubsub.expects(:subscribe).with('action_cable/User#lifo') + @server.expects(:pubsub).returns(pubsub) + + open_connection + end + + test "should unsubscribe from internal channel on close" do + open_connection_with_stubbed_pubsub + + pubsub = mock('pubsub') + pubsub.expects(:unsubscribe_proc).with('action_cable/User#lifo', kind_of(Proc)) + @server.expects(:pubsub).returns(pubsub) + + close_connection + end + + test "processing disconnect message" do + open_connection_with_stubbed_pubsub + + @connection.websocket.expects(:close) + message = { 'type' => 'disconnect' }.to_json + @connection.process_internal_message message + end + + test "processing invalid message" do + open_connection_with_stubbed_pubsub + + @connection.websocket.expects(:close).never + message = { 'type' => 'unknown' }.to_json + @connection.process_internal_message message + end + + protected + def open_connection_with_stubbed_pubsub + @server.stubs(:pubsub).returns(stub_everything('pubsub')) + open_connection + end + + def open_connection + @connection.process + @connection.send :on_open + end + + def close_connection + @connection.send :on_close + end +end diff --git a/test/stubs/user.rb b/test/stubs/user.rb index af90007af7..bce7dfc49e 100644 --- a/test/stubs/user.rb +++ b/test/stubs/user.rb @@ -4,4 +4,8 @@ class User def initialize(name) @name = name end + + def to_global_id + "User##{name}" + end end -- cgit v1.2.3 From e6812303e62dfb1dec18059aa31c07f1ca724c45 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Mon, 13 Jul 2015 20:37:28 +1000 Subject: Update readme to remove js from example, copies structure from https://github.com/rails/actioncable-examples --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 45daac5f46..e025846b36 100644 --- a/README.md +++ b/README.md @@ -82,14 +82,9 @@ potentially disconnect them all if the user is deleted or deauthorized). The client-side needs to setup a consumer instance of this connection. That's done like so: -```javascript -//app/assets/javascripts/application.js - -//= require cable -``` - ```coffeescript # app/assets/javascripts/application_cable.coffee +#= require cable @App = {} App.cable = Cable.createConsumer "ws://cable.example.com" -- cgit v1.2.3 From e1da6814b44110bc640a297bbca98bd87950b192 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 13 Jul 2015 10:43:52 -0500 Subject: Always load all the stub files --- test/channel/base_test.rb | 3 +-- test/channel/periodic_timers_test.rb | 3 +-- test/channel/stream_test.rb | 3 +-- test/stubs/room.rb | 12 ++++++++++++ test/test_helper.rb | 3 +++ 5 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 test/stubs/room.rb diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb index e525631642..aa31d23297 100644 --- a/test/channel/base_test.rb +++ b/test/channel/base_test.rb @@ -1,9 +1,8 @@ require 'test_helper' require 'stubs/test_connection' +require 'stubs/room' class ActionCable::Channel::BaseTest < ActiveSupport::TestCase - Room = Struct.new(:id) - class ChatChannel < ActionCable::Channel::Base attr_reader :room, :last_action on_subscribe :toggle_subscribed diff --git a/test/channel/periodic_timers_test.rb b/test/channel/periodic_timers_test.rb index 96d3e01783..1590a12f09 100644 --- a/test/channel/periodic_timers_test.rb +++ b/test/channel/periodic_timers_test.rb @@ -1,9 +1,8 @@ require 'test_helper' require 'stubs/test_connection' +require 'stubs/room' class ActionCable::Channel::PeriodicTimersTest < ActiveSupport::TestCase - Room = Struct.new(:id) - class ChatChannel < ActionCable::Channel::Base periodically -> { ping }, every: 5 periodically :send_updates, every: 1 diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index 2f4c988adf..1a8c259d11 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -1,9 +1,8 @@ require 'test_helper' require 'stubs/test_connection' +require 'stubs/room' class ActionCable::Channel::StreamTest < ActiveSupport::TestCase - Room = Struct.new(:id) - class ChatChannel < ActionCable::Channel::Base def subscribed @room = Room.new params[:id] diff --git a/test/stubs/room.rb b/test/stubs/room.rb new file mode 100644 index 0000000000..388b5ae33f --- /dev/null +++ b/test/stubs/room.rb @@ -0,0 +1,12 @@ +class Room + attr_reader :id, :name + + def initialize(id, name='Campfire') + @id = id + @name = name + end + + def to_global_id + "Room##{id}-#{name}" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 5b8ba110db..0e0191e804 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -13,6 +13,9 @@ require 'mocha/mini_test' require 'action_cable' ActiveSupport.test_order = :sorted +# Require all the stubs and models +Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } + class ActionCableTest < ActiveSupport::TestCase PORT = 420420 -- cgit v1.2.3 From 56891644d6a2ce2059f557d8cae44aca6cdfaf45 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 13 Jul 2015 11:30:43 -0500 Subject: Tests for channel subscriptions --- test/connection/subscriptions_test.rb | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 test/connection/subscriptions_test.rb diff --git a/test/connection/subscriptions_test.rb b/test/connection/subscriptions_test.rb new file mode 100644 index 0000000000..4e134b6420 --- /dev/null +++ b/test/connection/subscriptions_test.rb @@ -0,0 +1,87 @@ +require 'test_helper' + +class ActionCable::Connection::SubscriptionsTest < ActiveSupport::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket + 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 + end + + setup do + @server = TestServer.new + @server.stubs(:channel_classes).returns([ ChatChannel ]) + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + + @subscriptions = ActionCable::Connection::Subscriptions.new(@connection) + @chat_identifier = { id: 1, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json + end + + test "subscribe command" do + channel = subscribe_to_chat_channel + + assert_kind_of ChatChannel, channel + assert_equal 1, channel.room.id + end + + test "subscribe command without an identifier" do + @subscriptions.execute_command 'command' => 'subscribe' + assert @subscriptions.identifiers.empty? + end + + test "unsubscribe command" do + subscribe_to_chat_channel + + channel = subscribe_to_chat_channel + channel.expects(:unsubscribe_from_channel) + + @subscriptions.execute_command 'command' => 'unsubscribe', 'identifier' => @chat_identifier + assert @subscriptions.identifiers.empty? + end + + test "unsubscribe command without an identifier" do + @subscriptions.execute_command 'command' => 'unsubscribe' + assert @subscriptions.identifiers.empty? + end + + test "message command" do + channel = subscribe_to_chat_channel + + data = { 'content' => 'Hello World!', 'action' => 'speak' } + @subscriptions.execute_command 'command' => 'message', 'identifier' => @chat_identifier, 'data' => data.to_json + + assert_equal [ data ], channel.lines + end + + test "unsubscrib from all" do + channel1 = subscribe_to_chat_channel + + channel2_id = { id: 2, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json + channel2 = subscribe_to_chat_channel(channel2_id) + + channel1.expects(:unsubscribe_from_channel) + channel2.expects(:unsubscribe_from_channel) + + @subscriptions.unsubscribe_from_all + 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 +end -- cgit v1.2.3 From 05b760d95991c75fbe3be83336d0a4479c66e803 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 13 Jul 2015 15:00:19 -0500 Subject: Remove busted tests --- test/server_test.rb | 33 --------------------------------- test/test_helper.rb | 28 ---------------------------- 2 files changed, 61 deletions(-) delete mode 100644 test/server_test.rb diff --git a/test/server_test.rb b/test/server_test.rb deleted file mode 100644 index bd83953702..0000000000 --- a/test/server_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'test_helper' - -# FIXME: Currently busted. -# -# class ServerTest < ActionCableTest -# class ChatChannel < ActionCable::Channel::Base -# end -# -# class ChatServer < ActionCable::Server::Base -# register_channels ChatChannel -# end -# -# def app -# ChatServer -# end -# -# test "channel registration" do -# assert_equal ChatServer.channel_classes, Set.new([ ChatChannel ]) -# end -# -# test "subscribing to a channel with valid params" do -# ws = Faye::WebSocket::Client.new(websocket_url) -# -# ws.on(:message) do |message| -# puts message.inspect -# end -# -# ws.send command: 'subscribe', identifier: { channel: 'chat'}.to_json -# end -# -# test "subscribing to a channel with invalid params" do -# end -# end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0e0191e804..4b8e66b569 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,31 +15,3 @@ ActiveSupport.test_order = :sorted # Require all the stubs and models Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } - -class ActionCableTest < ActiveSupport::TestCase - PORT = 420420 - - setup :start_puma_server - teardown :stop_puma_server - - def start_puma_server - events = Puma::Events.new(StringIO.new, StringIO.new) - binder = Puma::Binder.new(events) - binder.parse(["tcp://0.0.0.0:#{PORT}"], self) - @server = Puma::Server.new(app, events) - @server.binder = binder - @server.run - end - - def stop_puma_server - @server.stop(true) - end - - def websocket_url - "ws://0.0.0.0:#{PORT}/" - end - - def log(*args) - end - -end -- cgit v1.2.3 From f1b9095bdc30913a914b321c590d64068c6c1836 Mon Sep 17 00:00:00 2001 From: Jeremy Kemper Date: Mon, 13 Jul 2015 19:28:14 -0700 Subject: Move dev dependencies from Gemfile to the gemspec: rake, puma, mocha --- Gemfile | 9 +-------- Gemfile.lock | 2 +- actioncable.gemspec | 4 ++++ 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Gemfile b/Gemfile index ba63fae6d0..851fabc21d 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,2 @@ -source 'http://rubygems.org' +source 'https://rubygems.org' gemspec - -group :test do - gem 'rake' - gem 'puma' - gem 'mocha' -end - diff --git a/Gemfile.lock b/Gemfile.lock index 63fb266523..2bb6a1e31c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ PATH websocket-driver (= 0.5.4) GEM - remote: http://rubygems.org/ + remote: https://rubygems.org/ specs: actionpack (4.2.3) actionview (= 4.2.3) diff --git a/actioncable.gemspec b/actioncable.gemspec index 4103a51314..714670c391 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -19,6 +19,10 @@ Gem::Specification.new do |s| s.add_dependency 'em-hiredis', '~> 0.3.0' s.add_dependency 'redis', '~> 3.0' + s.add_development_dependency 'rake' + s.add_development_dependency 'puma' + s.add_development_dependency 'mocha' + s.files = Dir['README', 'lib/**/*'] s.has_rdoc = false -- cgit v1.2.3 From 729f57af3d3f3461d8f2b6c8421920c71ea9b03a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Jul 2015 10:18:57 -0500 Subject: Update the README re: tests --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e025846b36..3437832c48 100644 --- a/README.md +++ b/README.md @@ -314,8 +314,8 @@ framework alongside Action Pack, Active Record, and the like once we cross the b to beta software (which will happen once the API and missing pieces have solidified). Finally, note that testing is a unfinished/unstarted area of this framework. The framework -has been developed in-app up until this point. We need to find a good way to test both the framework -itself and allow the user to test their connection and channel logic. +has been developed in-app up until this point. We need to find a good way to allow the user to test +their connection and channel logic. ## License -- cgit v1.2.3 From b75bff4225608497f23119f9fdcff34e980fb74b Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Jul 2015 10:58:46 -0500 Subject: Worker tests --- test/test_helper.rb | 2 ++ test/worker_test.rb | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 test/worker_test.rb diff --git a/test/test_helper.rb b/test/test_helper.rb index 4b8e66b569..759ea18524 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,3 +15,5 @@ ActiveSupport.test_order = :sorted # Require all the stubs and models Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } + +Celluloid.logger = Logger.new(StringIO.new) diff --git a/test/worker_test.rb b/test/worker_test.rb new file mode 100644 index 0000000000..e1fa6f561b --- /dev/null +++ b/test/worker_test.rb @@ -0,0 +1,46 @@ +require 'test_helper' + +class WorkerTest < ActiveSupport::TestCase + class Receiver + attr_accessor :last_action + + def run + @last_action = :run + end + + def process(message) + @last_action = [ :process, message ] + end + end + + setup do + Celluloid.boot + + @worker = ActionCable::Server::Worker.new + @receiver = Receiver.new + end + + teardown do + @receiver.last_action = nil + end + + test "invoke" do + @worker.invoke @receiver, :run + assert_equal :run, @receiver.last_action + end + + test "invoke with arguments" do + @worker.invoke @receiver, :process, "Hello" + assert_equal [ :process, "Hello" ], @receiver.last_action + end + + test "running periodic timers with a proc" do + @worker.run_periodic_timer @receiver, @receiver.method(:run) + assert_equal :run, @receiver.last_action + end + + test "running periodic timers with a method" do + @worker.run_periodic_timer @receiver, :run + assert_equal :run, @receiver.last_action + end +end -- cgit v1.2.3 From 2f8a3b58a62acbd218bd670415c96ea9dacee487 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Jul 2015 10:59:08 -0500 Subject: Include the module for clearing db connections --- lib/action_cable/server/worker.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb index 01d2c25c8a..5b03000142 100644 --- a/lib/action_cable/server/worker.rb +++ b/lib/action_cable/server/worker.rb @@ -4,6 +4,7 @@ module ActionCable class Worker include ActiveSupport::Callbacks include Celluloid + include ClearDatabaseConnections define_callbacks :work -- cgit v1.2.3 From 130b8f9ddf321d49f43b730b6677bb25e51d4c32 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 14 Jul 2015 11:00:08 -0500 Subject: Include the connection module after defining the work callback --- lib/action_cable/server/worker.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb index 5b03000142..d7823ecf93 100644 --- a/lib/action_cable/server/worker.rb +++ b/lib/action_cable/server/worker.rb @@ -4,9 +4,9 @@ module ActionCable class Worker include ActiveSupport::Callbacks include Celluloid - include ClearDatabaseConnections define_callbacks :work + include ClearDatabaseConnections def invoke(receiver, method, *args) run_callbacks :work do -- cgit v1.2.3 From a0048e30202d846420057e26c6cf15fd46a77c42 Mon Sep 17 00:00:00 2001 From: constXife Date: Mon, 20 Jul 2015 00:44:19 +0600 Subject: Add coffee-rails as a dependency. --- Gemfile.lock | 15 +++++++++++++++ actioncable.gemspec | 1 + 2 files changed, 16 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 2bb6a1e31c..a857cc0d8d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ PATH actionpack (>= 4.2.0) activesupport (>= 4.2.0) celluloid (~> 0.16.0) + coffee-rails (~> 4.1.0) em-hiredis (~> 0.3.0) faye-websocket (~> 0.9.2) redis (~> 3.0) @@ -35,11 +36,19 @@ GEM builder (3.2.2) celluloid (0.16.0) timers (~> 4.0.0) + coffee-rails (4.1.0) + coffee-script (>= 2.2.0) + railties (>= 4.0.0, < 5.0) + coffee-script (2.3.0) + coffee-script-source + execjs + coffee-script-source (1.9.0) em-hiredis (0.3.0) eventmachine (~> 1.0) hiredis (~> 0.5.0) erubis (2.7.0) eventmachine (1.0.7) + execjs (2.5.2) faye-websocket (0.9.2) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) @@ -69,8 +78,14 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.2) loofah (~> 2.0) + railties (4.2.3) + actionpack (= 4.2.3) + activesupport (= 4.2.3) + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) rake (10.4.2) redis (3.2.1) + thor (0.19.1) thread_safe (0.3.5) timers (4.0.1) hitimes diff --git a/actioncable.gemspec b/actioncable.gemspec index 714670c391..186a2ab1b7 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -18,6 +18,7 @@ Gem::Specification.new do |s| s.add_dependency 'celluloid', '~> 0.16.0' s.add_dependency 'em-hiredis', '~> 0.3.0' s.add_dependency 'redis', '~> 3.0' + s.add_dependency 'coffee-rails', '~> 4.1.0' s.add_development_dependency 'rake' s.add_development_dependency 'puma' -- cgit v1.2.3 From c47e628da4d7a8028b4b9247c04e5d26d382c008 Mon Sep 17 00:00:00 2001 From: constXife Date: Mon, 20 Jul 2015 09:22:29 +0600 Subject: Remove version requirements. --- Gemfile.lock | 6 +++--- actioncable.gemspec | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a857cc0d8d..822f27d5b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,7 +5,7 @@ PATH actionpack (>= 4.2.0) activesupport (>= 4.2.0) celluloid (~> 0.16.0) - coffee-rails (~> 4.1.0) + coffee-rails em-hiredis (~> 0.3.0) faye-websocket (~> 0.9.2) redis (~> 3.0) @@ -39,10 +39,10 @@ GEM coffee-rails (4.1.0) coffee-script (>= 2.2.0) railties (>= 4.0.0, < 5.0) - coffee-script (2.3.0) + coffee-script (2.4.1) coffee-script-source execjs - coffee-script-source (1.9.0) + coffee-script-source (1.9.1.1) em-hiredis (0.3.0) eventmachine (~> 1.0) hiredis (~> 0.5.0) diff --git a/actioncable.gemspec b/actioncable.gemspec index 186a2ab1b7..509c5ca75a 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |s| s.add_dependency 'celluloid', '~> 0.16.0' s.add_dependency 'em-hiredis', '~> 0.3.0' s.add_dependency 'redis', '~> 3.0' - s.add_dependency 'coffee-rails', '~> 4.1.0' + s.add_dependency 'coffee-rails' s.add_development_dependency 'rake' s.add_development_dependency 'puma' -- cgit v1.2.3 From 3ac5ed5c23bf628ae504f4b662217f458833d9a6 Mon Sep 17 00:00:00 2001 From: Ted Toer Date: Mon, 20 Jul 2015 19:49:03 +0500 Subject: disconnect method added to singleton server --- lib/action_cable/server/base.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 23cd8388bd..8d64afa1d9 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -21,6 +21,11 @@ module ActionCable config.connection_class.new(self, env).process end + # Disconnect all the remote connections established for subject, finded by identifiers + def disconnect(identifiers) + remote_connections.where(identifiers).disconnect + end + # Gateway to RemoteConnections. See that class for details. def remote_connections @remote_connections ||= RemoteConnections.new(self) -- cgit v1.2.3 From b25758820f32837987665ea664160c222668bd2d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 20 Jul 2015 17:16:49 +0200 Subject: Clearer doc --- lib/action_cable/server/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 8d64afa1d9..fe7966e090 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -21,7 +21,7 @@ module ActionCable config.connection_class.new(self, env).process end - # Disconnect all the remote connections established for subject, finded by identifiers + # Disconnect all the connections identified by `identifiers` on this server or any others via RemoteConnections. def disconnect(identifiers) remote_connections.where(identifiers).disconnect end @@ -66,4 +66,4 @@ module ActionCable end end end -end \ No newline at end of file +end -- cgit v1.2.3 From 5d8c84827361ab7bfe7e2b7afac446f8017d3a02 Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Wed, 22 Jul 2015 10:37:57 -0700 Subject: Update gems and requires --- Gemfile.lock | 59 +++++++++++++++++++++++++++++++++++++++++++++-------- actioncable.gemspec | 6 +++--- lib/action_cable.rb | 2 +- test/test_helper.rb | 2 ++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 822f27d5b1..7a84c5fed5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,12 +4,12 @@ PATH actioncable (0.1.0) actionpack (>= 4.2.0) activesupport (>= 4.2.0) - celluloid (~> 0.16.0) + celluloid (~> 0.17.0) coffee-rails em-hiredis (~> 0.3.0) - faye-websocket (~> 0.9.2) + faye-websocket (~> 0.10.0) redis (~> 3.0) - websocket-driver (= 0.5.4) + websocket-driver (~> 0.6.1) GEM remote: https://rubygems.org/ @@ -34,7 +34,46 @@ GEM thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) builder (3.2.2) - celluloid (0.16.0) + celluloid (0.17.0) + bundler + celluloid-essentials + celluloid-extras + celluloid-fsm + celluloid-pool + celluloid-supervision + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (~> 4.0.0) + celluloid-essentials (0.20.1.1) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (~> 4.0.0) + celluloid-extras (0.20.0) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (~> 4.0.0) + celluloid-fsm (0.20.0) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (~> 4.0.0) + celluloid-pool (0.20.0) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (~> 4.0.0) + celluloid-supervision (0.20.0) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) timers (~> 4.0.0) coffee-rails (4.1.0) coffee-script (>= 2.2.0) @@ -43,13 +82,14 @@ GEM coffee-script-source execjs coffee-script-source (1.9.1.1) + dotenv (2.0.2) em-hiredis (0.3.0) eventmachine (~> 1.0) hiredis (~> 0.5.0) erubis (2.7.0) eventmachine (1.0.7) execjs (2.5.2) - faye-websocket (0.9.2) + faye-websocket (0.10.0) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) hiredis (0.5.2) @@ -63,11 +103,11 @@ GEM minitest (5.7.0) mocha (1.1.0) metaclass (~> 0.0.1) + nenv (0.2.0) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) - puma (2.10.2) - rack (>= 1.1, < 2.0) - rack (1.6.0) + puma (2.12.2) + rack (1.6.4) rack-test (0.6.3) rack (>= 1.0) rails-deprecated_sanitizer (1.0.3) @@ -85,13 +125,14 @@ GEM thor (>= 0.18.1, < 2.0) rake (10.4.2) redis (3.2.1) + rspec-logsplit (0.1.3) thor (0.19.1) thread_safe (0.3.5) timers (4.0.1) hitimes tzinfo (1.2.2) thread_safe (~> 0.1) - websocket-driver (0.5.4) + websocket-driver (0.6.2) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) diff --git a/actioncable.gemspec b/actioncable.gemspec index 509c5ca75a..2d07d0d9eb 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -13,9 +13,9 @@ Gem::Specification.new do |s| s.add_dependency 'activesupport', '>= 4.2.0' s.add_dependency 'actionpack', '>= 4.2.0' - s.add_dependency 'faye-websocket', '~> 0.9.2' - s.add_dependency 'websocket-driver', '= 0.5.4' - s.add_dependency 'celluloid', '~> 0.16.0' + s.add_dependency 'faye-websocket', '~> 0.10.0' + s.add_dependency 'websocket-driver', '~> 0.6.1' + s.add_dependency 'celluloid', '~> 0.17.0' s.add_dependency 'em-hiredis', '~> 0.3.0' s.add_dependency 'redis', '~> 3.0' s.add_dependency 'coffee-rails' diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 968adafc25..9ce63dae28 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -11,7 +11,7 @@ require 'active_support/core_ext/module/delegation' require 'active_support/callbacks' require 'faye/websocket' -require 'celluloid' +require 'celluloid/current' require 'em-hiredis' require 'redis' diff --git a/test/test_helper.rb b/test/test_helper.rb index 759ea18524..5640178f34 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,8 @@ Bundler.require :default, :test require 'puma' require 'mocha/mini_test' +require 'rack/mock' + require 'action_cable' ActiveSupport.test_order = :sorted -- cgit v1.2.3 From a97a1fc7452088b875edb6b258c92d6eab1c19a4 Mon Sep 17 00:00:00 2001 From: Cristian Bica Date: Thu, 23 Jul 2015 00:10:22 +0300 Subject: Improve channel actions dispatcher to allow inheritance/mixins Fixes #14 --- lib/action_cable/channel/base.rb | 39 +++++++++++++++++++++++++++++++++++++-- test/channel/base_test.rb | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 87ae3a1211..d6bd98d180 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -75,6 +75,42 @@ module ActionCable attr_reader :params, :connection 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 + + protected + # 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! + @action_methods = nil + end + + # Refresh the cached action_methods when a new action_method is added. + def method_added(name) + super + clear_action_methods! + end + end + def initialize(connection, identifier, params = {}) @connection = connection @identifier = identifier @@ -147,7 +183,7 @@ module ActionCable end def processable_action?(action) - self.class.instance_methods(false).include?(action) + self.class.action_methods.include?(action.to_s) end def dispatch_action(action, data) @@ -168,7 +204,6 @@ module ActionCable end end - def run_subscribe_callbacks self.class.on_subscribe_callbacks.each { |callback| send(callback) } end diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb index aa31d23297..e7944ff06b 100644 --- a/test/channel/base_test.rb +++ b/test/channel/base_test.rb @@ -3,7 +3,22 @@ require 'stubs/test_connection' require 'stubs/room' class ActionCable::Channel::BaseTest < ActiveSupport::TestCase - class ChatChannel < ActionCable::Channel::Base + 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 on_subscribe :toggle_subscribed on_unsubscribe :toggle_subscribed @@ -29,6 +44,10 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase @last_action = [ :speak, data ] end + def topic(data) + @last_action = [ :topic, data ] + end + def subscribed? @subscribed end @@ -87,11 +106,28 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase assert_equal [ :speak, data ], @channel.last_action end - test "try calling a private method" do + 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 "transmitting data" do @channel.perform_action 'action' => :get_latest -- cgit v1.2.3 From dc4f8b352cb6b14aff8690f410e4a5bcb2a8280e Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Thu, 23 Jul 2015 17:20:04 -0400 Subject: Don't unsubscribe on the server when another subscription with the same identifier is active --- lib/assets/javascripts/cable/subscriptions.js.coffee | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee index 3bc53f2d6a..fe6975c870 100644 --- a/lib/assets/javascripts/cable/subscriptions.js.coffee +++ b/lib/assets/javascripts/cable/subscriptions.js.coffee @@ -29,8 +29,12 @@ class Cable.Subscriptions @notify(subscription, "connected") remove: (subscription) -> - @sendCommand(subscription, "unsubscribe") @subscriptions = (s for s in @subscriptions when s isnt subscription) + unless @findAll(subscription.identifier).length + @sendCommand(subscription, "unsubscribe") + + findAll: (identifier) -> + s for s in @subscriptions when s.identifier is identifier notifyAll: (callbackName, args...) -> for subscription in @subscriptions @@ -38,7 +42,7 @@ class Cable.Subscriptions notify: (subscription, callbackName, args...) -> if typeof subscription is "string" - subscriptions = (s for s in @subscriptions when s.identifier is subscription) + subscriptions = @findAll(subscription) else subscriptions = [subscription] -- cgit v1.2.3 From ccfb1a8b1c6b8651062cce5a80c405013ea8f465 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 24 Jul 2015 12:05:31 -0400 Subject: Use Celluloid 0.16.0 until termination issue in 0.17.0 is resolved The issue: https://github.com/celluloid/celluloid/issues/637 --- Gemfile.lock | 46 ++-------------------------------------------- actioncable.gemspec | 3 ++- lib/action_cable.rb | 2 +- 3 files changed, 5 insertions(+), 46 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7a84c5fed5..0299c50f9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,7 @@ PATH actioncable (0.1.0) actionpack (>= 4.2.0) activesupport (>= 4.2.0) - celluloid (~> 0.17.0) + celluloid (~> 0.16.0) coffee-rails em-hiredis (~> 0.3.0) faye-websocket (~> 0.10.0) @@ -34,46 +34,7 @@ GEM thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) builder (3.2.2) - celluloid (0.17.0) - bundler - celluloid-essentials - celluloid-extras - celluloid-fsm - celluloid-pool - celluloid-supervision - dotenv - nenv - rspec-logsplit (>= 0.1.2) - timers (~> 4.0.0) - celluloid-essentials (0.20.1.1) - bundler - dotenv - nenv - rspec-logsplit (>= 0.1.2) - timers (~> 4.0.0) - celluloid-extras (0.20.0) - bundler - dotenv - nenv - rspec-logsplit (>= 0.1.2) - timers (~> 4.0.0) - celluloid-fsm (0.20.0) - bundler - dotenv - nenv - rspec-logsplit (>= 0.1.2) - timers (~> 4.0.0) - celluloid-pool (0.20.0) - bundler - dotenv - nenv - rspec-logsplit (>= 0.1.2) - timers (~> 4.0.0) - celluloid-supervision (0.20.0) - bundler - dotenv - nenv - rspec-logsplit (>= 0.1.2) + celluloid (0.16.0) timers (~> 4.0.0) coffee-rails (4.1.0) coffee-script (>= 2.2.0) @@ -82,7 +43,6 @@ GEM coffee-script-source execjs coffee-script-source (1.9.1.1) - dotenv (2.0.2) em-hiredis (0.3.0) eventmachine (~> 1.0) hiredis (~> 0.5.0) @@ -103,7 +63,6 @@ GEM minitest (5.7.0) mocha (1.1.0) metaclass (~> 0.0.1) - nenv (0.2.0) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) puma (2.12.2) @@ -125,7 +84,6 @@ GEM thor (>= 0.18.1, < 2.0) rake (10.4.2) redis (3.2.1) - rspec-logsplit (0.1.3) thor (0.19.1) thread_safe (0.3.5) timers (4.0.1) diff --git a/actioncable.gemspec b/actioncable.gemspec index 2d07d0d9eb..e3aaa21fe7 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -15,7 +15,8 @@ Gem::Specification.new do |s| s.add_dependency 'actionpack', '>= 4.2.0' s.add_dependency 'faye-websocket', '~> 0.10.0' s.add_dependency 'websocket-driver', '~> 0.6.1' - s.add_dependency 'celluloid', '~> 0.17.0' + # Use 0.16.0 until https://github.com/celluloid/celluloid/issues/637 is resolved + s.add_dependency 'celluloid', '~> 0.16.0' s.add_dependency 'em-hiredis', '~> 0.3.0' s.add_dependency 'redis', '~> 3.0' s.add_dependency 'coffee-rails' diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 9ce63dae28..968adafc25 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -11,7 +11,7 @@ require 'active_support/core_ext/module/delegation' require 'active_support/callbacks' require 'faye/websocket' -require 'celluloid/current' +require 'celluloid' require 'em-hiredis' require 'redis' -- cgit v1.2.3 From 319c43970a825beeb53388cfb371f5ed531ee9a7 Mon Sep 17 00:00:00 2001 From: Jon Moss Date: Sun, 26 Jul 2015 17:13:19 -0400 Subject: Update README.md This includes small styling / grammar changes to the README --- README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3437832c48..5e5e8f645b 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,13 @@ domain model written with ActiveRecord or your ORM of choice. ## Terminology A single Action Cable server can handle multiple connection instances. It has one -connection instance per websocket connection. A single user may well have multiple +connection instance per websocket connection. A single user may have multiple websockets open to your application if they use multiple browser tabs or devices. The client of a websocket connection is called the consumer. Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates -a logical unit of work, similar to what a controller does in a regular MVC setup. So -you may have a `ChatChannel` and a `AppearancesChannel`. The consumer can be subscribed to either -or to both. At the very least, a consumer should be subscribed to one channel. +a logical unit of work, similar to what a controller does in a regular MVC setup. For example, you could have a `ChatChannel` and a `AppearancesChannel`, and a consumer could be subscribed to either +or to both of these channels. At the very least, a consumer should be subscribed to one channel. When the consumer is subscribed to a channel, they act as a subscriber. The connection between the subscriber and the channel is, surprise-surprise, called a subscription. A consumer @@ -102,7 +101,7 @@ is defined by declaring channels on the server and allowing the consumer to subs ## Channel example 1: User appearances Here's a simple example of a channel that tracks whether a user is online or not and what page they're on. -(That's useful for creating presence features like showing a green dot next to a user name if they're online). +(This is useful for creating presence features like showing a green dot next to a user name if they're online). First you declare the server-side channel: @@ -186,7 +185,7 @@ class WebNotificationsChannel < ApplicationCable::Channel ```coffeescript # Somewhere in your app this is called, perhaps from a NewCommentJob ActionCable.server.broadcast \ - "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' } + "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } # Client-side which assumes you've already requested the right to send web notifications App.cable.subscriptions.create "WebNotificationsChannel", @@ -194,7 +193,7 @@ App.cable.subscriptions.create "WebNotificationsChannel", new Notification data['title'], body: data['body'] ``` -The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under the broadcasting name of `web_notifications_1`. +The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under a separate broadcasting name for each user. For a user with an ID of 1, the broadcasting name would be `web_notifications_1`. The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the `#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip across the wire, and unpacked for the data argument arriving to `#received`. @@ -265,7 +264,7 @@ Then you start the server using a binstub in bin/cable ala: bundle exec puma -p 28080 cable/config.ru ``` -That'll start a cable server on port 28080. Remember to point your client-side setup against that using something like: +The above will start a cable server on port 28080. Remember to point your client-side setup against that using something like: `App.cable.createConsumer('ws://basecamp.dev:28080')`. Beware that currently the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. -- cgit v1.2.3 From 00ba651f40de5ae57cf9b433ba0c6b7150d71a56 Mon Sep 17 00:00:00 2001 From: Craig Sheen Date: Mon, 27 Jul 2015 08:23:39 +0100 Subject: Move VERSION constant to version file and use this in the gemspec --- actioncable.gemspec | 5 ++++- lib/action_cable.rb | 2 -- lib/action_cable/version.rb | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 lib/action_cable/version.rb diff --git a/actioncable.gemspec b/actioncable.gemspec index e3aaa21fe7..02350186db 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -1,6 +1,9 @@ +$:.push File.expand_path("../lib", __FILE__) +require 'action_cable/version' + Gem::Specification.new do |s| s.name = 'actioncable' - s.version = '0.1.0' + s.version = ActionCable::VERSION s.summary = 'Websockets framework for Rails.' s.description = 'Structure many real-time application concerns into channels over a single websockets connection.' s.license = 'MIT' diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 968adafc25..62cb9bcf8a 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -18,8 +18,6 @@ require 'redis' require 'action_cable/engine' if defined?(Rails) module ActionCable - VERSION = '0.0.3' - autoload :Server, 'action_cable/server' autoload :Connection, 'action_cable/connection' autoload :Channel, 'action_cable/channel' diff --git a/lib/action_cable/version.rb b/lib/action_cable/version.rb new file mode 100644 index 0000000000..4947029dcc --- /dev/null +++ b/lib/action_cable/version.rb @@ -0,0 +1,3 @@ +module ActionCable + VERSION = '0.0.3' +end \ No newline at end of file -- cgit v1.2.3 From fa7292e3c39b62619d9ab90cba79fcf76a6a3180 Mon Sep 17 00:00:00 2001 From: Craig Sheen Date: Mon, 27 Jul 2015 08:59:30 +0100 Subject: require new version file so the constant is available to the application --- lib/action_cable.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 62cb9bcf8a..13c5c77578 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -16,6 +16,7 @@ require 'em-hiredis' require 'redis' require 'action_cable/engine' if defined?(Rails) +require 'action_cable/version' module ActionCable autoload :Server, 'action_cable/server' -- cgit v1.2.3 From 5954fd1e0aa907e07ffff932aedc51109d4ce56d Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Wed, 22 Jul 2015 11:03:47 +1000 Subject: add broadcast_to and stream_for methods as per #26 --- lib/action_cable/channel.rb | 2 ++ lib/action_cable/channel/base.rb | 2 ++ lib/action_cable/channel/broadcasting.rb | 27 +++++++++++++++++++++++ lib/action_cable/channel/naming.rb | 22 +++++++++++++++++++ lib/action_cable/channel/streams.rb | 37 +++++++++++++++++++++++++------- lib/action_cable/server/base.rb | 4 ++-- lib/action_cable/server/broadcasting.rb | 2 +- test/channel/broadcasting_test.rb | 29 +++++++++++++++++++++++++ test/channel/naming_test.rb | 10 +++++++++ test/channel/stream_test.rb | 14 +++++++++--- test/stubs/room.rb | 4 ++++ 11 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 lib/action_cable/channel/broadcasting.rb create mode 100644 lib/action_cable/channel/naming.rb create mode 100644 test/channel/broadcasting_test.rb create mode 100644 test/channel/naming_test.rb diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb index 9e4d3d3f93..3b973ba0a7 100644 --- a/lib/action_cable/channel.rb +++ b/lib/action_cable/channel.rb @@ -1,7 +1,9 @@ module ActionCable module Channel autoload :Base, 'action_cable/channel/base' + autoload :Broadcasting, 'action_cable/channel/broadcasting' autoload :Callbacks, 'action_cable/channel/callbacks' + autoload :Naming, 'action_cable/channel/naming' autoload :PeriodicTimers, 'action_cable/channel/periodic_timers' autoload :Streams, 'action_cable/channel/streams' end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 87ae3a1211..c83c3b74fd 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -68,6 +68,8 @@ module ActionCable include Callbacks include PeriodicTimers include Streams + include Naming + include Broadcasting on_subscribe :subscribed on_unsubscribe :unsubscribed diff --git a/lib/action_cable/channel/broadcasting.rb b/lib/action_cable/channel/broadcasting.rb new file mode 100644 index 0000000000..ee4117bc0a --- /dev/null +++ b/lib/action_cable/channel/broadcasting.rb @@ -0,0 +1,27 @@ +module ActionCable + module Channel + module Broadcasting + extend ActiveSupport::Concern + + delegate :broadcasting_for, to: :class + + class_methods do + # Broadcast a hash to a unique broadcasting for this model in this channel. + def broadcast_to(model, message) + ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message) + end + + def broadcasting_for(model) #:nodoc: + case + when model.is_a?(Array) + model.map { |m| broadcasting_for(m) }.join(':') + when model.respond_to?(:to_gid_param) + model.to_gid_param + else + model.to_param + end + end + 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 0000000000..4c9d53b15a --- /dev/null +++ b/lib/action_cable/channel/naming.rb @@ -0,0 +1,22 @@ +module ActionCable + module Channel + module Naming + extend ActiveSupport::Concern + + class_methods do + # 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' + def channel_name + @channel_name ||= name.sub(/Channel$/, '').gsub('::',':').underscore + end + end + + # Delegates to the class' channel_name + delegate :channel_name, to: :class + end + end +end diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index 6a3dc76c1d..f711b065ca 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -1,7 +1,7 @@ module ActionCable module Channel # Streams allow channels to route broadcastings to the subscriber. A broadcasting is an discussed elsewhere a pub/sub queue where any data - # put 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 + # put 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'll not get that update when connecting later. # # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between @@ -12,7 +12,7 @@ module ActionCable # def follow(data) # stream_from "comments_for_#{data['recording_id']}" # end - # + # # def unfollow # stop_all_streams # end @@ -23,23 +23,37 @@ module ActionCable # # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell' # - # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that let's you alter what goes out. + # 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 to subscribe to a broadcasting that would be something 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) + # + # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that let's you alter what goes out. # Example below 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_from @room.channel, -> (message) do + # + # stream_for @room, -> (message) do # message = ActiveSupport::JSON.decode(m) - # + # # 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 @@ -63,6 +77,13 @@ module ActionCable logger.info "#{self.class.name} is streaming from #{broadcasting}" 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. + def stream_for(model, callback = nil) + stream_from(broadcasting_for([ channel_name, model ]), callback) + end + def stop_all_streams streams.each do |broadcasting, callback| pubsub.unsubscribe_proc broadcasting, callback diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index fe7966e090..b09fbf6da4 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -9,7 +9,7 @@ module ActionCable include ActionCable::Server::Connections cattr_accessor(:config, instance_accessor: true) { ActionCable::Server::Configuration.new } - + def self.logger; config.logger; end delegate :logger, to: :config @@ -56,7 +56,7 @@ module ActionCable logger.info "[ActionCable] Redis reconnect failed." # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." # @connections.map &:close - end + end end end diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 4f72ffd96f..037b98951e 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -32,7 +32,7 @@ module ActionCable # The redis instance used for broadcasting. Not intended for direct user use. def broadcasting_redis @broadcasting_redis ||= Redis.new(config.redis) - end + end private class Broadcaster diff --git a/test/channel/broadcasting_test.rb b/test/channel/broadcasting_test.rb new file mode 100644 index 0000000000..1de04243e5 --- /dev/null +++ b/test/channel/broadcasting_test.rb @@ -0,0 +1,29 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::BroadcastingTest < ActiveSupport::TestCase + class ChatChannel < ActionCable::Channel::Base + end + + setup do + @connection = TestConnection.new + end + + test "broadcasts_to" do + ActionCable.stubs(:server).returns mock().tap { |m| m.expects(:broadcast).with('action_cable:channel:broadcasting_test:chat:Room#1-Campfire', "Hello World") } + ChatChannel.broadcast_to(Room.new(1), "Hello World") + end + + test "broadcasting_for with an object" do + assert_equal "Room#1-Campfire", ChatChannel.broadcasting_for(Room.new(1)) + end + + test "broadcasting_for with an array" do + assert_equal "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 "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 0000000000..89ef6ad8b0 --- /dev/null +++ b/test/channel/naming_test.rb @@ -0,0 +1,10 @@ +require 'test_helper' + +class ActionCable::Channel::NamingTest < ActiveSupport::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/stream_test.rb b/test/channel/stream_test.rb index 1a8c259d11..b0a6f49072 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -5,8 +5,10 @@ require 'stubs/room' class ActionCable::Channel::StreamTest < ActiveSupport::TestCase class ChatChannel < ActionCable::Channel::Base def subscribed - @room = Room.new params[:id] - stream_from "test_room_#{@room.id}" + if params[:id] + @room = Room.new params[:id] + stream_from "test_room_#{@room.id}" + end end end @@ -15,10 +17,16 @@ class ActionCable::Channel::StreamTest < ActiveSupport::TestCase end test "streaming start and stop" do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe) } + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1") } channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } channel.unsubscribe_from_channel end + + test "stream_for" do + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire") } + channel = ChatChannel.new @connection, "" + channel.stream_for Room.new(1) + end end diff --git a/test/stubs/room.rb b/test/stubs/room.rb index 388b5ae33f..246d6a98af 100644 --- a/test/stubs/room.rb +++ b/test/stubs/room.rb @@ -9,4 +9,8 @@ class Room def to_global_id "Room##{id}-#{name}" end + + def to_gid_param + to_global_id.to_param + end end -- cgit v1.2.3 From 0a6787e598de5b15f0bb8a24ab02bf1bafdc254f Mon Sep 17 00:00:00 2001 From: Kasper Timm Hansen Date: Wed, 29 Jul 2015 22:03:42 +0200 Subject: Run tests with Travis. --- .travis.yml | 19 +++++++++++++++++++ README.md | 1 + 2 files changed, 20 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..99a95ae240 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +sudo: false +cache: bundler +rvm: + - 2.2 + - ruby-head +matrix: + fast_finish: true +notifications: + email: false + irc: + on_success: change + on_failure: always + channels: + - "irc.freenode.org#rails-contrib" + campfire: + on_success: change + on_failure: always + rooms: + - secure: "EZmqsgjEQbWouCx6xL/30jslug7xcq+Dl09twDGjBs369GB5LiUm17/I7d6H1YQFY0Vu2LpiQ/zs+6ihlBjslRV/2RYM3AgAA9OOC3pn7uENFVTXaECi/io1wjvlbMNrf1YJSc3aUyiWKykRsdZnZSFszkDs4DMnZG1s/Oxf1JTYEGNWW3WcOFfYkzcS7NWlOW9OBf4RuzjtLYF05IO4t4FZI1aTWrNV3NNMZ+tqmiQHHNrQE/CzQE3ujqFiea2vVZ7PwvmjVWJgC29UZqS7HcNuq6cCMtMZZuubCZmyT85GjJ/SKTShxFqfV1oCpY3y6kyWcTAQsUoLtPEX0OxLeX+CgWNIJK0rY5+5/v5pZP1uwRsMfLerfp2a9g4fAnlcAKaZjalOc39rOkJl8FdvLQtqFIGWxpjWdJbMrCt3SrnnOccpDqDWpAL798LVBONcOuor71rEeNj1dZ6fCoHTKhLVy6UVm9eUI8zt1APM0xzHgTBI1KBVZi0ikqPcaW604rrNUSk8g/AFQk0pIKyDzV9qYMJD2wnr42cyPKg0gfk1tc9KRCNeH+My1HdZS6Zogpjkc3plAzJQ1DAPY0EBWUlEKghpkyCunjpxN3cw390iKgZUN52phtmGMRkyNnwI8+ELnT4I+Jata1mFyWiETM85q8Rqx+FeA0W/BBsEAp8=" \ No newline at end of file diff --git a/README.md b/README.md index 3437832c48..054e478332 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Action Cable – Integrated websockets for Rails +[![Build Status](https://travis-ci.org/rails/actioncable.svg)](https://travis-ci.org/rails/actioncable) Action Cable seamlessly integrates websockets with the rest of your Rails application. It allows for real-time features to be written in Ruby in the same style -- cgit v1.2.3 From 65fde3bf75cf82bf18d8cfa36bc07f52c9a866ff Mon Sep 17 00:00:00 2001 From: Kasper Timm Hansen Date: Wed, 29 Jul 2015 22:22:39 +0200 Subject: Fix the gem version in Gemfile.lock --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0299c50f9f..5548531abe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - actioncable (0.1.0) + actioncable (0.0.3) actionpack (>= 4.2.0) activesupport (>= 4.2.0) celluloid (~> 0.16.0) -- cgit v1.2.3 From 60e2fa5e955ce819c90f2081320554b5ed0ee83c Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Tue, 28 Jul 2015 15:13:07 +1000 Subject: refactor channel look up to use a hash instead of an array and reduce the number of calls to safe_constantize because it can be slow --- lib/action_cable/connection/subscriptions.rb | 4 +--- lib/action_cable/server/base.rb | 4 ++-- test/connection/subscriptions_test.rb | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 0411d96413..69e3f60706 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -24,9 +24,7 @@ module ActionCable id_key = data['identifier'] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - subscription_klass = connection.server.channel_classes.detect do |channel_class| - channel_class == id_options[:channel].safe_constantize - end + subscription_klass = connection.server.channel_classes[id_options[:channel]] if subscription_klass subscriptions[id_key] ||= subscription_klass.new(connection, id_key, id_options) diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index b09fbf6da4..43849928b9 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -36,11 +36,11 @@ module ActionCable @worker_pool ||= ActionCable::Server::Worker.pool(size: config.worker_pool_size) end - # Requires and returns an array of all the channel class constants in this application. + # Requires and returns an hash of all the channel class constants keyed by name. def channel_classes @channel_classes ||= begin config.channel_paths.each { |channel_path| require channel_path } - config.channel_class_names.collect { |name| name.constantize } + config.channel_class_names.each_with_object({}) { |name, hash| hash[name] = name.constantize } end end diff --git a/test/connection/subscriptions_test.rb b/test/connection/subscriptions_test.rb index 4e134b6420..24fe8f9300 100644 --- a/test/connection/subscriptions_test.rb +++ b/test/connection/subscriptions_test.rb @@ -20,7 +20,7 @@ class ActionCable::Connection::SubscriptionsTest < ActiveSupport::TestCase setup do @server = TestServer.new - @server.stubs(:channel_classes).returns([ ChatChannel ]) + @server.stubs(:channel_classes).returns(ChatChannel.name => ChatChannel) env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' @connection = Connection.new(@server, env) -- cgit v1.2.3 From 123fc0dc676f3a114bdfaa168692cfd0445aee8c Mon Sep 17 00:00:00 2001 From: Rajarshi Das Date: Thu, 30 Jul 2015 13:23:36 +0530 Subject: [ci skip] small description on readme for identified_by --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 054e478332..13d528995f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ module ApplicationCable end end ``` +Here `identified_by` is a connection identifier that can be used to find the specific connection again or later. +Note that anything marked as an identifier will automatically create a delegate by the same name on any channel instances created off the connection. Then you should define your `ApplicationCable::Channel` class in Ruby. This is the place where you put shared logic between your channels. -- cgit v1.2.3 From 8544df97f7a77401292fb6fa5a07087f47764f33 Mon Sep 17 00:00:00 2001 From: Jason Dew Date: Thu, 30 Jul 2015 14:21:12 -0400 Subject: Fixing some documentation, correcting grammar, and removing unnecessary whitespace --- lib/action_cable/channel/streams.rb | 10 +++++----- lib/action_cable/connection/base.rb | 12 ++++++------ lib/action_cable/connection/heartbeat.rb | 6 +++--- lib/action_cable/connection/identification.rb | 2 +- lib/action_cable/remote_connections.rb | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index f711b065ca..a2bc42e5db 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -1,6 +1,6 @@ module ActionCable module Channel - # Streams allow channels to route broadcastings to the subscriber. A broadcasting is an discussed elsewhere a pub/sub queue where any data + # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pub/sub queue where any data # put 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'll not get that update when connecting later. # @@ -24,7 +24,7 @@ module ActionCable # 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 to subscribe to a broadcasting that would be something like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE` + # The following example would subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE` # # class CommentsChannel < ApplicationCable::Channel # def subscribed @@ -37,15 +37,15 @@ module ActionCable # # CommentsChannel.broadcast_to(@post) # - # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that let's you alter what goes out. + # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that lets you alter what goes out. # Example below 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, -> (message) do - # message = ActiveSupport::JSON.decode(m) + # stream_for @room, -> (encoded_message) do + # message = ActiveSupport::JSON.decode(encoded_message) # # if message['originated_at'].present? # elapsed_time = (Time.now.to_f - message['originated_at']).round(2) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 5671dc1f88..08a75156a3 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -12,7 +12,7 @@ module ActionCable # 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 @@ -21,7 +21,7 @@ module ActionCable # def disconnect # # Any cleanup work needed when the cable connection is cut. # end - # + # # protected # def find_verified_user # if current_user = User.find_by_identity cookies.signed[:identity_id] @@ -37,7 +37,7 @@ module ActionCable # established for that current_user (and potentially disconnect them if the user was removed from an account). You can declare as many # identification indexes as you like. Declaring an identification means that a attr_accessor is automatically set for that key. # - # Second, we rely on the fact that the websocket connection is established with the cookies from that domain being sent along. This makes + # 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 name of the current user to easily distinguish their messages in the log. @@ -75,14 +75,14 @@ module ActionCable websocket.on(:open) { |event| send_async :on_open } websocket.on(:message) { |event| on_message event.data } websocket.on(:close) { |event| send_async :on_close } - + respond_to_successful_request else respond_to_invalid_request end end - # Data received over the cable is handled by this method. It's expected that everything inbound is encoded with JSON. + # Data received over the cable is handled by this method. It's expected that everything inbound is JSON encoded. # The data is routed to the proper channel that the connection has subscribed to. def receive(data_in_json) if websocket.alive? @@ -177,7 +177,7 @@ module ActionCable # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. def new_tagged_logger - TaggedLoggerProxy.new server.logger, + TaggedLoggerProxy.new server.logger, tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end diff --git a/lib/action_cable/connection/heartbeat.rb b/lib/action_cable/connection/heartbeat.rb index e0f4a97f53..2918938ba5 100644 --- a/lib/action_cable/connection/heartbeat.rb +++ b/lib/action_cable/connection/heartbeat.rb @@ -5,7 +5,7 @@ module ActionCable # disconnect. class Heartbeat BEAT_INTERVAL = 3 - + def initialize(connection) @connection = connection end @@ -21,10 +21,10 @@ module ActionCable private attr_reader :connection - + def beat connection.transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) end end end -end \ No newline at end of file +end diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 113e41ca3f..1be6f9ac76 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -16,7 +16,7 @@ module ActionCable # channel instances created off the connection. def identified_by(*identifiers) Array(identifiers).each { |identifier| attr_accessor identifier } - self.identifiers += identifiers + self.identifiers += identifiers end end diff --git a/lib/action_cable/remote_connections.rb b/lib/action_cable/remote_connections.rb index ae7145891c..1230d905ad 100644 --- a/lib/action_cable/remote_connections.rb +++ b/lib/action_cable/remote_connections.rb @@ -25,7 +25,7 @@ module ActionCable end private - # Represents a single remote connection found via ActionCable.server.remote_connections.where(*). + # Represents a single remote connection found via ActionCable.server.remote_connections.where(*). # Exists for the solely for the purpose of calling #disconnect on that connection. class RemoteConnection class InvalidIdentifiersError < StandardError; end -- cgit v1.2.3 From 92b0a993909f6f413a5486017f6401b5845762d0 Mon Sep 17 00:00:00 2001 From: Jason Dew Date: Thu, 30 Jul 2015 14:37:18 -0400 Subject: Fixes test failure in ActionCable::Channel::BroadcastingTest when run by itself --- lib/action_cable.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 13c5c77578..6c1627a694 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -8,6 +8,7 @@ require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' require 'active_support/core_ext/module/delegation' +require 'active_support/core_ext/object/to_param' require 'active_support/callbacks' require 'faye/websocket' -- cgit v1.2.3 From 26de1dd8a48a7c5b2d74a4591881a5436b405a77 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Fri, 31 Jul 2015 09:57:25 +1000 Subject: update docs for broadcast_to to pass a message to broadcast. --- lib/action_cable/channel/streams.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index f711b065ca..540f7a18f4 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -35,7 +35,7 @@ module ActionCable # # You can then broadcast to this channel using: # - # CommentsChannel.broadcast_to(@post) + # CommentsChannel.broadcast_to(@post, @comment) # # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that let's you alter what goes out. # Example below shows how you can use this to provide performance introspection in the process: -- cgit v1.2.3 From 9c9f3a529ff7f982830e03653635a4401d078a46 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Sun, 2 Aug 2015 19:22:18 +1000 Subject: move the require of object/to_param to channel/broadcasting because that is where it is needed. --- lib/action_cable.rb | 1 - lib/action_cable/channel/broadcasting.rb | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 6c1627a694..13c5c77578 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -8,7 +8,6 @@ require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' require 'active_support/core_ext/module/delegation' -require 'active_support/core_ext/object/to_param' require 'active_support/callbacks' require 'faye/websocket' diff --git a/lib/action_cable/channel/broadcasting.rb b/lib/action_cable/channel/broadcasting.rb index ee4117bc0a..afc23d7d1a 100644 --- a/lib/action_cable/channel/broadcasting.rb +++ b/lib/action_cable/channel/broadcasting.rb @@ -1,3 +1,5 @@ +require 'active_support/core_ext/object/to_param' + module ActionCable module Channel module Broadcasting -- cgit v1.2.3 From 84e7d76e37bbefc2ddf2f4a132aefac2b6c2a33f Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Fri, 7 Aug 2015 18:58:22 +1000 Subject: log to stdout in development mode. --- lib/action_cable/process/logging.rb | 2 ++ lib/action_cable/server/configuration.rb | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/lib/action_cable/process/logging.rb b/lib/action_cable/process/logging.rb index bcceff4bec..827a58fdb8 100644 --- a/lib/action_cable/process/logging.rb +++ b/lib/action_cable/process/logging.rb @@ -4,3 +4,5 @@ EM.error_handler do |e| end Celluloid.logger = ActionCable.server.logger + +ActionCable.server.config.log_to_stdout if Rails.env.development? \ No newline at end of file diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb index ac9fa7b085..4808d170ff 100644 --- a/lib/action_cable/server/configuration.rb +++ b/lib/action_cable/server/configuration.rb @@ -18,6 +18,14 @@ module ActionCable @channels_path = Rails.root.join('app/channels') end + def log_to_stdout + console = ActiveSupport::Logger.new($stdout) + console.formatter = @logger.formatter + console.level = @logger.level + + @logger.extend(ActiveSupport::Logger.broadcast(console)) + end + def channel_paths @channels ||= Dir["#{channels_path}/**/*_channel.rb"] end -- cgit v1.2.3 From 8fcdfc31c88b33529893c97ace8544200e4a231c Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Mon, 10 Aug 2015 16:50:52 +1000 Subject: Clear out the streams when they are stopped. Otherwise we will keep trying to stop them. --- lib/action_cable/channel/streams.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index a37194b884..2d1506ee98 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -88,7 +88,7 @@ module ActionCable streams.each do |broadcasting, callback| pubsub.unsubscribe_proc broadcasting, callback logger.info "#{self.class.name} stopped streaming from #{broadcasting}" - end + end.clear end private -- cgit v1.2.3 From f2c270cd0e6846ef46c91d73a2c996b759cc5da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20Chen=20=3D=20=E9=99=88=E6=81=BA?= Date: Thu, 13 Aug 2015 15:02:00 +0800 Subject: Remove out-of-date AC::Broadcaster reference Remove out-of-data autoload reference of ActionCable::Broadcaster that removed at e1a99a83ca135523ff8513be756f156500999cb8 . --- lib/action_cable.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 13c5c77578..b269386c81 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -22,9 +22,7 @@ module ActionCable autoload :Server, 'action_cable/server' autoload :Connection, 'action_cable/connection' autoload :Channel, 'action_cable/channel' - autoload :RemoteConnections, 'action_cable/remote_connections' - autoload :Broadcaster, 'action_cable/broadcaster' # Singleton instance of the server module_function def server -- cgit v1.2.3 From fa362c724d9cbfd002166cf20f6b4c3ee1f4c4ca Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Sun, 23 Aug 2015 17:57:09 -0400 Subject: Immediately reconnect when visibilityState changes to "visible" --- lib/assets/javascripts/cable/connection_monitor.js.coffee | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index cac65d9043..5573ea5a77 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -30,9 +30,11 @@ class Cable.ConnectionMonitor delete @stoppedAt @startedAt = now() @poll() + document.addEventListener("visibilitychange", @visibilityDidChange) stop: -> @stoppedAt = now() + document.removeEventListener("visibilitychange", @visibilityDidChange) poll: -> setTimeout => @@ -57,6 +59,13 @@ class Cable.ConnectionMonitor else secondsSince(@startedAt) > @staleThreshold.startedAt + visibilityDidChange: => + if document.visibilityState is "visible" + setTimeout => + if @connectionIsStale() or not @consumer.connection.isOpen() + @consumer.connection.reopen() + , 200 + toJSON: -> interval = @getInterval() connectionIsStale = @connectionIsStale() -- cgit v1.2.3 From 5ee5e419be5c190aaec001c497db4aa76264b70e Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Sun, 23 Aug 2015 17:58:06 -0400 Subject: Record last 20 Subscription notifications for inspection --- lib/assets/javascripts/cable/subscriptions.js.coffee | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee index fe6975c870..eeaa697081 100644 --- a/lib/assets/javascripts/cable/subscriptions.js.coffee +++ b/lib/assets/javascripts/cable/subscriptions.js.coffee @@ -9,6 +9,7 @@ class Cable.Subscriptions constructor: (@consumer) -> @subscriptions = [] + @history = [] create: (channelName, mixin) -> channel = channelName @@ -49,6 +50,10 @@ class Cable.Subscriptions for subscription in subscriptions subscription[callbackName]?(args...) + if callbackName in ["initialized", "connected", "disconnected"] + {identifier} = subscription + @record(notification: {identifier, callbackName, args}) + sendCommand: (subscription, command) -> {identifier} = subscription if identifier is Cable.PING_IDENTIFIER @@ -56,5 +61,11 @@ class Cable.Subscriptions else @consumer.send({command, identifier}) + record: (data) -> + data.time = new Date() + @history = @history.slice(-19) + @history.push(data) + toJSON: -> - subscription.identifier for subscription in @subscriptions + history: @history + identifiers: (subscription.identifier for subscription in @subscriptions) -- cgit v1.2.3 From c74f8df1217e85d2418da43c74c205b888bc2600 Mon Sep 17 00:00:00 2001 From: Mark Humphreys Date: Fri, 21 Aug 2015 21:41:27 +1000 Subject: support connection identifiers that don't implement to_global_id by defaulting to to_s --- lib/action_cable/connection/identification.rb | 2 +- test/connection/string_identifier_test.rb | 39 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 test/connection/string_identifier_test.rb diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 1be6f9ac76..701e6885ad 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -27,7 +27,7 @@ module ActionCable private def connection_gid(ids) - ids.map { |o| o.to_global_id.to_s }.sort.join(":") + ids.map { |o| (o.try(:to_global_id) || o).to_s }.sort.join(":") end end end diff --git a/test/connection/string_identifier_test.rb b/test/connection/string_identifier_test.rb new file mode 100644 index 0000000000..87a9025008 --- /dev/null +++ b/test/connection/string_identifier_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::StringIdentifierTest < ActiveSupport::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_token + + def connect + self.current_token = "random-string" + end + end + + setup do + @server = TestServer.new + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + end + + test "connection identifier" do + open_connection_with_stubbed_pubsub + assert_equal "random-string", @connection.connection_identifier + end + + protected + def open_connection_with_stubbed_pubsub + @server.stubs(:pubsub).returns(stub_everything('pubsub')) + open_connection + end + + def open_connection + @connection.process + @connection.send :on_open + end + + def close_connection + @connection.send :on_close + end +end -- cgit v1.2.3 From cf426a7ee680e8cd30a4b5afccf7e140537836f4 Mon Sep 17 00:00:00 2001 From: "Mike A. Owens" Date: Mon, 24 Aug 2015 13:35:59 -0400 Subject: Use ActiveSupport::Callbacks for Channel subscription callbacks. * Rely on AS::Callbacks for callback handling. * Add before_subscribe, after_subscribe, before_unsubscribe and after_unsubscribe convenience methods * alias on_subscribe and on_unsubscribe to after_subscribe and after_unsubscribe, respectively. * Remove `subscribed` and `unsubscribed` from the callback chain: these methods are now executed as the subject of the callbacks. * Update portions of ActionCable to use the more specific callback names. --- lib/action_cable/channel/base.rb | 19 +++++---------- lib/action_cable/channel/callbacks.rb | 38 +++++++++++++++++------------ lib/action_cable/channel/periodic_timers.rb | 6 ++--- test/channel/base_test.rb | 4 +-- 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 2f1b4a187d..171558e371 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -71,9 +71,6 @@ module ActionCable include Naming include Broadcasting - on_subscribe :subscribed - on_unsubscribe :unsubscribed - attr_reader :params, :connection delegate :logger, to: :connection @@ -138,7 +135,9 @@ module ActionCable # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. def unsubscribe_from_channel - run_unsubscribe_callbacks + run_callbacks :unsubscribe do + unsubscribed + end logger.info "#{self.class.name} unsubscribed" end @@ -176,7 +175,9 @@ module ActionCable def subscribe_to_channel logger.info "#{self.class.name} subscribing" - run_subscribe_callbacks + run_callbacks :subscribe do + subscribed + end end @@ -205,14 +206,6 @@ module ActionCable end end end - - def run_subscribe_callbacks - self.class.on_subscribe_callbacks.each { |callback| send(callback) } - end - - def run_unsubscribe_callbacks - self.class.on_unsubscribe_callbacks.each { |callback| send(callback) } - end end end end diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb index dcdd27b9a7..f050fc95c0 100644 --- a/lib/action_cable/channel/callbacks.rb +++ b/lib/action_cable/channel/callbacks.rb @@ -1,28 +1,36 @@ +require 'active_support/callbacks' + module ActionCable module Channel module Callbacks - extend ActiveSupport::Concern + extend ActiveSupport::Concern + include ActiveSupport::Callbacks included do - class_attribute :on_subscribe_callbacks, :on_unsubscribe_callbacks, instance_reader: false - - self.on_subscribe_callbacks = [] - self.on_unsubscribe_callbacks = [] + define_callbacks :subscribe + define_callbacks :unsubscribe end - module ClassMethods - # Name methods that should be called when the channel is subscribed to. - # (These methods should be private, so they're not callable by the user). - def on_subscribe(*methods) - self.on_subscribe_callbacks += methods + class_methods do + def before_subscribe(*methods, &block) + set_callback(:subscribe, :before, *methods, &block) + end + + 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 - # Name methods that should be called when the channel is unsubscribed from. - # (These methods should be private, so they're not callable by the user). - def on_unsubscribe(*methods) - self.on_unsubscribe_callbacks += methods + def after_unsubscribe(*methods, &block) + set_callback(:unsubscribe, :after, *methods, &block) end + alias_method :on_unsubscribe, :after_unsubscribe end end end -end \ No newline at end of file +end diff --git a/lib/action_cable/channel/periodic_timers.rb b/lib/action_cable/channel/periodic_timers.rb index 9bdcc87aa5..25fe8e5e54 100644 --- a/lib/action_cable/channel/periodic_timers.rb +++ b/lib/action_cable/channel/periodic_timers.rb @@ -7,8 +7,8 @@ module ActionCable class_attribute :periodic_timers, instance_reader: false self.periodic_timers = [] - on_subscribe :start_periodic_timers - on_unsubscribe :stop_periodic_timers + after_subscribe :start_periodic_timers + after_unsubscribe :stop_periodic_timers end module ClassMethods @@ -38,4 +38,4 @@ module ActionCable end end end -end \ No newline at end of file +end diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb index e7944ff06b..ca632b124b 100644 --- a/test/channel/base_test.rb +++ b/test/channel/base_test.rb @@ -20,8 +20,8 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase class ChatChannel < BasicChannel attr_reader :room, :last_action - on_subscribe :toggle_subscribed - on_unsubscribe :toggle_subscribed + after_subscribe :toggle_subscribed + after_unsubscribe :toggle_subscribed def subscribed @room = Room.new params[:id] -- cgit v1.2.3 From 1379e973bef49b087dfb680756740665e3fe879c Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 28 Aug 2015 17:45:07 -0400 Subject: Simplify WebSocket reconnects and guard against opening multiple connections --- lib/assets/javascripts/cable/connection.js.coffee | 36 ++++++----------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 464f0c1ff7..73a40acfe4 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -11,19 +11,18 @@ class Cable.Connection false open: -> - return if @isState("open", "connecting") - @webSocket = new WebSocket(@consumer.url) - @installEventHandlers() + if @isOpen() + throw new Error("Must close existing connection before opening") + else + @webSocket = new WebSocket(@consumer.url) + @installEventHandlers() close: -> - return if @isState("closed", "closing") @webSocket?.close() reopen: -> - if @isOpen() - @closeSilently => @open() - else - @open() + @close() + @open() isOpen: -> @isState("open") @@ -37,26 +36,10 @@ class Cable.Connection return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState null - closeSilently: (callback = ->) -> - @uninstallEventHandlers() - @installEventHandler("close", callback) - @installEventHandler("error", callback) - try - @webSocket.close() - finally - @uninstallEventHandlers() - installEventHandlers: -> for eventName of @events - @installEventHandler(eventName) - - installEventHandler: (eventName, handler) -> - handler ?= @events[eventName].bind(this) - @webSocket.addEventListener(eventName, handler) - - uninstallEventHandlers: -> - for eventName of @events - @webSocket.removeEventListener(eventName) + handler = @events[eventName].bind(this) + @webSocket["on#{eventName}"] = handler events: message: (event) -> @@ -71,7 +54,6 @@ class Cable.Connection error: -> @consumer.subscriptions.notifyAll("disconnected") - @closeSilently() toJSON: -> state: @getState() -- cgit v1.2.3 From ddfd649c11cad494a678f3baeb7b0873f4ad6fa9 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 28 Aug 2015 17:52:46 -0400 Subject: Only send "disconnected" notification once --- lib/assets/javascripts/cable/connection.js.coffee | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee index 73a40acfe4..2259ddcedd 100644 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ b/lib/assets/javascripts/cable/connection.js.coffee @@ -47,13 +47,19 @@ class Cable.Connection @consumer.subscriptions.notify(identifier, "received", message) open: -> + @disconnected = false @consumer.subscriptions.reload() close: -> - @consumer.subscriptions.notifyAll("disconnected") + @disconnect() error: -> - @consumer.subscriptions.notifyAll("disconnected") + @disconnect() + + disconnect: -> + return if @disconnected + @disconnected = true + @consumer.subscriptions.notifyAll("disconnected") toJSON: -> state: @getState() -- cgit v1.2.3 From f4b5a4ecdf09d3dafb6974cbd67fd98c03717d8f Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 28 Aug 2015 18:05:46 -0400 Subject: Immediately reconnect after first disconnect --- lib/assets/javascripts/cable/connection_monitor.js.coffee | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index 5573ea5a77..60e14b51ad 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -19,6 +19,13 @@ class Cable.ConnectionMonitor @reset() @pingedAt = now() + disconnected: -> + if @reconnectAttempts is 0 + @reconnectAttempts += 1 + setTimeout => + @consumer.connection.open() + , 200 + received: -> @pingedAt = now() -- cgit v1.2.3 From 9e0c0afe1c4e8d1fb831de82049ff94c23b22924 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Mon, 31 Aug 2015 07:54:54 -0500 Subject: Guard against opening multiple connections --- lib/assets/javascripts/cable/connection_monitor.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index 60e14b51ad..c6949971fb 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -23,7 +23,7 @@ class Cable.ConnectionMonitor if @reconnectAttempts is 0 @reconnectAttempts += 1 setTimeout => - @consumer.connection.open() + @consumer.connection.open() unless @consumer.connection.isOpen() , 200 received: -> -- cgit v1.2.3 From 60adbaf4af97dc4aa9cd3a96058e0c94f642e911 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Mon, 31 Aug 2015 08:14:17 -0500 Subject: Increment style --- lib/assets/javascripts/cable/connection_monitor.js.coffee | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee index c6949971fb..30ce11957c 100644 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.js.coffee @@ -20,8 +20,7 @@ class Cable.ConnectionMonitor @pingedAt = now() disconnected: -> - if @reconnectAttempts is 0 - @reconnectAttempts += 1 + if @reconnectAttempts++ is 0 setTimeout => @consumer.connection.open() unless @consumer.connection.isOpen() , 200 @@ -57,7 +56,7 @@ class Cable.ConnectionMonitor reconnectIfStale: -> if @connectionIsStale() - @reconnectAttempts += 1 + @reconnectAttempts++ @consumer.connection.reopen() connectionIsStale: -> -- cgit v1.2.3 From eb8c713c987480e7a0362ae3de617ba0c0f27d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 2 Sep 2015 02:57:38 -0300 Subject: .js.coffee -> .coffee It was initially required, but support for the shorthand has been supported since sprockets 2.1. Eventually 4.x will only support the shorthand version. Just want to get new people using the prefer stuff ASAP. --- lib/assets/javascripts/cable.coffee | 8 ++ lib/assets/javascripts/cable.js.coffee | 8 -- lib/assets/javascripts/cable/connection.coffee | 65 ++++++++++++++++ lib/assets/javascripts/cable/connection.js.coffee | 65 ---------------- .../javascripts/cable/connection_monitor.coffee | 87 ++++++++++++++++++++++ .../javascripts/cable/connection_monitor.js.coffee | 87 ---------------------- lib/assets/javascripts/cable/consumer.coffee | 31 ++++++++ lib/assets/javascripts/cable/consumer.js.coffee | 31 -------- lib/assets/javascripts/cable/subscription.coffee | 68 +++++++++++++++++ .../javascripts/cable/subscription.js.coffee | 68 ----------------- lib/assets/javascripts/cable/subscriptions.coffee | 71 ++++++++++++++++++ .../javascripts/cable/subscriptions.js.coffee | 71 ------------------ 12 files changed, 330 insertions(+), 330 deletions(-) create mode 100644 lib/assets/javascripts/cable.coffee delete mode 100644 lib/assets/javascripts/cable.js.coffee create mode 100644 lib/assets/javascripts/cable/connection.coffee delete mode 100644 lib/assets/javascripts/cable/connection.js.coffee create mode 100644 lib/assets/javascripts/cable/connection_monitor.coffee delete mode 100644 lib/assets/javascripts/cable/connection_monitor.js.coffee create mode 100644 lib/assets/javascripts/cable/consumer.coffee delete mode 100644 lib/assets/javascripts/cable/consumer.js.coffee create mode 100644 lib/assets/javascripts/cable/subscription.coffee delete mode 100644 lib/assets/javascripts/cable/subscription.js.coffee create mode 100644 lib/assets/javascripts/cable/subscriptions.coffee delete mode 100644 lib/assets/javascripts/cable/subscriptions.js.coffee diff --git a/lib/assets/javascripts/cable.coffee b/lib/assets/javascripts/cable.coffee new file mode 100644 index 0000000000..0bd1757505 --- /dev/null +++ b/lib/assets/javascripts/cable.coffee @@ -0,0 +1,8 @@ +#= require_self +#= require cable/consumer + +@Cable = + PING_IDENTIFIER: "_ping" + + createConsumer: (url) -> + new Cable.Consumer url diff --git a/lib/assets/javascripts/cable.js.coffee b/lib/assets/javascripts/cable.js.coffee deleted file mode 100644 index 0bd1757505..0000000000 --- a/lib/assets/javascripts/cable.js.coffee +++ /dev/null @@ -1,8 +0,0 @@ -#= require_self -#= require cable/consumer - -@Cable = - PING_IDENTIFIER: "_ping" - - createConsumer: (url) -> - new Cable.Consumer url diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee new file mode 100644 index 0000000000..2259ddcedd --- /dev/null +++ b/lib/assets/javascripts/cable/connection.coffee @@ -0,0 +1,65 @@ +# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. +class Cable.Connection + constructor: (@consumer) -> + @open() + + send: (data) -> + if @isOpen() + @webSocket.send(JSON.stringify(data)) + true + else + false + + open: -> + if @isOpen() + throw new Error("Must close existing connection before opening") + else + @webSocket = new WebSocket(@consumer.url) + @installEventHandlers() + + close: -> + @webSocket?.close() + + reopen: -> + @close() + @open() + + isOpen: -> + @isState("open") + + # Private + + isState: (states...) -> + @getState() in states + + getState: -> + return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState + null + + installEventHandlers: -> + for eventName of @events + handler = @events[eventName].bind(this) + @webSocket["on#{eventName}"] = handler + + events: + message: (event) -> + {identifier, message} = JSON.parse(event.data) + @consumer.subscriptions.notify(identifier, "received", message) + + open: -> + @disconnected = false + @consumer.subscriptions.reload() + + close: -> + @disconnect() + + error: -> + @disconnect() + + disconnect: -> + return if @disconnected + @disconnected = true + @consumer.subscriptions.notifyAll("disconnected") + + toJSON: -> + state: @getState() diff --git a/lib/assets/javascripts/cable/connection.js.coffee b/lib/assets/javascripts/cable/connection.js.coffee deleted file mode 100644 index 2259ddcedd..0000000000 --- a/lib/assets/javascripts/cable/connection.js.coffee +++ /dev/null @@ -1,65 +0,0 @@ -# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. -class Cable.Connection - constructor: (@consumer) -> - @open() - - send: (data) -> - if @isOpen() - @webSocket.send(JSON.stringify(data)) - true - else - false - - open: -> - if @isOpen() - throw new Error("Must close existing connection before opening") - else - @webSocket = new WebSocket(@consumer.url) - @installEventHandlers() - - close: -> - @webSocket?.close() - - reopen: -> - @close() - @open() - - isOpen: -> - @isState("open") - - # Private - - isState: (states...) -> - @getState() in states - - getState: -> - return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState - null - - installEventHandlers: -> - for eventName of @events - handler = @events[eventName].bind(this) - @webSocket["on#{eventName}"] = handler - - events: - message: (event) -> - {identifier, message} = JSON.parse(event.data) - @consumer.subscriptions.notify(identifier, "received", message) - - open: -> - @disconnected = false - @consumer.subscriptions.reload() - - close: -> - @disconnect() - - error: -> - @disconnect() - - disconnect: -> - return if @disconnected - @disconnected = true - @consumer.subscriptions.notifyAll("disconnected") - - toJSON: -> - state: @getState() diff --git a/lib/assets/javascripts/cable/connection_monitor.coffee b/lib/assets/javascripts/cable/connection_monitor.coffee new file mode 100644 index 0000000000..30ce11957c --- /dev/null +++ b/lib/assets/javascripts/cable/connection_monitor.coffee @@ -0,0 +1,87 @@ +# Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting +# revival reconnections if things go astray. Internal class, not intended for direct user manipulation. +class Cable.ConnectionMonitor + identifier: Cable.PING_IDENTIFIER + + pollInterval: + min: 2 + max: 30 + + staleThreshold: + startedAt: 4 + pingedAt: 8 + + constructor: (@consumer) -> + @consumer.subscriptions.add(this) + @start() + + connected: -> + @reset() + @pingedAt = now() + + disconnected: -> + if @reconnectAttempts++ is 0 + setTimeout => + @consumer.connection.open() unless @consumer.connection.isOpen() + , 200 + + received: -> + @pingedAt = now() + + reset: -> + @reconnectAttempts = 0 + + start: -> + @reset() + delete @stoppedAt + @startedAt = now() + @poll() + document.addEventListener("visibilitychange", @visibilityDidChange) + + stop: -> + @stoppedAt = now() + document.removeEventListener("visibilitychange", @visibilityDidChange) + + poll: -> + setTimeout => + unless @stoppedAt + @reconnectIfStale() + @poll() + , @getInterval() + + getInterval: -> + {min, max} = @pollInterval + interval = 4 * Math.log(@reconnectAttempts + 1) + clamp(interval, min, max) * 1000 + + reconnectIfStale: -> + if @connectionIsStale() + @reconnectAttempts++ + @consumer.connection.reopen() + + connectionIsStale: -> + if @pingedAt + secondsSince(@pingedAt) > @staleThreshold.pingedAt + else + secondsSince(@startedAt) > @staleThreshold.startedAt + + visibilityDidChange: => + if document.visibilityState is "visible" + setTimeout => + if @connectionIsStale() or not @consumer.connection.isOpen() + @consumer.connection.reopen() + , 200 + + toJSON: -> + interval = @getInterval() + connectionIsStale = @connectionIsStale() + {@startedAt, @stoppedAt, @pingedAt, @reconnectAttempts, connectionIsStale, interval} + + now = -> + new Date().getTime() + + secondsSince = (time) -> + (now() - time) / 1000 + + clamp = (number, min, max) -> + Math.max(min, Math.min(max, number)) diff --git a/lib/assets/javascripts/cable/connection_monitor.js.coffee b/lib/assets/javascripts/cable/connection_monitor.js.coffee deleted file mode 100644 index 30ce11957c..0000000000 --- a/lib/assets/javascripts/cable/connection_monitor.js.coffee +++ /dev/null @@ -1,87 +0,0 @@ -# Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting -# revival reconnections if things go astray. Internal class, not intended for direct user manipulation. -class Cable.ConnectionMonitor - identifier: Cable.PING_IDENTIFIER - - pollInterval: - min: 2 - max: 30 - - staleThreshold: - startedAt: 4 - pingedAt: 8 - - constructor: (@consumer) -> - @consumer.subscriptions.add(this) - @start() - - connected: -> - @reset() - @pingedAt = now() - - disconnected: -> - if @reconnectAttempts++ is 0 - setTimeout => - @consumer.connection.open() unless @consumer.connection.isOpen() - , 200 - - received: -> - @pingedAt = now() - - reset: -> - @reconnectAttempts = 0 - - start: -> - @reset() - delete @stoppedAt - @startedAt = now() - @poll() - document.addEventListener("visibilitychange", @visibilityDidChange) - - stop: -> - @stoppedAt = now() - document.removeEventListener("visibilitychange", @visibilityDidChange) - - poll: -> - setTimeout => - unless @stoppedAt - @reconnectIfStale() - @poll() - , @getInterval() - - getInterval: -> - {min, max} = @pollInterval - interval = 4 * Math.log(@reconnectAttempts + 1) - clamp(interval, min, max) * 1000 - - reconnectIfStale: -> - if @connectionIsStale() - @reconnectAttempts++ - @consumer.connection.reopen() - - connectionIsStale: -> - if @pingedAt - secondsSince(@pingedAt) > @staleThreshold.pingedAt - else - secondsSince(@startedAt) > @staleThreshold.startedAt - - visibilityDidChange: => - if document.visibilityState is "visible" - setTimeout => - if @connectionIsStale() or not @consumer.connection.isOpen() - @consumer.connection.reopen() - , 200 - - toJSON: -> - interval = @getInterval() - connectionIsStale = @connectionIsStale() - {@startedAt, @stoppedAt, @pingedAt, @reconnectAttempts, connectionIsStale, interval} - - now = -> - new Date().getTime() - - secondsSince = (time) -> - (now() - time) / 1000 - - clamp = (number, min, max) -> - Math.max(min, Math.min(max, number)) diff --git a/lib/assets/javascripts/cable/consumer.coffee b/lib/assets/javascripts/cable/consumer.coffee new file mode 100644 index 0000000000..05a7398e79 --- /dev/null +++ b/lib/assets/javascripts/cable/consumer.coffee @@ -0,0 +1,31 @@ +#= require cable/connection +#= require cable/connection_monitor +#= require cable/subscriptions +#= require cable/subscription + +# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, +# the Cable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. +# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription +# method. +# +# The following example shows how this can be setup: +# +# @App = {} +# App.cable = Cable.createConsumer "ws://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. +class Cable.Consumer + constructor: (@url) -> + @subscriptions = new Cable.Subscriptions this + @connection = new Cable.Connection this + @connectionMonitor = new Cable.ConnectionMonitor this + + send: (data) -> + @connection.send(data) + + inspect: -> + JSON.stringify(this, null, 2) + + toJSON: -> + {@url, @subscriptions, @connection, @connectionMonitor} diff --git a/lib/assets/javascripts/cable/consumer.js.coffee b/lib/assets/javascripts/cable/consumer.js.coffee deleted file mode 100644 index 05a7398e79..0000000000 --- a/lib/assets/javascripts/cable/consumer.js.coffee +++ /dev/null @@ -1,31 +0,0 @@ -#= require cable/connection -#= require cable/connection_monitor -#= require cable/subscriptions -#= require cable/subscription - -# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, -# the Cable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. -# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription -# method. -# -# The following example shows how this can be setup: -# -# @App = {} -# App.cable = Cable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. -class Cable.Consumer - constructor: (@url) -> - @subscriptions = new Cable.Subscriptions this - @connection = new Cable.Connection this - @connectionMonitor = new Cable.ConnectionMonitor this - - send: (data) -> - @connection.send(data) - - inspect: -> - JSON.stringify(this, null, 2) - - toJSON: -> - {@url, @subscriptions, @connection, @connectionMonitor} diff --git a/lib/assets/javascripts/cable/subscription.coffee b/lib/assets/javascripts/cable/subscription.coffee new file mode 100644 index 0000000000..5b024d4e15 --- /dev/null +++ b/lib/assets/javascripts/cable/subscription.coffee @@ -0,0 +1,68 @@ +# A new subscription is created through the Cable.Subscriptions instance available on the consumer. +# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding +# Channel instance on the server side. +# +# An example demonstrates the basic functionality: +# +# App.appearance = App.cable.subscriptions.create "AppearanceChannel", +# connected: -> +# # Called once the subscription has been successfully completed +# +# appear: -> +# @perform 'appear', appearing_on: @appearingOn() +# +# away: -> +# @perform 'away' +# +# appearingOn: -> +# $('main').data 'appearing-on' +# +# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server +# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). +# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. +# +# This is how the server component would look: +# +# class AppearanceChannel < ApplicationCable::Channel +# def subscribed +# current_user.appear +# end +# +# def unsubscribed +# current_user.disappear +# end +# +# def appear(data) +# current_user.appear on: data['appearing_on'] +# end +# +# def away +# current_user.away +# end +# end +# +# The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. +# The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method. +class Cable.Subscription + constructor: (@subscriptions, params = {}, mixin) -> + @identifier = JSON.stringify(params) + extend(this, mixin) + @subscriptions.add(this) + @consumer = @subscriptions.consumer + + # Perform a channel action with the optional data passed as an attribute + perform: (action, data = {}) -> + data.action = action + @send(data) + + send: (data) -> + @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) + + unsubscribe: -> + @subscriptions.remove(this) + + extend = (object, properties) -> + if properties? + for key, value of properties + object[key] = value + object diff --git a/lib/assets/javascripts/cable/subscription.js.coffee b/lib/assets/javascripts/cable/subscription.js.coffee deleted file mode 100644 index 5b024d4e15..0000000000 --- a/lib/assets/javascripts/cable/subscription.js.coffee +++ /dev/null @@ -1,68 +0,0 @@ -# A new subscription is created through the Cable.Subscriptions instance available on the consumer. -# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding -# Channel instance on the server side. -# -# An example demonstrates the basic functionality: -# -# App.appearance = App.cable.subscriptions.create "AppearanceChannel", -# connected: -> -# # Called once the subscription has been successfully completed -# -# appear: -> -# @perform 'appear', appearing_on: @appearingOn() -# -# away: -> -# @perform 'away' -# -# appearingOn: -> -# $('main').data 'appearing-on' -# -# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server -# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). -# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. -# -# This is how the server component would look: -# -# class AppearanceChannel < ApplicationCable::Channel -# def subscribed -# current_user.appear -# end -# -# def unsubscribed -# current_user.disappear -# end -# -# def appear(data) -# current_user.appear on: data['appearing_on'] -# end -# -# def away -# current_user.away -# end -# end -# -# The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. -# The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method. -class Cable.Subscription - constructor: (@subscriptions, params = {}, mixin) -> - @identifier = JSON.stringify(params) - extend(this, mixin) - @subscriptions.add(this) - @consumer = @subscriptions.consumer - - # Perform a channel action with the optional data passed as an attribute - perform: (action, data = {}) -> - data.action = action - @send(data) - - send: (data) -> - @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) - - unsubscribe: -> - @subscriptions.remove(this) - - extend = (object, properties) -> - if properties? - for key, value of properties - object[key] = value - object diff --git a/lib/assets/javascripts/cable/subscriptions.coffee b/lib/assets/javascripts/cable/subscriptions.coffee new file mode 100644 index 0000000000..eeaa697081 --- /dev/null +++ b/lib/assets/javascripts/cable/subscriptions.coffee @@ -0,0 +1,71 @@ +# Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user +# us Cable.Subscriptions#create, and it should be called through the consumer like so: +# +# @App = {} +# App.cable = Cable.createConsumer "ws://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. +class Cable.Subscriptions + constructor: (@consumer) -> + @subscriptions = [] + @history = [] + + create: (channelName, mixin) -> + channel = channelName + params = if typeof channel is "object" then channel else {channel} + new Cable.Subscription this, params, mixin + + # Private + + add: (subscription) -> + @subscriptions.push(subscription) + @notify(subscription, "initialized") + if @sendCommand(subscription, "subscribe") + @notify(subscription, "connected") + + reload: -> + for subscription in @subscriptions + if @sendCommand(subscription, "subscribe") + @notify(subscription, "connected") + + remove: (subscription) -> + @subscriptions = (s for s in @subscriptions when s isnt subscription) + unless @findAll(subscription.identifier).length + @sendCommand(subscription, "unsubscribe") + + findAll: (identifier) -> + s for s in @subscriptions when s.identifier is identifier + + notifyAll: (callbackName, args...) -> + for subscription in @subscriptions + @notify(subscription, callbackName, args...) + + notify: (subscription, callbackName, args...) -> + if typeof subscription is "string" + subscriptions = @findAll(subscription) + else + subscriptions = [subscription] + + for subscription in subscriptions + subscription[callbackName]?(args...) + + if callbackName in ["initialized", "connected", "disconnected"] + {identifier} = subscription + @record(notification: {identifier, callbackName, args}) + + sendCommand: (subscription, command) -> + {identifier} = subscription + if identifier is Cable.PING_IDENTIFIER + @consumer.connection.isOpen() + else + @consumer.send({command, identifier}) + + record: (data) -> + data.time = new Date() + @history = @history.slice(-19) + @history.push(data) + + toJSON: -> + history: @history + identifiers: (subscription.identifier for subscription in @subscriptions) diff --git a/lib/assets/javascripts/cable/subscriptions.js.coffee b/lib/assets/javascripts/cable/subscriptions.js.coffee deleted file mode 100644 index eeaa697081..0000000000 --- a/lib/assets/javascripts/cable/subscriptions.js.coffee +++ /dev/null @@ -1,71 +0,0 @@ -# Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user -# us Cable.Subscriptions#create, and it should be called through the consumer like so: -# -# @App = {} -# App.cable = Cable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. -class Cable.Subscriptions - constructor: (@consumer) -> - @subscriptions = [] - @history = [] - - create: (channelName, mixin) -> - channel = channelName - params = if typeof channel is "object" then channel else {channel} - new Cable.Subscription this, params, mixin - - # Private - - add: (subscription) -> - @subscriptions.push(subscription) - @notify(subscription, "initialized") - if @sendCommand(subscription, "subscribe") - @notify(subscription, "connected") - - reload: -> - for subscription in @subscriptions - if @sendCommand(subscription, "subscribe") - @notify(subscription, "connected") - - remove: (subscription) -> - @subscriptions = (s for s in @subscriptions when s isnt subscription) - unless @findAll(subscription.identifier).length - @sendCommand(subscription, "unsubscribe") - - findAll: (identifier) -> - s for s in @subscriptions when s.identifier is identifier - - notifyAll: (callbackName, args...) -> - for subscription in @subscriptions - @notify(subscription, callbackName, args...) - - notify: (subscription, callbackName, args...) -> - if typeof subscription is "string" - subscriptions = @findAll(subscription) - else - subscriptions = [subscription] - - for subscription in subscriptions - subscription[callbackName]?(args...) - - if callbackName in ["initialized", "connected", "disconnected"] - {identifier} = subscription - @record(notification: {identifier, callbackName, args}) - - sendCommand: (subscription, command) -> - {identifier} = subscription - if identifier is Cable.PING_IDENTIFIER - @consumer.connection.isOpen() - else - @consumer.send({command, identifier}) - - record: (data) -> - data.time = new Date() - @history = @history.slice(-19) - @history.push(data) - - toJSON: -> - history: @history - identifiers: (subscription.identifier for subscription in @subscriptions) -- cgit v1.2.3 From 98855fea634aa1c427c18841cf14fa3777201c4b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2015 15:59:41 -0700 Subject: EventMachine: shush epoll warnings by checking for support before enabling. Ditto for kqueue. --- lib/action_cable.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index b269386c81..d2c5251634 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -1,5 +1,6 @@ require 'eventmachine' -EM.epoll +EventMachine.epoll if EventMachine.epoll? +EventMachine.kqueue if EventMachine.kqueue? require 'set' -- cgit v1.2.3 From 6707819986796cbfeaafb7ac31e6f12cd3735b1e Mon Sep 17 00:00:00 2001 From: Greg Molnar Date: Sat, 3 Oct 2015 19:47:54 +0200 Subject: use correct filename in example closes #80 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fc74a084a9..a9eb6ecb85 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ This is a web notification channel that allows you to trigger client-side web no streams: ```ruby -# app/channels/web_notifications.rb +# app/channels/web_notifications_channel.rb class WebNotificationsChannel < ApplicationCable::Channel def subscribed stream_from "web_notifications_#{current_user.id}" -- cgit v1.2.3 From fdbf759ac56eea40765bd2d45eaf9453011b5bd3 Mon Sep 17 00:00:00 2001 From: Alex Peattie Date: Tue, 6 Oct 2015 19:59:27 +0100 Subject: Fix NoMethodError when using a custom Rails.logger class --- lib/action_cable/connection/base.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 08a75156a3..84393845c4 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -56,7 +56,7 @@ module ActionCable def initialize(server, env) @server, @env = server, env - @logger = new_tagged_logger + @logger = new_tagged_logger || server.logger @websocket = ActionCable::Connection::WebSocket.new(env) @heartbeat = ActionCable::Connection::Heartbeat.new(self) @@ -177,8 +177,10 @@ module ActionCable # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. def new_tagged_logger - TaggedLoggerProxy.new server.logger, - tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + if server.logger.respond_to?(:tagged) + TaggedLoggerProxy.new server.logger, + tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + end end def started_request_message -- cgit v1.2.3 From 04317173a02eb67c103364b5c8b5756ac61fac98 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 7 Oct 2015 11:24:20 -0500 Subject: First take at cross site forgery protection --- lib/action_cable.rb | 1 + lib/action_cable/connection/base.rb | 24 ++++++++++++- lib/action_cable/server/configuration.rb | 3 ++ test/connection/base_test.rb | 1 + test/connection/cross_site_forgery_test.rb | 55 ++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 test/connection/cross_site_forgery_test.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index d2c5251634..49f14b51bc 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -8,6 +8,7 @@ require 'active_support' require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/array/wrap' require 'active_support/core_ext/module/delegation' require 'active_support/callbacks' diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 08a75156a3..a2ef99dff4 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -71,7 +71,7 @@ module ActionCable def process logger.info started_request_message - if websocket.possible? + if websocket.possible? && allow_request_origin? websocket.on(:open) { |event| send_async :on_open } websocket.on(:message) { |event| on_message event.data } websocket.on(:close) { |event| send_async :on_close } @@ -165,6 +165,28 @@ module ActionCable end + def allow_request_origin? + return true if server.config.disable_request_forgery_protection + + if env['HTTP_ORIGIN'].present? + origin_host = URI.parse(env['HTTP_ORIGIN']).host + + allowed = if server.config.allowed_request_origins.present? + Array.wrap(server.config.allowed_request_origins).include? origin_host + else + request.host == origin_host + end + + logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}") unless allowed + allowed + else + logger.error("Request origin missing.") + false + end + rescue URI::InvalidURIError + false + end + def respond_to_successful_request websocket.rack_response end diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb index ac9fa7b085..315782ec3e 100644 --- a/lib/action_cable/server/configuration.rb +++ b/lib/action_cable/server/configuration.rb @@ -6,6 +6,7 @@ module ActionCable attr_accessor :logger, :log_tags attr_accessor :connection_class, :worker_pool_size attr_accessor :redis_path, :channels_path + attr_accessor :disable_request_forgery_protection, :allowed_request_origins def initialize @logger = Rails.logger @@ -16,6 +17,8 @@ module ActionCable @redis_path = Rails.root.join('config/redis/cable.yml') @channels_path = Rails.root.join('app/channels') + + @disable_request_forgery_protection = false end def channel_paths diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb index 2f008652ee..a4bd7f4a03 100644 --- a/test/connection/base_test.rb +++ b/test/connection/base_test.rb @@ -16,6 +16,7 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase setup do @server = TestServer.new + @server.config.disable_request_forgery_protection = true env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' @connection = Connection.new(@server, env) diff --git a/test/connection/cross_site_forgery_test.rb b/test/connection/cross_site_forgery_test.rb new file mode 100644 index 0000000000..b904dbd8b6 --- /dev/null +++ b/test/connection/cross_site_forgery_test.rb @@ -0,0 +1,55 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::CrossSiteForgeryTest < ActiveSupport::TestCase + HOST = 'rubyonrails.com' + + setup do + @server = TestServer.new + end + + test "default cross site forgery protection only allows origin same as the server host" do + assert_origin_allowed 'http://rubyonrails.com' + assert_origin_not_allowed 'http://hax.com' + 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 = '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( rubyonrails.com www.rubyonrails.com ) + assert_origin_allowed 'http://rubyonrails.com' + assert_origin_allowed 'http://www.rubyonrails.com' + assert_origin_allowed 'https://www.rubyonrails.com' + assert_origin_not_allowed 'http://hax.com' + 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) + ActionCable::Connection::Base.new(@server, env_for_origin(origin)).process + end + + def env_for_origin(origin) + Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', 'SERVER_NAME' => HOST, + 'HTTP_ORIGIN' => origin + end +end -- cgit v1.2.3 From 8275cfb20fda9c89c5b8d7fc17f9f88822fc34d2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 7 Oct 2015 14:43:47 -0500 Subject: Use Array() instead of Array.wrap --- lib/action_cable.rb | 1 - lib/action_cable/connection/base.rb | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 49f14b51bc..d2c5251634 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -8,7 +8,6 @@ require 'active_support' require 'active_support/json' require 'active_support/concern' require 'active_support/core_ext/hash/indifferent_access' -require 'active_support/core_ext/array/wrap' require 'active_support/core_ext/module/delegation' require 'active_support/callbacks' diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index a2ef99dff4..5bf7086b60 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -172,7 +172,7 @@ module ActionCable origin_host = URI.parse(env['HTTP_ORIGIN']).host allowed = if server.config.allowed_request_origins.present? - Array.wrap(server.config.allowed_request_origins).include? origin_host + Array(server.config.allowed_request_origins).include? origin_host else request.host == origin_host end -- cgit v1.2.3 From b099a7d2705428a4434813079f399dec54ec7611 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 8 Oct 2015 14:30:58 -0500 Subject: Run a single eventmachine timer to send heartbeats --- lib/action_cable/connection.rb | 1 - lib/action_cable/connection/base.rb | 10 ++++++---- lib/action_cable/connection/heartbeat.rb | 30 ------------------------------ lib/action_cable/server/base.rb | 1 + lib/action_cable/server/connections.rb | 11 +++++++++++ test/connection/base_test.rb | 4 +--- 6 files changed, 19 insertions(+), 38 deletions(-) delete mode 100644 lib/action_cable/connection/heartbeat.rb diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index c63621c519..3d6ed6a6e8 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -2,7 +2,6 @@ module ActionCable module Connection autoload :Authorization, 'action_cable/connection/authorization' autoload :Base, 'action_cable/connection/base' - autoload :Heartbeat, 'action_cable/connection/heartbeat' autoload :Identification, 'action_cable/connection/identification' autoload :InternalChannel, 'action_cable/connection/internal_channel' autoload :MessageBuffer, 'action_cable/connection/message_buffer' diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 08a75156a3..de1369f009 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -59,7 +59,6 @@ module ActionCable @logger = new_tagged_logger @websocket = ActionCable::Connection::WebSocket.new(env) - @heartbeat = ActionCable::Connection::Heartbeat.new(self) @subscriptions = ActionCable::Connection::Subscriptions.new(self) @message_buffer = ActionCable::Connection::MessageBuffer.new(self) @@ -115,6 +114,10 @@ module ActionCable { identifier: connection_identifier, started_at: @started_at, subscriptions: subscriptions.identifiers } end + def beat + transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) + end + protected # The request that initiated the websocket connection is available here. This gives access to the environment, cookies, etc. @@ -133,14 +136,14 @@ module ActionCable private attr_reader :websocket - attr_reader :heartbeat, :subscriptions, :message_buffer + attr_reader :subscriptions, :message_buffer def on_open server.add_connection(self) connect if respond_to?(:connect) subscribe_to_internal_channel - heartbeat.start + beat message_buffer.process! rescue ActionCable::Connection::Authorization::UnauthorizedError @@ -159,7 +162,6 @@ module ActionCable subscriptions.unsubscribe_from_all unsubscribe_from_internal_channel - heartbeat.stop disconnect if respond_to?(:disconnect) end diff --git a/lib/action_cable/connection/heartbeat.rb b/lib/action_cable/connection/heartbeat.rb deleted file mode 100644 index 2918938ba5..0000000000 --- a/lib/action_cable/connection/heartbeat.rb +++ /dev/null @@ -1,30 +0,0 @@ -module ActionCable - module Connection - # 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 receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically - # disconnect. - class Heartbeat - BEAT_INTERVAL = 3 - - def initialize(connection) - @connection = connection - end - - def start - beat - @timer = EventMachine.add_periodic_timer(BEAT_INTERVAL) { beat } - end - - def stop - EventMachine.cancel_timer(@timer) if @timer - end - - private - attr_reader :connection - - def beat - connection.transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) - end - end - end -end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 43849928b9..9315a48f20 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -18,6 +18,7 @@ module ActionCable # Called by rack to setup the server. def call(env) + setup_heartbeat_timer config.connection_class.new(self, env).process end diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb index 15d7c3c8c7..455ff1ab29 100644 --- a/lib/action_cable/server/connections.rb +++ b/lib/action_cable/server/connections.rb @@ -4,6 +4,8 @@ module ActionCable # you can't use this collection as an full list of all the connections established against your application. Use RemoteConnections for that. # As such, this is primarily for internal use. module Connections + BEAT_INTERVAL = 3 + def connections @connections ||= [] end @@ -16,6 +18,15 @@ module ActionCable 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 receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically + # disconnect. + def setup_heartbeat_timer + @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do + EM.next_tick { connections.map &:beat } + end + end + def open_connections_statistics connections.map(&:statistics) end diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb index 2f008652ee..81009f0849 100644 --- a/test/connection/base_test.rb +++ b/test/connection/base_test.rb @@ -3,7 +3,7 @@ require 'stubs/test_server' class ActionCable::Connection::BaseTest < ActiveSupport::TestCase class Connection < ActionCable::Connection::Base - attr_reader :websocket, :heartbeat, :subscriptions, :message_buffer, :connected + attr_reader :websocket, :subscriptions, :message_buffer, :connected def connect @connected = true @@ -40,7 +40,6 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase test "on connection open" do assert ! @connection.connected - EventMachine.expects(:add_periodic_timer) @connection.websocket.expects(:transmit).with(regexp_matches(/\_ping/)) @connection.message_buffer.expects(:process!) @@ -56,7 +55,6 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase @connection.send :on_open assert @connection.connected - EventMachine.expects(:cancel_timer) @connection.subscriptions.expects(:unsubscribe_from_all) @connection.send :on_close -- cgit v1.2.3 From 935d0450c038b6c51d31c29ab2ce449848cfd7dc Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 8 Oct 2015 16:40:33 -0500 Subject: Setup the heartbeat_timer in next tick to make sure EM reactor loop is running --- lib/action_cable/server/connections.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb index 455ff1ab29..4a5e21f5fd 100644 --- a/lib/action_cable/server/connections.rb +++ b/lib/action_cable/server/connections.rb @@ -22,8 +22,10 @@ module ActionCable # then can't rely on being able to receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically # disconnect. def setup_heartbeat_timer - @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do - EM.next_tick { connections.map &:beat } + @heartbeat_timer ||= EM.next_tick do + EventMachine.add_periodic_timer(BEAT_INTERVAL) do + EM.next_tick { connections.map &:beat } + end end end -- cgit v1.2.3 From 9ad20352e94f493be9a70e310104fb9932a2af2c Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 8 Oct 2015 16:58:20 -0500 Subject: Be sure to initialize @heartbeat_timer. Third time is a charm! --- lib/action_cable/server/connections.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb index 4a5e21f5fd..b3d1632cf7 100644 --- a/lib/action_cable/server/connections.rb +++ b/lib/action_cable/server/connections.rb @@ -22,8 +22,8 @@ module ActionCable # then can't rely on being able to receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically # disconnect. def setup_heartbeat_timer - @heartbeat_timer ||= EM.next_tick do - EventMachine.add_periodic_timer(BEAT_INTERVAL) do + EM.next_tick do + @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do EM.next_tick { connections.map &:beat } end end -- cgit v1.2.3 From 5858bf3bec7d72361116e84d9bcde5daa5195ea2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 9 Oct 2015 09:09:24 -0500 Subject: Better schedule pubsub subscribe --- lib/action_cable/channel/streams.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index 2d1506ee98..9fffdf1789 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -72,7 +72,7 @@ module ActionCable callback ||= default_stream_callback(broadcasting) streams << [ broadcasting, callback ] - pubsub.subscribe broadcasting, &callback + EM.next_tick { pubsub.subscribe broadcasting, &callback } logger.info "#{self.class.name} is streaming from #{broadcasting}" end -- cgit v1.2.3 From 1d4d274b0777793a8d39f76ded6a6b292f97abb0 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 9 Oct 2015 09:10:51 -0500 Subject: Include request id in statistics to make it to search the logs --- lib/action_cable/connection/base.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index de1369f009..cd7f76f118 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -111,7 +111,12 @@ module ActionCable # Return a basic hash of statistics for the connection keyed with `identifier`, `started_at`, and `subscriptions`. # This can be returned by a health check against the connection. def statistics - { identifier: connection_identifier, started_at: @started_at, subscriptions: subscriptions.identifiers } + { + identifier: connection_identifier, + started_at: @started_at, + subscriptions: subscriptions.identifiers, + request_id: @env['action_dispatch.request_id'] + } end def beat -- cgit v1.2.3 From aa3c1154edfacecbbf676af4d8728d8674d97cf4 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 9 Oct 2015 09:11:26 -0500 Subject: Be sure not to cache an empty string as the connection_identifier --- lib/action_cable/connection/identification.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 701e6885ad..4e9beac058 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -22,7 +22,11 @@ module ActionCable # Return a single connection identifier that combines the value of all the registered identifiers into a single gid. def connection_identifier - @connection_identifier ||= connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact + if @connection_identifier.blank? + @connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact + end + + @connection_identifier end private -- cgit v1.2.3 From d222f7de572f28fc2fa185e9f21cac6f7e6c84f0 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 12 Oct 2015 12:33:48 -0500 Subject: Make sure active record queries are logged with the current connection tags --- lib/action_cable/connection/tagged_logger_proxy.rb | 5 ++++- lib/action_cable/server.rb | 2 +- lib/action_cable/server/worker.rb | 7 ++++++- .../worker/active_record_connection_management.rb | 22 ++++++++++++++++++++++ .../server/worker/clear_database_connections.rb | 22 ---------------------- 5 files changed, 33 insertions(+), 25 deletions(-) create mode 100644 lib/action_cable/server/worker/active_record_connection_management.rb delete mode 100644 lib/action_cable/server/worker/clear_database_connections.rb diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb index 854f613f1c..34063c1d42 100644 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -4,6 +4,8 @@ module ActionCable # 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 @@ -22,7 +24,8 @@ module ActionCable protected def log(type, message) - @logger.tagged(*@tags) { @logger.send type, message } + current_tags = tags - @logger.formatter.current_tags + @logger.tagged(*current_tags) { @logger.send type, message } end end end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 919ebd94de..2278509341 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -6,6 +6,6 @@ module ActionCable autoload :Configuration, 'action_cable/server/configuration' autoload :Worker, 'action_cable/server/worker' - autoload :ClearDatabaseConnections, 'action_cable/server/worker/clear_database_connections' + autoload :ActiveRecordConnectionManagement, 'action_cable/server/worker/active_record_connection_management' end end diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb index d7823ecf93..91496775b8 100644 --- a/lib/action_cable/server/worker.rb +++ b/lib/action_cable/server/worker.rb @@ -5,10 +5,13 @@ module ActionCable include ActiveSupport::Callbacks include Celluloid + attr_reader :connection define_callbacks :work - include ClearDatabaseConnections + include ActiveRecordConnectionManagement def invoke(receiver, method, *args) + @connection = receiver + run_callbacks :work do receiver.send method, *args end @@ -20,6 +23,8 @@ module ActionCable end def run_periodic_timer(channel, callback) + @connection = channel.connection + run_callbacks :work do callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback) 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 0000000000..1ede0095f8 --- /dev/null +++ b/lib/action_cable/server/worker/active_record_connection_management.rb @@ -0,0 +1,22 @@ +module ActionCable + module Server + class Worker + # Clear active connections between units of work so the long-running channel or connection processes do not hoard connections. + module ActiveRecordConnectionManagement + extend ActiveSupport::Concern + + included do + if defined?(ActiveRecord::Base) + set_callback :work, :around, :with_database_connections + end + end + + def with_database_connections + ActiveRecord::Base.logger.tagged(*connection.logger.tags) { yield } + ensure + ActiveRecord::Base.clear_active_connections! + end + end + end + end +end \ No newline at end of file diff --git a/lib/action_cable/server/worker/clear_database_connections.rb b/lib/action_cable/server/worker/clear_database_connections.rb deleted file mode 100644 index 722d363a41..0000000000 --- a/lib/action_cable/server/worker/clear_database_connections.rb +++ /dev/null @@ -1,22 +0,0 @@ -module ActionCable - module Server - class Worker - # Clear active connections between units of work so the long-running channel or connection processes do not hoard connections. - module ClearDatabaseConnections - extend ActiveSupport::Concern - - included do - if defined?(ActiveRecord::Base) - set_callback :work, :around, :with_database_connections - end - end - - def with_database_connections - yield - ensure - ActiveRecord::Base.clear_active_connections! - end - end - end - end -end \ No newline at end of file -- cgit v1.2.3 From d621ae41c11398992647c600b484446ecc76a11b Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 12 Oct 2015 17:33:07 -0500 Subject: Set appropriate origin and host in the tests --- test/connection/base_test.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb index a4bd7f4a03..6c8bacde9a 100644 --- a/test/connection/base_test.rb +++ b/test/connection/base_test.rb @@ -16,9 +16,10 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase setup do @server = TestServer.new - @server.config.disable_request_forgery_protection = true - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', + 'SERVER_NAME' => 'rubyonrails.com', 'HTTP_ORIGIN' => 'http://rubyonrails.com' + @connection = Connection.new(@server, env) @response = @connection.process end -- cgit v1.2.3 From ecab8314eba8519bd593cbc097ef60ee0c285cf2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 12 Oct 2015 18:14:14 -0500 Subject: Treat ORIGIN as an opaque identifier and do equality comparison with the specified whitelist --- lib/action_cable/connection/base.rb | 17 +++-------------- test/connection/base_test.rb | 3 ++- test/connection/cross_site_forgery_test.rb | 12 ++++++------ 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 5bf7086b60..f7c5f050d8 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -168,23 +168,12 @@ module ActionCable def allow_request_origin? return true if server.config.disable_request_forgery_protection - if env['HTTP_ORIGIN'].present? - origin_host = URI.parse(env['HTTP_ORIGIN']).host - - allowed = if server.config.allowed_request_origins.present? - Array(server.config.allowed_request_origins).include? origin_host - else - request.host == origin_host - end - - logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}") unless allowed - allowed + if Array(server.config.allowed_request_origins).include? env['HTTP_ORIGIN'] + true else - logger.error("Request origin missing.") + logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}") false end - rescue URI::InvalidURIError - false end def respond_to_successful_request diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb index 6c8bacde9a..bc8b5ba568 100644 --- a/test/connection/base_test.rb +++ b/test/connection/base_test.rb @@ -16,9 +16,10 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase setup do @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', - 'SERVER_NAME' => 'rubyonrails.com', 'HTTP_ORIGIN' => 'http://rubyonrails.com' + 'HTTP_ORIGIN' => 'http://rubyonrails.com' @connection = Connection.new(@server, env) @response = @connection.process diff --git a/test/connection/cross_site_forgery_test.rb b/test/connection/cross_site_forgery_test.rb index b904dbd8b6..6073f89287 100644 --- a/test/connection/cross_site_forgery_test.rb +++ b/test/connection/cross_site_forgery_test.rb @@ -6,11 +6,12 @@ class ActionCable::Connection::CrossSiteForgeryTest < ActiveSupport::TestCase setup do @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) end - test "default cross site forgery protection only allows origin same as the server host" do - assert_origin_allowed 'http://rubyonrails.com' - assert_origin_not_allowed 'http://hax.com' + teardown do + @server.config.disable_request_forgery_protection = false + @server.config.allowed_request_origins = [] end test "disable forgery protection" do @@ -20,16 +21,15 @@ class ActionCable::Connection::CrossSiteForgeryTest < ActiveSupport::TestCase end test "explicitly specified a single allowed origin" do - @server.config.allowed_request_origins = 'hax.com' + @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( rubyonrails.com www.rubyonrails.com ) + @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_allowed 'https://www.rubyonrails.com' assert_origin_not_allowed 'http://hax.com' end -- cgit v1.2.3 From f300627b68f61489f681328a9ce0b12c9eea0402 Mon Sep 17 00:00:00 2001 From: Charles DuBose Date: Mon, 12 Oct 2015 20:15:17 -0500 Subject: Adding some examples to the README. Since ActionCable is an engine, it can be run in-app in the same way that websocket-rails can be (and I will admit to shamelessly stealing the implementation to websocket rails). Also adding examples of passing params to subscription and rebroadcasting received message. --- README.md | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a9eb6ecb85..74b0cea8ca 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ and scalable. It's a full-stack offering that provides both a client-side JavaScript framework and a server-side Ruby framework. You have access to your full domain model written with ActiveRecord or your ORM of choice. + ## Terminology A single Action Cable server can handle multiple connection instances. It has one @@ -33,8 +34,9 @@ As you can see, this is a fairly deep architectural stack. There's a lot of new to identify the new pieces, and on top of that, you're dealing with both client and server side reflections of each unit. +## Examples -## A full-stack example +### A full-stack example The first thing you must do is define your `ApplicationCable::Connection` class in Ruby. This is the place where you authorize the incoming connection, and proceed to establish it @@ -99,7 +101,7 @@ itself. This just gives you the plumbing. To make stuff happen, you need content is defined by declaring channels on the server and allowing the consumer to subscribe to them. -## Channel example 1: User appearances +### Channel example 1: User appearances Here's a simple example of a channel that tracks whether a user is online or not and what page they're on. (This is useful for creating presence features like showing a green dot next to a user name if they're online). @@ -165,7 +167,7 @@ Finally, we expose `App.appearance` to the machinations of the application itsel Turbolinks `page:change` callback and allowing the user to click a data-behavior link that triggers the `#away` call. -## Channel example 2: Receiving new web notifications +### Channel example 2: Receiving new web notifications The appearance example was all about exposing server functionality to client-side invocation over the websocket connection. But the great thing about websockets is that it's a two-way street. So now let's show an example where the server invokes @@ -177,9 +179,9 @@ streams: ```ruby # app/channels/web_notifications_channel.rb class WebNotificationsChannel < ApplicationCable::Channel - def subscribed - stream_from "web_notifications_#{current_user.id}" - end + def subscribed + stream_from "web_notifications_#{current_user.id}" + end end ``` @@ -199,10 +201,70 @@ The channel has been instructed to stream everything that arrives at `web_notifi `#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip across the wire, and unpacked for the data argument arriving to `#received`. -## More complete examples + +### Passing Parameters to Channel + +You can pass parameters from the client side to the server side when creating a subscription. For example: + +```ruby +# app/channels/chat_channel.rb +class ChatChannel < ApplicationCable::Channel + def subscribed + stream_from "chat_#{params[:room]}" + end + end +``` + +Pass an object as the first argument to `subscriptions.create`, and that object will become your params hash in your cable channel. The keyword `channel` is required. + +```coffeescript +# Client-side which assumes you've already requested the right to send web notifications +App.cable.subscriptions.create {channel: "ChatChannel", room: "Best Room"}, + received: (data) -> + new Message data['sent_by'], body: data['body'] +``` + +```ruby +# Somewhere in your app this is called, perhaps from a NewCommentJob +ActionCable.server.broadcast \ + "chat_#{room}", { sent_by: 'Paul', body: 'This is a cool chat app.' } +``` + + +### Rebroadcasting message + +A common use case is to rebroadcast a message sent by one client to any other connected clients. + +```ruby +# app/channels/chat_channel.rb +class ChatChannel < ApplicationCable::Channel + def subscribed + stream_from "chat_#{params[:room]}" + end + + def receive(data) + ActionCable.server.broadcast "chat_#{params[:room]}", data + end +end +``` + +```coffeescript +# Client-side which assumes you've already requested the right to send web notifications +sub = App.cable.subscriptions.create {channel: "ChatChannel", room: "Best Room"}, + received: (data) -> + new Message data['sent_by'], body: data['body'] + +sub.send {sent_by: 'Peter', body: 'Hello Paul, thanks for the compliment.'} +``` + +The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. + + +### More complete examples See the [rails/actioncable-examples](http://github.com/rails/actioncable-examples) repository for a full example of how to setup Action Cable in a Rails app and adding channels. + ## Configuration The only must-configure part of Action Cable is the Redis connection. By default, `ActionCable::Server::Base` will look for a configuration @@ -244,9 +306,11 @@ For a full list of all configuration options, see the `ActionCable::Server::Conf Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 100, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. -## Starting the cable server -As mentioned, the cable server(s) is separated from your normal application server. It's still a rack application, but it is its own rack +## Running the cable server + +### Standalone +The cable server(s) is separated from your normal application server. It's still a rack application, but it is its own rack application. The recommended basic setup is as follows: ```ruby @@ -268,9 +332,26 @@ bundle exec puma -p 28080 cable/config.ru The above will start a cable server on port 28080. Remember to point your client-side setup against that using something like: `App.cable.createConsumer('ws://basecamp.dev:28080')`. +### In app + +If you are using a threaded server like Puma or Thin, the current implementation of ActionCable can run side-along with your Rails application. For example, to listen for websocket requests on `/websocket`, match requests on that path: + +```ruby +# config/routes.rb +Example::Application.routes.draw do + match "/websocket", :to => ActionCable.server, via: [:get, :post] +end +``` + +You can use `App.cable.createConsumer('ws://' + window.location.host + '/websocket')` to connect to the cable server. + +For every instance of your server you create and for every worker your server spawns, you will also have a new instance of ActionCable, but the use of Redis keeps messages synced across connections. + +### Notes + Beware that currently the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. -Note: We'll get all this abstracted properly when the framework is integrated into Rails. +We'll get all this abstracted properly when the framework is integrated into Rails. ## Dependencies @@ -283,7 +364,6 @@ Redis installed and running. The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluloid](https://github.com/celluloid/celluloid). - ## Deployment Action Cable is powered by a combination of EventMachine and threads. The -- cgit v1.2.3 From d8d733f76eaea0c88ee33076b4dbe244f27a2240 Mon Sep 17 00:00:00 2001 From: Charles DuBose Date: Mon, 12 Oct 2015 21:43:48 -0500 Subject: splitting out a bit of ruby from some coffeescript that it was grouped with --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 74b0cea8ca..aa3d6101df 100644 --- a/README.md +++ b/README.md @@ -186,16 +186,18 @@ class WebNotificationsChannel < ApplicationCable::Channel ``` ```coffeescript -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } - # Client-side 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'] ``` +```ruby +# Somewhere in your app this is called, perhaps from a NewCommentJob +ActionCable.server.broadcast \ + "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } +``` + The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under a separate broadcasting name for each user. For a user with an ID of 1, the broadcasting name would be `web_notifications_1`. The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the `#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip -- cgit v1.2.3 From aba40cc0189ac0a3dcc062fb80cfbfceb8694e4c Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Wed, 14 Oct 2015 20:19:36 +1100 Subject: add railtie and set default allowed_request_origins for development --- lib/action_cable.rb | 2 ++ lib/action_cable/railtie.rb | 19 +++++++++++++++++++ lib/action_cable/server/base.rb | 2 ++ 3 files changed, 23 insertions(+) create mode 100644 lib/action_cable/railtie.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index d2c5251634..de1b08f789 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -17,6 +17,8 @@ require 'em-hiredis' require 'redis' require 'action_cable/engine' if defined?(Rails) +require 'action_cable/railtie' if defined?(Rails) + require 'action_cable/version' module ActionCable diff --git a/lib/action_cable/railtie.rb b/lib/action_cable/railtie.rb new file mode 100644 index 0000000000..0be6d19620 --- /dev/null +++ b/lib/action_cable/railtie.rb @@ -0,0 +1,19 @@ +module ActionCable + class Railtie < Rails::Railtie + config.action_cable = ActiveSupport::OrderedOptions.new + + initializer "action_cable.logger" do + ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } + end + + initializer "action_cable.set_configs" do |app| + options = app.config.action_cable + + options.allowed_request_origins ||= "http://localhost:3000" if ::Rails.env.development? + + ActiveSupport.on_load(:action_cable) do + options.each { |k,v| send("#{k}=", v) } + end + end + end +end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 9315a48f20..5b7ddf4185 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -66,5 +66,7 @@ module ActionCable config.connection_class.identifiers end end + + ActiveSupport.run_load_hooks(:action_cable, Base.config) end end -- cgit v1.2.3 From e07972711ed9e9404d4e6051c1ad23275a6d6645 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 14 Oct 2015 17:06:21 -0500 Subject: Improve guard against opening multiple web sockets --- lib/assets/javascripts/cable/connection.coffee | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 2259ddcedd..8256f731c5 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -10,12 +10,13 @@ class Cable.Connection else false - open: -> - if @isOpen() - throw new Error("Must close existing connection before opening") + open: => + if @webSocket and not @isState("closed") + throw new Error("Existing connection must be closed before opening") else @webSocket = new WebSocket(@consumer.url) @installEventHandlers() + true close: -> @webSocket?.close() -- cgit v1.2.3 From e8e6937d96f969c16011f3e1f136d6b8c21f8c97 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 14 Oct 2015 17:09:10 -0500 Subject: Add delay before reopening --- lib/assets/javascripts/cable/connection.coffee | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 8256f731c5..801b3d97b1 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -1,5 +1,7 @@ # Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. class Cable.Connection + @reopenDelay: 500 + constructor: (@consumer) -> @open() @@ -22,8 +24,13 @@ class Cable.Connection @webSocket?.close() reopen: -> - @close() - @open() + if @isState("closed") + @open() + else + try + @close() + finally + setTimeout(@open, @constructor.reopenDelay) isOpen: -> @isState("open") -- cgit v1.2.3 From 7ec1290f27656e51c7a6ad4ef4238c1dc22f7615 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 14 Oct 2015 17:09:32 -0500 Subject: Avoid returning results of loop --- lib/assets/javascripts/cable/connection.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 801b3d97b1..90d8fac3e1 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -48,6 +48,7 @@ class Cable.Connection for eventName of @events handler = @events[eventName].bind(this) @webSocket["on#{eventName}"] = handler + return events: message: (event) -> -- cgit v1.2.3 From af7ddfce698c79bfa7276015f1e2c947d6135261 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 14 Oct 2015 17:49:02 -0500 Subject: Remove some excessive logging --- lib/action_cable/channel/base.rb | 2 -- lib/action_cable/connection/base.rb | 1 - 2 files changed, 3 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 2f1b4a187d..3f0c4d62d9 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -139,7 +139,6 @@ module ActionCable # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. def unsubscribe_from_channel run_unsubscribe_callbacks - logger.info "#{self.class.name} unsubscribed" end @@ -175,7 +174,6 @@ module ActionCable def subscribe_to_channel - logger.info "#{self.class.name} subscribing" run_subscribe_callbacks end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2f2fa1fdec..15229242f6 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -99,7 +99,6 @@ module ActionCable # Close the websocket connection. def close - logger.error "Closing connection" websocket.close end -- cgit v1.2.3 From 889d8ae3d702004eef6f42c00f31538e16e09fbb Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 14 Oct 2015 18:06:23 -0500 Subject: Remove immediate reconnect to help avoid thundering herd after server restarts --- lib/assets/javascripts/cable/connection_monitor.coffee | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/assets/javascripts/cable/connection_monitor.coffee b/lib/assets/javascripts/cable/connection_monitor.coffee index 30ce11957c..b8be94ae60 100644 --- a/lib/assets/javascripts/cable/connection_monitor.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.coffee @@ -20,10 +20,6 @@ class Cable.ConnectionMonitor @pingedAt = now() disconnected: -> - if @reconnectAttempts++ is 0 - setTimeout => - @consumer.connection.open() unless @consumer.connection.isOpen() - , 200 received: -> @pingedAt = now() -- cgit v1.2.3 From c0e554c9432e688935d129a18c0288a518faecc7 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Wed, 14 Oct 2015 18:07:08 -0500 Subject: Tweak reconnect timing --- .../javascripts/cable/connection_monitor.coffee | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/assets/javascripts/cable/connection_monitor.coffee b/lib/assets/javascripts/cable/connection_monitor.coffee index b8be94ae60..bf99dee34d 100644 --- a/lib/assets/javascripts/cable/connection_monitor.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.coffee @@ -1,15 +1,13 @@ # Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting # revival reconnections if things go astray. Internal class, not intended for direct user manipulation. class Cable.ConnectionMonitor - identifier: Cable.PING_IDENTIFIER - - pollInterval: - min: 2 + @pollInterval: + min: 3 max: 30 - staleThreshold: - startedAt: 4 - pingedAt: 8 + @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) + + identifier: Cable.PING_IDENTIFIER constructor: (@consumer) -> @consumer.subscriptions.add(this) @@ -18,8 +16,10 @@ class Cable.ConnectionMonitor connected: -> @reset() @pingedAt = now() + delete @disconnectedAt disconnected: -> + @disconnectedAt = now() received: -> @pingedAt = now() @@ -46,20 +46,21 @@ class Cable.ConnectionMonitor , @getInterval() getInterval: -> - {min, max} = @pollInterval - interval = 4 * Math.log(@reconnectAttempts + 1) + {min, max} = @constructor.pollInterval + interval = 5 * Math.log(@reconnectAttempts + 1) clamp(interval, min, max) * 1000 reconnectIfStale: -> if @connectionIsStale() @reconnectAttempts++ - @consumer.connection.reopen() + unless @disconnectedRecently() + @consumer.connection.reopen() connectionIsStale: -> - if @pingedAt - secondsSince(@pingedAt) > @staleThreshold.pingedAt - else - secondsSince(@startedAt) > @staleThreshold.startedAt + secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold + + disconnectedRecently: -> + @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold visibilityDidChange: => if document.visibilityState is "visible" -- cgit v1.2.3 From 773d3c2310b12ab808b4434168ad5f017361712b Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 14 Oct 2015 18:12:58 -0500 Subject: Don't add the current connection to the connections array until after all the callbacks are run --- lib/action_cable/connection/base.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 15229242f6..2443b03018 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -143,13 +143,12 @@ module ActionCable attr_reader :subscriptions, :message_buffer def on_open - server.add_connection(self) - connect if respond_to?(:connect) subscribe_to_internal_channel beat message_buffer.process! + server.add_connection(self) rescue ActionCable::Connection::Authorization::UnauthorizedError respond_to_invalid_request close -- cgit v1.2.3 From ba95d86ef8df10cedc6c00a14c50b24d2dee3af6 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 14 Oct 2015 18:14:50 -0500 Subject: Subscribe and unsubscribe from the internal redis channels in the primary EM thread --- lib/action_cable/connection/internal_channel.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/internal_channel.rb b/lib/action_cable/connection/internal_channel.rb index b00e21824c..c065a24ab7 100644 --- a/lib/action_cable/connection/internal_channel.rb +++ b/lib/action_cable/connection/internal_channel.rb @@ -15,14 +15,14 @@ module ActionCable @_internal_redis_subscriptions ||= [] @_internal_redis_subscriptions << [ internal_redis_channel, callback ] - pubsub.subscribe(internal_redis_channel, &callback) + EM.next_tick { pubsub.subscribe(internal_redis_channel, &callback) } logger.info "Registered connection (#{connection_identifier})" end end def unsubscribe_from_internal_channel if @_internal_redis_subscriptions.present? - @_internal_redis_subscriptions.each { |channel, callback| pubsub.unsubscribe_proc(channel, callback) } + @_internal_redis_subscriptions.each { |channel, callback| EM.next_tick { pubsub.unsubscribe_proc(channel, callback) } } end end -- cgit v1.2.3 From db56e8bf3ba8f562219f9f87d300153e848ed8b2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 15 Oct 2015 12:42:55 -0500 Subject: Fix the variable name in error message to make sure it does not raise an exception --- lib/action_cable/connection/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 2443b03018..0c2e07489e 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -87,7 +87,7 @@ module ActionCable if websocket.alive? subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) else - logger.error "Received data without a live websocket (#{data.inspect})" + logger.error "Received data without a live websocket (#{data_in_json.inspect})" end end -- cgit v1.2.3 From ee16ca8990e80da731e6566b34640e65f6b337e6 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 15 Oct 2015 21:11:49 -0500 Subject: Run connection tests in EM loop --- test/channel/stream_test.rb | 23 +++--- test/connection/authorization_test.rb | 20 +++--- test/connection/base_test.rb | 110 +++++++++++++++++++---------- test/connection/cross_site_forgery_test.rb | 17 ++++- test/connection/identifier_test.rb | 70 +++++++++--------- test/connection/string_identifier_test.rb | 23 +++--- test/connection/subscriptions_test.rb | 83 +++++++++++++++------- test/stubs/test_server.rb | 3 + test/test_helper.rb | 18 +++++ test/worker_test.rb | 3 + 10 files changed, 242 insertions(+), 128 deletions(-) diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index b0a6f49072..5914b39be0 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -2,7 +2,7 @@ require 'test_helper' require 'stubs/test_connection' require 'stubs/room' -class ActionCable::Channel::StreamTest < ActiveSupport::TestCase +class ActionCable::Channel::StreamTest < ActionCable::TestCase class ChatChannel < ActionCable::Channel::Base def subscribed if params[:id] @@ -17,16 +17,23 @@ class ActionCable::Channel::StreamTest < ActiveSupport::TestCase end test "streaming start and stop" do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1") } - channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + run_in_eventmachine do + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1") } + channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } - channel.unsubscribe_from_channel + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } + channel.unsubscribe_from_channel + end end test "stream_for" do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire") } - channel = ChatChannel.new @connection, "" - channel.stream_for Room.new(1) + run_in_eventmachine do + EM.next_tick do + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire") } + end + + channel = ChatChannel.new @connection, "" + channel.stream_for Room.new(1) + end end end diff --git a/test/connection/authorization_test.rb b/test/connection/authorization_test.rb index 09dfead8c8..762c90fbbc 100644 --- a/test/connection/authorization_test.rb +++ b/test/connection/authorization_test.rb @@ -1,7 +1,7 @@ require 'test_helper' require 'stubs/test_server' -class ActionCable::Connection::AuthorizationTest < ActiveSupport::TestCase +class ActionCable::Connection::AuthorizationTest < ActionCable::TestCase class Connection < ActionCable::Connection::Base attr_reader :websocket @@ -10,17 +10,15 @@ class ActionCable::Connection::AuthorizationTest < ActiveSupport::TestCase end end - setup do - @server = TestServer.new - - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(@server, env) - end - test "unauthorized connection" do - @connection.websocket.expects(:close) + run_in_eventmachine do + server = TestServer.new + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection.process - @connection.send :on_open + connection = Connection.new(server, env) + connection.websocket.expects(:close) + connection.process + connection.send :on_open + end end end diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb index 7118c34d9e..da6041db4a 100644 --- a/test/connection/base_test.rb +++ b/test/connection/base_test.rb @@ -1,7 +1,7 @@ require 'test_helper' require 'stubs/test_server' -class ActionCable::Connection::BaseTest < ActiveSupport::TestCase +class ActionCable::Connection::BaseTest < ActionCable::TestCase class Connection < ActionCable::Connection::Base attr_reader :websocket, :subscriptions, :message_buffer, :connected @@ -12,69 +12,107 @@ class ActionCable::Connection::BaseTest < ActiveSupport::TestCase def disconnect @connected = false end + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end end setup do @server = TestServer.new @server.config.allowed_request_origins = %w( http://rubyonrails.com ) - - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', - 'HTTP_ORIGIN' => 'http://rubyonrails.com' - - @connection = Connection.new(@server, env) - @response = @connection.process end test "making a connection with invalid headers" do - connection = ActionCable::Connection::Base.new(@server, Rack::MockRequest.env_for("/test")) - response = connection.process - assert_equal 404, response[0] + run_in_eventmachine do + connection = ActionCable::Connection::Base.new(@server, Rack::MockRequest.env_for("/test")) + response = connection.process + assert_equal 404, response[0] + end end test "websocket connection" do - assert @connection.websocket.possible? - assert @connection.websocket.alive? + run_in_eventmachine do + connection = open_connection + connection.process + + assert connection.websocket.possible? + assert connection.websocket.alive? + end end test "rack response" do - assert_equal [ -1, {}, [] ], @response + run_in_eventmachine do + connection = open_connection + response = connection.process + + assert_equal [ -1, {}, [] ], response + end end test "on connection open" do - assert ! @connection.connected - - @connection.websocket.expects(:transmit).with(regexp_matches(/\_ping/)) - @connection.message_buffer.expects(:process!) - - @connection.send :on_open - - assert_equal [ @connection ], @server.connections - assert @connection.connected + run_in_eventmachine do + connection = open_connection + connection.process + + connection.websocket.expects(:transmit).with(regexp_matches(/\_ping/)) + connection.message_buffer.expects(:process!) + + # Allow EM to run on_open callback + EM.next_tick do + assert_equal [ connection ], @server.connections + assert connection.connected + end + end end test "on connection close" do - # Setup the connection - EventMachine.stubs(:add_periodic_timer).returns(true) - @connection.send :on_open - assert @connection.connected + run_in_eventmachine do + connection = open_connection + connection.process + + # Setup the connection + EventMachine.stubs(:add_periodic_timer).returns(true) + connection.send :on_open + assert connection.connected - @connection.subscriptions.expects(:unsubscribe_from_all) - @connection.send :on_close + connection.subscriptions.expects(:unsubscribe_from_all) + connection.send :on_close - assert ! @connection.connected - assert_equal [], @server.connections + assert ! connection.connected + assert_equal [], @server.connections + end end test "connection statistics" do - statistics = @connection.statistics + run_in_eventmachine do + connection = open_connection + connection.process + + statistics = connection.statistics - assert statistics[:identifier].blank? - assert_kind_of Time, statistics[:started_at] - assert_equal [], statistics[:subscriptions] + assert statistics[:identifier].blank? + assert_kind_of Time, statistics[:started_at] + assert_equal [], statistics[:subscriptions] + end end test "explicitly closing a connection" do - @connection.websocket.expects(:close) - @connection.close + run_in_eventmachine do + connection = open_connection + connection.process + + connection.websocket.expects(:close) + connection.close + end end + + private + def open_connection + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', + 'HTTP_ORIGIN' => 'http://rubyonrails.com' + + Connection.new(@server, env) + end end diff --git a/test/connection/cross_site_forgery_test.rb b/test/connection/cross_site_forgery_test.rb index 6073f89287..166abb7b38 100644 --- a/test/connection/cross_site_forgery_test.rb +++ b/test/connection/cross_site_forgery_test.rb @@ -1,9 +1,16 @@ require 'test_helper' require 'stubs/test_server' -class ActionCable::Connection::CrossSiteForgeryTest < ActiveSupport::TestCase +class ActionCable::Connection::CrossSiteForgeryTest < ActionCable::TestCase HOST = 'rubyonrails.com' + class Connection < ActionCable::Connection::Base + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end + end + setup do @server = TestServer.new @server.config.allowed_request_origins = %w( http://rubyonrails.com ) @@ -45,7 +52,13 @@ class ActionCable::Connection::CrossSiteForgeryTest < ActiveSupport::TestCase end def connect_with_origin(origin) - ActionCable::Connection::Base.new(@server, env_for_origin(origin)).process + response = nil + + run_in_eventmachine do + response = Connection.new(@server, env_for_origin(origin)).process + end + + response end def env_for_origin(origin) diff --git a/test/connection/identifier_test.rb b/test/connection/identifier_test.rb index 745cf308d0..f34b66f9fd 100644 --- a/test/connection/identifier_test.rb +++ b/test/connection/identifier_test.rb @@ -2,7 +2,7 @@ require 'test_helper' require 'stubs/test_server' require 'stubs/user' -class ActionCable::Connection::IdentifierTest < ActiveSupport::TestCase +class ActionCable::Connection::IdentifierTest < ActionCable::TestCase class Connection < ActionCable::Connection::Base identified_by :current_user attr_reader :websocket @@ -14,59 +14,59 @@ class ActionCable::Connection::IdentifierTest < ActiveSupport::TestCase end end - setup do - @server = TestServer.new - - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(@server, env) - end - test "connection identifier" do - open_connection_with_stubbed_pubsub - assert_equal "User#lifo", @connection.connection_identifier - end - - test "should subscribe to internal channel on open" do - pubsub = mock('pubsub') - pubsub.expects(:subscribe).with('action_cable/User#lifo') - @server.expects(:pubsub).returns(pubsub) - - open_connection + run_in_eventmachine do + open_connection_with_stubbed_pubsub + assert_equal "User#lifo", @connection.connection_identifier + end end - test "should unsubscribe from internal channel on close" do - open_connection_with_stubbed_pubsub + test "should subscribe to internal channel on open and unsubscribe on close" do + run_in_eventmachine do + pubsub = mock('pubsub') + pubsub.expects(:subscribe).with('action_cable/User#lifo') + pubsub.expects(:unsubscribe_proc).with('action_cable/User#lifo', kind_of(Proc)) - pubsub = mock('pubsub') - pubsub.expects(:unsubscribe_proc).with('action_cable/User#lifo', kind_of(Proc)) - @server.expects(:pubsub).returns(pubsub) + server = TestServer.new + server.stubs(:pubsub).returns(pubsub) - close_connection + open_connection server: server + close_connection + end end test "processing disconnect message" do - open_connection_with_stubbed_pubsub + run_in_eventmachine do + open_connection_with_stubbed_pubsub - @connection.websocket.expects(:close) - message = { 'type' => 'disconnect' }.to_json - @connection.process_internal_message message + @connection.websocket.expects(:close) + message = { 'type' => 'disconnect' }.to_json + @connection.process_internal_message message + end end test "processing invalid message" do - open_connection_with_stubbed_pubsub + run_in_eventmachine do + open_connection_with_stubbed_pubsub - @connection.websocket.expects(:close).never - message = { 'type' => 'unknown' }.to_json - @connection.process_internal_message message + @connection.websocket.expects(:close).never + message = { 'type' => 'unknown' }.to_json + @connection.process_internal_message message + end end protected def open_connection_with_stubbed_pubsub - @server.stubs(:pubsub).returns(stub_everything('pubsub')) - open_connection + server = TestServer.new + server.stubs(:pubsub).returns(stub_everything('pubsub')) + + open_connection server: server end - def open_connection + def open_connection(server:) + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(server, env) + @connection.process @connection.send :on_open end diff --git a/test/connection/string_identifier_test.rb b/test/connection/string_identifier_test.rb index 87a9025008..ab69df57b3 100644 --- a/test/connection/string_identifier_test.rb +++ b/test/connection/string_identifier_test.rb @@ -1,34 +1,39 @@ require 'test_helper' require 'stubs/test_server' -class ActionCable::Connection::StringIdentifierTest < ActiveSupport::TestCase +class ActionCable::Connection::StringIdentifierTest < ActionCable::TestCase class Connection < ActionCable::Connection::Base identified_by :current_token def connect self.current_token = "random-string" end - end - - setup do - @server = TestServer.new - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(@server, env) + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end end test "connection identifier" do - open_connection_with_stubbed_pubsub - assert_equal "random-string", @connection.connection_identifier + run_in_eventmachine do + open_connection_with_stubbed_pubsub + assert_equal "random-string", @connection.connection_identifier + end end protected def open_connection_with_stubbed_pubsub + @server = TestServer.new @server.stubs(:pubsub).returns(stub_everything('pubsub')) + open_connection end def open_connection + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + @connection.process @connection.send :on_open end diff --git a/test/connection/subscriptions_test.rb b/test/connection/subscriptions_test.rb index 24fe8f9300..55ad74b962 100644 --- a/test/connection/subscriptions_test.rb +++ b/test/connection/subscriptions_test.rb @@ -1,8 +1,13 @@ require 'test_helper' -class ActionCable::Connection::SubscriptionsTest < ActiveSupport::TestCase +class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase class Connection < ActionCable::Connection::Base attr_reader :websocket + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end end class ChatChannel < ActionCable::Channel::Base @@ -22,59 +27,76 @@ class ActionCable::Connection::SubscriptionsTest < ActiveSupport::TestCase @server = TestServer.new @server.stubs(:channel_classes).returns(ChatChannel.name => ChatChannel) - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(@server, env) - - @subscriptions = ActionCable::Connection::Subscriptions.new(@connection) @chat_identifier = { id: 1, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json end test "subscribe command" do - channel = subscribe_to_chat_channel + run_in_eventmachine do + setup_connection + channel = subscribe_to_chat_channel - assert_kind_of ChatChannel, channel - assert_equal 1, channel.room.id + assert_kind_of ChatChannel, channel + assert_equal 1, channel.room.id + end end test "subscribe command without an identifier" do - @subscriptions.execute_command 'command' => 'subscribe' - assert @subscriptions.identifiers.empty? + run_in_eventmachine do + setup_connection + + @subscriptions.execute_command 'command' => 'subscribe' + assert @subscriptions.identifiers.empty? + end end test "unsubscribe command" do - subscribe_to_chat_channel + run_in_eventmachine do + setup_connection + subscribe_to_chat_channel - channel = subscribe_to_chat_channel - channel.expects(:unsubscribe_from_channel) + channel = subscribe_to_chat_channel + channel.expects(:unsubscribe_from_channel) - @subscriptions.execute_command 'command' => 'unsubscribe', 'identifier' => @chat_identifier - assert @subscriptions.identifiers.empty? + @subscriptions.execute_command 'command' => 'unsubscribe', 'identifier' => @chat_identifier + assert @subscriptions.identifiers.empty? + end end test "unsubscribe command without an identifier" do - @subscriptions.execute_command 'command' => 'unsubscribe' - assert @subscriptions.identifiers.empty? + run_in_eventmachine do + setup_connection + + @subscriptions.execute_command 'command' => 'unsubscribe' + assert @subscriptions.identifiers.empty? + end end test "message command" do - channel = subscribe_to_chat_channel + run_in_eventmachine do + setup_connection + channel = subscribe_to_chat_channel - data = { 'content' => 'Hello World!', 'action' => 'speak' } - @subscriptions.execute_command 'command' => 'message', 'identifier' => @chat_identifier, 'data' => data.to_json + data = { 'content' => 'Hello World!', 'action' => 'speak' } + @subscriptions.execute_command 'command' => 'message', 'identifier' => @chat_identifier, 'data' => data.to_json - assert_equal [ data ], channel.lines + assert_equal [ data ], channel.lines + end end test "unsubscrib from all" do - channel1 = subscribe_to_chat_channel + run_in_eventmachine do + setup_connection - channel2_id = { id: 2, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json - channel2 = subscribe_to_chat_channel(channel2_id) + channel1 = subscribe_to_chat_channel - channel1.expects(:unsubscribe_from_channel) - channel2.expects(:unsubscribe_from_channel) + channel2_id = { id: 2, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json + channel2 = subscribe_to_chat_channel(channel2_id) - @subscriptions.unsubscribe_from_all + channel1.expects(:unsubscribe_from_channel) + channel2.expects(:unsubscribe_from_channel) + + @subscriptions.unsubscribe_from_all + end end private @@ -84,4 +106,11 @@ class ActionCable::Connection::SubscriptionsTest < ActiveSupport::TestCase @subscriptions.send :find, 'identifier' => identifier end + + def setup_connection + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + + @subscriptions = ActionCable::Connection::Subscriptions.new(@connection) + end end diff --git a/test/stubs/test_server.rb b/test/stubs/test_server.rb index 2a7ac3e927..f9168f9b78 100644 --- a/test/stubs/test_server.rb +++ b/test/stubs/test_server.rb @@ -9,4 +9,7 @@ class TestServer @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) @config = OpenStruct.new(log_tags: []) end + + def send_async + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 5640178f34..39fa98c1f9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -19,3 +19,21 @@ ActiveSupport.test_order = :sorted Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } Celluloid.logger = Logger.new(StringIO.new) + +class Faye::WebSocket + # We don't want Faye to start the EM reactor in tests because it makes testing much harder. + # We want to be able to start and stop EW loop in tests to make things simpler. + def self.ensure_reactor_running + # no-op + end +end + +class ActionCable::TestCase < ActiveSupport::TestCase + def run_in_eventmachine + EM.run do + yield + + EM::Timer.new(0.1) { EM.stop } + end + end +end diff --git a/test/worker_test.rb b/test/worker_test.rb index e1fa6f561b..69c4b6529d 100644 --- a/test/worker_test.rb +++ b/test/worker_test.rb @@ -11,6 +11,9 @@ class WorkerTest < ActiveSupport::TestCase def process(message) @last_action = [ :process, message ] end + + def connection + end end setup do -- cgit v1.2.3 From 0dc7f801778c05339ae91a1508002a5c08a88fe4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2015 23:25:54 -0700 Subject: Websockets -> WebSocket spelling [ci skip] --- README.md | 18 +++++++++--------- actioncable.gemspec | 4 ++-- lib/action_cable/channel/base.rb | 2 +- lib/action_cable/connection/base.rb | 22 +++++++++++----------- lib/action_cable/connection/message_buffer.rb | 4 ++-- lib/action_cable/server/connections.rb | 4 ++-- test/test_helper.rb | 2 +- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index aa3d6101df..798ca5292f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Action Cable – Integrated websockets for Rails +# Action Cable – Integrated WebSockets for Rails [![Build Status](https://travis-ci.org/rails/actioncable.svg)](https://travis-ci.org/rails/actioncable) -Action Cable seamlessly integrates websockets with the rest of your Rails application. +Action Cable seamlessly integrates WebSockets with the rest of your Rails application. It allows for real-time features to be written in Ruby in the same style and form as the rest of your Rails application, while still being performant and scalable. It's a full-stack offering that provides both a client-side @@ -12,9 +12,9 @@ domain model written with ActiveRecord or your ORM of choice. ## Terminology A single Action Cable server can handle multiple connection instances. It has one -connection instance per websocket connection. A single user may have multiple -websockets open to your application if they use multiple browser tabs or devices. -The client of a websocket connection is called the consumer. +connection instance per WebSocket connection. A single user may have multiple +WebSockets open to your application if they use multiple browser tabs or devices. +The client of a WebSocket connection is called the consumer. Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates a logical unit of work, similar to what a controller does in a regular MVC setup. For example, you could have a `ChatChannel` and a `AppearancesChannel`, and a consumer could be subscribed to either @@ -169,8 +169,8 @@ Turbolinks `page:change` callback and allowing the user to click a data-behavior ### Channel example 2: Receiving new web notifications -The appearance example was all about exposing server functionality to client-side invocation over the websocket connection. -But the great thing about websockets is that it's a two-way street. So now let's show an example where the server invokes +The appearance example was all about exposing server functionality to client-side invocation over the WebSocket connection. +But the great thing about WebSockets is that it's a two-way street. So now let's show an example where the server invokes action on the client. This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right @@ -336,7 +336,7 @@ The above will start a cable server on port 28080. Remember to point your client ### In app -If you are using a threaded server like Puma or Thin, the current implementation of ActionCable can run side-along with your Rails application. For example, to listen for websocket requests on `/websocket`, match requests on that path: +If you are using a threaded server like Puma or Thin, the current implementation of ActionCable can run side-along with your Rails application. For example, to listen for WebSocket requests on `/websocket`, match requests on that path: ```ruby # config/routes.rb @@ -359,7 +359,7 @@ We'll get all this abstracted properly when the framework is integrated into Rai ## Dependencies Action Cable is currently tied to Redis through its use of the pubsub feature to route -messages back and forth over the websocket cable connection. This dependency may well +messages back and forth over the WebSocket cable connection. This dependency may well be alleviated in the future, but for the moment that's what it is. So be sure to have Redis installed and running. diff --git a/actioncable.gemspec b/actioncable.gemspec index 02350186db..41d75fdab8 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -4,8 +4,8 @@ require 'action_cable/version' Gem::Specification.new do |s| s.name = 'actioncable' s.version = ActionCable::VERSION - s.summary = 'Websockets framework for Rails.' - s.description = 'Structure many real-time application concerns into channels over a single websockets connection.' + s.summary = 'WebSocket framework for Rails.' + s.description = 'Structure many real-time application concerns into channels over a single WebSocket connection.' s.license = 'MIT' s.author = ['Pratik Naik', 'David Heinemeier Hansson'] diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 3f0c4d62d9..17ac1a97af 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -1,6 +1,6 @@ module ActionCable module Channel - # The channel provides the basic structure of grouping behavior into logical units when communicating over the websocket connection. + # 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. # diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 0c2e07489e..bc07f5c51f 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -2,7 +2,7 @@ require 'action_dispatch/http/request' module ActionCable module Connection - # For every websocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent + # For every WebSocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent # of all 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 cable consumer. The Connection itself does not deal with any specific application logic beyond # authentication and authorization. @@ -37,8 +37,8 @@ module ActionCable # established for that current_user (and potentially disconnect them if the user was removed from an account). You can declare as many # identification indexes as you like. Declaring an identification means that a 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. + # 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 name of the current user to easily distinguish their messages in the log. # @@ -65,7 +65,7 @@ module ActionCable @started_at = Time.now end - # Called by the server when a new websocket connection is established. This configures the callbacks intended for overwriting by the user. + # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user. # This method should not be called directly. Rely on the #connect (and #disconnect) callback instead. def process logger.info started_request_message @@ -87,17 +87,17 @@ module ActionCable if websocket.alive? subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) else - logger.error "Received data without a live websocket (#{data_in_json.inspect})" + logger.error "Received data without a live WebSocket (#{data_in_json.inspect})" end end - # Send raw data straight back down the websocket. This is not intended to be called directly. Use the #transmit available on the + # Send raw data straight back down the WebSocket. This is not intended to be called directly. Use the #transmit available on the # Channel instead, as that'll automatically address the correct subscriber and wrap the message in JSON. def transmit(data) websocket.transmit data end - # Close the websocket connection. + # Close the WebSocket connection. def close websocket.close end @@ -124,7 +124,7 @@ module ActionCable protected - # The request that initiated the websocket connection is available here. This gives access to the environment, cookies, etc. + # 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 @@ -132,7 +132,7 @@ module ActionCable end end - # The cookies of the request that initiated the websocket connection. Useful for performing authorization checks. + # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks. def cookies request.cookie_jar end @@ -201,7 +201,7 @@ module ActionCable 'Started %s "%s"%s for %s at %s' % [ request.request_method, request.filtered_path, - websocket.possible? ? ' [Websocket]' : '', + websocket.possible? ? ' [WebSocket]' : '', request.ip, Time.now.to_default_s ] end @@ -209,7 +209,7 @@ module ActionCable def finished_request_message 'Finished "%s"%s for %s at %s' % [ request.filtered_path, - websocket.possible? ? ' [Websocket]' : '', + websocket.possible? ? ' [WebSocket]' : '', request.ip, Time.now.to_default_s ] end diff --git a/lib/action_cable/connection/message_buffer.rb b/lib/action_cable/connection/message_buffer.rb index d5a8e9eba9..25cff75b41 100644 --- a/lib/action_cable/connection/message_buffer.rb +++ b/lib/action_cable/connection/message_buffer.rb @@ -1,6 +1,6 @@ module ActionCable module Connection - # Allows us to buffer messages received from the websocket before the Connection has been fully initialized and is ready to receive them. + # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized and is ready to receive them. # Entirely internal operation and should not be used directly by the user. class MessageBuffer def initialize(connection) @@ -50,4 +50,4 @@ module ActionCable end end end -end \ No newline at end of file +end diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb index b3d1632cf7..153cebd710 100644 --- a/lib/action_cable/server/connections.rb +++ b/lib/action_cable/server/connections.rb @@ -18,7 +18,7 @@ module ActionCable 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 + # 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 receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically # disconnect. def setup_heartbeat_timer @@ -34,4 +34,4 @@ module ActionCable end end end -end \ No newline at end of file +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 39fa98c1f9..49fb1495f4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,7 +22,7 @@ Celluloid.logger = Logger.new(StringIO.new) class Faye::WebSocket # We don't want Faye to start the EM reactor in tests because it makes testing much harder. - # We want to be able to start and stop EW loop in tests to make things simpler. + # We want to be able to start and stop EM loop in tests to make things simpler. def self.ensure_reactor_running # no-op end -- cgit v1.2.3 From b1cc3223dd1e4db7c8c2f72d283cfe7f006a4fb2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2015 23:27:29 -0700 Subject: README was moved to README.md at a9c3fd5 --- actioncable.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actioncable.gemspec b/actioncable.gemspec index 41d75fdab8..2d4901e345 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'puma' s.add_development_dependency 'mocha' - s.files = Dir['README', 'lib/**/*'] + s.files = Dir['README.md', 'lib/**/*'] s.has_rdoc = false s.require_path = 'lib' -- cgit v1.2.3 From e456d734f70de3dfb245c2d2e3ce9c7e22ebdb71 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2015 23:29:50 -0700 Subject: gemspec: prefer requiring from head of the load path --- actioncable.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actioncable.gemspec b/actioncable.gemspec index 2d4901e345..2f4ae41dc1 100644 --- a/actioncable.gemspec +++ b/actioncable.gemspec @@ -1,4 +1,4 @@ -$:.push File.expand_path("../lib", __FILE__) +$:.unshift File.expand_path("../lib", __FILE__) require 'action_cable/version' Gem::Specification.new do |s| -- cgit v1.2.3 From 4d6f1b0cbe814e3b62a991450ade6e9c79c966bf Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2015 00:32:46 -0700 Subject: Shush some low-hanging Ruby warnings --- Rakefile | 1 + lib/action_cable/connection/identification.rb | 2 +- lib/action_cable/server/connections.rb | 2 +- test/channel/base_test.rb | 5 +++++ test/test_helper.rb | 8 ++++++-- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Rakefile b/Rakefile index c2ae16b7d9..69c95468e9 100644 --- a/Rakefile +++ b/Rakefile @@ -7,5 +7,6 @@ Rake::TestTask.new(:test) do |t| t.libs << "test" t.pattern = 'test/**/*_test.rb' t.verbose = true + t.warning = false end Rake::Task['test'].comment = "Run tests" diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 4e9beac058..95863795dd 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -22,7 +22,7 @@ module ActionCable # Return a single connection identifier that combines the value of all the registered identifiers into a single gid. def connection_identifier - if @connection_identifier.blank? + unless defined? @connection_identifier @connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact end diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb index 153cebd710..47dcea8c20 100644 --- a/lib/action_cable/server/connections.rb +++ b/lib/action_cable/server/connections.rb @@ -24,7 +24,7 @@ module ActionCable def setup_heartbeat_timer EM.next_tick do @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do - EM.next_tick { connections.map &:beat } + EM.next_tick { connections.map(&:beat) } end end end diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb index e7944ff06b..bac8569780 100644 --- a/test/channel/base_test.rb +++ b/test/channel/base_test.rb @@ -23,6 +23,11 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase on_subscribe :toggle_subscribed on_unsubscribe :toggle_subscribed + def initialize(*) + @subscribed = false + super + end + def subscribed @room = Room.new params[:id] @actions = [] diff --git a/test/test_helper.rb b/test/test_helper.rb index 49fb1495f4..b9cb34f891 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -18,12 +18,16 @@ ActiveSupport.test_order = :sorted # Require all the stubs and models Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } +$CELLULOID_DEBUG = false +$CELLULOID_TEST = false Celluloid.logger = Logger.new(StringIO.new) -class Faye::WebSocket +class << Faye::WebSocket + remove_method :ensure_reactor_running + # We don't want Faye to start the EM reactor in tests because it makes testing much harder. # We want to be able to start and stop EM loop in tests to make things simpler. - def self.ensure_reactor_running + def ensure_reactor_running # no-op end end -- cgit v1.2.3 From d7ab5c8f1ffd04acd2cf610f5756fc18fdfc6160 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2015 00:33:26 -0700 Subject: Test against Rails edge by default. CI against 4.2 also. * Don't deep-require to AD::Http::Request since it misses Mime autoload --- .gitignore | 3 +- .travis.yml | 13 +++++- Gemfile | 6 +++ Gemfile.lock | 93 +++++++++++++++++++++++-------------- gemfiles/rails_42.gemfile | 5 ++ lib/action_cable/connection/base.rb | 2 +- 6 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 gemfiles/rails_42.gemfile diff --git a/.gitignore b/.gitignore index 1918a1b0ee..cb2bc5e743 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test/tests.log \ No newline at end of file +/gemfiles/*.lock +/test/tests.log diff --git a/.travis.yml b/.travis.yml index 99a95ae240..5e156e2b77 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,19 @@ -sudo: false +language: ruby cache: bundler +sudo: false + rvm: - 2.2 - ruby-head + +gemfile: + - Gemfile + - gemfiles/rails_4-2-stable.gemfile + matrix: fast_finish: true + allow_failures: ruby-head + notifications: email: false irc: @@ -16,4 +25,4 @@ notifications: on_success: change on_failure: always rooms: - - secure: "EZmqsgjEQbWouCx6xL/30jslug7xcq+Dl09twDGjBs369GB5LiUm17/I7d6H1YQFY0Vu2LpiQ/zs+6ihlBjslRV/2RYM3AgAA9OOC3pn7uENFVTXaECi/io1wjvlbMNrf1YJSc3aUyiWKykRsdZnZSFszkDs4DMnZG1s/Oxf1JTYEGNWW3WcOFfYkzcS7NWlOW9OBf4RuzjtLYF05IO4t4FZI1aTWrNV3NNMZ+tqmiQHHNrQE/CzQE3ujqFiea2vVZ7PwvmjVWJgC29UZqS7HcNuq6cCMtMZZuubCZmyT85GjJ/SKTShxFqfV1oCpY3y6kyWcTAQsUoLtPEX0OxLeX+CgWNIJK0rY5+5/v5pZP1uwRsMfLerfp2a9g4fAnlcAKaZjalOc39rOkJl8FdvLQtqFIGWxpjWdJbMrCt3SrnnOccpDqDWpAL798LVBONcOuor71rEeNj1dZ6fCoHTKhLVy6UVm9eUI8zt1APM0xzHgTBI1KBVZi0ikqPcaW604rrNUSk8g/AFQk0pIKyDzV9qYMJD2wnr42cyPKg0gfk1tc9KRCNeH+My1HdZS6Zogpjkc3plAzJQ1DAPY0EBWUlEKghpkyCunjpxN3cw390iKgZUN52phtmGMRkyNnwI8+ELnT4I+Jata1mFyWiETM85q8Rqx+FeA0W/BBsEAp8=" \ No newline at end of file + - secure: "EZmqsgjEQbWouCx6xL/30jslug7xcq+Dl09twDGjBs369GB5LiUm17/I7d6H1YQFY0Vu2LpiQ/zs+6ihlBjslRV/2RYM3AgAA9OOC3pn7uENFVTXaECi/io1wjvlbMNrf1YJSc3aUyiWKykRsdZnZSFszkDs4DMnZG1s/Oxf1JTYEGNWW3WcOFfYkzcS7NWlOW9OBf4RuzjtLYF05IO4t4FZI1aTWrNV3NNMZ+tqmiQHHNrQE/CzQE3ujqFiea2vVZ7PwvmjVWJgC29UZqS7HcNuq6cCMtMZZuubCZmyT85GjJ/SKTShxFqfV1oCpY3y6kyWcTAQsUoLtPEX0OxLeX+CgWNIJK0rY5+5/v5pZP1uwRsMfLerfp2a9g4fAnlcAKaZjalOc39rOkJl8FdvLQtqFIGWxpjWdJbMrCt3SrnnOccpDqDWpAL798LVBONcOuor71rEeNj1dZ6fCoHTKhLVy6UVm9eUI8zt1APM0xzHgTBI1KBVZi0ikqPcaW604rrNUSk8g/AFQk0pIKyDzV9qYMJD2wnr42cyPKg0gfk1tc9KRCNeH+My1HdZS6Zogpjkc3plAzJQ1DAPY0EBWUlEKghpkyCunjpxN3cw390iKgZUN52phtmGMRkyNnwI8+ELnT4I+Jata1mFyWiETM85q8Rqx+FeA0W/BBsEAp8=" diff --git a/Gemfile b/Gemfile index 851fabc21d..d2eaf07c80 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,8 @@ source 'https://rubygems.org' + +gem 'activesupport', github: 'rails/rails' +gem 'actionpack', github: 'rails/rails' +gem 'arel', github: 'rails/arel' +gem 'rack', github: 'rack/rack' + gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 5548531abe..7f128bbdd1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,47 @@ +GIT + remote: git://github.com/rack/rack.git + revision: 6216a3f8a3560639ee1ddadc1e0d6bf9e5f31830 + specs: + rack (2.0.0.alpha) + json + +GIT + remote: git://github.com/rails/arel.git + revision: 3c429c5d86e9e2201c2a35d934ca6a8911c18e69 + specs: + arel (7.0.0.alpha) + +GIT + remote: git://github.com/rails/rails.git + revision: 960de47f0eef79d234eb3cfc47fabb470fef1529 + specs: + actionpack (5.0.0.alpha) + actionview (= 5.0.0.alpha) + activesupport (= 5.0.0.alpha) + rack (~> 2.x) + rack-test (~> 0.6.3) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (5.0.0.alpha) + activesupport (= 5.0.0.alpha) + builder (~> 3.1) + erubis (~> 2.7.0) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + activesupport (5.0.0.alpha) + concurrent-ruby (~> 1.0.0.pre3, < 2.0.0) + i18n (~> 0.7) + json (~> 1.7, >= 1.7.7) + method_source + minitest (~> 5.1) + tzinfo (~> 1.1) + railties (5.0.0.alpha) + actionpack (= 5.0.0.alpha) + activesupport (= 5.0.0.alpha) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + PATH remote: . specs: @@ -14,27 +58,8 @@ PATH GEM remote: https://rubygems.org/ specs: - actionpack (4.2.3) - actionview (= 4.2.3) - activesupport (= 4.2.3) - rack (~> 1.6) - rack-test (~> 0.6.2) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.3) - activesupport (= 4.2.3) - builder (~> 3.1) - erubis (~> 2.7.0) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - activesupport (4.2.3) - i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) builder (3.2.2) - celluloid (0.16.0) + celluloid (0.16.1) timers (~> 4.0.0) coffee-rails (4.1.0) coffee-script (>= 2.2.0) @@ -43,50 +68,46 @@ GEM coffee-script-source execjs coffee-script-source (1.9.1.1) + concurrent-ruby (1.0.0.pre4) em-hiredis (0.3.0) eventmachine (~> 1.0) hiredis (~> 0.5.0) erubis (2.7.0) - eventmachine (1.0.7) - execjs (2.5.2) + eventmachine (1.0.8) + execjs (2.6.0) faye-websocket (0.10.0) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) hiredis (0.5.2) - hitimes (1.2.2) + hitimes (1.2.3) i18n (0.7.0) json (1.8.3) - loofah (2.0.2) + loofah (2.0.3) nokogiri (>= 1.5.9) metaclass (0.0.4) + method_source (0.8.2) mini_portile (0.6.2) - minitest (5.7.0) + minitest (5.8.1) mocha (1.1.0) metaclass (~> 0.0.1) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) puma (2.12.2) - rack (1.6.4) rack-test (0.6.3) rack (>= 1.0) rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.6) + rails-dom-testing (1.0.7) activesupport (>= 4.2.0.beta, < 5.0) nokogiri (~> 1.6.0) rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.2) loofah (~> 2.0) - railties (4.2.3) - actionpack (= 4.2.3) - activesupport (= 4.2.3) - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) rake (10.4.2) redis (3.2.1) thor (0.19.1) thread_safe (0.3.5) - timers (4.0.1) + timers (4.0.4) hitimes tzinfo (1.2.2) thread_safe (~> 0.1) @@ -99,9 +120,13 @@ PLATFORMS DEPENDENCIES actioncable! + actionpack! + activesupport! + arel! mocha puma + rack! rake BUNDLED WITH - 1.10.5 + 1.10.6 diff --git a/gemfiles/rails_42.gemfile b/gemfiles/rails_42.gemfile new file mode 100644 index 0000000000..8ca60d69db --- /dev/null +++ b/gemfiles/rails_42.gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +gem 'rails', '~> 4.2.4' + +gemspec path: '..' diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index bc07f5c51f..de27628d7d 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -1,4 +1,4 @@ -require 'action_dispatch/http/request' +require 'action_dispatch' module ActionCable module Connection -- cgit v1.2.3 From 5efd82dbe5307982c1eac3ada7a2478993fd59be Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Sat, 17 Oct 2015 00:44:12 +1100 Subject: Speed up tests be calling EM.run_deferred_callbacks instead of setting a timer The run_in_eventmachine test helper method is setting a 0.1 second timer to stop the event machine loop. This causes each test that requires an event machine loop to wait for 0.1 second regardless of how long the test takes to process. This changes that to call EM.run_deferred_callbacks, which immediatly process pending actions in the event loop and then is able to exit the event loop without doing any waiting. Before this change, running tests produced Finished in 2.957857s, 15.8899 runs/s, 27.7228 assertions/s. After, the tests get Finished in 0.065942s, 712.7514 runs/s, 1243.5237 assertions/s. --- test/test_helper.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 49fb1495f4..a2e0df8fa8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -33,7 +33,8 @@ class ActionCable::TestCase < ActiveSupport::TestCase EM.run do yield - EM::Timer.new(0.1) { EM.stop } + EM.run_deferred_callbacks + EM.stop end end end -- cgit v1.2.3 From acfdcf556888a88dc2d2026553d585f920fae105 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2015 01:02:22 -0700 Subject: Devolve blanket #require to reveal intent and responsibility * Move specific requires close to where they're needed. * Use the private active_support/rails dep to wrap up common needs like eager autoload and module delegation. * Use a single Rails engine rather than an engine and a railtie. * Prefer `AS::JSON.encode` to `Object#to_json`. --- lib/action_cable.rb | 35 +++++++++------------------ lib/action_cable/channel.rb | 16 +++++++----- lib/action_cable/channel/base.rb | 4 ++- lib/action_cable/connection.rb | 20 +++++++++------ lib/action_cable/connection/base.rb | 6 ++--- lib/action_cable/connection/identification.rb | 10 +++++++- lib/action_cable/connection/subscriptions.rb | 2 ++ lib/action_cable/connection/web_socket.rb | 2 ++ lib/action_cable/engine.rb | 18 ++++++++++++++ lib/action_cable/railtie.rb | 19 --------------- lib/action_cable/server.rb | 20 ++++++++++----- lib/action_cable/server/base.rb | 2 ++ lib/action_cable/server/broadcasting.rb | 6 +++-- lib/action_cable/server/configuration.rb | 2 ++ lib/action_cable/server/worker.rb | 5 +++- test/connection/identifier_test.rb | 4 +-- test/connection/subscriptions_test.rb | 6 ++--- test/test_helper.rb | 2 ++ 18 files changed, 103 insertions(+), 76 deletions(-) delete mode 100644 lib/action_cable/railtie.rb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index de1b08f789..89ffa1fda7 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -1,34 +1,21 @@ -require 'eventmachine' -EventMachine.epoll if EventMachine.epoll? -EventMachine.kqueue if EventMachine.kqueue? - -require 'set' - require 'active_support' -require 'active_support/json' -require 'active_support/concern' -require 'active_support/core_ext/hash/indifferent_access' -require 'active_support/core_ext/module/delegation' -require 'active_support/callbacks' - -require 'faye/websocket' -require 'celluloid' -require 'em-hiredis' -require 'redis' - -require 'action_cable/engine' if defined?(Rails) -require 'action_cable/railtie' if defined?(Rails) - +require 'active_support/rails' require 'action_cable/version' module ActionCable - autoload :Server, 'action_cable/server' - autoload :Connection, 'action_cable/connection' - autoload :Channel, 'action_cable/channel' - autoload :RemoteConnections, 'action_cable/remote_connections' + extend ActiveSupport::Autoload # Singleton instance of the server module_function def server @server ||= ActionCable::Server::Base.new end + + eager_autoload do + autoload :Server + autoload :Connection + autoload :Channel + autoload :RemoteConnections + end end + +require 'action_cable/engine' if defined?(Rails) diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb index 3b973ba0a7..7ae262ce5f 100644 --- a/lib/action_cable/channel.rb +++ b/lib/action_cable/channel.rb @@ -1,10 +1,14 @@ module ActionCable module Channel - autoload :Base, 'action_cable/channel/base' - autoload :Broadcasting, 'action_cable/channel/broadcasting' - autoload :Callbacks, 'action_cable/channel/callbacks' - autoload :Naming, 'action_cable/channel/naming' - autoload :PeriodicTimers, 'action_cable/channel/periodic_timers' - autoload :Streams, 'action_cable/channel/streams' + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Base + autoload :Broadcasting + autoload :Callbacks + autoload :Naming + autoload :PeriodicTimers + autoload :Streams + end end end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 17ac1a97af..df87064195 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -1,3 +1,5 @@ +require 'set' + module ActionCable module Channel # The channel provides the basic structure of grouping behavior into logical units when communicating over the WebSocket connection. @@ -159,7 +161,7 @@ module ActionCable # the proper channel identifier marked as the recipient. def transmit(data, via: nil) logger.info "#{self.class.name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } - connection.transmit({ identifier: @identifier, message: data }.to_json) + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, message: data) end diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb index 3d6ed6a6e8..b672e00682 100644 --- a/lib/action_cable/connection.rb +++ b/lib/action_cable/connection.rb @@ -1,12 +1,16 @@ module ActionCable module Connection - autoload :Authorization, 'action_cable/connection/authorization' - autoload :Base, 'action_cable/connection/base' - autoload :Identification, 'action_cable/connection/identification' - autoload :InternalChannel, 'action_cable/connection/internal_channel' - autoload :MessageBuffer, 'action_cable/connection/message_buffer' - autoload :WebSocket, 'action_cable/connection/web_socket' - autoload :Subscriptions, 'action_cable/connection/subscriptions' - autoload :TaggedLoggerProxy, 'action_cable/connection/tagged_logger_proxy' + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Authorization + autoload :Base + autoload :Identification + autoload :InternalChannel + autoload :MessageBuffer + autoload :WebSocket + autoload :Subscriptions + autoload :TaggedLoggerProxy + end end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index de27628d7d..9f74226f98 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -119,7 +119,7 @@ module ActionCable end def beat - transmit({ identifier: '_ping', message: Time.now.to_i }.to_json) + transmit ActiveSupport::JSON.encode(identifier: '_ping', message: Time.now.to_i) end @@ -203,7 +203,7 @@ module ActionCable request.filtered_path, websocket.possible? ? ' [WebSocket]' : '', request.ip, - Time.now.to_default_s ] + Time.now.to_s ] end def finished_request_message @@ -211,7 +211,7 @@ module ActionCable request.filtered_path, websocket.possible? ? ' [WebSocket]' : '', request.ip, - Time.now.to_default_s ] + Time.now.to_s ] end end end diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 95863795dd..431493aa70 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -1,3 +1,5 @@ +require 'set' + module ActionCable module Connection module Identification @@ -31,7 +33,13 @@ module ActionCable private def connection_gid(ids) - ids.map { |o| (o.try(:to_global_id) || o).to_s }.sort.join(":") + ids.map do |o| + if o.respond_to? :to_global_id + o.to_global_id + else + o.to_s + end + end.sort.join(":") end end end diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 69e3f60706..229be2a316 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -1,3 +1,5 @@ +require 'active_support/core_ext/hash/indifferent_access' + module ActionCable module Connection # Collection class for all the channel subscriptions established on a given connection. Responsible for routing incoming commands that arrive on diff --git a/lib/action_cable/connection/web_socket.rb b/lib/action_cable/connection/web_socket.rb index 135a28cfe4..169b683b8c 100644 --- a/lib/action_cable/connection/web_socket.rb +++ b/lib/action_cable/connection/web_socket.rb @@ -1,3 +1,5 @@ +require 'faye/websocket' + module ActionCable module Connection # Decorate the Faye::WebSocket with helpers we need. diff --git a/lib/action_cable/engine.rb b/lib/action_cable/engine.rb index 6c943c7971..613a9b99f2 100644 --- a/lib/action_cable/engine.rb +++ b/lib/action_cable/engine.rb @@ -1,4 +1,22 @@ +require 'rails/engine' +require 'active_support/ordered_options' + module ActionCable class Engine < ::Rails::Engine + config.action_cable = ActiveSupport::OrderedOptions.new + + initializer "action_cable.logger" do + ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } + end + + initializer "action_cable.set_configs" do |app| + options = app.config.action_cable + + options.allowed_request_origins ||= "http://localhost:3000" if ::Rails.env.development? + + ActiveSupport.on_load(:action_cable) do + options.each { |k,v| send("#{k}=", v) } + end + end end end diff --git a/lib/action_cable/railtie.rb b/lib/action_cable/railtie.rb deleted file mode 100644 index 0be6d19620..0000000000 --- a/lib/action_cable/railtie.rb +++ /dev/null @@ -1,19 +0,0 @@ -module ActionCable - class Railtie < Rails::Railtie - config.action_cable = ActiveSupport::OrderedOptions.new - - initializer "action_cable.logger" do - ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } - end - - initializer "action_cable.set_configs" do |app| - options = app.config.action_cable - - options.allowed_request_origins ||= "http://localhost:3000" if ::Rails.env.development? - - ActiveSupport.on_load(:action_cable) do - options.each { |k,v| send("#{k}=", v) } - end - end - end -end diff --git a/lib/action_cable/server.rb b/lib/action_cable/server.rb index 2278509341..a2a89d5f1e 100644 --- a/lib/action_cable/server.rb +++ b/lib/action_cable/server.rb @@ -1,11 +1,19 @@ +require 'eventmachine' +EventMachine.epoll if EventMachine.epoll? +EventMachine.kqueue if EventMachine.kqueue? + module ActionCable module Server - autoload :Base, 'action_cable/server/base' - autoload :Broadcasting, 'action_cable/server/broadcasting' - autoload :Connections, 'action_cable/server/connections' - autoload :Configuration, 'action_cable/server/configuration' + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Base + autoload :Broadcasting + autoload :Connections + autoload :Configuration - autoload :Worker, 'action_cable/server/worker' - autoload :ActiveRecordConnectionManagement, 'action_cable/server/worker/active_record_connection_management' + autoload :Worker + autoload :ActiveRecordConnectionManagement, 'action_cable/server/worker/active_record_connection_management' + end end end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 5b7ddf4185..f1585dc776 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -1,3 +1,5 @@ +require 'em-hiredis' + module ActionCable module Server # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the rack process that starts the cable server, but diff --git a/lib/action_cable/server/broadcasting.rb b/lib/action_cable/server/broadcasting.rb index 037b98951e..6e0fbae387 100644 --- a/lib/action_cable/server/broadcasting.rb +++ b/lib/action_cable/server/broadcasting.rb @@ -1,3 +1,5 @@ +require 'redis' + module ActionCable module Server # Broadcasting is how other parts of your application can send messages to the channel subscribers. As explained in Channel, most of the time, these @@ -44,9 +46,9 @@ module ActionCable def broadcast(message) server.logger.info "[ActionCable] Broadcasting to #{broadcasting}: #{message}" - server.broadcasting_redis.publish broadcasting, message.to_json + server.broadcasting_redis.publish broadcasting, ActiveSupport::JSON.encode(message) end end end end -end \ No newline at end of file +end diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb index 315782ec3e..b22de273b8 100644 --- a/lib/action_cable/server/configuration.rb +++ b/lib/action_cable/server/configuration.rb @@ -1,3 +1,5 @@ +require 'active_support/core_ext/hash/indifferent_access' + module ActionCable module Server # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak the configuration points diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb index 91496775b8..e063b2a2e1 100644 --- a/lib/action_cable/server/worker.rb +++ b/lib/action_cable/server/worker.rb @@ -1,3 +1,6 @@ +require 'celluloid' +require 'active_support/callbacks' + module ActionCable module Server # Worker used by Server.send_async to do connection work in threads. Only for internal use. @@ -36,4 +39,4 @@ module ActionCable end end end -end \ No newline at end of file +end diff --git a/test/connection/identifier_test.rb b/test/connection/identifier_test.rb index f34b66f9fd..02e6b21845 100644 --- a/test/connection/identifier_test.rb +++ b/test/connection/identifier_test.rb @@ -40,7 +40,7 @@ class ActionCable::Connection::IdentifierTest < ActionCable::TestCase open_connection_with_stubbed_pubsub @connection.websocket.expects(:close) - message = { 'type' => 'disconnect' }.to_json + message = ActiveSupport::JSON.encode('type' => 'disconnect') @connection.process_internal_message message end end @@ -50,7 +50,7 @@ class ActionCable::Connection::IdentifierTest < ActionCable::TestCase open_connection_with_stubbed_pubsub @connection.websocket.expects(:close).never - message = { 'type' => 'unknown' }.to_json + message = ActiveSupport::JSON.encode('type' => 'unknown') @connection.process_internal_message message end end diff --git a/test/connection/subscriptions_test.rb b/test/connection/subscriptions_test.rb index 55ad74b962..4f6760827e 100644 --- a/test/connection/subscriptions_test.rb +++ b/test/connection/subscriptions_test.rb @@ -27,7 +27,7 @@ class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase @server = TestServer.new @server.stubs(:channel_classes).returns(ChatChannel.name => ChatChannel) - @chat_identifier = { id: 1, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json + @chat_identifier = ActiveSupport::JSON.encode(id: 1, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel') end test "subscribe command" do @@ -77,7 +77,7 @@ class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase channel = subscribe_to_chat_channel data = { 'content' => 'Hello World!', 'action' => 'speak' } - @subscriptions.execute_command 'command' => 'message', 'identifier' => @chat_identifier, 'data' => data.to_json + @subscriptions.execute_command 'command' => 'message', 'identifier' => @chat_identifier, 'data' => ActiveSupport::JSON.encode(data) assert_equal [ data ], channel.lines end @@ -89,7 +89,7 @@ class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase channel1 = subscribe_to_chat_channel - channel2_id = { id: 2, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel' }.to_json + channel2_id = ActiveSupport::JSON.encode(id: 2, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel') channel2 = subscribe_to_chat_channel(channel2_id) channel1.expects(:unsubscribe_from_channel) diff --git a/test/test_helper.rb b/test/test_helper.rb index b9cb34f891..34c7def46b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -18,10 +18,12 @@ ActiveSupport.test_order = :sorted # Require all the stubs and models Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } +require 'celluloid' $CELLULOID_DEBUG = false $CELLULOID_TEST = false Celluloid.logger = Logger.new(StringIO.new) +require 'faye/websocket' class << Faye::WebSocket remove_method :ensure_reactor_running -- cgit v1.2.3 From 515c5b76d5c6b80e33d540376dce5412c2a7a274 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2015 10:11:31 -0700 Subject: Cover stray deps for the logging convenience require --- lib/action_cable/process/logging.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/action_cable/process/logging.rb b/lib/action_cable/process/logging.rb index bcceff4bec..72b1a080d1 100644 --- a/lib/action_cable/process/logging.rb +++ b/lib/action_cable/process/logging.rb @@ -1,3 +1,7 @@ +require 'action_cable/server' +require 'eventmachine' +require 'celluloid' + EM.error_handler do |e| puts "Error raised inside the event loop: #{e.message}" puts e.backtrace.join("\n") -- cgit v1.2.3 From df5a32dfbc94723d847aa8d8034041a2bd8751e2 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 16 Oct 2015 18:19:35 -0500 Subject: Fix stream tests --- test/channel/stream_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index 5914b39be0..4e0248d7b4 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -18,7 +18,7 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase test "streaming start and stop" do run_in_eventmachine do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1") } + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1").returns stub_everything(:pubsub) } channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } @@ -29,7 +29,7 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase test "stream_for" do run_in_eventmachine do EM.next_tick do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire") } + @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire").returns stub_everything(:pubsub) } end channel = ChatChannel.new @connection, "" -- cgit v1.2.3 From 84b1f0a3e622d35bf1fb1b2662bc0262a040e119 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 16 Oct 2015 21:05:33 -0500 Subject: Send subscription confirmation from server to the client to avoid race conditions. Without this, it's very easy to send messages over a subscription even before the redis pubsub has been fully initialized. Now we delay calling the subscription#connected method on the client side until we receive a subscription confirmation message from the server. --- lib/action_cable/channel/base.rb | 22 ++++++++++++++ lib/action_cable/channel/streams.rb | 12 ++++++-- lib/assets/javascripts/cable.coffee | 2 ++ lib/assets/javascripts/cable/connection.coffee | 10 +++++-- lib/assets/javascripts/cable/subscriptions.coffee | 6 ++-- test/channel/base_test.rb | 6 ++++ test/channel/stream_test.rb | 35 +++++++++++++++++------ test/test_helper.rb | 1 + 8 files changed, 76 insertions(+), 18 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index df87064195..c8292be183 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -73,6 +73,8 @@ module ActionCable include Naming include Broadcasting + SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE = 'confirm_subscription' + on_subscribe :subscribed on_unsubscribe :unsubscribed @@ -120,6 +122,10 @@ module ActionCable @identifier = identifier @params = params + # When a channel is streaming via redis pubsub, we want to delay the confirmation + # transmission until redis pubsub subscription is confirmed. + @defer_subscription_confirmation = false + delegate_connection_identifiers subscribe_to_channel end @@ -165,6 +171,15 @@ module ActionCable end + protected + def defer_subscription_confirmation! + @defer_subscription_confirmation = true + end + + def defer_subscription_confirmation? + @defer_subscription_confirmation + end + private def delegate_connection_identifiers connection.identifiers.each do |identifier| @@ -177,6 +192,7 @@ module ActionCable def subscribe_to_channel run_subscribe_callbacks + transmit_subscription_confirmation unless defer_subscription_confirmation? end @@ -213,6 +229,12 @@ module ActionCable def run_unsubscribe_callbacks self.class.on_unsubscribe_callbacks.each { |callback| send(callback) } end + + def transmit_subscription_confirmation + logger.info "#{self.class.name} is transmitting the subscription confirmation" + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE) + end + end end end diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb index 9fffdf1789..b5ffa17f72 100644 --- a/lib/action_cable/channel/streams.rb +++ b/lib/action_cable/channel/streams.rb @@ -69,12 +69,18 @@ module ActionCable # 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. def stream_from(broadcasting, callback = nil) - callback ||= default_stream_callback(broadcasting) + # Hold off the confirmation until pubsub#subscribe is successful + defer_subscription_confirmation! + callback ||= default_stream_callback(broadcasting) streams << [ broadcasting, callback ] - EM.next_tick { pubsub.subscribe broadcasting, &callback } - logger.info "#{self.class.name} is streaming from #{broadcasting}" + EM.next_tick do + pubsub.subscribe(broadcasting, &callback).callback do |reply| + transmit_subscription_confirmation + logger.info "#{self.class.name} is streaming from #{broadcasting}" + end + end end # Start streaming the pubsub queue for the model in this channel. Optionally, you can pass a diff --git a/lib/assets/javascripts/cable.coffee b/lib/assets/javascripts/cable.coffee index 0bd1757505..476d90ef72 100644 --- a/lib/assets/javascripts/cable.coffee +++ b/lib/assets/javascripts/cable.coffee @@ -3,6 +3,8 @@ @Cable = PING_IDENTIFIER: "_ping" + INTERNAL_MESSAGES: + SUBSCRIPTION_CONFIRMATION: 'confirm_subscription' createConsumer: (url) -> new Cable.Consumer url diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 90d8fac3e1..33159130c7 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -52,8 +52,14 @@ class Cable.Connection events: message: (event) -> - {identifier, message} = JSON.parse(event.data) - @consumer.subscriptions.notify(identifier, "received", message) + {identifier, message, type} = JSON.parse(event.data) + + if type? + switch type + when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_CONFIRMATION + @consumer.subscriptions.notify(identifier, "connected") + else + @consumer.subscriptions.notify(identifier, "received", message) open: -> @disconnected = false diff --git a/lib/assets/javascripts/cable/subscriptions.coffee b/lib/assets/javascripts/cable/subscriptions.coffee index eeaa697081..4efb384ee2 100644 --- a/lib/assets/javascripts/cable/subscriptions.coffee +++ b/lib/assets/javascripts/cable/subscriptions.coffee @@ -21,13 +21,11 @@ class Cable.Subscriptions add: (subscription) -> @subscriptions.push(subscription) @notify(subscription, "initialized") - if @sendCommand(subscription, "subscribe") - @notify(subscription, "connected") + @sendCommand(subscription, "subscribe") reload: -> for subscription in @subscriptions - if @sendCommand(subscription, "subscribe") - @notify(subscription, "connected") + @sendCommand(subscription, "subscribe") remove: (subscription) -> @subscriptions = (s for s in @subscriptions when s isnt subscription) diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb index bac8569780..7eb8e15845 100644 --- a/test/channel/base_test.rb +++ b/test/channel/base_test.rb @@ -139,4 +139,10 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "message" => { "data" => "latest" } assert_equal expected, @connection.last_transmission end + + test "subscription confirmation" do + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" + assert_equal expected, @connection.last_transmission + end + end diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index 4e0248d7b4..cd0d3d1b83 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -12,28 +12,45 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase end end - setup do - @connection = TestConnection.new - end - test "streaming start and stop" do run_in_eventmachine do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1").returns stub_everything(:pubsub) } - channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + connection = TestConnection.new + connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1").returns stub_everything(:pubsub) } + channel = ChatChannel.new connection, "{id: 1}", { id: 1 } - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } + connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } channel.unsubscribe_from_channel end end test "stream_for" do run_in_eventmachine do + connection = TestConnection.new EM.next_tick do - @connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire").returns stub_everything(:pubsub) } + connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire").returns stub_everything(:pubsub) } end - channel = ChatChannel.new @connection, "" + channel = ChatChannel.new connection, "" channel.stream_for Room.new(1) end end + + test "stream_from subscription confirmation" do + EM.run do + connection = TestConnection.new + connection.expects(:pubsub).returns EM::Hiredis.connect.pubsub + + channel = ChatChannel.new connection, "{id: 1}", { id: 1 } + assert_nil connection.last_transmission + + EM::Timer.new(0.1) do + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" + assert_equal expected, connection.last_transmission, "Did not receive verification confirmation within 0.1s" + + EM.run_deferred_callbacks + EM.stop + end + end + end + end diff --git a/test/test_helper.rb b/test/test_helper.rb index f8a9971077..935e50e900 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,6 +8,7 @@ Bundler.setup Bundler.require :default, :test require 'puma' +require 'em-hiredis' require 'mocha/mini_test' require 'rack/mock' -- cgit v1.2.3 From 06b59451ffadd2c93c0dec9520c1664448c6cfa4 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 16 Oct 2015 21:13:46 -0500 Subject: Fix an error message in the subscription tests --- test/channel/stream_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index cd0d3d1b83..08cfef5736 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -45,7 +45,7 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase EM::Timer.new(0.1) do expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" - assert_equal expected, connection.last_transmission, "Did not receive verification confirmation within 0.1s" + assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation within 0.1s" EM.run_deferred_callbacks EM.stop -- cgit v1.2.3 From 60748de10402f46c21b6a288072b4e37cd3a607c Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Oct 2015 09:24:26 -0500 Subject: Freeze the SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE value --- lib/action_cable/channel/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index c8292be183..0b9aa2f4ad 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -73,7 +73,7 @@ module ActionCable include Naming include Broadcasting - SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE = 'confirm_subscription' + SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE = 'confirm_subscription'.freeze on_subscribe :subscribed on_unsubscribe :unsubscribed -- cgit v1.2.3 From 506d84c157b041208f40b7034cac9c5d5e66fff5 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Oct 2015 15:14:22 -0500 Subject: Make sure the subscription confirmaion is only sent out once --- lib/action_cable/channel/base.rb | 12 ++++++++++-- test/channel/stream_test.rb | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 0b9aa2f4ad..221730dbc4 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -180,6 +180,10 @@ module ActionCable @defer_subscription_confirmation end + def subscription_confirmation_sent? + @subscription_confirmation_sent + end + private def delegate_connection_identifiers connection.identifiers.each do |identifier| @@ -231,8 +235,12 @@ module ActionCable end def transmit_subscription_confirmation - logger.info "#{self.class.name} is transmitting the subscription confirmation" - connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE) + unless subscription_confirmation_sent? + logger.info "#{self.class.name} is transmitting the subscription confirmation" + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE) + + @subscription_confirmation_sent = true + end end end diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index 08cfef5736..d651ba1746 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -10,6 +10,11 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase stream_from "test_room_#{@room.id}" end end + + def send_confirmation + transmit_subscription_confirmation + end + end test "streaming start and stop" do @@ -53,4 +58,23 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase end end + test "stream_from subscription confirmation should only be sent out once" do + EM.run do + connection = TestConnection.new + connection.stubs(:pubsub).returns EM::Hiredis.connect.pubsub + + channel = ChatChannel.new connection, "test_channel" + channel.send_confirmation + channel.send_confirmation + + EM.run_deferred_callbacks + + expected = ActiveSupport::JSON.encode "identifier" => "test_channel", "type" => "confirm_subscription" + assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation within 0.1s" + + assert_equal 1, connection.transmissions.size + EM.stop + end + end + end -- cgit v1.2.3 From a02e5f418b437d96269f86eb993fa2b8e52ef5bb Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Oct 2015 15:27:43 -0500 Subject: Better test name --- test/channel/stream_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index d651ba1746..68edb5befe 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -58,7 +58,7 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase end end - test "stream_from subscription confirmation should only be sent out once" do + test "subscription confirmation should only be sent out once" do EM.run do connection = TestConnection.new connection.stubs(:pubsub).returns EM::Hiredis.connect.pubsub -- cgit v1.2.3 From 904b83b9c3d5dc1f1ceac1552dd5b80513aa3232 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Mon, 19 Oct 2015 15:28:26 -0500 Subject: Fix the error message in tests --- test/channel/stream_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb index 68edb5befe..5e4e01abbf 100644 --- a/test/channel/stream_test.rb +++ b/test/channel/stream_test.rb @@ -70,7 +70,7 @@ class ActionCable::Channel::StreamTest < ActionCable::TestCase EM.run_deferred_callbacks expected = ActiveSupport::JSON.encode "identifier" => "test_channel", "type" => "confirm_subscription" - assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation within 0.1s" + assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation" assert_equal 1, connection.transmissions.size EM.stop -- cgit v1.2.3 From 0ce0cf0c04b53ee6c7038d8912dd1ed433f7935f Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Tue, 20 Oct 2015 17:17:18 -0500 Subject: Allow rejecting subscriptions from the channel --- lib/action_cable/channel/base.rb | 30 ++++++++++++++++++++--- lib/action_cable/connection/base.rb | 4 +-- lib/action_cable/connection/subscriptions.rb | 8 ++++-- lib/assets/javascripts/cable.coffee | 1 + lib/assets/javascripts/cable/connection.coffee | 2 ++ lib/assets/javascripts/cable/subscriptions.coffee | 15 ++++++++++-- test/channel/rejection_test.rb | 25 +++++++++++++++++++ 7 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 test/channel/rejection_test.rb diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 221730dbc4..31b8dece4a 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -74,11 +74,12 @@ module ActionCable include Broadcasting SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE = 'confirm_subscription'.freeze + SUBSCRIPTION_REJECTION_INTERNAL_MESSAGE = 'reject_subscription'.freeze on_subscribe :subscribed on_unsubscribe :unsubscribed - attr_reader :params, :connection + attr_reader :params, :connection, :identifier delegate :logger, to: :connection class << self @@ -170,8 +171,6 @@ module ActionCable connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, message: data) end - - protected def defer_subscription_confirmation! @defer_subscription_confirmation = true end @@ -184,6 +183,14 @@ module ActionCable @subscription_confirmation_sent end + def reject! + @reject_subscription = true + end + + def subscription_rejected? + @reject_subscription + end + private def delegate_connection_identifiers connection.identifiers.each do |identifier| @@ -196,7 +203,12 @@ module ActionCable def subscribe_to_channel run_subscribe_callbacks - transmit_subscription_confirmation unless defer_subscription_confirmation? + + if subscription_rejected? + reject_subscription + else + transmit_subscription_confirmation unless defer_subscription_confirmation? + end end @@ -243,6 +255,16 @@ module ActionCable end end + def reject_subscription + connection.subscriptions.remove_subscription self + transmit_subscription_rejection + end + + def transmit_subscription_rejection + logger.info "#{self.class.name} is transmitting the subscription rejection" + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_REJECTION_INTERNAL_MESSAGE) + end + end end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 9f74226f98..b3de4dd4a9 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -48,7 +48,7 @@ module ActionCable include InternalChannel include Authorization - attr_reader :server, :env + attr_reader :server, :env, :subscriptions delegate :worker_pool, :pubsub, to: :server attr_reader :logger @@ -140,7 +140,7 @@ module ActionCable private attr_reader :websocket - attr_reader :subscriptions, :message_buffer + attr_reader :message_buffer def on_open connect if respond_to?(:connect) diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb index 229be2a316..6199db4898 100644 --- a/lib/action_cable/connection/subscriptions.rb +++ b/lib/action_cable/connection/subscriptions.rb @@ -37,8 +37,12 @@ module ActionCable def remove(data) logger.info "Unsubscribing from channel: #{data['identifier']}" - subscriptions[data['identifier']].unsubscribe_from_channel - subscriptions.delete(data['identifier']) + remove_subscription subscriptions[data['identifier']] + end + + def remove_subscription(subscription) + subscription.unsubscribe_from_channel + subscriptions.delete(subscription.identifier) end def perform_action(data) diff --git a/lib/assets/javascripts/cable.coffee b/lib/assets/javascripts/cable.coffee index 476d90ef72..fca5e095b5 100644 --- a/lib/assets/javascripts/cable.coffee +++ b/lib/assets/javascripts/cable.coffee @@ -5,6 +5,7 @@ PING_IDENTIFIER: "_ping" INTERNAL_MESSAGES: SUBSCRIPTION_CONFIRMATION: 'confirm_subscription' + SUBSCRIPTION_REJECTION: 'reject_subscription' createConsumer: (url) -> new Cable.Consumer url diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 33159130c7..9de3cc0be4 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -58,6 +58,8 @@ class Cable.Connection switch type when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_CONFIRMATION @consumer.subscriptions.notify(identifier, "connected") + when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_REJECTION + @consumer.subscriptions.rejectSubscription(identifier) else @consumer.subscriptions.notify(identifier, "received", message) diff --git a/lib/assets/javascripts/cable/subscriptions.coffee b/lib/assets/javascripts/cable/subscriptions.coffee index 4efb384ee2..497fcb074e 100644 --- a/lib/assets/javascripts/cable/subscriptions.coffee +++ b/lib/assets/javascripts/cable/subscriptions.coffee @@ -27,11 +27,22 @@ class Cable.Subscriptions for subscription in @subscriptions @sendCommand(subscription, "subscribe") + rejectSubscription: (identifier) -> + subscriptions = @findAll(identifier) + + for subscription in subscriptions + @removeSubscription(subscription) + @notify(subscription, "rejected") + remove: (subscription) -> - @subscriptions = (s for s in @subscriptions when s isnt subscription) + @removeSubscription(subscription) + unless @findAll(subscription.identifier).length @sendCommand(subscription, "unsubscribe") + removeSubscription: (subscription) -> + @subscriptions = (s for s in @subscriptions when s isnt subscription) + findAll: (identifier) -> s for s in @subscriptions when s.identifier is identifier @@ -48,7 +59,7 @@ class Cable.Subscriptions for subscription in subscriptions subscription[callbackName]?(args...) - if callbackName in ["initialized", "connected", "disconnected"] + if callbackName in ["initialized", "connected", "disconnected", "rejected"] {identifier} = subscription @record(notification: {identifier, callbackName, args}) diff --git a/test/channel/rejection_test.rb b/test/channel/rejection_test.rb new file mode 100644 index 0000000000..0e9725742c --- /dev/null +++ b/test/channel/rejection_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase + class SecretChannel < ActionCable::Channel::Base + def subscribed + reject! if params[:id] > 0 + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + end + + test "subscription rejection" do + @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } + @channel = SecretChannel.new @connection, "{id: 1}", { id: 1 } + + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "reject_subscription" + assert_equal expected, @connection.last_transmission + end + +end -- cgit v1.2.3 From ee06b33e19019e771f0305a40b15885c22499a8b Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 22 Oct 2015 10:53:19 -0500 Subject: Better method names in Javascript based on the feedback from @javan --- lib/assets/javascripts/cable/connection.coffee | 13 +++++++----- lib/assets/javascripts/cable/subscriptions.coffee | 24 +++++++++++------------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 9de3cc0be4..b6b99413dc 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -55,14 +55,17 @@ class Cable.Connection {identifier, message, type} = JSON.parse(event.data) if type? - switch type - when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_CONFIRMATION - @consumer.subscriptions.notify(identifier, "connected") - when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_REJECTION - @consumer.subscriptions.rejectSubscription(identifier) + @handleTypeMessage(type) else @consumer.subscriptions.notify(identifier, "received", message) + onTypeMessage: (type) -> + switch type + when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_CONFIRMATION + @consumer.subscriptions.notify(identifier, "connected") + when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_REJECTION + @consumer.subscriptions.reject(identifier) + open: -> @disconnected = false @consumer.subscriptions.reload() diff --git a/lib/assets/javascripts/cable/subscriptions.coffee b/lib/assets/javascripts/cable/subscriptions.coffee index 497fcb074e..13db32eb2c 100644 --- a/lib/assets/javascripts/cable/subscriptions.coffee +++ b/lib/assets/javascripts/cable/subscriptions.coffee @@ -23,29 +23,27 @@ class Cable.Subscriptions @notify(subscription, "initialized") @sendCommand(subscription, "subscribe") - reload: -> - for subscription in @subscriptions - @sendCommand(subscription, "subscribe") - - rejectSubscription: (identifier) -> - subscriptions = @findAll(identifier) - - for subscription in subscriptions - @removeSubscription(subscription) - @notify(subscription, "rejected") - remove: (subscription) -> - @removeSubscription(subscription) + @forget(subscription) unless @findAll(subscription.identifier).length @sendCommand(subscription, "unsubscribe") - removeSubscription: (subscription) -> + reject: (identifier) -> + for subscription in @findAll(identifier) + @forget(subscription) + @notify(subscription, "rejected") + + forget: (subscription) -> @subscriptions = (s for s in @subscriptions when s isnt subscription) findAll: (identifier) -> s for s in @subscriptions when s.identifier is identifier + reload: -> + for subscription in @subscriptions + @sendCommand(subscription, "subscribe") + notifyAll: (callbackName, args...) -> for subscription in @subscriptions @notify(subscription, callbackName, args...) -- cgit v1.2.3 From 8601ad1f3cfa2c15c97be900899d2c037cd19d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 23 Oct 2015 20:29:33 -0200 Subject: Fix travis matrix --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5e156e2b77..33d3db2618 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,8 @@ gemfile: matrix: fast_finish: true - allow_failures: ruby-head + allow_failures: + - ruby-head notifications: email: false -- cgit v1.2.3 From 01d36932eaa27a613c0bee734f3fa9a00d283783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 23 Oct 2015 20:36:23 -0200 Subject: Use our standard name for the Gemfile --- .travis.yml | 2 +- gemfiles/Gemfile.rails-4-2 | 5 +++++ gemfiles/rails_42.gemfile | 5 ----- 3 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 gemfiles/Gemfile.rails-4-2 delete mode 100644 gemfiles/rails_42.gemfile diff --git a/.travis.yml b/.travis.yml index 33d3db2618..350da659b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ rvm: gemfile: - Gemfile - - gemfiles/rails_4-2-stable.gemfile + - gemfiles/Gemfile.rails-4-2 matrix: fast_finish: true diff --git a/gemfiles/Gemfile.rails-4-2 b/gemfiles/Gemfile.rails-4-2 new file mode 100644 index 0000000000..8ca60d69db --- /dev/null +++ b/gemfiles/Gemfile.rails-4-2 @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +gem 'rails', '~> 4.2.4' + +gemspec path: '..' diff --git a/gemfiles/rails_42.gemfile b/gemfiles/rails_42.gemfile deleted file mode 100644 index 8ca60d69db..0000000000 --- a/gemfiles/rails_42.gemfile +++ /dev/null @@ -1,5 +0,0 @@ -source 'https://rubygems.org' - -gem 'rails', '~> 4.2.4' - -gemspec path: '..' -- cgit v1.2.3 From d88bad3e96911ea5e43f8473f81ad9a92d8df3de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 23 Oct 2015 20:41:33 -0200 Subject: Don't ask an yanked celluloid version --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7f128bbdd1..3b67b1f67d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: git://github.com/rack/rack.git - revision: 6216a3f8a3560639ee1ddadc1e0d6bf9e5f31830 + revision: 35599cfc2751e0ee611c0ff799924b8e7fe0c0b4 specs: rack (2.0.0.alpha) json @@ -13,7 +13,7 @@ GIT GIT remote: git://github.com/rails/rails.git - revision: 960de47f0eef79d234eb3cfc47fabb470fef1529 + revision: f94e328cf801fd5c8055b06c4ee5439273146833 specs: actionpack (5.0.0.alpha) actionview (= 5.0.0.alpha) @@ -59,7 +59,7 @@ GEM remote: https://rubygems.org/ specs: builder (3.2.2) - celluloid (0.16.1) + celluloid (0.16.0) timers (~> 4.0.0) coffee-rails (4.1.0) coffee-script (>= 2.2.0) @@ -92,7 +92,7 @@ GEM metaclass (~> 0.0.1) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) - puma (2.12.2) + puma (2.14.0) rack-test (0.6.3) rack (>= 1.0) rails-deprecated_sanitizer (1.0.3) -- cgit v1.2.3 From bb322ee19d203ff70cee79a058f3cae406ae338c Mon Sep 17 00:00:00 2001 From: Ming Qu Date: Sat, 24 Oct 2015 21:09:14 +0800 Subject: Remove unnecessary space in README [ci skip] --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b29bcdecf..94c40f079f 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,7 @@ application. The recommended basic setup is as follows: ```ruby # cable/config.ru -require ::File.expand_path('../../config/environment', __FILE__) +require ::File.expand_path('../../config/environment', __FILE__) Rails.application.eager_load! require 'action_cable/process/logging' @@ -330,7 +330,7 @@ run ActionCable.server Then you start the server using a binstub in bin/cable ala: ``` #!/bin/bash -bundle exec puma -p 28080 cable/config.ru +bundle exec puma -p 28080 cable/config.ru ``` The above will start a cable server on port 28080. Remember to point your client-side setup against that using something like: -- cgit v1.2.3 From 351a663e57f2f20b3cd43b9ed751430665463659 Mon Sep 17 00:00:00 2001 From: Diego Ballona Date: Sat, 31 Oct 2015 18:50:54 -0200 Subject: Fixing subscription callbacks --- .travis.yml | 3 +++ lib/action_cable/channel/base.rb | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 350da659b9..ff7376b9e5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,9 @@ matrix: allow_failures: - ruby-head +services: + - redis-server + notifications: email: false irc: diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 7607b5ad59..2d528dfdbf 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -143,7 +143,9 @@ module ActionCable # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. def unsubscribe_from_channel - _run_unsubscribe_callbacks { unsubscribed } + run_callbacks :unsubscribe do + unsubscribed + end end @@ -192,7 +194,9 @@ module ActionCable def subscribe_to_channel - _run_subscribe_callbacks { subscribed } + run_callbacks :subscribe do + subscribed + end transmit_subscription_confirmation unless defer_subscription_confirmation? end @@ -227,7 +231,6 @@ module ActionCable unless subscription_confirmation_sent? logger.info "#{self.class.name} is transmitting the subscription confirmation" connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE) - @subscription_confirmation_sent = true end end -- cgit v1.2.3 From 7c1631fa48b8862f37d1026b4f0cf1061dd6947a Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 4 Nov 2015 12:38:43 -0600 Subject: Make sure cable closes the connection if open when responding to an invalid request --- lib/action_cable/connection/base.rb | 3 ++- test/connection/authorization_test.rb | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index a629f29643..ac45124a28 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -151,7 +151,6 @@ module ActionCable server.add_connection(self) rescue ActionCable::Connection::Authorization::UnauthorizedError respond_to_invalid_request - close end def on_message(message) @@ -186,6 +185,8 @@ module ActionCable end def respond_to_invalid_request + close if websocket.alive? + logger.info finished_request_message [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] end diff --git a/test/connection/authorization_test.rb b/test/connection/authorization_test.rb index 762c90fbbc..68668b2835 100644 --- a/test/connection/authorization_test.rb +++ b/test/connection/authorization_test.rb @@ -8,17 +8,25 @@ class ActionCable::Connection::AuthorizationTest < ActionCable::TestCase def connect reject_unauthorized_connection end + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end end test "unauthorized connection" do run_in_eventmachine do server = TestServer.new - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + server.config.allowed_request_origins = %w( http://rubyonrails.com ) + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', + 'HTTP_ORIGIN' => 'http://rubyonrails.com' connection = Connection.new(server, env) connection.websocket.expects(:close) + connection.process - connection.send :on_open end end end -- cgit v1.2.3 From 476aca0967730360c31bc9aa5c08cf6aa7c0c9fe Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 4 Nov 2015 17:23:24 -0600 Subject: Fix a merge fail syntax issue --- lib/action_cable/channel/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 67b312e5ea..8bfb74fa89 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -76,7 +76,7 @@ module ActionCable SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE = 'confirm_subscription'.freeze SUBSCRIPTION_REJECTION_INTERNAL_MESSAGE = 'reject_subscription'.freeze - attr_reader :params, :connection, ::identifier + attr_reader :params, :connection, :identifier delegate :logger, to: :connection class << self -- cgit v1.2.3 From 1ce0e66f090631f5113cb844be32b2b7fe4dc88e Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Wed, 4 Nov 2015 17:40:53 -0600 Subject: Add some documentation explaining subscription rejection --- README.md | 3 +++ lib/action_cable/channel/base.rb | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index 94c40f079f..5c6cb1c0fe 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,9 @@ App.appearance = App.cable.subscriptions.create "AppearanceChannel", connected: -> # Called once the subscription has been successfully completed + rejected: -> + # Called when the subscription is rejected by the server + appear: -> @perform 'appear', appearing_on: @appearingOn() diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 8bfb74fa89..66d60d7e99 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -66,6 +66,22 @@ module ActionCable # # 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 #reject! + # + # Example: + # + # 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, Channel#rejected callback will get invoked when the server rejects the subscription request. class Base include Callbacks include PeriodicTimers -- cgit v1.2.3 From 37fe48928e4d4b254791eb8034c20baabe06a483 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 5 Nov 2015 09:37:15 -0600 Subject: Rename Subscription#reject! to Subscription#reject as there's only one version of the method --- lib/action_cable/channel/base.rb | 4 ++-- test/channel/rejection_test.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index 66d60d7e99..b0e112ce38 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -76,7 +76,7 @@ module ActionCable # class ChatChannel < ApplicationCable::Channel # def subscribed # @room = Chat::Room[params[:room_number]] - # reject! unless current_user.can_access?(@room) + # reject unless current_user.can_access?(@room) # end # end # @@ -198,7 +198,7 @@ module ActionCable @subscription_confirmation_sent end - def reject! + def reject @reject_subscription = true end diff --git a/test/channel/rejection_test.rb b/test/channel/rejection_test.rb index 0e9725742c..aa93396d44 100644 --- a/test/channel/rejection_test.rb +++ b/test/channel/rejection_test.rb @@ -5,7 +5,7 @@ require 'stubs/room' class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase class SecretChannel < ActionCable::Channel::Base def subscribed - reject! if params[:id] > 0 + reject if params[:id] > 0 end end -- cgit v1.2.3 From e95e598b43d4468107b23097e44700275d11af5e Mon Sep 17 00:00:00 2001 From: Jan Habermann Date: Thu, 5 Nov 2015 23:38:44 +0100 Subject: Fix an error when using multiple gid identifiers --- lib/action_cable/connection/identification.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 431493aa70..76cb7d5ea1 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -35,7 +35,7 @@ module ActionCable def connection_gid(ids) ids.map do |o| if o.respond_to? :to_global_id - o.to_global_id + o.to_global_id.to_s else o.to_s end -- cgit v1.2.3 From a74bcae30a69039baa75205f538b523835929887 Mon Sep 17 00:00:00 2001 From: Jan Habermann Date: Fri, 6 Nov 2015 00:28:58 +0100 Subject: Add multiple identifiers test --- test/connection/multiple_identifiers_test.rb | 41 ++++++++++++++++++++++++++++ test/stubs/global_id.rb | 8 ++++++ test/stubs/room.rb | 2 +- test/stubs/user.rb | 2 +- 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 test/connection/multiple_identifiers_test.rb create mode 100644 test/stubs/global_id.rb diff --git a/test/connection/multiple_identifiers_test.rb b/test/connection/multiple_identifiers_test.rb new file mode 100644 index 0000000000..55a9f96cb3 --- /dev/null +++ b/test/connection/multiple_identifiers_test.rb @@ -0,0 +1,41 @@ +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 + run_in_eventmachine do + open_connection_with_stubbed_pubsub + assert_equal "Room#my-room:User#lifo", @connection.connection_identifier + end + end + + protected + def open_connection_with_stubbed_pubsub + server = TestServer.new + server.stubs(:pubsub).returns(stub_everything('pubsub')) + + open_connection server: server + end + + def open_connection(server:) + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(server, env) + + @connection.process + @connection.send :on_open + end + + def close_connection + @connection.send :on_close + end +end diff --git a/test/stubs/global_id.rb b/test/stubs/global_id.rb new file mode 100644 index 0000000000..334f0d03e8 --- /dev/null +++ b/test/stubs/global_id.rb @@ -0,0 +1,8 @@ +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 index 246d6a98af..cd66a0b687 100644 --- a/test/stubs/room.rb +++ b/test/stubs/room.rb @@ -7,7 +7,7 @@ class Room end def to_global_id - "Room##{id}-#{name}" + GlobalID.new("Room##{id}-#{name}") end def to_gid_param diff --git a/test/stubs/user.rb b/test/stubs/user.rb index bce7dfc49e..d033e6208b 100644 --- a/test/stubs/user.rb +++ b/test/stubs/user.rb @@ -6,6 +6,6 @@ class User end def to_global_id - "User##{name}" + GlobalID.new("User##{name}") end end -- cgit v1.2.3 From e2ed68cc70925372681c2fb50b505b110825c8f7 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 6 Nov 2015 10:49:10 -0600 Subject: Missed updating a method name --- lib/assets/javascripts/cable/connection.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index b6b99413dc..7823240587 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -55,7 +55,7 @@ class Cable.Connection {identifier, message, type} = JSON.parse(event.data) if type? - @handleTypeMessage(type) + @onTypeMessage(type) else @consumer.subscriptions.notify(identifier, "received", message) -- cgit v1.2.3 From 365891d5eff15a53919688ac78ac9af3edc0d664 Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 6 Nov 2015 14:09:38 -0500 Subject: Share internal identifiers and message types with the JavaScript client --- lib/action_cable.rb | 10 ++++++++++ lib/action_cable/channel/base.rb | 7 ++----- lib/action_cable/connection/base.rb | 2 +- lib/assets/javascripts/cable.coffee | 11 ----------- lib/assets/javascripts/cable.coffee.erb | 8 ++++++++ lib/assets/javascripts/cable/connection.coffee | 15 +++++++-------- lib/assets/javascripts/cable/connection_monitor.coffee | 2 +- lib/assets/javascripts/cable/subscriptions.coffee | 2 +- 8 files changed, 30 insertions(+), 27 deletions(-) delete mode 100644 lib/assets/javascripts/cable.coffee create mode 100644 lib/assets/javascripts/cable.coffee.erb diff --git a/lib/action_cable.rb b/lib/action_cable.rb index 89ffa1fda7..3919812161 100644 --- a/lib/action_cable.rb +++ b/lib/action_cable.rb @@ -5,6 +5,16 @@ require 'action_cable/version' module ActionCable extend ActiveSupport::Autoload + INTERNAL = { + identifiers: { + ping: '_ping'.freeze + }, + message_types: { + confirmation: 'confirm_subscription'.freeze, + rejection: 'reject_subscription'.freeze + } + } + # Singleton instance of the server module_function def server @server ||= ActionCable::Server::Base.new diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb index b0e112ce38..ca903a810d 100644 --- a/lib/action_cable/channel/base.rb +++ b/lib/action_cable/channel/base.rb @@ -89,9 +89,6 @@ module ActionCable include Naming include Broadcasting - SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE = 'confirm_subscription'.freeze - SUBSCRIPTION_REJECTION_INTERNAL_MESSAGE = 'reject_subscription'.freeze - attr_reader :params, :connection, :identifier delegate :logger, to: :connection @@ -258,7 +255,7 @@ module ActionCable def transmit_subscription_confirmation unless subscription_confirmation_sent? logger.info "#{self.class.name} is transmitting the subscription confirmation" - connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_CONFIRMATION_INTERNAL_MESSAGE) + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]) @subscription_confirmation_sent = true end end @@ -270,7 +267,7 @@ module ActionCable def transmit_subscription_rejection logger.info "#{self.class.name} is transmitting the subscription rejection" - connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: SUBSCRIPTION_REJECTION_INTERNAL_MESSAGE) + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]) end end end diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index a988060d56..6df168e4c3 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -119,7 +119,7 @@ module ActionCable end def beat - transmit ActiveSupport::JSON.encode(identifier: '_ping', message: Time.now.to_i) + transmit ActiveSupport::JSON.encode(identifier: ActionCable::INTERNAL[:identifiers][:ping], message: Time.now.to_i) end diff --git a/lib/assets/javascripts/cable.coffee b/lib/assets/javascripts/cable.coffee deleted file mode 100644 index fca5e095b5..0000000000 --- a/lib/assets/javascripts/cable.coffee +++ /dev/null @@ -1,11 +0,0 @@ -#= require_self -#= require cable/consumer - -@Cable = - PING_IDENTIFIER: "_ping" - INTERNAL_MESSAGES: - SUBSCRIPTION_CONFIRMATION: 'confirm_subscription' - SUBSCRIPTION_REJECTION: 'reject_subscription' - - createConsumer: (url) -> - new Cable.Consumer url diff --git a/lib/assets/javascripts/cable.coffee.erb b/lib/assets/javascripts/cable.coffee.erb new file mode 100644 index 0000000000..8498233c11 --- /dev/null +++ b/lib/assets/javascripts/cable.coffee.erb @@ -0,0 +1,8 @@ +#= require_self +#= require cable/consumer + +@Cable = + INTERNAL: <%= ActionCable::INTERNAL.to_json %> + + createConsumer: (url) -> + new Cable.Consumer url diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee index 7823240587..b2abe8dcb2 100644 --- a/lib/assets/javascripts/cable/connection.coffee +++ b/lib/assets/javascripts/cable/connection.coffee @@ -1,4 +1,7 @@ # Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. + +{message_types} = Cable.INTERNAL + class Cable.Connection @reopenDelay: 500 @@ -54,17 +57,13 @@ class Cable.Connection message: (event) -> {identifier, message, type} = JSON.parse(event.data) - if type? - @onTypeMessage(type) - else - @consumer.subscriptions.notify(identifier, "received", message) - - onTypeMessage: (type) -> switch type - when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_CONFIRMATION + when message_types.confirmation @consumer.subscriptions.notify(identifier, "connected") - when Cable.INTERNAL_MESSAGES.SUBSCRIPTION_REJECTION + when message_types.rejection @consumer.subscriptions.reject(identifier) + else + @consumer.subscriptions.notify(identifier, "received", message) open: -> @disconnected = false diff --git a/lib/assets/javascripts/cable/connection_monitor.coffee b/lib/assets/javascripts/cable/connection_monitor.coffee index bf99dee34d..435efcc361 100644 --- a/lib/assets/javascripts/cable/connection_monitor.coffee +++ b/lib/assets/javascripts/cable/connection_monitor.coffee @@ -7,7 +7,7 @@ class Cable.ConnectionMonitor @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) - identifier: Cable.PING_IDENTIFIER + identifier: Cable.INTERNAL.identifiers.ping constructor: (@consumer) -> @consumer.subscriptions.add(this) diff --git a/lib/assets/javascripts/cable/subscriptions.coffee b/lib/assets/javascripts/cable/subscriptions.coffee index 13db32eb2c..7955565f06 100644 --- a/lib/assets/javascripts/cable/subscriptions.coffee +++ b/lib/assets/javascripts/cable/subscriptions.coffee @@ -63,7 +63,7 @@ class Cable.Subscriptions sendCommand: (subscription, command) -> {identifier} = subscription - if identifier is Cable.PING_IDENTIFIER + if identifier is Cable.INTERNAL.identifiers.ping @consumer.connection.isOpen() else @consumer.send({command, identifier}) -- cgit v1.2.3 From 6be2604aa719713c602b8a873337d328196f8f57 Mon Sep 17 00:00:00 2001 From: Kasper Timm Hansen Date: Sun, 15 Nov 2015 21:09:14 +0100 Subject: Tune whitespace in README.md * Realign `end` statements with the opening `class` statement. * Pad JavaScript objects with spaces to match Rails styleguide and for consistency with other examples. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5c6cb1c0fe..4a7c3ca707 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ class WebNotificationsChannel < ApplicationCable::Channel def subscribed stream_from "web_notifications_#{current_user.id}" end - end +end ``` ```coffeescript @@ -219,14 +219,14 @@ class ChatChannel < ApplicationCable::Channel def subscribed stream_from "chat_#{params[:room]}" end - end +end ``` Pass an object as the first argument to `subscriptions.create`, and that object will become your params hash in your cable channel. The keyword `channel` is required. ```coffeescript # Client-side which assumes you've already requested the right to send web notifications -App.cable.subscriptions.create {channel: "ChatChannel", room: "Best Room"}, +App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, received: (data) -> new Message data['sent_by'], body: data['body'] ``` @@ -257,11 +257,11 @@ end ```coffeescript # Client-side which assumes you've already requested the right to send web notifications -sub = App.cable.subscriptions.create {channel: "ChatChannel", room: "Best Room"}, +sub = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, received: (data) -> new Message data['sent_by'], body: data['body'] -sub.send {sent_by: 'Peter', body: 'Hello Paul, thanks for the compliment.'} +sub.send { sent_by: 'Peter', body: 'Hello Paul, thanks for the compliment.' } ``` The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. -- cgit v1.2.3 From 952887a8bd1fc1e197a482b01c6b1b1ccbcdba2d Mon Sep 17 00:00:00 2001 From: Jan Habermann Date: Wed, 18 Nov 2015 12:15:18 +0100 Subject: Use to_gid_param for connection identifiers --- lib/action_cable/connection/identification.rb | 4 ++-- test/stubs/user.rb | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb index 76cb7d5ea1..2d75ff8d6d 100644 --- a/lib/action_cable/connection/identification.rb +++ b/lib/action_cable/connection/identification.rb @@ -34,8 +34,8 @@ module ActionCable private def connection_gid(ids) ids.map do |o| - if o.respond_to? :to_global_id - o.to_global_id.to_s + if o.respond_to? :to_gid_param + o.to_gid_param else o.to_s end diff --git a/test/stubs/user.rb b/test/stubs/user.rb index d033e6208b..a66b4f87d5 100644 --- a/test/stubs/user.rb +++ b/test/stubs/user.rb @@ -8,4 +8,8 @@ class User def to_global_id GlobalID.new("User##{name}") end + + def to_gid_param + to_global_id.to_param + end end -- cgit v1.2.3 From 43c6c7787954d959347a1f90d71b11ba0cb7a8c7 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Wed, 18 Nov 2015 22:53:23 +1100 Subject: Handle cases where logger is not a tagged logger. Previously, a TaggedLoggerProxy was only created if the logger responded to :tagged, but was still used as if it was a TaggedLoggerProxy elsewhere in the code, causing undefined method errors. This moved the check for tagging abilities inside the TaggedLoggerProxy so the code can always tread the logger like a tagged logger, and if it is not a tagged logger the tags will just be ignored. This prevents needing to check if the logger is tagged every time we use it. --- lib/action_cable/connection/base.rb | 8 +++----- lib/action_cable/connection/tagged_logger_proxy.rb | 12 ++++++++++-- .../server/worker/active_record_connection_management.rb | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 6df168e4c3..b93b6a8a50 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -56,7 +56,7 @@ module ActionCable def initialize(server, env) @server, @env = server, env - @logger = new_tagged_logger || server.logger + @logger = new_tagged_logger @websocket = ActionCable::Connection::WebSocket.new(env) @subscriptions = ActionCable::Connection::Subscriptions.new(self) @@ -194,10 +194,8 @@ module ActionCable # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. def new_tagged_logger - if server.logger.respond_to?(:tagged) - TaggedLoggerProxy.new server.logger, - tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } - end + TaggedLoggerProxy.new server.logger, + tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } end def started_request_message diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb index 34063c1d42..e5319087fb 100644 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/lib/action_cable/connection/tagged_logger_proxy.rb @@ -16,6 +16,15 @@ module ActionCable @tags = @tags.uniq end + def tag(logger) + if logger.respond_to?(:tagged) + current_tags = tags - logger.formatter.current_tags + logger.tagged(*current_tags) { yield } + else + yield + end + end + %i( debug info warn error fatal unknown ).each do |severity| define_method(severity) do |message| log severity, message @@ -24,8 +33,7 @@ module ActionCable protected def log(type, message) - current_tags = tags - @logger.formatter.current_tags - @logger.tagged(*current_tags) { @logger.send type, message } + tag(@logger) { @logger.send type, message } 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 index 1ede0095f8..ecece4e270 100644 --- a/lib/action_cable/server/worker/active_record_connection_management.rb +++ b/lib/action_cable/server/worker/active_record_connection_management.rb @@ -12,7 +12,7 @@ module ActionCable end def with_database_connections - ActiveRecord::Base.logger.tagged(*connection.logger.tags) { yield } + connection.logger.tag(ActiveRecord::Base.logger) { yield } ensure ActiveRecord::Base.clear_active_connections! end -- cgit v1.2.3 From e17b65e46b725d222032473dddbb308565790f45 Mon Sep 17 00:00:00 2001 From: Michael Carroll Date: Fri, 20 Nov 2015 08:50:33 -0500 Subject: Add README.md instructions on configuring ActionCable.server.config.allowed_request_origins --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4a7c3ca707..1ac8d023b7 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,11 @@ See the [rails/actioncable-examples](http://github.com/rails/actioncable-example ## Configuration -The only must-configure part of Action Cable is the Redis connection. By default, `ActionCable::Server::Base` will look for a configuration -file in `Rails.root.join('config/redis/cable.yml')`. The file must follow the following format: +Action Cable has two required configurations: the Redis connection and specifying allowed request origins. + +### Redis + +By default, `ActionCable::Server::Base` will look for a configuration file in `Rails.root.join('config/redis/cable.yml')`. The file must follow the following format: ```yaml production: &production @@ -299,6 +302,24 @@ a Rails initializer with something like: ActionCable.server.config.redis_path = Rails.root('somewhere/else/cable.yml') ``` +### Allowed Request Origins + +Action Cable will only accepting requests from specified origins, which are passed to the server config as an array: + +```ruby +ActionCable.server.config.allowed_request_origins = %w( http://rubyonrails.com ) +``` + +To disable and allow requests from any origin: + +```ruby +ActionCable.server.config.disable_request_forgery_protection = true +``` + +By default, Action Cable allows all requests from localhost:3000 when running in the development environment. + +### Other Configurations + The other common option to configure is the log tags applied to the per-connection logger. Here's close to what we're using in Basecamp: ```ruby @@ -416,4 +437,4 @@ Action Cable is released under the MIT license: Bug reports can be filed for the alpha development project here: -* https://github.com/rails/actioncable/issues +* https://github.com/rails/actioncable/issues \ No newline at end of file -- cgit v1.2.3 From 6bd458d97d228be1199e8ff02c5b0f0a2c73eddf Mon Sep 17 00:00:00 2001 From: Takayuki Matsubara Date: Thu, 3 Dec 2015 23:06:14 +0900 Subject: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a7c3ca707..102a4b720e 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ $(document).on 'click', '[data-behavior~=appear_away]', -> ``` Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, -which in turn is linked to original `App.consumer` -> `ApplicationCable::Connection` instances. +which in turn is linked to original `App.cable` -> `ApplicationCable::Connection` instances. We then link `App.appearance#appear` to `AppearanceChannel#appear(data)`. This is possible because the server-side channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these -- cgit v1.2.3 From a3d293b4b3695666ac8ea468dc62a93430541b2c Mon Sep 17 00:00:00 2001 From: Tair Assimov Date: Tue, 8 Dec 2015 10:48:07 +0100 Subject: Update README find_verified_user example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 102a4b720e..f9861d7530 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ module ApplicationCable protected def find_verified_user - if current_user = User.find(cookies.signed[:user_id]) + if current_user = User.find_by(id: cookies.signed[:user_id]) current_user else reject_unauthorized_connection -- cgit v1.2.3 From 3816fac41d44e79b2ada7de3289a7525ca45abfd Mon Sep 17 00:00:00 2001 From: Greg Molnar Date: Fri, 11 Dec 2015 16:38:16 +0000 Subject: add devise example to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f9861d7530..c246b9ea97 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,7 @@ Beware that currently the cable server will _not_ auto-reload any changes in the We'll get all this abstracted properly when the framework is integrated into Rails. +The WebSocket server doesn't have access to the session, but it has access to the cookies. This can be used when you need to handle authentication. You can see one way of doing that with Devise in this [article](http://www.rubytutorial.io/actioncable-devise-authentication). ## Dependencies -- cgit v1.2.3 From 14628ffbff7271e00de63f36fdbe2cabdf95d86f Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 11 Dec 2015 17:09:02 -0500 Subject: Freshen up the client-side subscription examples. Fixes #118 --- README.md | 72 ++++++++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ebc505db19..34b41835be 100644 --- a/README.md +++ b/README.md @@ -137,40 +137,52 @@ Redis or a database or whatever else. Here's what the client-side of that looks ```coffeescript # app/assets/javascripts/cable/subscriptions/appearance.coffee -App.appearance = App.cable.subscriptions.create "AppearanceChannel", +App.cable.subscriptions.create "AppearanceChannel", + # Called when the subscription is ready for use on the server connected: -> - # Called once the subscription has been successfully completed + @install() + @appear() + # Called when the WebSocket connection is closed + disconnected: -> + @uninstall() + + # Called when the subscription is rejected by the server rejected: -> - # Called when the subscription is rejected by the server + @uninstall() + # Calls `AppearanceChannel#appear(data)` on the server appear: -> - @perform 'appear', appearing_on: @appearingOn() + @perform("appear", appearing_on: $("main").data("appearing-on")) + # Calls `AppearanceChannel#away` on the server away: -> - @perform 'away' + @perform("away") + + + buttonSelector = "[data-behavior~=appear_away]" - appearingOn: -> - $('main').data 'appearing-on' + install: -> + $(document).on "page:change.appearance", => + @appear() -$(document).on 'page:change', -> - App.appearance.appear() + $(document).on "click.appearance", buttonSelector, => + @away() + false -$(document).on 'click', '[data-behavior~=appear_away]', -> - App.appearance.away() - false + $(buttonSelector).show() + + uninstall: -> + $(document).off(".appearance") + $(buttonSelector).hide() ``` Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, which in turn is linked to original `App.cable` -> `ApplicationCable::Connection` instances. -We then link `App.appearance#appear` to `AppearanceChannel#appear(data)`. This is possible because the server-side +We then link the client-side `appear` method to `AppearanceChannel#appear(data)`. This is possible because the server-side channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these -can be reached as remote procedure calls via `App.appearance#perform`. - -Finally, we expose `App.appearance` to the machinations of the application itself by hooking the `#appear` call into the -Turbolinks `page:change` callback and allowing the user to click a data-behavior link that triggers the `#away` call. - +can be reached as remote procedure calls via a subscription's `perform` method. ### Channel example 2: Receiving new web notifications @@ -194,7 +206,7 @@ end # Client-side 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'] + new Notification data["title"], body: data["body"] ``` ```ruby @@ -228,7 +240,19 @@ Pass an object as the first argument to `subscriptions.create`, and that object # Client-side which assumes you've already requested the right to send web notifications App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, received: (data) -> - new Message data['sent_by'], body: data['body'] + @appendLine(data) + + appendLine: (data) -> + html = @createLine(data) + $("[data-chat-room='Best Room']").append(html) + + createLine: (data) -> + """ +
+ #{data["sent_by"]} + #{data["body"]} +
+ """ ``` ```ruby @@ -257,11 +281,11 @@ end ```coffeescript # Client-side which assumes you've already requested the right to send web notifications -sub = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, +App.chatChannel = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, received: (data) -> - new Message data['sent_by'], body: data['body'] + # data => { sent_by: "Paul", body: "This is a cool chat app." } -sub.send { sent_by: 'Peter', body: 'Hello Paul, thanks for the compliment.' } +App.chatChannel.send({ sent_by: "Paul", body: "This is a cool chat app." }) ``` The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. @@ -437,4 +461,4 @@ Action Cable is released under the MIT license: Bug reports can be filed for the alpha development project here: -* https://github.com/rails/actioncable/issues \ No newline at end of file +* https://github.com/rails/actioncable/issues -- cgit v1.2.3 From 2b33675528b9d35597ca3069ac2060b2461aed3f Mon Sep 17 00:00:00 2001 From: Javan Makhmali Date: Fri, 11 Dec 2015 17:17:18 -0500 Subject: Associate comments with `perform` --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 34b41835be..c017c5925e 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,12 @@ App.cable.subscriptions.create "AppearanceChannel", rejected: -> @uninstall() - # Calls `AppearanceChannel#appear(data)` on the server appear: -> + # Calls `AppearanceChannel#appear(data)` on the server @perform("appear", appearing_on: $("main").data("appearing-on")) - # Calls `AppearanceChannel#away` on the server away: -> + # Calls `AppearanceChannel#away` on the server @perform("away") -- cgit v1.2.3 From 62250e38a1269030e036cf23a6ea6714fb00ffac Mon Sep 17 00:00:00 2001 From: Mike Virata-Stone Date: Thu, 3 Sep 2015 17:17:48 -0700 Subject: Easy websocket url configuration Thanks to feedback from @ascrazy, @jeremy, and @javan --- README.md | 20 +++++++++++++++++ lib/action_cable/engine.rb | 5 +++++ lib/action_cable/helpers/action_cable_helper.rb | 29 +++++++++++++++++++++++++ lib/action_cable/server/configuration.rb | 1 + lib/assets/javascripts/cable.coffee.erb | 6 ++++- 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 lib/action_cable/helpers/action_cable_helper.rb diff --git a/README.md b/README.md index ebc505db19..601d5764e0 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,26 @@ ActionCable.server.config.log_tags = [ ] ``` +Your websocket url might change between environments. If you host your production server via https, you will need to use the wss scheme +for your ActionCable server, but development might remain http and use the ws scheme. You might use localhost in development and your +domain in production. In any case, to vary the websocket url between environments, add the following configuration to each environment: + +```ruby +config.action_cable.url = "ws://example.com:28080" +``` + +Then add the following line to your layout before your JavaScript tag: + +```erb +<%= action_cable_meta_tag %> +``` + +And finally, create your consumer like so: + +```coffeescript +App.cable = Cable.createConsumer() +``` + For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 100, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. diff --git a/lib/action_cable/engine.rb b/lib/action_cable/engine.rb index 613a9b99f2..4777c3886b 100644 --- a/lib/action_cable/engine.rb +++ b/lib/action_cable/engine.rb @@ -1,10 +1,15 @@ require 'rails/engine' require 'active_support/ordered_options' +require 'action_cable/helpers/action_cable_helper' module ActionCable class Engine < ::Rails::Engine config.action_cable = ActiveSupport::OrderedOptions.new + config.to_prepare do + ApplicationController.helper ActionCable::Helpers::ActionCableHelper + end + initializer "action_cable.logger" do ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } 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 0000000000..b82751468a --- /dev/null +++ b/lib/action_cable/helpers/action_cable_helper.rb @@ -0,0 +1,29 @@ +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-turbolinks-track' => true %> + # + # + # This is then used by ActionCable to determine the url of your websocket server. + # Your CoffeeScript can then connect to the server without needing to specify the + # url directly: + # + # #= require cable + # @App = {} + # App.cable = Cable.createConsumer() + # + # Make sure to specify the correct server location in each of your environments + # config file: + # + # config.action_cable.url = "ws://example.com:28080" + def action_cable_meta_tag + tag "meta", name: "action-cable-url", content: Rails.application.config.action_cable.url + end + end + end +end diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb index 89a0caddb4..f7fcee019b 100644 --- a/lib/action_cable/server/configuration.rb +++ b/lib/action_cable/server/configuration.rb @@ -9,6 +9,7 @@ module ActionCable attr_accessor :connection_class, :worker_pool_size attr_accessor :redis_path, :channels_path attr_accessor :disable_request_forgery_protection, :allowed_request_origins + attr_accessor :url def initialize @logger = Rails.logger diff --git a/lib/assets/javascripts/cable.coffee.erb b/lib/assets/javascripts/cable.coffee.erb index 8498233c11..25a9fc79c2 100644 --- a/lib/assets/javascripts/cable.coffee.erb +++ b/lib/assets/javascripts/cable.coffee.erb @@ -4,5 +4,9 @@ @Cable = INTERNAL: <%= ActionCable::INTERNAL.to_json %> - createConsumer: (url) -> + createConsumer: (url = @getConfig("url")) -> new Cable.Consumer url + + getConfig: (name) -> + element = document.head.querySelector("meta[name='action-cable-#{name}']") + element?.getAttribute("content") -- cgit v1.2.3 From 09e10ef643f00c6b4c5877438ed50d2a5f199522 Mon Sep 17 00:00:00 2001 From: adamliesko Date: Sat, 5 Dec 2015 22:58:31 +0100 Subject: Allow regexp for a allowed_request_origins array --- README.md | 6 +++--- lib/action_cable/connection/base.rb | 7 ++++++- test/connection/cross_site_forgery_test.rb | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ebc505db19..bbc5858c0d 100644 --- a/README.md +++ b/README.md @@ -304,10 +304,10 @@ ActionCable.server.config.redis_path = Rails.root('somewhere/else/cable.yml') ### Allowed Request Origins -Action Cable will only accepting requests from specified origins, which are passed to the server config as an array: +Action Cable will only accept requests from specified origins, which are passed to the server config as an array. The origins can be instances of strings or regular expressions, against which a check for match will be performed. ```ruby -ActionCable.server.config.allowed_request_origins = %w( http://rubyonrails.com ) +ActionCable.server.config.allowed_request_origins = ['http://rubyonrails.com', /http:\/\/ruby.*/] ``` To disable and allow requests from any origin: @@ -437,4 +437,4 @@ Action Cable is released under the MIT license: Bug reports can be filed for the alpha development project here: -* https://github.com/rails/actioncable/issues \ No newline at end of file +* https://github.com/rails/actioncable/issues diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index b93b6a8a50..95af9c2928 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -172,7 +172,7 @@ module ActionCable def allow_request_origin? return true if server.config.disable_request_forgery_protection - if Array(server.config.allowed_request_origins).include? env['HTTP_ORIGIN'] + if Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env['HTTP_ORIGIN'] } true else logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}") @@ -180,6 +180,11 @@ module ActionCable end end + def allowed_origins_match? origin + allowed_origins = Array(server.config.allowed_request_origins) + allowed_origins.any? { |allowed_origin| allowed_origin.is_a?(Regexp) ? allowed_origin =~ origin : allowed_origin == origin } + end + def respond_to_successful_request websocket.rack_response end diff --git a/test/connection/cross_site_forgery_test.rb b/test/connection/cross_site_forgery_test.rb index 166abb7b38..ede3057e30 100644 --- a/test/connection/cross_site_forgery_test.rb +++ b/test/connection/cross_site_forgery_test.rb @@ -40,6 +40,20 @@ class ActionCable::Connection::CrossSiteForgeryTest < ActionCable::TestCase 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 + private def assert_origin_allowed(origin) response = connect_with_origin origin -- cgit v1.2.3 From 1c6fb5e3975a96e70684965ca47291206caab6c3 Mon Sep 17 00:00:00 2001 From: adamliesko Date: Sun, 13 Dec 2015 12:47:08 +0100 Subject: Remove unused method allowed_origins in Connection::Base --- lib/action_cable/connection/base.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb index 95af9c2928..7e9eec7508 100644 --- a/lib/action_cable/connection/base.rb +++ b/lib/action_cable/connection/base.rb @@ -180,11 +180,6 @@ module ActionCable end end - def allowed_origins_match? origin - allowed_origins = Array(server.config.allowed_request_origins) - allowed_origins.any? { |allowed_origin| allowed_origin.is_a?(Regexp) ? allowed_origin =~ origin : allowed_origin == origin } - end - def respond_to_successful_request websocket.rack_response end -- cgit v1.2.3 From bf40bddfceebaff637161be6c5d992d6978679ff Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 14 Dec 2015 15:48:54 +0100 Subject: Get ready to merge into Rails --- Gemfile | 8 - Gemfile.lock | 132 ------ LICENSE | 20 - README.md | 485 --------------------- Rakefile | 12 - actioncable.gemspec | 35 -- actioncable/Gemfile | 8 + actioncable/Gemfile.lock | 132 ++++++ actioncable/LICENSE | 20 + actioncable/README.md | 485 +++++++++++++++++++++ actioncable/Rakefile | 12 + actioncable/actioncable.gemspec | 35 ++ actioncable/lib/action_cable.rb | 31 ++ actioncable/lib/action_cable/channel.rb | 14 + actioncable/lib/action_cable/channel/base.rb | 274 ++++++++++++ .../lib/action_cable/channel/broadcasting.rb | 29 ++ actioncable/lib/action_cable/channel/callbacks.rb | 35 ++ actioncable/lib/action_cable/channel/naming.rb | 22 + .../lib/action_cable/channel/periodic_timers.rb | 41 ++ actioncable/lib/action_cable/channel/streams.rb | 114 +++++ actioncable/lib/action_cable/connection.rb | 16 + .../lib/action_cable/connection/authorization.rb | 13 + actioncable/lib/action_cable/connection/base.rb | 219 ++++++++++ .../lib/action_cable/connection/identification.rb | 46 ++ .../action_cable/connection/internal_channel.rb | 45 ++ .../lib/action_cable/connection/message_buffer.rb | 53 +++ .../lib/action_cable/connection/subscriptions.rb | 75 ++++ .../action_cable/connection/tagged_logger_proxy.rb | 40 ++ .../lib/action_cable/connection/web_socket.rb | 29 ++ actioncable/lib/action_cable/engine.rb | 27 ++ .../action_cable/helpers/action_cable_helper.rb | 29 ++ actioncable/lib/action_cable/process/logging.rb | 12 + actioncable/lib/action_cable/remote_connections.rb | 64 +++ actioncable/lib/action_cable/server.rb | 19 + actioncable/lib/action_cable/server/base.rb | 74 ++++ .../lib/action_cable/server/broadcasting.rb | 54 +++ .../lib/action_cable/server/configuration.rb | 67 +++ actioncable/lib/action_cable/server/connections.rb | 37 ++ actioncable/lib/action_cable/server/worker.rb | 42 ++ .../worker/active_record_connection_management.rb | 22 + actioncable/lib/action_cable/version.rb | 3 + actioncable/lib/actioncable.rb | 2 + .../lib/assets/javascripts/cable.coffee.erb | 12 + .../lib/assets/javascripts/cable/connection.coffee | 84 ++++ .../javascripts/cable/connection_monitor.coffee | 84 ++++ .../lib/assets/javascripts/cable/consumer.coffee | 31 ++ .../assets/javascripts/cable/subscription.coffee | 68 +++ .../assets/javascripts/cable/subscriptions.coffee | 78 ++++ actioncable/test/channel/base_test.rb | 148 +++++++ actioncable/test/channel/broadcasting_test.rb | 29 ++ actioncable/test/channel/naming_test.rb | 10 + actioncable/test/channel/periodic_timers_test.rb | 40 ++ actioncable/test/channel/rejection_test.rb | 25 ++ actioncable/test/channel/stream_test.rb | 80 ++++ actioncable/test/connection/authorization_test.rb | 32 ++ actioncable/test/connection/base_test.rb | 118 +++++ .../test/connection/cross_site_forgery_test.rb | 82 ++++ actioncable/test/connection/identifier_test.rb | 77 ++++ .../test/connection/multiple_identifiers_test.rb | 41 ++ .../test/connection/string_identifier_test.rb | 44 ++ actioncable/test/connection/subscriptions_test.rb | 116 +++++ actioncable/test/stubs/global_id.rb | 8 + actioncable/test/stubs/room.rb | 16 + actioncable/test/stubs/test_connection.rb | 21 + actioncable/test/stubs/test_server.rb | 15 + actioncable/test/stubs/user.rb | 15 + actioncable/test/test_helper.rb | 47 ++ actioncable/test/worker_test.rb | 49 +++ lib/action_cable.rb | 31 -- lib/action_cable/channel.rb | 14 - lib/action_cable/channel/base.rb | 274 ------------ lib/action_cable/channel/broadcasting.rb | 29 -- lib/action_cable/channel/callbacks.rb | 35 -- lib/action_cable/channel/naming.rb | 22 - lib/action_cable/channel/periodic_timers.rb | 41 -- lib/action_cable/channel/streams.rb | 114 ----- lib/action_cable/connection.rb | 16 - lib/action_cable/connection/authorization.rb | 13 - lib/action_cable/connection/base.rb | 219 ---------- lib/action_cable/connection/identification.rb | 46 -- lib/action_cable/connection/internal_channel.rb | 45 -- lib/action_cable/connection/message_buffer.rb | 53 --- lib/action_cable/connection/subscriptions.rb | 75 ---- lib/action_cable/connection/tagged_logger_proxy.rb | 40 -- lib/action_cable/connection/web_socket.rb | 29 -- lib/action_cable/engine.rb | 27 -- lib/action_cable/helpers/action_cable_helper.rb | 29 -- lib/action_cable/process/logging.rb | 12 - lib/action_cable/remote_connections.rb | 64 --- lib/action_cable/server.rb | 19 - lib/action_cable/server/base.rb | 74 ---- lib/action_cable/server/broadcasting.rb | 54 --- lib/action_cable/server/configuration.rb | 67 --- lib/action_cable/server/connections.rb | 37 -- lib/action_cable/server/worker.rb | 42 -- .../worker/active_record_connection_management.rb | 22 - lib/action_cable/version.rb | 3 - lib/actioncable.rb | 2 - lib/assets/javascripts/cable.coffee.erb | 12 - lib/assets/javascripts/cable/connection.coffee | 84 ---- .../javascripts/cable/connection_monitor.coffee | 84 ---- lib/assets/javascripts/cable/consumer.coffee | 31 -- lib/assets/javascripts/cable/subscription.coffee | 68 --- lib/assets/javascripts/cable/subscriptions.coffee | 78 ---- test/channel/base_test.rb | 148 ------- test/channel/broadcasting_test.rb | 29 -- test/channel/naming_test.rb | 10 - test/channel/periodic_timers_test.rb | 40 -- test/channel/rejection_test.rb | 25 -- test/channel/stream_test.rb | 80 ---- test/connection/authorization_test.rb | 32 -- test/connection/base_test.rb | 118 ----- test/connection/cross_site_forgery_test.rb | 82 ---- test/connection/identifier_test.rb | 77 ---- test/connection/multiple_identifiers_test.rb | 41 -- test/connection/string_identifier_test.rb | 44 -- test/connection/subscriptions_test.rb | 116 ----- test/stubs/global_id.rb | 8 - test/stubs/room.rb | 16 - test/stubs/test_connection.rb | 21 - test/stubs/test_server.rb | 15 - test/stubs/user.rb | 15 - test/test_helper.rb | 47 -- test/worker_test.rb | 49 --- 124 files changed, 3610 insertions(+), 3610 deletions(-) delete mode 100644 Gemfile delete mode 100644 Gemfile.lock delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 Rakefile delete mode 100644 actioncable.gemspec create mode 100644 actioncable/Gemfile create mode 100644 actioncable/Gemfile.lock create mode 100644 actioncable/LICENSE create mode 100644 actioncable/README.md create mode 100644 actioncable/Rakefile create mode 100644 actioncable/actioncable.gemspec create mode 100644 actioncable/lib/action_cable.rb create mode 100644 actioncable/lib/action_cable/channel.rb create mode 100644 actioncable/lib/action_cable/channel/base.rb create mode 100644 actioncable/lib/action_cable/channel/broadcasting.rb create mode 100644 actioncable/lib/action_cable/channel/callbacks.rb create mode 100644 actioncable/lib/action_cable/channel/naming.rb create mode 100644 actioncable/lib/action_cable/channel/periodic_timers.rb create mode 100644 actioncable/lib/action_cable/channel/streams.rb create mode 100644 actioncable/lib/action_cable/connection.rb create mode 100644 actioncable/lib/action_cable/connection/authorization.rb create mode 100644 actioncable/lib/action_cable/connection/base.rb create mode 100644 actioncable/lib/action_cable/connection/identification.rb create mode 100644 actioncable/lib/action_cable/connection/internal_channel.rb create mode 100644 actioncable/lib/action_cable/connection/message_buffer.rb create mode 100644 actioncable/lib/action_cable/connection/subscriptions.rb create mode 100644 actioncable/lib/action_cable/connection/tagged_logger_proxy.rb create mode 100644 actioncable/lib/action_cable/connection/web_socket.rb create mode 100644 actioncable/lib/action_cable/engine.rb create mode 100644 actioncable/lib/action_cable/helpers/action_cable_helper.rb create mode 100644 actioncable/lib/action_cable/process/logging.rb create mode 100644 actioncable/lib/action_cable/remote_connections.rb create mode 100644 actioncable/lib/action_cable/server.rb create mode 100644 actioncable/lib/action_cable/server/base.rb create mode 100644 actioncable/lib/action_cable/server/broadcasting.rb create mode 100644 actioncable/lib/action_cable/server/configuration.rb create mode 100644 actioncable/lib/action_cable/server/connections.rb create mode 100644 actioncable/lib/action_cable/server/worker.rb create mode 100644 actioncable/lib/action_cable/server/worker/active_record_connection_management.rb create mode 100644 actioncable/lib/action_cable/version.rb create mode 100644 actioncable/lib/actioncable.rb create mode 100644 actioncable/lib/assets/javascripts/cable.coffee.erb create mode 100644 actioncable/lib/assets/javascripts/cable/connection.coffee create mode 100644 actioncable/lib/assets/javascripts/cable/connection_monitor.coffee create mode 100644 actioncable/lib/assets/javascripts/cable/consumer.coffee create mode 100644 actioncable/lib/assets/javascripts/cable/subscription.coffee create mode 100644 actioncable/lib/assets/javascripts/cable/subscriptions.coffee create mode 100644 actioncable/test/channel/base_test.rb create mode 100644 actioncable/test/channel/broadcasting_test.rb create mode 100644 actioncable/test/channel/naming_test.rb create mode 100644 actioncable/test/channel/periodic_timers_test.rb create mode 100644 actioncable/test/channel/rejection_test.rb create mode 100644 actioncable/test/channel/stream_test.rb create mode 100644 actioncable/test/connection/authorization_test.rb create mode 100644 actioncable/test/connection/base_test.rb create mode 100644 actioncable/test/connection/cross_site_forgery_test.rb create mode 100644 actioncable/test/connection/identifier_test.rb create mode 100644 actioncable/test/connection/multiple_identifiers_test.rb create mode 100644 actioncable/test/connection/string_identifier_test.rb create mode 100644 actioncable/test/connection/subscriptions_test.rb create mode 100644 actioncable/test/stubs/global_id.rb create mode 100644 actioncable/test/stubs/room.rb create mode 100644 actioncable/test/stubs/test_connection.rb create mode 100644 actioncable/test/stubs/test_server.rb create mode 100644 actioncable/test/stubs/user.rb create mode 100644 actioncable/test/test_helper.rb create mode 100644 actioncable/test/worker_test.rb delete mode 100644 lib/action_cable.rb delete mode 100644 lib/action_cable/channel.rb delete mode 100644 lib/action_cable/channel/base.rb delete mode 100644 lib/action_cable/channel/broadcasting.rb delete mode 100644 lib/action_cable/channel/callbacks.rb delete mode 100644 lib/action_cable/channel/naming.rb delete mode 100644 lib/action_cable/channel/periodic_timers.rb delete mode 100644 lib/action_cable/channel/streams.rb delete mode 100644 lib/action_cable/connection.rb delete mode 100644 lib/action_cable/connection/authorization.rb delete mode 100644 lib/action_cable/connection/base.rb delete mode 100644 lib/action_cable/connection/identification.rb delete mode 100644 lib/action_cable/connection/internal_channel.rb delete mode 100644 lib/action_cable/connection/message_buffer.rb delete mode 100644 lib/action_cable/connection/subscriptions.rb delete mode 100644 lib/action_cable/connection/tagged_logger_proxy.rb delete mode 100644 lib/action_cable/connection/web_socket.rb delete mode 100644 lib/action_cable/engine.rb delete mode 100644 lib/action_cable/helpers/action_cable_helper.rb delete mode 100644 lib/action_cable/process/logging.rb delete mode 100644 lib/action_cable/remote_connections.rb delete mode 100644 lib/action_cable/server.rb delete mode 100644 lib/action_cable/server/base.rb delete mode 100644 lib/action_cable/server/broadcasting.rb delete mode 100644 lib/action_cable/server/configuration.rb delete mode 100644 lib/action_cable/server/connections.rb delete mode 100644 lib/action_cable/server/worker.rb delete mode 100644 lib/action_cable/server/worker/active_record_connection_management.rb delete mode 100644 lib/action_cable/version.rb delete mode 100644 lib/actioncable.rb delete mode 100644 lib/assets/javascripts/cable.coffee.erb delete mode 100644 lib/assets/javascripts/cable/connection.coffee delete mode 100644 lib/assets/javascripts/cable/connection_monitor.coffee delete mode 100644 lib/assets/javascripts/cable/consumer.coffee delete mode 100644 lib/assets/javascripts/cable/subscription.coffee delete mode 100644 lib/assets/javascripts/cable/subscriptions.coffee delete mode 100644 test/channel/base_test.rb delete mode 100644 test/channel/broadcasting_test.rb delete mode 100644 test/channel/naming_test.rb delete mode 100644 test/channel/periodic_timers_test.rb delete mode 100644 test/channel/rejection_test.rb delete mode 100644 test/channel/stream_test.rb delete mode 100644 test/connection/authorization_test.rb delete mode 100644 test/connection/base_test.rb delete mode 100644 test/connection/cross_site_forgery_test.rb delete mode 100644 test/connection/identifier_test.rb delete mode 100644 test/connection/multiple_identifiers_test.rb delete mode 100644 test/connection/string_identifier_test.rb delete mode 100644 test/connection/subscriptions_test.rb delete mode 100644 test/stubs/global_id.rb delete mode 100644 test/stubs/room.rb delete mode 100644 test/stubs/test_connection.rb delete mode 100644 test/stubs/test_server.rb delete mode 100644 test/stubs/user.rb delete mode 100644 test/test_helper.rb delete mode 100644 test/worker_test.rb diff --git a/Gemfile b/Gemfile deleted file mode 100644 index d2eaf07c80..0000000000 --- a/Gemfile +++ /dev/null @@ -1,8 +0,0 @@ -source 'https://rubygems.org' - -gem 'activesupport', github: 'rails/rails' -gem 'actionpack', github: 'rails/rails' -gem 'arel', github: 'rails/arel' -gem 'rack', github: 'rack/rack' - -gemspec diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 3b67b1f67d..0000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,132 +0,0 @@ -GIT - remote: git://github.com/rack/rack.git - revision: 35599cfc2751e0ee611c0ff799924b8e7fe0c0b4 - specs: - rack (2.0.0.alpha) - json - -GIT - remote: git://github.com/rails/arel.git - revision: 3c429c5d86e9e2201c2a35d934ca6a8911c18e69 - specs: - arel (7.0.0.alpha) - -GIT - remote: git://github.com/rails/rails.git - revision: f94e328cf801fd5c8055b06c4ee5439273146833 - specs: - actionpack (5.0.0.alpha) - actionview (= 5.0.0.alpha) - activesupport (= 5.0.0.alpha) - rack (~> 2.x) - rack-test (~> 0.6.3) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.0.0.alpha) - activesupport (= 5.0.0.alpha) - builder (~> 3.1) - erubis (~> 2.7.0) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - activesupport (5.0.0.alpha) - concurrent-ruby (~> 1.0.0.pre3, < 2.0.0) - i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) - method_source - minitest (~> 5.1) - tzinfo (~> 1.1) - railties (5.0.0.alpha) - actionpack (= 5.0.0.alpha) - activesupport (= 5.0.0.alpha) - method_source - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) - -PATH - remote: . - specs: - actioncable (0.0.3) - actionpack (>= 4.2.0) - activesupport (>= 4.2.0) - celluloid (~> 0.16.0) - coffee-rails - em-hiredis (~> 0.3.0) - faye-websocket (~> 0.10.0) - redis (~> 3.0) - websocket-driver (~> 0.6.1) - -GEM - remote: https://rubygems.org/ - specs: - builder (3.2.2) - celluloid (0.16.0) - timers (~> 4.0.0) - coffee-rails (4.1.0) - coffee-script (>= 2.2.0) - railties (>= 4.0.0, < 5.0) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.9.1.1) - concurrent-ruby (1.0.0.pre4) - em-hiredis (0.3.0) - eventmachine (~> 1.0) - hiredis (~> 0.5.0) - erubis (2.7.0) - eventmachine (1.0.8) - execjs (2.6.0) - faye-websocket (0.10.0) - eventmachine (>= 0.12.0) - websocket-driver (>= 0.5.1) - hiredis (0.5.2) - hitimes (1.2.3) - i18n (0.7.0) - json (1.8.3) - loofah (2.0.3) - nokogiri (>= 1.5.9) - metaclass (0.0.4) - method_source (0.8.2) - mini_portile (0.6.2) - minitest (5.8.1) - mocha (1.1.0) - metaclass (~> 0.0.1) - nokogiri (1.6.6.2) - mini_portile (~> 0.6.0) - puma (2.14.0) - rack-test (0.6.3) - rack (>= 1.0) - rails-deprecated_sanitizer (1.0.3) - activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.7) - activesupport (>= 4.2.0.beta, < 5.0) - nokogiri (~> 1.6.0) - rails-deprecated_sanitizer (>= 1.0.1) - rails-html-sanitizer (1.0.2) - loofah (~> 2.0) - rake (10.4.2) - redis (3.2.1) - thor (0.19.1) - thread_safe (0.3.5) - timers (4.0.4) - hitimes - tzinfo (1.2.2) - thread_safe (~> 0.1) - websocket-driver (0.6.2) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.2) - -PLATFORMS - ruby - -DEPENDENCIES - actioncable! - actionpack! - activesupport! - arel! - mocha - puma - rack! - rake - -BUNDLED WITH - 1.10.6 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a4910677eb..0000000000 --- a/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -Copyright (c) 2015 Basecamp, 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. diff --git a/README.md b/README.md deleted file mode 100644 index 6511126896..0000000000 --- a/README.md +++ /dev/null @@ -1,485 +0,0 @@ -# Action Cable – Integrated WebSockets for Rails -[![Build Status](https://travis-ci.org/rails/actioncable.svg)](https://travis-ci.org/rails/actioncable) - -Action Cable seamlessly integrates WebSockets with the rest of your Rails application. -It allows for real-time features to be written in Ruby in the same style -and form as the rest of your Rails application, while still being performant -and scalable. It's a full-stack offering that provides both a client-side -JavaScript framework and a server-side Ruby framework. You have access to your full -domain model written with ActiveRecord or your ORM of choice. - - -## Terminology - -A single Action Cable server can handle multiple connection instances. It has one -connection instance per WebSocket connection. A single user may have multiple -WebSockets open to your application if they use multiple browser tabs or devices. -The client of a WebSocket connection is called the consumer. - -Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates -a logical unit of work, similar to what a controller does in a regular MVC setup. For example, you could have a `ChatChannel` and a `AppearancesChannel`, and a consumer could be subscribed to either -or to both of these channels. At the very least, a consumer should be subscribed to one channel. - -When the consumer is subscribed to a channel, they act as a subscriber. The connection between -the subscriber and the channel is, surprise-surprise, called a subscription. A consumer -can act as a subscriber to a given channel any number of times. For example, a consumer -could subscribe to multiple chat rooms at the same time. (And remember that a physical user may -have multiple consumers, one per tab/device open to your connection). - -Each channel can then again be streaming zero or more broadcastings. A broadcasting is a -pubsub link where anything transmitted by the broadcaster is sent directly to the channel -subscribers who are streaming that named broadcasting. - -As you can see, this is a fairly deep architectural stack. There's a lot of new terminology -to identify the new pieces, and on top of that, you're dealing with both client and server side -reflections of each unit. - -## Examples - -### A full-stack example - -The first thing you must do is define your `ApplicationCable::Connection` class in Ruby. This -is the place where you authorize the incoming connection, and proceed to establish it -if all is well. Here's the simplest example starting with the server-side connection class: - -```ruby -# app/channels/application_cable/connection.rb -module ApplicationCable - class Connection < ActionCable::Connection::Base - identified_by :current_user - - def connect - self.current_user = find_verified_user - end - - protected - def find_verified_user - if current_user = User.find_by(id: cookies.signed[:user_id]) - current_user - else - reject_unauthorized_connection - end - end - end -end -``` -Here `identified_by` is a connection identifier that can be used to find the specific connection again or later. -Note that anything marked as an identifier will automatically create a delegate by the same name on any channel instances created off the connection. - -Then you should define your `ApplicationCable::Channel` class in Ruby. This is the place where you put -shared logic between your channels. - -```ruby -# app/channels/application_cable/channel.rb -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end -``` - -This relies on the fact that you will already have handled authentication of the user, and -that a successful authentication sets a signed cookie with the `user_id`. This cookie is then -automatically sent to the connection instance when a new connection is attempted, and you -use that to set the `current_user`. By identifying the connection by this same current_user, -you're also ensuring that you can later retrieve all open connections by a given user (and -potentially disconnect them all if the user is deleted or deauthorized). - -The client-side needs to setup a consumer instance of this connection. That's done like so: - -```coffeescript -# app/assets/javascripts/application_cable.coffee -#= require cable - -@App = {} -App.cable = Cable.createConsumer "ws://cable.example.com" -``` - -The ws://cable.example.com address must point to your set of Action Cable servers, and it -must share a cookie namespace with the rest of the application (which may live under http://example.com). -This ensures that the signed cookie will be correctly sent. - -That's all you need to establish the connection! But of course, this isn't very useful in -itself. This just gives you the plumbing. To make stuff happen, you need content. That content -is defined by declaring channels on the server and allowing the consumer to subscribe to them. - - -### Channel example 1: User appearances - -Here's a simple example of a channel that tracks whether a user is online or not and what page they're on. -(This is useful for creating presence features like showing a green dot next to a user name if they're online). - -First you declare the server-side channel: - -```ruby -# app/channels/appearance_channel.rb -class AppearanceChannel < ApplicationCable::Channel - def subscribed - current_user.appear - end - - def unsubscribed - current_user.disappear - end - - def appear(data) - current_user.appear on: data['appearing_on'] - end - - def away - current_user.away - end -end -``` - -The `#subscribed` callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, -we take that opportunity to say "the current user has indeed appeared". That appear/disappear API could be backed by -Redis or a database or whatever else. Here's what the client-side of that looks like: - -```coffeescript -# app/assets/javascripts/cable/subscriptions/appearance.coffee -App.cable.subscriptions.create "AppearanceChannel", - # Called when the subscription is ready for use on the server - connected: -> - @install() - @appear() - - # Called when the WebSocket connection is closed - disconnected: -> - @uninstall() - - # Called when the subscription is rejected by the server - rejected: -> - @uninstall() - - appear: -> - # Calls `AppearanceChannel#appear(data)` on the server - @perform("appear", appearing_on: $("main").data("appearing-on")) - - away: -> - # Calls `AppearanceChannel#away` on the server - @perform("away") - - - buttonSelector = "[data-behavior~=appear_away]" - - install: -> - $(document).on "page:change.appearance", => - @appear() - - $(document).on "click.appearance", buttonSelector, => - @away() - false - - $(buttonSelector).show() - - uninstall: -> - $(document).off(".appearance") - $(buttonSelector).hide() -``` - -Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, -which in turn is linked to original `App.cable` -> `ApplicationCable::Connection` instances. - -We then link the client-side `appear` method to `AppearanceChannel#appear(data)`. This is possible because the server-side -channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these -can be reached as remote procedure calls via a subscription's `perform` method. - -### Channel example 2: Receiving new web notifications - -The appearance example was all about exposing server functionality to client-side invocation over the WebSocket connection. -But the great thing about WebSockets is that it's a two-way street. So now let's show an example where the server invokes -action on the client. - -This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right -streams: - -```ruby -# app/channels/web_notifications_channel.rb -class WebNotificationsChannel < ApplicationCable::Channel - def subscribed - stream_from "web_notifications_#{current_user.id}" - end -end -``` - -```coffeescript -# Client-side 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"] -``` - -```ruby -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } -``` - -The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under a separate broadcasting name for each user. For a user with an ID of 1, the broadcasting name would be `web_notifications_1`. -The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the -`#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip -across the wire, and unpacked for the data argument arriving to `#received`. - - -### Passing Parameters to Channel - -You can pass parameters from the client side to the server side when creating a subscription. For example: - -```ruby -# app/channels/chat_channel.rb -class ChatChannel < ApplicationCable::Channel - def subscribed - stream_from "chat_#{params[:room]}" - end -end -``` - -Pass an object as the first argument to `subscriptions.create`, and that object will become your params hash in your cable channel. The keyword `channel` is required. - -```coffeescript -# Client-side which assumes you've already requested the right to send web notifications -App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, - received: (data) -> - @appendLine(data) - - appendLine: (data) -> - html = @createLine(data) - $("[data-chat-room='Best Room']").append(html) - - createLine: (data) -> - """ -
- #{data["sent_by"]} - #{data["body"]} -
- """ -``` - -```ruby -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "chat_#{room}", { sent_by: 'Paul', body: 'This is a cool chat app.' } -``` - - -### Rebroadcasting message - -A common use case is to rebroadcast a message sent by one client to any other connected clients. - -```ruby -# app/channels/chat_channel.rb -class ChatChannel < ApplicationCable::Channel - def subscribed - stream_from "chat_#{params[:room]}" - end - - def receive(data) - ActionCable.server.broadcast "chat_#{params[:room]}", data - end -end -``` - -```coffeescript -# Client-side which assumes you've already requested the right to send web notifications -App.chatChannel = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, - received: (data) -> - # data => { sent_by: "Paul", body: "This is a cool chat app." } - -App.chatChannel.send({ sent_by: "Paul", body: "This is a cool chat app." }) -``` - -The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. - - -### More complete examples - -See the [rails/actioncable-examples](http://github.com/rails/actioncable-examples) repository for a full example of how to setup Action Cable in a Rails app and adding channels. - - -## Configuration - -Action Cable has two required configurations: the Redis connection and specifying allowed request origins. - -### Redis - -By default, `ActionCable::Server::Base` will look for a configuration file in `Rails.root.join('config/redis/cable.yml')`. The file must follow the following format: - -```yaml -production: &production - :url: redis://10.10.3.153:6381 - :host: 10.10.3.153 - :port: 6381 - :timeout: 1 -development: &development - :url: redis://localhost:6379 - :host: localhost - :port: 6379 - :timeout: 1 - :inline: true -test: *development -``` - -This format allows you to specify one configuration per Rails environment. You can also change the location of the Redis config file in -a Rails initializer with something like: - -```ruby -ActionCable.server.config.redis_path = Rails.root('somewhere/else/cable.yml') -``` - -### Allowed Request Origins - -Action Cable will only accept requests from specified origins, which are passed to the server config as an array. The origins can be instances of strings or regular expressions, against which a check for match will be performed. - -```ruby -ActionCable.server.config.allowed_request_origins = ['http://rubyonrails.com', /http:\/\/ruby.*/] -``` - -To disable and allow requests from any origin: - -```ruby -ActionCable.server.config.disable_request_forgery_protection = true -``` - -By default, Action Cable allows all requests from localhost:3000 when running in the development environment. - -### Other Configurations - -The other common option to configure is the log tags applied to the per-connection logger. Here's close to what we're using in Basecamp: - -```ruby -ActionCable.server.config.log_tags = [ - -> request { request.env['bc.account_id'] || "no-account" }, - :action_cable, - -> request { request.uuid } -] -``` - -Your websocket url might change between environments. If you host your production server via https, you will need to use the wss scheme -for your ActionCable server, but development might remain http and use the ws scheme. You might use localhost in development and your -domain in production. In any case, to vary the websocket url between environments, add the following configuration to each environment: - -```ruby -config.action_cable.url = "ws://example.com:28080" -``` - -Then add the following line to your layout before your JavaScript tag: - -```erb -<%= action_cable_meta_tag %> -``` - -And finally, create your consumer like so: - -```coffeescript -App.cable = Cable.createConsumer() -``` - -For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. - -Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 100, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. - - -## Running the cable server - -### Standalone -The cable server(s) is separated from your normal application server. It's still a rack application, but it is its own rack -application. The recommended basic setup is as follows: - -```ruby -# cable/config.ru -require ::File.expand_path('../../config/environment', __FILE__) -Rails.application.eager_load! - -require 'action_cable/process/logging' - -run ActionCable.server -``` - -Then you start the server using a binstub in bin/cable ala: -``` -#!/bin/bash -bundle exec puma -p 28080 cable/config.ru -``` - -The above will start a cable server on port 28080. Remember to point your client-side setup against that using something like: -`App.cable.createConsumer('ws://basecamp.dev:28080')`. - -### In app - -If you are using a threaded server like Puma or Thin, the current implementation of ActionCable can run side-along with your Rails application. For example, to listen for WebSocket requests on `/websocket`, match requests on that path: - -```ruby -# config/routes.rb -Example::Application.routes.draw do - match "/websocket", :to => ActionCable.server, via: [:get, :post] -end -``` - -You can use `App.cable.createConsumer('ws://' + window.location.host + '/websocket')` to connect to the cable server. - -For every instance of your server you create and for every worker your server spawns, you will also have a new instance of ActionCable, but the use of Redis keeps messages synced across connections. - -### Notes - -Beware that currently the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. - -We'll get all this abstracted properly when the framework is integrated into Rails. - -The WebSocket server doesn't have access to the session, but it has access to the cookies. This can be used when you need to handle authentication. You can see one way of doing that with Devise in this [article](http://www.rubytutorial.io/actioncable-devise-authentication). - -## Dependencies - -Action Cable is currently tied to Redis through its use of the pubsub feature to route -messages back and forth over the WebSocket cable connection. This dependency may well -be alleviated in the future, but for the moment that's what it is. So be sure to have -Redis installed and running. - -The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluloid](https://github.com/celluloid/celluloid). - - -## Deployment - -Action Cable is powered by a combination of EventMachine and threads. The -framework plumbing needed for connection handling is handled in the -EventMachine loop, but the actual channel, user-specified, work is handled -in a normal Ruby thread. This means you can use all your regular Rails models -with no problem, as long as you haven't committed any thread-safety sins. - -But this also means that Action Cable needs to run in its own server process. -So you'll have one set of server processes for your normal web work, and another -set of server processes for the Action Cable. The former can be single-threaded, -like Unicorn, but the latter must be multi-threaded, like Puma. - - -## Alpha disclaimer - -Action Cable is currently considered alpha software. The API is almost guaranteed to change between -now and its first production release as part of Rails 5.0. Real applications using the framework -are all well underway, but as of July 8th, 2015, there are no deployments in the wild yet. - -So this current release, which resides in rails/actioncable, is primarily intended for -the adventurous kind, who do not mind reading the full source code of the framework. And it -serves as an invitation for all those crafty folks to contribute to and test what we have so far, -in advance of that general production release. - -Action Cable will move from rails/actioncable to rails/rails and become a full-fledged default -framework alongside Action Pack, Active Record, and the like once we cross the bridge from alpha -to beta software (which will happen once the API and missing pieces have solidified). - -Finally, note that testing is a unfinished/unstarted area of this framework. The framework -has been developed in-app up until this point. We need to find a good way to allow the user to test -their connection and channel logic. - - -## License - -Action Cable is released under the MIT license: - -* http://www.opensource.org/licenses/MIT - - -## Support - -Bug reports can be filed for the alpha development project here: - -* https://github.com/rails/actioncable/issues diff --git a/Rakefile b/Rakefile deleted file mode 100644 index 69c95468e9..0000000000 --- a/Rakefile +++ /dev/null @@ -1,12 +0,0 @@ -require 'rake' -require 'rake/testtask' - -task :default => :test - -Rake::TestTask.new(:test) do |t| - t.libs << "test" - t.pattern = 'test/**/*_test.rb' - t.verbose = true - t.warning = false -end -Rake::Task['test'].comment = "Run tests" diff --git a/actioncable.gemspec b/actioncable.gemspec deleted file mode 100644 index 2f4ae41dc1..0000000000 --- a/actioncable.gemspec +++ /dev/null @@ -1,35 +0,0 @@ -$:.unshift File.expand_path("../lib", __FILE__) -require 'action_cable/version' - -Gem::Specification.new do |s| - s.name = 'actioncable' - s.version = ActionCable::VERSION - s.summary = 'WebSocket framework for Rails.' - s.description = 'Structure many real-time application concerns into channels over a single WebSocket connection.' - s.license = 'MIT' - - s.author = ['Pratik Naik', 'David Heinemeier Hansson'] - s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] - s.homepage = 'http://rubyonrails.org' - - s.platform = Gem::Platform::RUBY - - s.add_dependency 'activesupport', '>= 4.2.0' - s.add_dependency 'actionpack', '>= 4.2.0' - s.add_dependency 'faye-websocket', '~> 0.10.0' - s.add_dependency 'websocket-driver', '~> 0.6.1' - # Use 0.16.0 until https://github.com/celluloid/celluloid/issues/637 is resolved - s.add_dependency 'celluloid', '~> 0.16.0' - s.add_dependency 'em-hiredis', '~> 0.3.0' - s.add_dependency 'redis', '~> 3.0' - s.add_dependency 'coffee-rails' - - s.add_development_dependency 'rake' - s.add_development_dependency 'puma' - s.add_development_dependency 'mocha' - - s.files = Dir['README.md', 'lib/**/*'] - s.has_rdoc = false - - s.require_path = 'lib' -end diff --git a/actioncable/Gemfile b/actioncable/Gemfile new file mode 100644 index 0000000000..d2eaf07c80 --- /dev/null +++ b/actioncable/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +gem 'activesupport', github: 'rails/rails' +gem 'actionpack', github: 'rails/rails' +gem 'arel', github: 'rails/arel' +gem 'rack', github: 'rack/rack' + +gemspec diff --git a/actioncable/Gemfile.lock b/actioncable/Gemfile.lock new file mode 100644 index 0000000000..3b67b1f67d --- /dev/null +++ b/actioncable/Gemfile.lock @@ -0,0 +1,132 @@ +GIT + remote: git://github.com/rack/rack.git + revision: 35599cfc2751e0ee611c0ff799924b8e7fe0c0b4 + specs: + rack (2.0.0.alpha) + json + +GIT + remote: git://github.com/rails/arel.git + revision: 3c429c5d86e9e2201c2a35d934ca6a8911c18e69 + specs: + arel (7.0.0.alpha) + +GIT + remote: git://github.com/rails/rails.git + revision: f94e328cf801fd5c8055b06c4ee5439273146833 + specs: + actionpack (5.0.0.alpha) + actionview (= 5.0.0.alpha) + activesupport (= 5.0.0.alpha) + rack (~> 2.x) + rack-test (~> 0.6.3) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (5.0.0.alpha) + activesupport (= 5.0.0.alpha) + builder (~> 3.1) + erubis (~> 2.7.0) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + activesupport (5.0.0.alpha) + concurrent-ruby (~> 1.0.0.pre3, < 2.0.0) + i18n (~> 0.7) + json (~> 1.7, >= 1.7.7) + method_source + minitest (~> 5.1) + tzinfo (~> 1.1) + railties (5.0.0.alpha) + actionpack (= 5.0.0.alpha) + activesupport (= 5.0.0.alpha) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + +PATH + remote: . + specs: + actioncable (0.0.3) + actionpack (>= 4.2.0) + activesupport (>= 4.2.0) + celluloid (~> 0.16.0) + coffee-rails + em-hiredis (~> 0.3.0) + faye-websocket (~> 0.10.0) + redis (~> 3.0) + websocket-driver (~> 0.6.1) + +GEM + remote: https://rubygems.org/ + specs: + builder (3.2.2) + celluloid (0.16.0) + timers (~> 4.0.0) + coffee-rails (4.1.0) + coffee-script (>= 2.2.0) + railties (>= 4.0.0, < 5.0) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.9.1.1) + concurrent-ruby (1.0.0.pre4) + em-hiredis (0.3.0) + eventmachine (~> 1.0) + hiredis (~> 0.5.0) + erubis (2.7.0) + eventmachine (1.0.8) + execjs (2.6.0) + faye-websocket (0.10.0) + eventmachine (>= 0.12.0) + websocket-driver (>= 0.5.1) + hiredis (0.5.2) + hitimes (1.2.3) + i18n (0.7.0) + json (1.8.3) + loofah (2.0.3) + nokogiri (>= 1.5.9) + metaclass (0.0.4) + method_source (0.8.2) + mini_portile (0.6.2) + minitest (5.8.1) + mocha (1.1.0) + metaclass (~> 0.0.1) + nokogiri (1.6.6.2) + mini_portile (~> 0.6.0) + puma (2.14.0) + rack-test (0.6.3) + rack (>= 1.0) + rails-deprecated_sanitizer (1.0.3) + activesupport (>= 4.2.0.alpha) + rails-dom-testing (1.0.7) + activesupport (>= 4.2.0.beta, < 5.0) + nokogiri (~> 1.6.0) + rails-deprecated_sanitizer (>= 1.0.1) + rails-html-sanitizer (1.0.2) + loofah (~> 2.0) + rake (10.4.2) + redis (3.2.1) + thor (0.19.1) + thread_safe (0.3.5) + timers (4.0.4) + hitimes + tzinfo (1.2.2) + thread_safe (~> 0.1) + websocket-driver (0.6.2) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.2) + +PLATFORMS + ruby + +DEPENDENCIES + actioncable! + actionpack! + activesupport! + arel! + mocha + puma + rack! + rake + +BUNDLED WITH + 1.10.6 diff --git a/actioncable/LICENSE b/actioncable/LICENSE new file mode 100644 index 0000000000..a4910677eb --- /dev/null +++ b/actioncable/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015 Basecamp, 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. diff --git a/actioncable/README.md b/actioncable/README.md new file mode 100644 index 0000000000..6511126896 --- /dev/null +++ b/actioncable/README.md @@ -0,0 +1,485 @@ +# Action Cable – Integrated WebSockets for Rails +[![Build Status](https://travis-ci.org/rails/actioncable.svg)](https://travis-ci.org/rails/actioncable) + +Action Cable seamlessly integrates WebSockets with the rest of your Rails application. +It allows for real-time features to be written in Ruby in the same style +and form as the rest of your Rails application, while still being performant +and scalable. It's a full-stack offering that provides both a client-side +JavaScript framework and a server-side Ruby framework. You have access to your full +domain model written with ActiveRecord or your ORM of choice. + + +## Terminology + +A single Action Cable server can handle multiple connection instances. It has one +connection instance per WebSocket connection. A single user may have multiple +WebSockets open to your application if they use multiple browser tabs or devices. +The client of a WebSocket connection is called the consumer. + +Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates +a logical unit of work, similar to what a controller does in a regular MVC setup. For example, you could have a `ChatChannel` and a `AppearancesChannel`, and a consumer could be subscribed to either +or to both of these channels. At the very least, a consumer should be subscribed to one channel. + +When the consumer is subscribed to a channel, they act as a subscriber. The connection between +the subscriber and the channel is, surprise-surprise, called a subscription. A consumer +can act as a subscriber to a given channel any number of times. For example, a consumer +could subscribe to multiple chat rooms at the same time. (And remember that a physical user may +have multiple consumers, one per tab/device open to your connection). + +Each channel can then again be streaming zero or more broadcastings. A broadcasting is a +pubsub link where anything transmitted by the broadcaster is sent directly to the channel +subscribers who are streaming that named broadcasting. + +As you can see, this is a fairly deep architectural stack. There's a lot of new terminology +to identify the new pieces, and on top of that, you're dealing with both client and server side +reflections of each unit. + +## Examples + +### A full-stack example + +The first thing you must do is define your `ApplicationCable::Connection` class in Ruby. This +is the place where you authorize the incoming connection, and proceed to establish it +if all is well. Here's the simplest example starting with the server-side connection class: + +```ruby +# app/channels/application_cable/connection.rb +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + protected + def find_verified_user + if current_user = User.find_by(id: cookies.signed[:user_id]) + current_user + else + reject_unauthorized_connection + end + end + end +end +``` +Here `identified_by` is a connection identifier that can be used to find the specific connection again or later. +Note that anything marked as an identifier will automatically create a delegate by the same name on any channel instances created off the connection. + +Then you should define your `ApplicationCable::Channel` class in Ruby. This is the place where you put +shared logic between your channels. + +```ruby +# app/channels/application_cable/channel.rb +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end +``` + +This relies on the fact that you will already have handled authentication of the user, and +that a successful authentication sets a signed cookie with the `user_id`. This cookie is then +automatically sent to the connection instance when a new connection is attempted, and you +use that to set the `current_user`. By identifying the connection by this same current_user, +you're also ensuring that you can later retrieve all open connections by a given user (and +potentially disconnect them all if the user is deleted or deauthorized). + +The client-side needs to setup a consumer instance of this connection. That's done like so: + +```coffeescript +# app/assets/javascripts/application_cable.coffee +#= require cable + +@App = {} +App.cable = Cable.createConsumer "ws://cable.example.com" +``` + +The ws://cable.example.com address must point to your set of Action Cable servers, and it +must share a cookie namespace with the rest of the application (which may live under http://example.com). +This ensures that the signed cookie will be correctly sent. + +That's all you need to establish the connection! But of course, this isn't very useful in +itself. This just gives you the plumbing. To make stuff happen, you need content. That content +is defined by declaring channels on the server and allowing the consumer to subscribe to them. + + +### Channel example 1: User appearances + +Here's a simple example of a channel that tracks whether a user is online or not and what page they're on. +(This is useful for creating presence features like showing a green dot next to a user name if they're online). + +First you declare the server-side channel: + +```ruby +# app/channels/appearance_channel.rb +class AppearanceChannel < ApplicationCable::Channel + def subscribed + current_user.appear + end + + def unsubscribed + current_user.disappear + end + + def appear(data) + current_user.appear on: data['appearing_on'] + end + + def away + current_user.away + end +end +``` + +The `#subscribed` callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, +we take that opportunity to say "the current user has indeed appeared". That appear/disappear API could be backed by +Redis or a database or whatever else. Here's what the client-side of that looks like: + +```coffeescript +# app/assets/javascripts/cable/subscriptions/appearance.coffee +App.cable.subscriptions.create "AppearanceChannel", + # Called when the subscription is ready for use on the server + connected: -> + @install() + @appear() + + # Called when the WebSocket connection is closed + disconnected: -> + @uninstall() + + # Called when the subscription is rejected by the server + rejected: -> + @uninstall() + + appear: -> + # Calls `AppearanceChannel#appear(data)` on the server + @perform("appear", appearing_on: $("main").data("appearing-on")) + + away: -> + # Calls `AppearanceChannel#away` on the server + @perform("away") + + + buttonSelector = "[data-behavior~=appear_away]" + + install: -> + $(document).on "page:change.appearance", => + @appear() + + $(document).on "click.appearance", buttonSelector, => + @away() + false + + $(buttonSelector).show() + + uninstall: -> + $(document).off(".appearance") + $(buttonSelector).hide() +``` + +Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, +which in turn is linked to original `App.cable` -> `ApplicationCable::Connection` instances. + +We then link the client-side `appear` method to `AppearanceChannel#appear(data)`. This is possible because the server-side +channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these +can be reached as remote procedure calls via a subscription's `perform` method. + +### Channel example 2: Receiving new web notifications + +The appearance example was all about exposing server functionality to client-side invocation over the WebSocket connection. +But the great thing about WebSockets is that it's a two-way street. So now let's show an example where the server invokes +action on the client. + +This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right +streams: + +```ruby +# app/channels/web_notifications_channel.rb +class WebNotificationsChannel < ApplicationCable::Channel + def subscribed + stream_from "web_notifications_#{current_user.id}" + end +end +``` + +```coffeescript +# Client-side 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"] +``` + +```ruby +# Somewhere in your app this is called, perhaps from a NewCommentJob +ActionCable.server.broadcast \ + "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } +``` + +The `ActionCable.server.broadcast` call places a message in the Redis' pubsub queue under a separate broadcasting name for each user. For a user with an ID of 1, the broadcasting name would be `web_notifications_1`. +The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the +`#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip +across the wire, and unpacked for the data argument arriving to `#received`. + + +### Passing Parameters to Channel + +You can pass parameters from the client side to the server side when creating a subscription. For example: + +```ruby +# app/channels/chat_channel.rb +class ChatChannel < ApplicationCable::Channel + def subscribed + stream_from "chat_#{params[:room]}" + end +end +``` + +Pass an object as the first argument to `subscriptions.create`, and that object will become your params hash in your cable channel. The keyword `channel` is required. + +```coffeescript +# Client-side which assumes you've already requested the right to send web notifications +App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, + received: (data) -> + @appendLine(data) + + appendLine: (data) -> + html = @createLine(data) + $("[data-chat-room='Best Room']").append(html) + + createLine: (data) -> + """ +
+ #{data["sent_by"]} + #{data["body"]} +
+ """ +``` + +```ruby +# Somewhere in your app this is called, perhaps from a NewCommentJob +ActionCable.server.broadcast \ + "chat_#{room}", { sent_by: 'Paul', body: 'This is a cool chat app.' } +``` + + +### Rebroadcasting message + +A common use case is to rebroadcast a message sent by one client to any other connected clients. + +```ruby +# app/channels/chat_channel.rb +class ChatChannel < ApplicationCable::Channel + def subscribed + stream_from "chat_#{params[:room]}" + end + + def receive(data) + ActionCable.server.broadcast "chat_#{params[:room]}", data + end +end +``` + +```coffeescript +# Client-side which assumes you've already requested the right to send web notifications +App.chatChannel = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, + received: (data) -> + # data => { sent_by: "Paul", body: "This is a cool chat app." } + +App.chatChannel.send({ sent_by: "Paul", body: "This is a cool chat app." }) +``` + +The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. + + +### More complete examples + +See the [rails/actioncable-examples](http://github.com/rails/actioncable-examples) repository for a full example of how to setup Action Cable in a Rails app and adding channels. + + +## Configuration + +Action Cable has two required configurations: the Redis connection and specifying allowed request origins. + +### Redis + +By default, `ActionCable::Server::Base` will look for a configuration file in `Rails.root.join('config/redis/cable.yml')`. The file must follow the following format: + +```yaml +production: &production + :url: redis://10.10.3.153:6381 + :host: 10.10.3.153 + :port: 6381 + :timeout: 1 +development: &development + :url: redis://localhost:6379 + :host: localhost + :port: 6379 + :timeout: 1 + :inline: true +test: *development +``` + +This format allows you to specify one configuration per Rails environment. You can also change the location of the Redis config file in +a Rails initializer with something like: + +```ruby +ActionCable.server.config.redis_path = Rails.root('somewhere/else/cable.yml') +``` + +### Allowed Request Origins + +Action Cable will only accept requests from specified origins, which are passed to the server config as an array. The origins can be instances of strings or regular expressions, against which a check for match will be performed. + +```ruby +ActionCable.server.config.allowed_request_origins = ['http://rubyonrails.com', /http:\/\/ruby.*/] +``` + +To disable and allow requests from any origin: + +```ruby +ActionCable.server.config.disable_request_forgery_protection = true +``` + +By default, Action Cable allows all requests from localhost:3000 when running in the development environment. + +### Other Configurations + +The other common option to configure is the log tags applied to the per-connection logger. Here's close to what we're using in Basecamp: + +```ruby +ActionCable.server.config.log_tags = [ + -> request { request.env['bc.account_id'] || "no-account" }, + :action_cable, + -> request { request.uuid } +] +``` + +Your websocket url might change between environments. If you host your production server via https, you will need to use the wss scheme +for your ActionCable server, but development might remain http and use the ws scheme. You might use localhost in development and your +domain in production. In any case, to vary the websocket url between environments, add the following configuration to each environment: + +```ruby +config.action_cable.url = "ws://example.com:28080" +``` + +Then add the following line to your layout before your JavaScript tag: + +```erb +<%= action_cable_meta_tag %> +``` + +And finally, create your consumer like so: + +```coffeescript +App.cable = Cable.createConsumer() +``` + +For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. + +Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 100, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. + + +## Running the cable server + +### Standalone +The cable server(s) is separated from your normal application server. It's still a rack application, but it is its own rack +application. The recommended basic setup is as follows: + +```ruby +# cable/config.ru +require ::File.expand_path('../../config/environment', __FILE__) +Rails.application.eager_load! + +require 'action_cable/process/logging' + +run ActionCable.server +``` + +Then you start the server using a binstub in bin/cable ala: +``` +#!/bin/bash +bundle exec puma -p 28080 cable/config.ru +``` + +The above will start a cable server on port 28080. Remember to point your client-side setup against that using something like: +`App.cable.createConsumer('ws://basecamp.dev:28080')`. + +### In app + +If you are using a threaded server like Puma or Thin, the current implementation of ActionCable can run side-along with your Rails application. For example, to listen for WebSocket requests on `/websocket`, match requests on that path: + +```ruby +# config/routes.rb +Example::Application.routes.draw do + match "/websocket", :to => ActionCable.server, via: [:get, :post] +end +``` + +You can use `App.cable.createConsumer('ws://' + window.location.host + '/websocket')` to connect to the cable server. + +For every instance of your server you create and for every worker your server spawns, you will also have a new instance of ActionCable, but the use of Redis keeps messages synced across connections. + +### Notes + +Beware that currently the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. + +We'll get all this abstracted properly when the framework is integrated into Rails. + +The WebSocket server doesn't have access to the session, but it has access to the cookies. This can be used when you need to handle authentication. You can see one way of doing that with Devise in this [article](http://www.rubytutorial.io/actioncable-devise-authentication). + +## Dependencies + +Action Cable is currently tied to Redis through its use of the pubsub feature to route +messages back and forth over the WebSocket cable connection. This dependency may well +be alleviated in the future, but for the moment that's what it is. So be sure to have +Redis installed and running. + +The Ruby side of things is built on top of [faye-websocket](https://github.com/faye/faye-websocket-ruby) and [celluloid](https://github.com/celluloid/celluloid). + + +## Deployment + +Action Cable is powered by a combination of EventMachine and threads. The +framework plumbing needed for connection handling is handled in the +EventMachine loop, but the actual channel, user-specified, work is handled +in a normal Ruby thread. This means you can use all your regular Rails models +with no problem, as long as you haven't committed any thread-safety sins. + +But this also means that Action Cable needs to run in its own server process. +So you'll have one set of server processes for your normal web work, and another +set of server processes for the Action Cable. The former can be single-threaded, +like Unicorn, but the latter must be multi-threaded, like Puma. + + +## Alpha disclaimer + +Action Cable is currently considered alpha software. The API is almost guaranteed to change between +now and its first production release as part of Rails 5.0. Real applications using the framework +are all well underway, but as of July 8th, 2015, there are no deployments in the wild yet. + +So this current release, which resides in rails/actioncable, is primarily intended for +the adventurous kind, who do not mind reading the full source code of the framework. And it +serves as an invitation for all those crafty folks to contribute to and test what we have so far, +in advance of that general production release. + +Action Cable will move from rails/actioncable to rails/rails and become a full-fledged default +framework alongside Action Pack, Active Record, and the like once we cross the bridge from alpha +to beta software (which will happen once the API and missing pieces have solidified). + +Finally, note that testing is a unfinished/unstarted area of this framework. The framework +has been developed in-app up until this point. We need to find a good way to allow the user to test +their connection and channel logic. + + +## License + +Action Cable is released under the MIT license: + +* http://www.opensource.org/licenses/MIT + + +## Support + +Bug reports can be filed for the alpha development project here: + +* https://github.com/rails/actioncable/issues diff --git a/actioncable/Rakefile b/actioncable/Rakefile new file mode 100644 index 0000000000..69c95468e9 --- /dev/null +++ b/actioncable/Rakefile @@ -0,0 +1,12 @@ +require 'rake' +require 'rake/testtask' + +task :default => :test + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.pattern = 'test/**/*_test.rb' + t.verbose = true + t.warning = false +end +Rake::Task['test'].comment = "Run tests" diff --git a/actioncable/actioncable.gemspec b/actioncable/actioncable.gemspec new file mode 100644 index 0000000000..2f4ae41dc1 --- /dev/null +++ b/actioncable/actioncable.gemspec @@ -0,0 +1,35 @@ +$:.unshift File.expand_path("../lib", __FILE__) +require 'action_cable/version' + +Gem::Specification.new do |s| + s.name = 'actioncable' + s.version = ActionCable::VERSION + s.summary = 'WebSocket framework for Rails.' + s.description = 'Structure many real-time application concerns into channels over a single WebSocket connection.' + s.license = 'MIT' + + s.author = ['Pratik Naik', 'David Heinemeier Hansson'] + s.email = ['pratiknaik@gmail.com', 'david@heinemeierhansson.com'] + s.homepage = 'http://rubyonrails.org' + + s.platform = Gem::Platform::RUBY + + s.add_dependency 'activesupport', '>= 4.2.0' + s.add_dependency 'actionpack', '>= 4.2.0' + s.add_dependency 'faye-websocket', '~> 0.10.0' + s.add_dependency 'websocket-driver', '~> 0.6.1' + # Use 0.16.0 until https://github.com/celluloid/celluloid/issues/637 is resolved + s.add_dependency 'celluloid', '~> 0.16.0' + s.add_dependency 'em-hiredis', '~> 0.3.0' + s.add_dependency 'redis', '~> 3.0' + s.add_dependency 'coffee-rails' + + s.add_development_dependency 'rake' + s.add_development_dependency 'puma' + s.add_development_dependency 'mocha' + + s.files = Dir['README.md', 'lib/**/*'] + s.has_rdoc = false + + s.require_path = 'lib' +end diff --git a/actioncable/lib/action_cable.rb b/actioncable/lib/action_cable.rb new file mode 100644 index 0000000000..3919812161 --- /dev/null +++ b/actioncable/lib/action_cable.rb @@ -0,0 +1,31 @@ +require 'active_support' +require 'active_support/rails' +require 'action_cable/version' + +module ActionCable + extend ActiveSupport::Autoload + + INTERNAL = { + identifiers: { + ping: '_ping'.freeze + }, + message_types: { + confirmation: 'confirm_subscription'.freeze, + rejection: 'reject_subscription'.freeze + } + } + + # Singleton instance of the server + module_function def server + @server ||= ActionCable::Server::Base.new + end + + eager_autoload do + autoload :Server + autoload :Connection + autoload :Channel + autoload :RemoteConnections + end +end + +require 'action_cable/engine' if defined?(Rails) diff --git a/actioncable/lib/action_cable/channel.rb b/actioncable/lib/action_cable/channel.rb new file mode 100644 index 0000000000..7ae262ce5f --- /dev/null +++ b/actioncable/lib/action_cable/channel.rb @@ -0,0 +1,14 @@ +module ActionCable + module Channel + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Base + autoload :Broadcasting + autoload :Callbacks + autoload :Naming + autoload :PeriodicTimers + autoload :Streams + end + end +end diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb new file mode 100644 index 0000000000..ca903a810d --- /dev/null +++ b/actioncable/lib/action_cable/channel/base.rb @@ -0,0 +1,274 @@ +require 'set' + +module ActionCable + module Channel + # 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 Action Controllers, channels do not follow a REST constraint form for its actions. It's an 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, subscribed/unsubscribed are not callable methods, as they were already declared in ActionCable::Channel::Base, but #appear/away + # are. #generate_connection_token is also not callable as its 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, 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 #reject! + # + # Example: + # + # 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, 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 + + 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 + + protected + # 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! + @action_methods = nil + end + + # Refresh the cached action_methods when a new action_method is added. + def method_added(name) + super + clear_action_methods! + end + end + + def initialize(connection, identifier, params = {}) + @connection = connection + @identifier = identifier + @params = params + + # When a channel is streaming via redis pubsub, we want to delay the confirmation + # transmission until redis pubsub subscription is confirmed. + @defer_subscription_confirmation = false + + delegate_connection_identifiers + subscribe_to_channel + 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) + dispatch_action(action, data) + else + logger.error "Unable to process #{action_signature(action, data)}" + end + end + + # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. + # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. + def unsubscribe_from_channel + run_callbacks :unsubscribe do + unsubscribed + end + end + + + protected + # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams + # you want this channel to be sending to the subscriber. + def subscribed + # Override in subclasses + end + + # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking + # people as offline or the like. + def unsubscribed + # 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) + logger.info "#{self.class.name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, message: data) + end + + def defer_subscription_confirmation! + @defer_subscription_confirmation = true + end + + def defer_subscription_confirmation? + @defer_subscription_confirmation + end + + def subscription_confirmation_sent? + @subscription_confirmation_sent + end + + def reject + @reject_subscription = true + end + + def subscription_rejected? + @reject_subscription + end + + private + def delegate_connection_identifiers + connection.identifiers.each do |identifier| + define_singleton_method(identifier) do + connection.send(identifier) + end + end + end + + + def subscribe_to_channel + run_callbacks :subscribe do + subscribed + end + + if subscription_rejected? + reject_subscription + else + transmit_subscription_confirmation unless defer_subscription_confirmation? + 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) + end + + def dispatch_action(action, data) + logger.info action_signature(action, data) + + if method(action).arity == 1 + public_send action, data + else + public_send action + end + end + + def action_signature(action, data) + "#{self.class.name}##{action}".tap do |signature| + if (arguments = data.except('action')).any? + signature << "(#{arguments.inspect})" + end + end + end + + def transmit_subscription_confirmation + unless subscription_confirmation_sent? + logger.info "#{self.class.name} is transmitting the subscription confirmation" + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]) + @subscription_confirmation_sent = true + end + end + + def reject_subscription + connection.subscriptions.remove_subscription self + transmit_subscription_rejection + end + + def transmit_subscription_rejection + logger.info "#{self.class.name} is transmitting the subscription rejection" + connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]) + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/broadcasting.rb b/actioncable/lib/action_cable/channel/broadcasting.rb new file mode 100644 index 0000000000..afc23d7d1a --- /dev/null +++ b/actioncable/lib/action_cable/channel/broadcasting.rb @@ -0,0 +1,29 @@ +require 'active_support/core_ext/object/to_param' + +module ActionCable + module Channel + module Broadcasting + extend ActiveSupport::Concern + + delegate :broadcasting_for, to: :class + + class_methods do + # Broadcast a hash to a unique broadcasting for this model in this channel. + def broadcast_to(model, message) + ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message) + end + + def broadcasting_for(model) #:nodoc: + case + when model.is_a?(Array) + model.map { |m| broadcasting_for(m) }.join(':') + when model.respond_to?(:to_gid_param) + model.to_gid_param + else + model.to_param + end + end + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/callbacks.rb b/actioncable/lib/action_cable/channel/callbacks.rb new file mode 100644 index 0000000000..295d750e86 --- /dev/null +++ b/actioncable/lib/action_cable/channel/callbacks.rb @@ -0,0 +1,35 @@ +require 'active_support/callbacks' + +module ActionCable + module Channel + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :subscribe + define_callbacks :unsubscribe + end + + class_methods do + def before_subscribe(*methods, &block) + set_callback(:subscribe, :before, *methods, &block) + end + + 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/actioncable/lib/action_cable/channel/naming.rb b/actioncable/lib/action_cable/channel/naming.rb new file mode 100644 index 0000000000..4c9d53b15a --- /dev/null +++ b/actioncable/lib/action_cable/channel/naming.rb @@ -0,0 +1,22 @@ +module ActionCable + module Channel + module Naming + extend ActiveSupport::Concern + + class_methods do + # 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' + def channel_name + @channel_name ||= name.sub(/Channel$/, '').gsub('::',':').underscore + end + end + + # Delegates to the class' channel_name + delegate :channel_name, to: :class + end + end +end diff --git a/actioncable/lib/action_cable/channel/periodic_timers.rb b/actioncable/lib/action_cable/channel/periodic_timers.rb new file mode 100644 index 0000000000..25fe8e5e54 --- /dev/null +++ b/actioncable/lib/action_cable/channel/periodic_timers.rb @@ -0,0 +1,41 @@ +module ActionCable + module Channel + module PeriodicTimers + extend ActiveSupport::Concern + + included do + class_attribute :periodic_timers, instance_reader: false + self.periodic_timers = [] + + after_subscribe :start_periodic_timers + after_unsubscribe :stop_periodic_timers + end + + module ClassMethods + # Allow you to call a private method every so often seconds. This periodic timer can be useful + # for sending a steady flow of updates to a client based off an object that was configured on subscription. + # It's an alternative to using streams if the channel is able to do the work internally. + def periodically(callback, every:) + 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 << EventMachine::PeriodicTimer.new(options[:every]) do + connection.worker_pool.async.run_periodic_timer(self, callback) + end + end + end + + def stop_periodic_timers + active_periodic_timers.each { |timer| timer.cancel } + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb new file mode 100644 index 0000000000..b5ffa17f72 --- /dev/null +++ b/actioncable/lib/action_cable/channel/streams.rb @@ -0,0 +1,114 @@ +module ActionCable + module Channel + # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pub/sub queue where any data + # put 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'll not get that update when connecting later. + # + # 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 + # + # So 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. + # That looks like so from that side of things: + # + # 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 supply a callback that lets you alter what goes out. + # Example below 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, -> (encoded_message) do + # message = ActiveSupport::JSON.decode(encoded_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 + # + # 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. + def stream_from(broadcasting, callback = nil) + # Hold off the confirmation until pubsub#subscribe is successful + defer_subscription_confirmation! + + callback ||= default_stream_callback(broadcasting) + streams << [ broadcasting, callback ] + + EM.next_tick do + pubsub.subscribe(broadcasting, &callback).callback do |reply| + transmit_subscription_confirmation + logger.info "#{self.class.name} is streaming from #{broadcasting}" + end + 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. + def stream_for(model, callback = nil) + stream_from(broadcasting_for([ channel_name, model ]), callback) + end + + def stop_all_streams + streams.each do |broadcasting, callback| + pubsub.unsubscribe_proc broadcasting, callback + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" + end.clear + end + + private + delegate :pubsub, to: :connection + + def streams + @_streams ||= [] + end + + def default_stream_callback(broadcasting) + -> (message) do + transmit ActiveSupport::JSON.decode(message), via: "streamed from #{broadcasting}" + end + end + end + end +end diff --git a/actioncable/lib/action_cable/connection.rb b/actioncable/lib/action_cable/connection.rb new file mode 100644 index 0000000000..b672e00682 --- /dev/null +++ b/actioncable/lib/action_cable/connection.rb @@ -0,0 +1,16 @@ +module ActionCable + module Connection + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Authorization + autoload :Base + autoload :Identification + autoload :InternalChannel + autoload :MessageBuffer + autoload :WebSocket + autoload :Subscriptions + autoload :TaggedLoggerProxy + end + end +end diff --git a/actioncable/lib/action_cable/connection/authorization.rb b/actioncable/lib/action_cable/connection/authorization.rb new file mode 100644 index 0000000000..070a70e4e2 --- /dev/null +++ b/actioncable/lib/action_cable/connection/authorization.rb @@ -0,0 +1,13 @@ +module ActionCable + module Connection + module Authorization + class UnauthorizedError < StandardError; end + + private + def reject_unauthorized_connection + logger.error "An unauthorized connection attempt was rejected" + raise UnauthorizedError + end + end + end +end \ No newline at end of file diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb new file mode 100644 index 0000000000..7e9eec7508 --- /dev/null +++ b/actioncable/lib/action_cable/connection/base.rb @@ -0,0 +1,219 @@ +require 'action_dispatch' + +module ActionCable + module Connection + # For every WebSocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent + # of all 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 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 + # + # protected + # def find_verified_user + # if current_user = User.find_by_identity cookies.signed[:identity_id] + # current_user + # else + # reject_unauthorized_connection + # end + # end + # end + # end + # + # First, we declare that this connection can be identified by its current_user. This allows us later to be able to find all connections + # established for that current_user (and potentially disconnect them if the user was removed from an account). You can declare as many + # identification indexes as you like. Declaring an identification means that a 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 name of the current user to easily distinguish their messages in the log. + # + # Pretty simple, eh? + class Base + include Identification + include InternalChannel + include Authorization + + attr_reader :server, :env, :subscriptions + delegate :worker_pool, :pubsub, to: :server + + attr_reader :logger + + def initialize(server, env) + @server, @env = server, env + + @logger = new_tagged_logger + + @websocket = ActionCable::Connection::WebSocket.new(env) + @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @message_buffer = ActionCable::Connection::MessageBuffer.new(self) + + @started_at = Time.now + end + + # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user. + # This method should not be called directly. Rely on the #connect (and #disconnect) callback instead. + def process + logger.info started_request_message + + if websocket.possible? && allow_request_origin? + websocket.on(:open) { |event| send_async :on_open } + websocket.on(:message) { |event| on_message event.data } + websocket.on(:close) { |event| send_async :on_close } + + respond_to_successful_request + else + respond_to_invalid_request + end + end + + # Data received over the cable is handled by this method. It's expected that everything inbound is JSON encoded. + # The data is routed to the proper channel that the connection has subscribed to. + def receive(data_in_json) + if websocket.alive? + subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) + else + logger.error "Received data without a live WebSocket (#{data_in_json.inspect})" + end + end + + # Send raw data straight back down the WebSocket. This is not intended to be called directly. Use the #transmit available on the + # Channel instead, as that'll automatically address the correct subscriber and wrap the message in JSON. + def transmit(data) + websocket.transmit data + end + + # Close the WebSocket connection. + def close + websocket.close + end + + # Invoke a method on the connection asynchronously through the pool of thread workers. + def send_async(method, *arguments) + worker_pool.async.invoke(self, method, *arguments) + end + + # Return a basic hash of statistics for the connection keyed with `identifier`, `started_at`, and `subscriptions`. + # 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 ActiveSupport::JSON.encode(identifier: ActionCable::INTERNAL[:identifiers][:ping], message: Time.now.to_i) + end + + + protected + # 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 + + # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks. + def cookies + request.cookie_jar + end + + + private + attr_reader :websocket + attr_reader :message_buffer + + def on_open + connect if respond_to?(:connect) + subscribe_to_internal_channel + beat + + message_buffer.process! + server.add_connection(self) + rescue ActionCable::Connection::Authorization::UnauthorizedError + respond_to_invalid_request + end + + def on_message(message) + message_buffer.append message + end + + def on_close + logger.info finished_request_message + + server.remove_connection(self) + + subscriptions.unsubscribe_from_all + unsubscribe_from_internal_channel + + disconnect if respond_to?(:disconnect) + end + + + def allow_request_origin? + return true if server.config.disable_request_forgery_protection + + if Array(server.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 + + def respond_to_successful_request + websocket.rack_response + end + + def respond_to_invalid_request + close if websocket.alive? + + logger.info finished_request_message + [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] + end + + + # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. + def new_tagged_logger + TaggedLoggerProxy.new server.logger, + tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + end + + def started_request_message + 'Started %s "%s"%s for %s at %s' % [ + request.request_method, + request.filtered_path, + websocket.possible? ? ' [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]' : '', + request.ip, + Time.now.to_s ] + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/identification.rb b/actioncable/lib/action_cable/connection/identification.rb new file mode 100644 index 0000000000..2d75ff8d6d --- /dev/null +++ b/actioncable/lib/action_cable/connection/identification.rb @@ -0,0 +1,46 @@ +require 'set' + +module ActionCable + module Connection + module Identification + extend ActiveSupport::Concern + + included do + class_attribute :identifiers + self.identifiers = Set.new + end + + class_methods do + # Mark a key as being a connection identifier index that can then 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.map { |id| instance_variable_get("@#{id}") }.compact + 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/actioncable/lib/action_cable/connection/internal_channel.rb b/actioncable/lib/action_cable/connection/internal_channel.rb new file mode 100644 index 0000000000..c065a24ab7 --- /dev/null +++ b/actioncable/lib/action_cable/connection/internal_channel.rb @@ -0,0 +1,45 @@ +module ActionCable + module Connection + # Makes it possible for the RemoteConnection to disconnect a specific connection. + module InternalChannel + extend ActiveSupport::Concern + + private + def internal_redis_channel + "action_cable/#{connection_identifier}" + end + + def subscribe_to_internal_channel + if connection_identifier.present? + callback = -> (message) { process_internal_message(message) } + @_internal_redis_subscriptions ||= [] + @_internal_redis_subscriptions << [ internal_redis_channel, callback ] + + EM.next_tick { pubsub.subscribe(internal_redis_channel, &callback) } + logger.info "Registered connection (#{connection_identifier})" + end + end + + def unsubscribe_from_internal_channel + if @_internal_redis_subscriptions.present? + @_internal_redis_subscriptions.each { |channel, callback| EM.next_tick { pubsub.unsubscribe_proc(channel, callback) } } + end + end + + def process_internal_message(message) + message = ActiveSupport::JSON.decode(message) + + case message['type'] + when 'disconnect' + logger.info "Removing connection (#{connection_identifier})" + websocket.close + 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/actioncable/lib/action_cable/connection/message_buffer.rb b/actioncable/lib/action_cable/connection/message_buffer.rb new file mode 100644 index 0000000000..25cff75b41 --- /dev/null +++ b/actioncable/lib/action_cable/connection/message_buffer.rb @@ -0,0 +1,53 @@ +module ActionCable + module Connection + # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized and is ready to receive them. + # Entirely internal operation and should not be used directly by the user. + class MessageBuffer + 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_accessor :buffered_messages + + def valid?(message) + message.is_a?(String) + end + + def receive(message) + connection.send_async :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 diff --git a/actioncable/lib/action_cable/connection/subscriptions.rb b/actioncable/lib/action_cable/connection/subscriptions.rb new file mode 100644 index 0000000000..6199db4898 --- /dev/null +++ b/actioncable/lib/action_cable/connection/subscriptions.rb @@ -0,0 +1,75 @@ +require 'active_support/core_ext/hash/indifferent_access' + +module ActionCable + module Connection + # 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. Should not be used directly by the user. + class Subscriptions + 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 + logger.error "Received unrecognized command in #{data.inspect}" + end + rescue Exception => e + logger.error "Could not execute command from #{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" + end + + def add(data) + id_key = data['identifier'] + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + + subscription_klass = connection.server.channel_classes[id_options[:channel]] + + if subscription_klass + subscriptions[id_key] ||= subscription_klass.new(connection, id_key, id_options) + else + logger.error "Subscription class not found (#{data.inspect})" + end + end + + def remove(data) + logger.info "Unsubscribing from channel: #{data['identifier']}" + remove_subscription subscriptions[data['identifier']] + 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| channel.unsubscribe_from_channel } + end + + + private + attr_reader :connection, :subscriptions + delegate :logger, to: :connection + + def find(data) + if subscription = subscriptions[data['identifier']] + subscription + else + raise "Unable to find subscription with identifier: #{data['identifier']}" + end + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb new file mode 100644 index 0000000000..e5319087fb --- /dev/null +++ b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb @@ -0,0 +1,40 @@ +module ActionCable + module Connection + # Allows the use of per-connection tags against the server logger. This wouldn't work using the tradional + # 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) + if logger.respond_to?(:tagged) + current_tags = tags - logger.formatter.current_tags + logger.tagged(*current_tags) { yield } + else + yield + end + end + + %i( debug info warn error fatal unknown ).each do |severity| + define_method(severity) do |message| + log severity, message + end + end + + protected + def log(type, message) + tag(@logger) { @logger.send type, message } + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/web_socket.rb b/actioncable/lib/action_cable/connection/web_socket.rb new file mode 100644 index 0000000000..169b683b8c --- /dev/null +++ b/actioncable/lib/action_cable/connection/web_socket.rb @@ -0,0 +1,29 @@ +require 'faye/websocket' + +module ActionCable + module Connection + # Decorate the Faye::WebSocket with helpers we need. + class WebSocket + delegate :rack_response, :close, :on, to: :websocket + + def initialize(env) + @websocket = Faye::WebSocket.websocket?(env) ? Faye::WebSocket.new(env) : nil + end + + def possible? + websocket + end + + def alive? + websocket && websocket.ready_state == Faye::WebSocket::API::OPEN + end + + def transmit(data) + websocket.send data + end + + private + attr_reader :websocket + end + end +end diff --git a/actioncable/lib/action_cable/engine.rb b/actioncable/lib/action_cable/engine.rb new file mode 100644 index 0000000000..4777c3886b --- /dev/null +++ b/actioncable/lib/action_cable/engine.rb @@ -0,0 +1,27 @@ +require 'rails/engine' +require 'active_support/ordered_options' +require 'action_cable/helpers/action_cable_helper' + +module ActionCable + class Engine < ::Rails::Engine + config.action_cable = ActiveSupport::OrderedOptions.new + + config.to_prepare do + ApplicationController.helper ActionCable::Helpers::ActionCableHelper + end + + initializer "action_cable.logger" do + ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } + end + + initializer "action_cable.set_configs" do |app| + options = app.config.action_cable + + options.allowed_request_origins ||= "http://localhost:3000" if ::Rails.env.development? + + ActiveSupport.on_load(:action_cable) do + options.each { |k,v| send("#{k}=", v) } + end + end + end +end diff --git a/actioncable/lib/action_cable/helpers/action_cable_helper.rb b/actioncable/lib/action_cable/helpers/action_cable_helper.rb new file mode 100644 index 0000000000..b82751468a --- /dev/null +++ b/actioncable/lib/action_cable/helpers/action_cable_helper.rb @@ -0,0 +1,29 @@ +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-turbolinks-track' => true %> + # + # + # This is then used by ActionCable to determine the url of your websocket server. + # Your CoffeeScript can then connect to the server without needing to specify the + # url directly: + # + # #= require cable + # @App = {} + # App.cable = Cable.createConsumer() + # + # Make sure to specify the correct server location in each of your environments + # config file: + # + # config.action_cable.url = "ws://example.com:28080" + def action_cable_meta_tag + tag "meta", name: "action-cable-url", content: Rails.application.config.action_cable.url + end + end + end +end diff --git a/actioncable/lib/action_cable/process/logging.rb b/actioncable/lib/action_cable/process/logging.rb new file mode 100644 index 0000000000..618ba7357a --- /dev/null +++ b/actioncable/lib/action_cable/process/logging.rb @@ -0,0 +1,12 @@ +require 'action_cable/server' +require 'eventmachine' +require 'celluloid' + +EM.error_handler do |e| + puts "Error raised inside the event loop: #{e.message}" + puts e.backtrace.join("\n") +end + +Celluloid.logger = ActionCable.server.logger + +ActionCable.server.config.log_to_stdout if Rails.env.development? \ No newline at end of file diff --git a/actioncable/lib/action_cable/remote_connections.rb b/actioncable/lib/action_cable/remote_connections.rb new file mode 100644 index 0000000000..1230d905ad --- /dev/null +++ b/actioncable/lib/action_cable/remote_connections.rb @@ -0,0 +1,64 @@ +module ActionCable + # If you need to disconnect a given connection, you go through the RemoteConnections. You find the connections you're looking for by + # searching the identifier declared on the connection. 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 + # + # That 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 these servers are subscribed to). + class RemoteConnections + attr_reader :server + + def initialize(server) + @server = server + end + + def where(identifier) + RemoteConnection.new(server, identifier) + end + + private + # Represents a single remote connection found via ActionCable.server.remote_connections.where(*). + # Exists for the 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 + server.broadcast internal_redis_channel, type: 'disconnect' + end + + # Returns all the identifiers that were applied to this connection. + def identifiers + server.connection_identifiers + end + + private + attr_reader :server + + 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/actioncable/lib/action_cable/server.rb b/actioncable/lib/action_cable/server.rb new file mode 100644 index 0000000000..a2a89d5f1e --- /dev/null +++ b/actioncable/lib/action_cable/server.rb @@ -0,0 +1,19 @@ +require 'eventmachine' +EventMachine.epoll if EventMachine.epoll? +EventMachine.kqueue if EventMachine.kqueue? + +module ActionCable + module Server + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Base + autoload :Broadcasting + autoload :Connections + autoload :Configuration + + autoload :Worker + autoload :ActiveRecordConnectionManagement, 'action_cable/server/worker/active_record_connection_management' + end + end +end diff --git a/actioncable/lib/action_cable/server/base.rb b/actioncable/lib/action_cable/server/base.rb new file mode 100644 index 0000000000..f1585dc776 --- /dev/null +++ b/actioncable/lib/action_cable/server/base.rb @@ -0,0 +1,74 @@ +require 'em-hiredis' + +module ActionCable + module Server + # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the rack process that starts the cable server, but + # also by the user to reach the RemoteConnections instead for finding and disconnecting connections across all servers. + # + # Also, this is the server instance used for broadcasting. See Broadcasting for details. + class Base + include ActionCable::Server::Broadcasting + include ActionCable::Server::Connections + + cattr_accessor(:config, instance_accessor: true) { ActionCable::Server::Configuration.new } + + def self.logger; config.logger; end + delegate :logger, to: :config + + def initialize + end + + # Called by rack to setup the server. + def call(env) + setup_heartbeat_timer + config.connection_class.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 + + # Gateway to RemoteConnections. See that class for details. + def remote_connections + @remote_connections ||= RemoteConnections.new(self) + end + + # The thread worker pool for handling all the connection work on this server. Default size is set by config.worker_pool_size. + def worker_pool + @worker_pool ||= ActionCable::Server::Worker.pool(size: config.worker_pool_size) + end + + # Requires and returns an hash of all the channel class constants keyed by name. + def channel_classes + @channel_classes ||= begin + config.channel_paths.each { |channel_path| require channel_path } + config.channel_class_names.each_with_object({}) { |name, hash| hash[name] = name.constantize } + end + end + + # The redis pubsub adapter used for all streams/broadcasting. + def pubsub + @pubsub ||= redis.pubsub + end + + # The EventMachine Redis instance used by the pubsub adapter. + def redis + @redis ||= EM::Hiredis.connect(config.redis[:url]).tap do |redis| + redis.on(:reconnect_failed) do + logger.info "[ActionCable] Redis reconnect failed." + # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." + # @connections.map &:close + end + end + end + + # All the identifiers applied to the connection class associated with this server. + def connection_identifiers + config.connection_class.identifiers + end + end + + ActiveSupport.run_load_hooks(:action_cable, Base.config) + end +end diff --git a/actioncable/lib/action_cable/server/broadcasting.rb b/actioncable/lib/action_cable/server/broadcasting.rb new file mode 100644 index 0000000000..6e0fbae387 --- /dev/null +++ b/actioncable/lib/action_cable/server/broadcasting.rb @@ -0,0 +1,54 @@ +require 'redis' + +module ActionCable + module Server + # Broadcasting is how other parts of your application can send messages to the channel 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 shit fit for print' } + # + # # Client-side coffescript 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. It'll automatically be JSON encoded. + def broadcast(broadcasting, message) + broadcaster_for(broadcasting).broadcast(message) + end + + # Returns a broadcaster for a named broadcasting that can be reused. Useful when you have a object that + # may need multiple spots to transmit to a specific broadcasting over and over. + def broadcaster_for(broadcasting) + Broadcaster.new(self, broadcasting) + end + + # The redis instance used for broadcasting. Not intended for direct user use. + def broadcasting_redis + @broadcasting_redis ||= Redis.new(config.redis) + end + + private + class Broadcaster + attr_reader :server, :broadcasting + + def initialize(server, broadcasting) + @server, @broadcasting = server, broadcasting + end + + def broadcast(message) + server.logger.info "[ActionCable] Broadcasting to #{broadcasting}: #{message}" + server.broadcasting_redis.publish broadcasting, ActiveSupport::JSON.encode(message) + end + end + end + end +end diff --git a/actioncable/lib/action_cable/server/configuration.rb b/actioncable/lib/action_cable/server/configuration.rb new file mode 100644 index 0000000000..f7fcee019b --- /dev/null +++ b/actioncable/lib/action_cable/server/configuration.rb @@ -0,0 +1,67 @@ +require 'active_support/core_ext/hash/indifferent_access' + +module ActionCable + module Server + # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak the configuration points + # in a Rails config initializer. + class Configuration + attr_accessor :logger, :log_tags + attr_accessor :connection_class, :worker_pool_size + attr_accessor :redis_path, :channels_path + attr_accessor :disable_request_forgery_protection, :allowed_request_origins + attr_accessor :url + + def initialize + @logger = Rails.logger + @log_tags = [] + + @connection_class = ApplicationCable::Connection + @worker_pool_size = 100 + + @redis_path = Rails.root.join('config/redis/cable.yml') + @channels_path = Rails.root.join('app/channels') + + @disable_request_forgery_protection = false + end + + def log_to_stdout + console = ActiveSupport::Logger.new($stdout) + console.formatter = @logger.formatter + console.level = @logger.level + + @logger.extend(ActiveSupport::Logger.broadcast(console)) + end + + def channel_paths + @channels ||= Dir["#{channels_path}/**/*_channel.rb"] + end + + def channel_class_names + @channel_class_names ||= channel_paths.collect do |channel_path| + Pathname.new(channel_path).basename.to_s.split('.').first.camelize + end + end + + def redis + @redis ||= config_for(redis_path).with_indifferent_access + end + + private + # FIXME: Extract this from Rails::Application in a way it can be used here. + def config_for(path) + if path.exist? + require "yaml" + require "erb" + (YAML.load(ERB.new(path.read).result) || {})[Rails.env] || {} + else + raise "Could not load configuration. No such file - #{path}" + end + rescue Psych::SyntaxError => e + raise "YAML syntax error occurred while parsing #{path}. " \ + "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \ + "Error: #{e.message}" + end + end + end +end + diff --git a/actioncable/lib/action_cable/server/connections.rb b/actioncable/lib/action_cable/server/connections.rb new file mode 100644 index 0000000000..47dcea8c20 --- /dev/null +++ b/actioncable/lib/action_cable/server/connections.rb @@ -0,0 +1,37 @@ +module ActionCable + module Server + # Collection class for all the connections that's been established on this specific server. Remember, usually you'll run many cable servers, so + # you can't use this collection as an full list of all the connections established against your application. Use RemoteConnections for that. + # As such, this is primarily for internal use. + module Connections + 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 receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically + # disconnect. + def setup_heartbeat_timer + EM.next_tick do + @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do + EM.next_tick { connections.map(&:beat) } + end + end + end + + def open_connections_statistics + connections.map(&:statistics) + end + end + end +end diff --git a/actioncable/lib/action_cable/server/worker.rb b/actioncable/lib/action_cable/server/worker.rb new file mode 100644 index 0000000000..e063b2a2e1 --- /dev/null +++ b/actioncable/lib/action_cable/server/worker.rb @@ -0,0 +1,42 @@ +require 'celluloid' +require 'active_support/callbacks' + +module ActionCable + module Server + # Worker used by Server.send_async to do connection work in threads. Only for internal use. + class Worker + include ActiveSupport::Callbacks + include Celluloid + + attr_reader :connection + define_callbacks :work + include ActiveRecordConnectionManagement + + def invoke(receiver, method, *args) + @connection = receiver + + run_callbacks :work do + receiver.send method, *args + end + 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 + + def run_periodic_timer(channel, callback) + @connection = channel.connection + + run_callbacks :work do + callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback) + end + end + + private + def logger + ActionCable.server.logger + end + end + end +end diff --git a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb new file mode 100644 index 0000000000..ecece4e270 --- /dev/null +++ b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb @@ -0,0 +1,22 @@ +module ActionCable + module Server + class Worker + # Clear active connections between units of work so the long-running channel or connection processes do not hoard connections. + module ActiveRecordConnectionManagement + extend ActiveSupport::Concern + + included do + if defined?(ActiveRecord::Base) + set_callback :work, :around, :with_database_connections + end + end + + def with_database_connections + connection.logger.tag(ActiveRecord::Base.logger) { yield } + ensure + ActiveRecord::Base.clear_active_connections! + end + end + end + end +end \ No newline at end of file diff --git a/actioncable/lib/action_cable/version.rb b/actioncable/lib/action_cable/version.rb new file mode 100644 index 0000000000..4947029dcc --- /dev/null +++ b/actioncable/lib/action_cable/version.rb @@ -0,0 +1,3 @@ +module ActionCable + VERSION = '0.0.3' +end \ No newline at end of file diff --git a/actioncable/lib/actioncable.rb b/actioncable/lib/actioncable.rb new file mode 100644 index 0000000000..f6df6fd063 --- /dev/null +++ b/actioncable/lib/actioncable.rb @@ -0,0 +1,2 @@ +# Pointer for auto-require +require 'action_cable' \ No newline at end of file diff --git a/actioncable/lib/assets/javascripts/cable.coffee.erb b/actioncable/lib/assets/javascripts/cable.coffee.erb new file mode 100644 index 0000000000..25a9fc79c2 --- /dev/null +++ b/actioncable/lib/assets/javascripts/cable.coffee.erb @@ -0,0 +1,12 @@ +#= require_self +#= require cable/consumer + +@Cable = + INTERNAL: <%= ActionCable::INTERNAL.to_json %> + + createConsumer: (url = @getConfig("url")) -> + new Cable.Consumer url + + getConfig: (name) -> + element = document.head.querySelector("meta[name='action-cable-#{name}']") + element?.getAttribute("content") diff --git a/actioncable/lib/assets/javascripts/cable/connection.coffee b/actioncable/lib/assets/javascripts/cable/connection.coffee new file mode 100644 index 0000000000..b2abe8dcb2 --- /dev/null +++ b/actioncable/lib/assets/javascripts/cable/connection.coffee @@ -0,0 +1,84 @@ +# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. + +{message_types} = Cable.INTERNAL + +class Cable.Connection + @reopenDelay: 500 + + constructor: (@consumer) -> + @open() + + send: (data) -> + if @isOpen() + @webSocket.send(JSON.stringify(data)) + true + else + false + + open: => + if @webSocket and not @isState("closed") + throw new Error("Existing connection must be closed before opening") + else + @webSocket = new WebSocket(@consumer.url) + @installEventHandlers() + true + + close: -> + @webSocket?.close() + + reopen: -> + if @isState("closed") + @open() + else + try + @close() + finally + setTimeout(@open, @constructor.reopenDelay) + + isOpen: -> + @isState("open") + + # Private + + isState: (states...) -> + @getState() in states + + getState: -> + return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState + null + + installEventHandlers: -> + for eventName of @events + handler = @events[eventName].bind(this) + @webSocket["on#{eventName}"] = handler + return + + events: + message: (event) -> + {identifier, message, type} = JSON.parse(event.data) + + switch type + when message_types.confirmation + @consumer.subscriptions.notify(identifier, "connected") + when message_types.rejection + @consumer.subscriptions.reject(identifier) + else + @consumer.subscriptions.notify(identifier, "received", message) + + open: -> + @disconnected = false + @consumer.subscriptions.reload() + + close: -> + @disconnect() + + error: -> + @disconnect() + + disconnect: -> + return if @disconnected + @disconnected = true + @consumer.subscriptions.notifyAll("disconnected") + + toJSON: -> + state: @getState() diff --git a/actioncable/lib/assets/javascripts/cable/connection_monitor.coffee b/actioncable/lib/assets/javascripts/cable/connection_monitor.coffee new file mode 100644 index 0000000000..435efcc361 --- /dev/null +++ b/actioncable/lib/assets/javascripts/cable/connection_monitor.coffee @@ -0,0 +1,84 @@ +# Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting +# revival reconnections if things go astray. Internal class, not intended for direct user manipulation. +class Cable.ConnectionMonitor + @pollInterval: + min: 3 + max: 30 + + @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) + + identifier: Cable.INTERNAL.identifiers.ping + + constructor: (@consumer) -> + @consumer.subscriptions.add(this) + @start() + + connected: -> + @reset() + @pingedAt = now() + delete @disconnectedAt + + disconnected: -> + @disconnectedAt = now() + + received: -> + @pingedAt = now() + + reset: -> + @reconnectAttempts = 0 + + start: -> + @reset() + delete @stoppedAt + @startedAt = now() + @poll() + document.addEventListener("visibilitychange", @visibilityDidChange) + + stop: -> + @stoppedAt = now() + document.removeEventListener("visibilitychange", @visibilityDidChange) + + poll: -> + setTimeout => + unless @stoppedAt + @reconnectIfStale() + @poll() + , @getInterval() + + getInterval: -> + {min, max} = @constructor.pollInterval + interval = 5 * Math.log(@reconnectAttempts + 1) + clamp(interval, min, max) * 1000 + + reconnectIfStale: -> + if @connectionIsStale() + @reconnectAttempts++ + unless @disconnectedRecently() + @consumer.connection.reopen() + + connectionIsStale: -> + secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold + + disconnectedRecently: -> + @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold + + visibilityDidChange: => + if document.visibilityState is "visible" + setTimeout => + if @connectionIsStale() or not @consumer.connection.isOpen() + @consumer.connection.reopen() + , 200 + + toJSON: -> + interval = @getInterval() + connectionIsStale = @connectionIsStale() + {@startedAt, @stoppedAt, @pingedAt, @reconnectAttempts, connectionIsStale, interval} + + now = -> + new Date().getTime() + + secondsSince = (time) -> + (now() - time) / 1000 + + clamp = (number, min, max) -> + Math.max(min, Math.min(max, number)) diff --git a/actioncable/lib/assets/javascripts/cable/consumer.coffee b/actioncable/lib/assets/javascripts/cable/consumer.coffee new file mode 100644 index 0000000000..05a7398e79 --- /dev/null +++ b/actioncable/lib/assets/javascripts/cable/consumer.coffee @@ -0,0 +1,31 @@ +#= require cable/connection +#= require cable/connection_monitor +#= require cable/subscriptions +#= require cable/subscription + +# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, +# the Cable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. +# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription +# method. +# +# The following example shows how this can be setup: +# +# @App = {} +# App.cable = Cable.createConsumer "ws://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. +class Cable.Consumer + constructor: (@url) -> + @subscriptions = new Cable.Subscriptions this + @connection = new Cable.Connection this + @connectionMonitor = new Cable.ConnectionMonitor this + + send: (data) -> + @connection.send(data) + + inspect: -> + JSON.stringify(this, null, 2) + + toJSON: -> + {@url, @subscriptions, @connection, @connectionMonitor} diff --git a/actioncable/lib/assets/javascripts/cable/subscription.coffee b/actioncable/lib/assets/javascripts/cable/subscription.coffee new file mode 100644 index 0000000000..5b024d4e15 --- /dev/null +++ b/actioncable/lib/assets/javascripts/cable/subscription.coffee @@ -0,0 +1,68 @@ +# A new subscription is created through the Cable.Subscriptions instance available on the consumer. +# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding +# Channel instance on the server side. +# +# An example demonstrates the basic functionality: +# +# App.appearance = App.cable.subscriptions.create "AppearanceChannel", +# connected: -> +# # Called once the subscription has been successfully completed +# +# appear: -> +# @perform 'appear', appearing_on: @appearingOn() +# +# away: -> +# @perform 'away' +# +# appearingOn: -> +# $('main').data 'appearing-on' +# +# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server +# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). +# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. +# +# This is how the server component would look: +# +# class AppearanceChannel < ApplicationCable::Channel +# def subscribed +# current_user.appear +# end +# +# def unsubscribed +# current_user.disappear +# end +# +# def appear(data) +# current_user.appear on: data['appearing_on'] +# end +# +# def away +# current_user.away +# end +# end +# +# The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. +# The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method. +class Cable.Subscription + constructor: (@subscriptions, params = {}, mixin) -> + @identifier = JSON.stringify(params) + extend(this, mixin) + @subscriptions.add(this) + @consumer = @subscriptions.consumer + + # Perform a channel action with the optional data passed as an attribute + perform: (action, data = {}) -> + data.action = action + @send(data) + + send: (data) -> + @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) + + unsubscribe: -> + @subscriptions.remove(this) + + extend = (object, properties) -> + if properties? + for key, value of properties + object[key] = value + object diff --git a/actioncable/lib/assets/javascripts/cable/subscriptions.coffee b/actioncable/lib/assets/javascripts/cable/subscriptions.coffee new file mode 100644 index 0000000000..7955565f06 --- /dev/null +++ b/actioncable/lib/assets/javascripts/cable/subscriptions.coffee @@ -0,0 +1,78 @@ +# Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user +# us Cable.Subscriptions#create, and it should be called through the consumer like so: +# +# @App = {} +# App.cable = Cable.createConsumer "ws://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. +class Cable.Subscriptions + constructor: (@consumer) -> + @subscriptions = [] + @history = [] + + create: (channelName, mixin) -> + channel = channelName + params = if typeof channel is "object" then channel else {channel} + new Cable.Subscription this, params, mixin + + # Private + + add: (subscription) -> + @subscriptions.push(subscription) + @notify(subscription, "initialized") + @sendCommand(subscription, "subscribe") + + remove: (subscription) -> + @forget(subscription) + + unless @findAll(subscription.identifier).length + @sendCommand(subscription, "unsubscribe") + + reject: (identifier) -> + for subscription in @findAll(identifier) + @forget(subscription) + @notify(subscription, "rejected") + + forget: (subscription) -> + @subscriptions = (s for s in @subscriptions when s isnt subscription) + + findAll: (identifier) -> + s for s in @subscriptions when s.identifier is identifier + + reload: -> + for subscription in @subscriptions + @sendCommand(subscription, "subscribe") + + notifyAll: (callbackName, args...) -> + for subscription in @subscriptions + @notify(subscription, callbackName, args...) + + notify: (subscription, callbackName, args...) -> + if typeof subscription is "string" + subscriptions = @findAll(subscription) + else + subscriptions = [subscription] + + for subscription in subscriptions + subscription[callbackName]?(args...) + + if callbackName in ["initialized", "connected", "disconnected", "rejected"] + {identifier} = subscription + @record(notification: {identifier, callbackName, args}) + + sendCommand: (subscription, command) -> + {identifier} = subscription + if identifier is Cable.INTERNAL.identifiers.ping + @consumer.connection.isOpen() + else + @consumer.send({command, identifier}) + + record: (data) -> + data.time = new Date() + @history = @history.slice(-19) + @history.push(data) + + toJSON: -> + history: @history + identifiers: (subscription.identifier for subscription in @subscriptions) diff --git a/actioncable/test/channel/base_test.rb b/actioncable/test/channel/base_test.rb new file mode 100644 index 0000000000..580338b44a --- /dev/null +++ b/actioncable/test/channel/base_test.rb @@ -0,0 +1,148 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::BaseTest < ActiveSupport::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 + + 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 + + private + def rm_rf + @last_action = [ :rm_rf ] + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + @channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + end + + test "should subscribe to a channel on initialize" do + assert_equal 1, @channel.room.id + end + + test "on subscribe callbacks" do + assert @channel.subscribed + end + + test "channel params" do + assert_equal({ id: 1 }, @channel.params) + end + + test "unsubscribing from a channel" do + assert @channel.room + assert @channel.subscribed? + + @channel.unsubscribe_from_channel + + assert ! @channel.room + assert ! @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 "transmitting data" do + @channel.perform_action 'action' => :get_latest + + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "message" => { "data" => "latest" } + assert_equal expected, @connection.last_transmission + end + + test "subscription confirmation" do + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" + assert_equal expected, @connection.last_transmission + end + +end diff --git a/actioncable/test/channel/broadcasting_test.rb b/actioncable/test/channel/broadcasting_test.rb new file mode 100644 index 0000000000..1de04243e5 --- /dev/null +++ b/actioncable/test/channel/broadcasting_test.rb @@ -0,0 +1,29 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::BroadcastingTest < ActiveSupport::TestCase + class ChatChannel < ActionCable::Channel::Base + end + + setup do + @connection = TestConnection.new + end + + test "broadcasts_to" do + ActionCable.stubs(:server).returns mock().tap { |m| m.expects(:broadcast).with('action_cable:channel:broadcasting_test:chat:Room#1-Campfire', "Hello World") } + ChatChannel.broadcast_to(Room.new(1), "Hello World") + end + + test "broadcasting_for with an object" do + assert_equal "Room#1-Campfire", ChatChannel.broadcasting_for(Room.new(1)) + end + + test "broadcasting_for with an array" do + assert_equal "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 "hello", ChatChannel.broadcasting_for("hello") + end +end diff --git a/actioncable/test/channel/naming_test.rb b/actioncable/test/channel/naming_test.rb new file mode 100644 index 0000000000..89ef6ad8b0 --- /dev/null +++ b/actioncable/test/channel/naming_test.rb @@ -0,0 +1,10 @@ +require 'test_helper' + +class ActionCable::Channel::NamingTest < ActiveSupport::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/actioncable/test/channel/periodic_timers_test.rb b/actioncable/test/channel/periodic_timers_test.rb new file mode 100644 index 0000000000..1590a12f09 --- /dev/null +++ b/actioncable/test/channel/periodic_timers_test.rb @@ -0,0 +1,40 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::PeriodicTimersTest < ActiveSupport::TestCase + class ChatChannel < ActionCable::Channel::Base + periodically -> { ping }, every: 5 + periodically :send_updates, every: 1 + + private + def ping + end + end + + setup do + @connection = TestConnection.new + end + + test "periodic timers definition" do + timers = ChatChannel.periodic_timers + + assert_equal 2, timers.size + + first_timer = timers[0] + assert_kind_of Proc, first_timer[0] + assert_equal 5, first_timer[1][:every] + + second_timer = timers[1] + assert_equal :send_updates, second_timer[0] + assert_equal 1, second_timer[1][:every] + end + + test "timer start and stop" do + EventMachine::PeriodicTimer.expects(:new).times(2).returns(true) + channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } + + channel.expects(:stop_periodic_timers).once + channel.unsubscribe_from_channel + end +end diff --git a/actioncable/test/channel/rejection_test.rb b/actioncable/test/channel/rejection_test.rb new file mode 100644 index 0000000000..aa93396d44 --- /dev/null +++ b/actioncable/test/channel/rejection_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase + class SecretChannel < ActionCable::Channel::Base + def subscribed + reject if params[:id] > 0 + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + end + + test "subscription rejection" do + @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } + @channel = SecretChannel.new @connection, "{id: 1}", { id: 1 } + + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "reject_subscription" + assert_equal expected, @connection.last_transmission + end + +end diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb new file mode 100644 index 0000000000..5e4e01abbf --- /dev/null +++ b/actioncable/test/channel/stream_test.rb @@ -0,0 +1,80 @@ +require 'test_helper' +require 'stubs/test_connection' +require 'stubs/room' + +class ActionCable::Channel::StreamTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + def subscribed + if params[:id] + @room = Room.new params[:id] + stream_from "test_room_#{@room.id}" + end + end + + def send_confirmation + transmit_subscription_confirmation + end + + end + + test "streaming start and stop" do + run_in_eventmachine do + connection = TestConnection.new + connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1").returns stub_everything(:pubsub) } + channel = ChatChannel.new connection, "{id: 1}", { id: 1 } + + connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } + channel.unsubscribe_from_channel + end + end + + test "stream_for" do + run_in_eventmachine do + connection = TestConnection.new + EM.next_tick do + connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire").returns stub_everything(:pubsub) } + end + + channel = ChatChannel.new connection, "" + channel.stream_for Room.new(1) + end + end + + test "stream_from subscription confirmation" do + EM.run do + connection = TestConnection.new + connection.expects(:pubsub).returns EM::Hiredis.connect.pubsub + + channel = ChatChannel.new connection, "{id: 1}", { id: 1 } + assert_nil connection.last_transmission + + EM::Timer.new(0.1) do + expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" + assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation within 0.1s" + + EM.run_deferred_callbacks + EM.stop + end + end + end + + test "subscription confirmation should only be sent out once" do + EM.run do + connection = TestConnection.new + connection.stubs(:pubsub).returns EM::Hiredis.connect.pubsub + + channel = ChatChannel.new connection, "test_channel" + channel.send_confirmation + channel.send_confirmation + + EM.run_deferred_callbacks + + expected = ActiveSupport::JSON.encode "identifier" => "test_channel", "type" => "confirm_subscription" + assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation" + + assert_equal 1, connection.transmissions.size + EM.stop + end + end + +end diff --git a/actioncable/test/connection/authorization_test.rb b/actioncable/test/connection/authorization_test.rb new file mode 100644 index 0000000000..68668b2835 --- /dev/null +++ b/actioncable/test/connection/authorization_test.rb @@ -0,0 +1,32 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::AuthorizationTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket + + def connect + reject_unauthorized_connection + end + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end + end + + test "unauthorized connection" do + run_in_eventmachine do + server = TestServer.new + server.config.allowed_request_origins = %w( http://rubyonrails.com ) + + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', + 'HTTP_ORIGIN' => 'http://rubyonrails.com' + + connection = Connection.new(server, env) + connection.websocket.expects(:close) + + connection.process + end + end +end diff --git a/actioncable/test/connection/base_test.rb b/actioncable/test/connection/base_test.rb new file mode 100644 index 0000000000..da6041db4a --- /dev/null +++ b/actioncable/test/connection/base_test.rb @@ -0,0 +1,118 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::BaseTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket, :subscriptions, :message_buffer, :connected + + def connect + @connected = true + end + + def disconnect + @connected = false + end + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "making a connection with invalid headers" do + run_in_eventmachine do + connection = ActionCable::Connection::Base.new(@server, Rack::MockRequest.env_for("/test")) + response = connection.process + assert_equal 404, response[0] + end + end + + test "websocket connection" do + run_in_eventmachine do + connection = open_connection + connection.process + + assert connection.websocket.possible? + assert connection.websocket.alive? + end + end + + test "rack response" do + run_in_eventmachine do + connection = open_connection + response = connection.process + + assert_equal [ -1, {}, [] ], response + end + end + + test "on connection open" do + run_in_eventmachine do + connection = open_connection + connection.process + + connection.websocket.expects(:transmit).with(regexp_matches(/\_ping/)) + connection.message_buffer.expects(:process!) + + # Allow EM to run on_open callback + EM.next_tick do + assert_equal [ connection ], @server.connections + assert connection.connected + end + end + end + + test "on connection close" do + run_in_eventmachine do + connection = open_connection + connection.process + + # Setup the connection + EventMachine.stubs(:add_periodic_timer).returns(true) + connection.send :on_open + assert connection.connected + + connection.subscriptions.expects(:unsubscribe_from_all) + connection.send :on_close + + assert ! connection.connected + assert_equal [], @server.connections + end + end + + test "connection statistics" do + run_in_eventmachine do + connection = open_connection + connection.process + + statistics = connection.statistics + + assert statistics[:identifier].blank? + assert_kind_of Time, statistics[:started_at] + assert_equal [], statistics[:subscriptions] + end + end + + test "explicitly closing a connection" do + run_in_eventmachine do + connection = open_connection + connection.process + + connection.websocket.expects(:close) + connection.close + end + end + + private + def open_connection + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', + 'HTTP_ORIGIN' => 'http://rubyonrails.com' + + Connection.new(@server, env) + end +end diff --git a/actioncable/test/connection/cross_site_forgery_test.rb b/actioncable/test/connection/cross_site_forgery_test.rb new file mode 100644 index 0000000000..ede3057e30 --- /dev/null +++ b/actioncable/test/connection/cross_site_forgery_test.rb @@ -0,0 +1,82 @@ +require 'test_helper' +require 'stubs/test_server' + +class ActionCable::Connection::CrossSiteForgeryTest < ActionCable::TestCase + HOST = 'rubyonrails.com' + + class Connection < ActionCable::Connection::Base + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + teardown do + @server.config.disable_request_forgery_protection = false + @server.config.allowed_request_origins = [] + 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 + + 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 = Connection.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_ORIGIN' => origin + end +end diff --git a/actioncable/test/connection/identifier_test.rb b/actioncable/test/connection/identifier_test.rb new file mode 100644 index 0000000000..02e6b21845 --- /dev/null +++ b/actioncable/test/connection/identifier_test.rb @@ -0,0 +1,77 @@ +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 + + test "connection identifier" do + run_in_eventmachine do + open_connection_with_stubbed_pubsub + assert_equal "User#lifo", @connection.connection_identifier + end + end + + test "should subscribe to internal channel on open and unsubscribe on close" do + run_in_eventmachine do + pubsub = mock('pubsub') + pubsub.expects(:subscribe).with('action_cable/User#lifo') + pubsub.expects(:unsubscribe_proc).with('action_cable/User#lifo', kind_of(Proc)) + + server = TestServer.new + server.stubs(:pubsub).returns(pubsub) + + open_connection server: server + close_connection + end + end + + test "processing disconnect message" do + run_in_eventmachine do + open_connection_with_stubbed_pubsub + + @connection.websocket.expects(:close) + message = ActiveSupport::JSON.encode('type' => 'disconnect') + @connection.process_internal_message message + end + end + + test "processing invalid message" do + run_in_eventmachine do + open_connection_with_stubbed_pubsub + + @connection.websocket.expects(:close).never + message = ActiveSupport::JSON.encode('type' => 'unknown') + @connection.process_internal_message message + end + end + + protected + def open_connection_with_stubbed_pubsub + server = TestServer.new + server.stubs(:pubsub).returns(stub_everything('pubsub')) + + open_connection server: server + end + + def open_connection(server:) + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(server, env) + + @connection.process + @connection.send :on_open + end + + def close_connection + @connection.send :on_close + end +end diff --git a/actioncable/test/connection/multiple_identifiers_test.rb b/actioncable/test/connection/multiple_identifiers_test.rb new file mode 100644 index 0000000000..55a9f96cb3 --- /dev/null +++ b/actioncable/test/connection/multiple_identifiers_test.rb @@ -0,0 +1,41 @@ +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 + run_in_eventmachine do + open_connection_with_stubbed_pubsub + assert_equal "Room#my-room:User#lifo", @connection.connection_identifier + end + end + + protected + def open_connection_with_stubbed_pubsub + server = TestServer.new + server.stubs(:pubsub).returns(stub_everything('pubsub')) + + open_connection server: server + end + + def open_connection(server:) + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(server, env) + + @connection.process + @connection.send :on_open + end + + def close_connection + @connection.send :on_close + end +end diff --git a/actioncable/test/connection/string_identifier_test.rb b/actioncable/test/connection/string_identifier_test.rb new file mode 100644 index 0000000000..ab69df57b3 --- /dev/null +++ b/actioncable/test/connection/string_identifier_test.rb @@ -0,0 +1,44 @@ +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 + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + end + end + + test "connection identifier" do + run_in_eventmachine do + open_connection_with_stubbed_pubsub + assert_equal "random-string", @connection.connection_identifier + end + end + + protected + def open_connection_with_stubbed_pubsub + @server = TestServer.new + @server.stubs(:pubsub).returns(stub_everything('pubsub')) + + open_connection + end + + def open_connection + env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + + @connection.process + @connection.send :on_open + end + + def close_connection + @connection.send :on_close + end +end diff --git a/actioncable/test/connection/subscriptions_test.rb b/actioncable/test/connection/subscriptions_test.rb new file mode 100644 index 0000000000..4f6760827e --- /dev/null +++ b/actioncable/test/connection/subscriptions_test.rb @@ -0,0 +1,116 @@ +require 'test_helper' + +class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket + + def send_async(method, *args) + # Bypass Celluloid + send method, *args + 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 + end + + setup do + @server = TestServer.new + @server.stubs(:channel_classes).returns(ChatChannel.name => ChatChannel) + + @chat_identifier = ActiveSupport::JSON.encode(id: 1, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel') + end + + test "subscribe command" do + run_in_eventmachine do + setup_connection + channel = subscribe_to_chat_channel + + assert_kind_of ChatChannel, channel + assert_equal 1, channel.room.id + end + end + + test "subscribe command without an identifier" do + run_in_eventmachine do + setup_connection + + @subscriptions.execute_command 'command' => 'subscribe' + assert @subscriptions.identifiers.empty? + end + end + + test "unsubscribe command" do + run_in_eventmachine do + setup_connection + subscribe_to_chat_channel + + channel = subscribe_to_chat_channel + channel.expects(:unsubscribe_from_channel) + + @subscriptions.execute_command 'command' => 'unsubscribe', 'identifier' => @chat_identifier + assert @subscriptions.identifiers.empty? + end + end + + test "unsubscribe command without an identifier" do + run_in_eventmachine do + setup_connection + + @subscriptions.execute_command 'command' => 'unsubscribe' + assert @subscriptions.identifiers.empty? + end + end + + test "message command" do + run_in_eventmachine 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 + end + + test "unsubscrib from all" do + run_in_eventmachine 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) + + channel1.expects(:unsubscribe_from_channel) + channel2.expects(:unsubscribe_from_channel) + + @subscriptions.unsubscribe_from_all + 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_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' + @connection = Connection.new(@server, env) + + @subscriptions = ActionCable::Connection::Subscriptions.new(@connection) + end +end diff --git a/actioncable/test/stubs/global_id.rb b/actioncable/test/stubs/global_id.rb new file mode 100644 index 0000000000..334f0d03e8 --- /dev/null +++ b/actioncable/test/stubs/global_id.rb @@ -0,0 +1,8 @@ +class GlobalID + attr_reader :uri + delegate :to_param, :to_s, to: :uri + + def initialize(gid, options = {}) + @uri = gid + end +end diff --git a/actioncable/test/stubs/room.rb b/actioncable/test/stubs/room.rb new file mode 100644 index 0000000000..cd66a0b687 --- /dev/null +++ b/actioncable/test/stubs/room.rb @@ -0,0 +1,16 @@ +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/actioncable/test/stubs/test_connection.rb b/actioncable/test/stubs/test_connection.rb new file mode 100644 index 0000000000..384abc5e76 --- /dev/null +++ b/actioncable/test/stubs/test_connection.rb @@ -0,0 +1,21 @@ +require 'stubs/user' + +class TestConnection + attr_reader :identifiers, :logger, :current_user, :transmissions + + def initialize(user = User.new("lifo")) + @identifiers = [ :current_user ] + + @current_user = user + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @transmissions = [] + end + + def transmit(data) + @transmissions << data + end + + def last_transmission + @transmissions.last + end +end diff --git a/actioncable/test/stubs/test_server.rb b/actioncable/test/stubs/test_server.rb new file mode 100644 index 0000000000..f9168f9b78 --- /dev/null +++ b/actioncable/test/stubs/test_server.rb @@ -0,0 +1,15 @@ +require 'ostruct' + +class TestServer + include ActionCable::Server::Connections + + attr_reader :logger, :config + + def initialize + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @config = OpenStruct.new(log_tags: []) + end + + def send_async + end +end diff --git a/actioncable/test/stubs/user.rb b/actioncable/test/stubs/user.rb new file mode 100644 index 0000000000..a66b4f87d5 --- /dev/null +++ b/actioncable/test/stubs/user.rb @@ -0,0 +1,15 @@ +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/actioncable/test/test_helper.rb b/actioncable/test/test_helper.rb new file mode 100644 index 0000000000..935e50e900 --- /dev/null +++ b/actioncable/test/test_helper.rb @@ -0,0 +1,47 @@ +require "rubygems" +require "bundler" + +gem 'minitest' +require "minitest/autorun" + +Bundler.setup +Bundler.require :default, :test + +require 'puma' +require 'em-hiredis' +require 'mocha/mini_test' + +require 'rack/mock' + +require 'action_cable' +ActiveSupport.test_order = :sorted + +# Require all the stubs and models +Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } + +require 'celluloid' +$CELLULOID_DEBUG = false +$CELLULOID_TEST = false +Celluloid.logger = Logger.new(StringIO.new) + +require 'faye/websocket' +class << Faye::WebSocket + remove_method :ensure_reactor_running + + # We don't want Faye to start the EM reactor in tests because it makes testing much harder. + # We want to be able to start and stop EM loop in tests to make things simpler. + def ensure_reactor_running + # no-op + end +end + +class ActionCable::TestCase < ActiveSupport::TestCase + def run_in_eventmachine + EM.run do + yield + + EM.run_deferred_callbacks + EM.stop + end + end +end diff --git a/actioncable/test/worker_test.rb b/actioncable/test/worker_test.rb new file mode 100644 index 0000000000..69c4b6529d --- /dev/null +++ b/actioncable/test/worker_test.rb @@ -0,0 +1,49 @@ +require 'test_helper' + +class WorkerTest < ActiveSupport::TestCase + class Receiver + attr_accessor :last_action + + def run + @last_action = :run + end + + def process(message) + @last_action = [ :process, message ] + end + + def connection + end + end + + setup do + Celluloid.boot + + @worker = ActionCable::Server::Worker.new + @receiver = Receiver.new + end + + teardown do + @receiver.last_action = nil + end + + test "invoke" do + @worker.invoke @receiver, :run + assert_equal :run, @receiver.last_action + end + + test "invoke with arguments" do + @worker.invoke @receiver, :process, "Hello" + assert_equal [ :process, "Hello" ], @receiver.last_action + end + + test "running periodic timers with a proc" do + @worker.run_periodic_timer @receiver, @receiver.method(:run) + assert_equal :run, @receiver.last_action + end + + test "running periodic timers with a method" do + @worker.run_periodic_timer @receiver, :run + assert_equal :run, @receiver.last_action + end +end diff --git a/lib/action_cable.rb b/lib/action_cable.rb deleted file mode 100644 index 3919812161..0000000000 --- a/lib/action_cable.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'active_support' -require 'active_support/rails' -require 'action_cable/version' - -module ActionCable - extend ActiveSupport::Autoload - - INTERNAL = { - identifiers: { - ping: '_ping'.freeze - }, - message_types: { - confirmation: 'confirm_subscription'.freeze, - rejection: 'reject_subscription'.freeze - } - } - - # Singleton instance of the server - module_function def server - @server ||= ActionCable::Server::Base.new - end - - eager_autoload do - autoload :Server - autoload :Connection - autoload :Channel - autoload :RemoteConnections - end -end - -require 'action_cable/engine' if defined?(Rails) diff --git a/lib/action_cable/channel.rb b/lib/action_cable/channel.rb deleted file mode 100644 index 7ae262ce5f..0000000000 --- a/lib/action_cable/channel.rb +++ /dev/null @@ -1,14 +0,0 @@ -module ActionCable - module Channel - extend ActiveSupport::Autoload - - eager_autoload do - autoload :Base - autoload :Broadcasting - autoload :Callbacks - autoload :Naming - autoload :PeriodicTimers - autoload :Streams - end - end -end diff --git a/lib/action_cable/channel/base.rb b/lib/action_cable/channel/base.rb deleted file mode 100644 index ca903a810d..0000000000 --- a/lib/action_cable/channel/base.rb +++ /dev/null @@ -1,274 +0,0 @@ -require 'set' - -module ActionCable - module Channel - # 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 Action Controllers, channels do not follow a REST constraint form for its actions. It's an 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, subscribed/unsubscribed are not callable methods, as they were already declared in ActionCable::Channel::Base, but #appear/away - # are. #generate_connection_token is also not callable as its 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, 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 #reject! - # - # Example: - # - # 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, 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 - - 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 - - protected - # 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! - @action_methods = nil - end - - # Refresh the cached action_methods when a new action_method is added. - def method_added(name) - super - clear_action_methods! - end - end - - def initialize(connection, identifier, params = {}) - @connection = connection - @identifier = identifier - @params = params - - # When a channel is streaming via redis pubsub, we want to delay the confirmation - # transmission until redis pubsub subscription is confirmed. - @defer_subscription_confirmation = false - - delegate_connection_identifiers - subscribe_to_channel - 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) - dispatch_action(action, data) - else - logger.error "Unable to process #{action_signature(action, data)}" - end - end - - # Called by the cable connection when its cut so the channel has a chance to cleanup with callbacks. - # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. - def unsubscribe_from_channel - run_callbacks :unsubscribe do - unsubscribed - end - end - - - protected - # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams - # you want this channel to be sending to the subscriber. - def subscribed - # Override in subclasses - end - - # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking - # people as offline or the like. - def unsubscribed - # 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) - logger.info "#{self.class.name} transmitting #{data.inspect}".tap { |m| m << " (via #{via})" if via } - connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, message: data) - end - - def defer_subscription_confirmation! - @defer_subscription_confirmation = true - end - - def defer_subscription_confirmation? - @defer_subscription_confirmation - end - - def subscription_confirmation_sent? - @subscription_confirmation_sent - end - - def reject - @reject_subscription = true - end - - def subscription_rejected? - @reject_subscription - end - - private - def delegate_connection_identifiers - connection.identifiers.each do |identifier| - define_singleton_method(identifier) do - connection.send(identifier) - end - end - end - - - def subscribe_to_channel - run_callbacks :subscribe do - subscribed - end - - if subscription_rejected? - reject_subscription - else - transmit_subscription_confirmation unless defer_subscription_confirmation? - 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) - end - - def dispatch_action(action, data) - logger.info action_signature(action, data) - - if method(action).arity == 1 - public_send action, data - else - public_send action - end - end - - def action_signature(action, data) - "#{self.class.name}##{action}".tap do |signature| - if (arguments = data.except('action')).any? - signature << "(#{arguments.inspect})" - end - end - end - - def transmit_subscription_confirmation - unless subscription_confirmation_sent? - logger.info "#{self.class.name} is transmitting the subscription confirmation" - connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]) - @subscription_confirmation_sent = true - end - end - - def reject_subscription - connection.subscriptions.remove_subscription self - transmit_subscription_rejection - end - - def transmit_subscription_rejection - logger.info "#{self.class.name} is transmitting the subscription rejection" - connection.transmit ActiveSupport::JSON.encode(identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]) - end - end - end -end diff --git a/lib/action_cable/channel/broadcasting.rb b/lib/action_cable/channel/broadcasting.rb deleted file mode 100644 index afc23d7d1a..0000000000 --- a/lib/action_cable/channel/broadcasting.rb +++ /dev/null @@ -1,29 +0,0 @@ -require 'active_support/core_ext/object/to_param' - -module ActionCable - module Channel - module Broadcasting - extend ActiveSupport::Concern - - delegate :broadcasting_for, to: :class - - class_methods do - # Broadcast a hash to a unique broadcasting for this model in this channel. - def broadcast_to(model, message) - ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message) - end - - def broadcasting_for(model) #:nodoc: - case - when model.is_a?(Array) - model.map { |m| broadcasting_for(m) }.join(':') - when model.respond_to?(:to_gid_param) - model.to_gid_param - else - model.to_param - end - end - end - end - end -end diff --git a/lib/action_cable/channel/callbacks.rb b/lib/action_cable/channel/callbacks.rb deleted file mode 100644 index 295d750e86..0000000000 --- a/lib/action_cable/channel/callbacks.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'active_support/callbacks' - -module ActionCable - module Channel - module Callbacks - extend ActiveSupport::Concern - include ActiveSupport::Callbacks - - included do - define_callbacks :subscribe - define_callbacks :unsubscribe - end - - class_methods do - def before_subscribe(*methods, &block) - set_callback(:subscribe, :before, *methods, &block) - end - - 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 deleted file mode 100644 index 4c9d53b15a..0000000000 --- a/lib/action_cable/channel/naming.rb +++ /dev/null @@ -1,22 +0,0 @@ -module ActionCable - module Channel - module Naming - extend ActiveSupport::Concern - - class_methods do - # 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' - def channel_name - @channel_name ||= name.sub(/Channel$/, '').gsub('::',':').underscore - end - end - - # Delegates to the class' channel_name - delegate :channel_name, to: :class - end - end -end diff --git a/lib/action_cable/channel/periodic_timers.rb b/lib/action_cable/channel/periodic_timers.rb deleted file mode 100644 index 25fe8e5e54..0000000000 --- a/lib/action_cable/channel/periodic_timers.rb +++ /dev/null @@ -1,41 +0,0 @@ -module ActionCable - module Channel - module PeriodicTimers - extend ActiveSupport::Concern - - included do - class_attribute :periodic_timers, instance_reader: false - self.periodic_timers = [] - - after_subscribe :start_periodic_timers - after_unsubscribe :stop_periodic_timers - end - - module ClassMethods - # Allow you to call a private method every so often seconds. This periodic timer can be useful - # for sending a steady flow of updates to a client based off an object that was configured on subscription. - # It's an alternative to using streams if the channel is able to do the work internally. - def periodically(callback, every:) - 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 << EventMachine::PeriodicTimer.new(options[:every]) do - connection.worker_pool.async.run_periodic_timer(self, callback) - end - end - end - - def stop_periodic_timers - active_periodic_timers.each { |timer| timer.cancel } - end - end - end -end diff --git a/lib/action_cable/channel/streams.rb b/lib/action_cable/channel/streams.rb deleted file mode 100644 index b5ffa17f72..0000000000 --- a/lib/action_cable/channel/streams.rb +++ /dev/null @@ -1,114 +0,0 @@ -module ActionCable - module Channel - # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pub/sub queue where any data - # put 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'll not get that update when connecting later. - # - # 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 - # - # So 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. - # That looks like so from that side of things: - # - # 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 supply a callback that lets you alter what goes out. - # Example below 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, -> (encoded_message) do - # message = ActiveSupport::JSON.decode(encoded_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 - # - # 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. - def stream_from(broadcasting, callback = nil) - # Hold off the confirmation until pubsub#subscribe is successful - defer_subscription_confirmation! - - callback ||= default_stream_callback(broadcasting) - streams << [ broadcasting, callback ] - - EM.next_tick do - pubsub.subscribe(broadcasting, &callback).callback do |reply| - transmit_subscription_confirmation - logger.info "#{self.class.name} is streaming from #{broadcasting}" - end - 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. - def stream_for(model, callback = nil) - stream_from(broadcasting_for([ channel_name, model ]), callback) - end - - def stop_all_streams - streams.each do |broadcasting, callback| - pubsub.unsubscribe_proc broadcasting, callback - logger.info "#{self.class.name} stopped streaming from #{broadcasting}" - end.clear - end - - private - delegate :pubsub, to: :connection - - def streams - @_streams ||= [] - end - - def default_stream_callback(broadcasting) - -> (message) do - transmit ActiveSupport::JSON.decode(message), via: "streamed from #{broadcasting}" - end - end - end - end -end diff --git a/lib/action_cable/connection.rb b/lib/action_cable/connection.rb deleted file mode 100644 index b672e00682..0000000000 --- a/lib/action_cable/connection.rb +++ /dev/null @@ -1,16 +0,0 @@ -module ActionCable - module Connection - extend ActiveSupport::Autoload - - eager_autoload do - autoload :Authorization - autoload :Base - autoload :Identification - autoload :InternalChannel - autoload :MessageBuffer - autoload :WebSocket - autoload :Subscriptions - autoload :TaggedLoggerProxy - end - end -end diff --git a/lib/action_cable/connection/authorization.rb b/lib/action_cable/connection/authorization.rb deleted file mode 100644 index 070a70e4e2..0000000000 --- a/lib/action_cable/connection/authorization.rb +++ /dev/null @@ -1,13 +0,0 @@ -module ActionCable - module Connection - module Authorization - class UnauthorizedError < StandardError; end - - private - def reject_unauthorized_connection - logger.error "An unauthorized connection attempt was rejected" - raise UnauthorizedError - end - end - end -end \ No newline at end of file diff --git a/lib/action_cable/connection/base.rb b/lib/action_cable/connection/base.rb deleted file mode 100644 index 7e9eec7508..0000000000 --- a/lib/action_cable/connection/base.rb +++ /dev/null @@ -1,219 +0,0 @@ -require 'action_dispatch' - -module ActionCable - module Connection - # For every WebSocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent - # of all 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 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 - # - # protected - # def find_verified_user - # if current_user = User.find_by_identity cookies.signed[:identity_id] - # current_user - # else - # reject_unauthorized_connection - # end - # end - # end - # end - # - # First, we declare that this connection can be identified by its current_user. This allows us later to be able to find all connections - # established for that current_user (and potentially disconnect them if the user was removed from an account). You can declare as many - # identification indexes as you like. Declaring an identification means that a 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 name of the current user to easily distinguish their messages in the log. - # - # Pretty simple, eh? - class Base - include Identification - include InternalChannel - include Authorization - - attr_reader :server, :env, :subscriptions - delegate :worker_pool, :pubsub, to: :server - - attr_reader :logger - - def initialize(server, env) - @server, @env = server, env - - @logger = new_tagged_logger - - @websocket = ActionCable::Connection::WebSocket.new(env) - @subscriptions = ActionCable::Connection::Subscriptions.new(self) - @message_buffer = ActionCable::Connection::MessageBuffer.new(self) - - @started_at = Time.now - end - - # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user. - # This method should not be called directly. Rely on the #connect (and #disconnect) callback instead. - def process - logger.info started_request_message - - if websocket.possible? && allow_request_origin? - websocket.on(:open) { |event| send_async :on_open } - websocket.on(:message) { |event| on_message event.data } - websocket.on(:close) { |event| send_async :on_close } - - respond_to_successful_request - else - respond_to_invalid_request - end - end - - # Data received over the cable is handled by this method. It's expected that everything inbound is JSON encoded. - # The data is routed to the proper channel that the connection has subscribed to. - def receive(data_in_json) - if websocket.alive? - subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json) - else - logger.error "Received data without a live WebSocket (#{data_in_json.inspect})" - end - end - - # Send raw data straight back down the WebSocket. This is not intended to be called directly. Use the #transmit available on the - # Channel instead, as that'll automatically address the correct subscriber and wrap the message in JSON. - def transmit(data) - websocket.transmit data - end - - # Close the WebSocket connection. - def close - websocket.close - end - - # Invoke a method on the connection asynchronously through the pool of thread workers. - def send_async(method, *arguments) - worker_pool.async.invoke(self, method, *arguments) - end - - # Return a basic hash of statistics for the connection keyed with `identifier`, `started_at`, and `subscriptions`. - # 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 ActiveSupport::JSON.encode(identifier: ActionCable::INTERNAL[:identifiers][:ping], message: Time.now.to_i) - end - - - protected - # 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 - - # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks. - def cookies - request.cookie_jar - end - - - private - attr_reader :websocket - attr_reader :message_buffer - - def on_open - connect if respond_to?(:connect) - subscribe_to_internal_channel - beat - - message_buffer.process! - server.add_connection(self) - rescue ActionCable::Connection::Authorization::UnauthorizedError - respond_to_invalid_request - end - - def on_message(message) - message_buffer.append message - end - - def on_close - logger.info finished_request_message - - server.remove_connection(self) - - subscriptions.unsubscribe_from_all - unsubscribe_from_internal_channel - - disconnect if respond_to?(:disconnect) - end - - - def allow_request_origin? - return true if server.config.disable_request_forgery_protection - - if Array(server.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 - - def respond_to_successful_request - websocket.rack_response - end - - def respond_to_invalid_request - close if websocket.alive? - - logger.info finished_request_message - [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ] - end - - - # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. - def new_tagged_logger - TaggedLoggerProxy.new server.logger, - tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } - end - - def started_request_message - 'Started %s "%s"%s for %s at %s' % [ - request.request_method, - request.filtered_path, - websocket.possible? ? ' [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]' : '', - request.ip, - Time.now.to_s ] - end - end - end -end diff --git a/lib/action_cable/connection/identification.rb b/lib/action_cable/connection/identification.rb deleted file mode 100644 index 2d75ff8d6d..0000000000 --- a/lib/action_cable/connection/identification.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'set' - -module ActionCable - module Connection - module Identification - extend ActiveSupport::Concern - - included do - class_attribute :identifiers - self.identifiers = Set.new - end - - class_methods do - # Mark a key as being a connection identifier index that can then 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.map { |id| instance_variable_get("@#{id}") }.compact - 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 deleted file mode 100644 index c065a24ab7..0000000000 --- a/lib/action_cable/connection/internal_channel.rb +++ /dev/null @@ -1,45 +0,0 @@ -module ActionCable - module Connection - # Makes it possible for the RemoteConnection to disconnect a specific connection. - module InternalChannel - extend ActiveSupport::Concern - - private - def internal_redis_channel - "action_cable/#{connection_identifier}" - end - - def subscribe_to_internal_channel - if connection_identifier.present? - callback = -> (message) { process_internal_message(message) } - @_internal_redis_subscriptions ||= [] - @_internal_redis_subscriptions << [ internal_redis_channel, callback ] - - EM.next_tick { pubsub.subscribe(internal_redis_channel, &callback) } - logger.info "Registered connection (#{connection_identifier})" - end - end - - def unsubscribe_from_internal_channel - if @_internal_redis_subscriptions.present? - @_internal_redis_subscriptions.each { |channel, callback| EM.next_tick { pubsub.unsubscribe_proc(channel, callback) } } - end - end - - def process_internal_message(message) - message = ActiveSupport::JSON.decode(message) - - case message['type'] - when 'disconnect' - logger.info "Removing connection (#{connection_identifier})" - websocket.close - 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/message_buffer.rb b/lib/action_cable/connection/message_buffer.rb deleted file mode 100644 index 25cff75b41..0000000000 --- a/lib/action_cable/connection/message_buffer.rb +++ /dev/null @@ -1,53 +0,0 @@ -module ActionCable - module Connection - # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized and is ready to receive them. - # Entirely internal operation and should not be used directly by the user. - class MessageBuffer - 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_accessor :buffered_messages - - def valid?(message) - message.is_a?(String) - end - - def receive(message) - connection.send_async :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 diff --git a/lib/action_cable/connection/subscriptions.rb b/lib/action_cable/connection/subscriptions.rb deleted file mode 100644 index 6199db4898..0000000000 --- a/lib/action_cable/connection/subscriptions.rb +++ /dev/null @@ -1,75 +0,0 @@ -require 'active_support/core_ext/hash/indifferent_access' - -module ActionCable - module Connection - # 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. Should not be used directly by the user. - class Subscriptions - 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 - logger.error "Received unrecognized command in #{data.inspect}" - end - rescue Exception => e - logger.error "Could not execute command from #{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" - end - - def add(data) - id_key = data['identifier'] - id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access - - subscription_klass = connection.server.channel_classes[id_options[:channel]] - - if subscription_klass - subscriptions[id_key] ||= subscription_klass.new(connection, id_key, id_options) - else - logger.error "Subscription class not found (#{data.inspect})" - end - end - - def remove(data) - logger.info "Unsubscribing from channel: #{data['identifier']}" - remove_subscription subscriptions[data['identifier']] - 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| channel.unsubscribe_from_channel } - end - - - private - attr_reader :connection, :subscriptions - delegate :logger, to: :connection - - def find(data) - if subscription = subscriptions[data['identifier']] - subscription - else - raise "Unable to find subscription with identifier: #{data['identifier']}" - end - end - end - end -end diff --git a/lib/action_cable/connection/tagged_logger_proxy.rb b/lib/action_cable/connection/tagged_logger_proxy.rb deleted file mode 100644 index e5319087fb..0000000000 --- a/lib/action_cable/connection/tagged_logger_proxy.rb +++ /dev/null @@ -1,40 +0,0 @@ -module ActionCable - module Connection - # Allows the use of per-connection tags against the server logger. This wouldn't work using the tradional - # 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) - if logger.respond_to?(:tagged) - current_tags = tags - logger.formatter.current_tags - logger.tagged(*current_tags) { yield } - else - yield - end - end - - %i( debug info warn error fatal unknown ).each do |severity| - define_method(severity) do |message| - log severity, message - end - end - - protected - def log(type, message) - tag(@logger) { @logger.send type, message } - end - end - end -end diff --git a/lib/action_cable/connection/web_socket.rb b/lib/action_cable/connection/web_socket.rb deleted file mode 100644 index 169b683b8c..0000000000 --- a/lib/action_cable/connection/web_socket.rb +++ /dev/null @@ -1,29 +0,0 @@ -require 'faye/websocket' - -module ActionCable - module Connection - # Decorate the Faye::WebSocket with helpers we need. - class WebSocket - delegate :rack_response, :close, :on, to: :websocket - - def initialize(env) - @websocket = Faye::WebSocket.websocket?(env) ? Faye::WebSocket.new(env) : nil - end - - def possible? - websocket - end - - def alive? - websocket && websocket.ready_state == Faye::WebSocket::API::OPEN - end - - def transmit(data) - websocket.send data - end - - private - attr_reader :websocket - end - end -end diff --git a/lib/action_cable/engine.rb b/lib/action_cable/engine.rb deleted file mode 100644 index 4777c3886b..0000000000 --- a/lib/action_cable/engine.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'rails/engine' -require 'active_support/ordered_options' -require 'action_cable/helpers/action_cable_helper' - -module ActionCable - class Engine < ::Rails::Engine - config.action_cable = ActiveSupport::OrderedOptions.new - - config.to_prepare do - ApplicationController.helper ActionCable::Helpers::ActionCableHelper - end - - initializer "action_cable.logger" do - ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } - end - - initializer "action_cable.set_configs" do |app| - options = app.config.action_cable - - options.allowed_request_origins ||= "http://localhost:3000" if ::Rails.env.development? - - ActiveSupport.on_load(:action_cable) do - options.each { |k,v| send("#{k}=", v) } - end - end - end -end diff --git a/lib/action_cable/helpers/action_cable_helper.rb b/lib/action_cable/helpers/action_cable_helper.rb deleted file mode 100644 index b82751468a..0000000000 --- a/lib/action_cable/helpers/action_cable_helper.rb +++ /dev/null @@ -1,29 +0,0 @@ -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-turbolinks-track' => true %> - # - # - # This is then used by ActionCable to determine the url of your websocket server. - # Your CoffeeScript can then connect to the server without needing to specify the - # url directly: - # - # #= require cable - # @App = {} - # App.cable = Cable.createConsumer() - # - # Make sure to specify the correct server location in each of your environments - # config file: - # - # config.action_cable.url = "ws://example.com:28080" - def action_cable_meta_tag - tag "meta", name: "action-cable-url", content: Rails.application.config.action_cable.url - end - end - end -end diff --git a/lib/action_cable/process/logging.rb b/lib/action_cable/process/logging.rb deleted file mode 100644 index 618ba7357a..0000000000 --- a/lib/action_cable/process/logging.rb +++ /dev/null @@ -1,12 +0,0 @@ -require 'action_cable/server' -require 'eventmachine' -require 'celluloid' - -EM.error_handler do |e| - puts "Error raised inside the event loop: #{e.message}" - puts e.backtrace.join("\n") -end - -Celluloid.logger = ActionCable.server.logger - -ActionCable.server.config.log_to_stdout if Rails.env.development? \ No newline at end of file diff --git a/lib/action_cable/remote_connections.rb b/lib/action_cable/remote_connections.rb deleted file mode 100644 index 1230d905ad..0000000000 --- a/lib/action_cable/remote_connections.rb +++ /dev/null @@ -1,64 +0,0 @@ -module ActionCable - # If you need to disconnect a given connection, you go through the RemoteConnections. You find the connections you're looking for by - # searching the identifier declared on the connection. 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 - # - # That 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 these servers are subscribed to). - class RemoteConnections - attr_reader :server - - def initialize(server) - @server = server - end - - def where(identifier) - RemoteConnection.new(server, identifier) - end - - private - # Represents a single remote connection found via ActionCable.server.remote_connections.where(*). - # Exists for the 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 - server.broadcast internal_redis_channel, type: 'disconnect' - end - - # Returns all the identifiers that were applied to this connection. - def identifiers - server.connection_identifiers - end - - private - attr_reader :server - - 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.rb b/lib/action_cable/server.rb deleted file mode 100644 index a2a89d5f1e..0000000000 --- a/lib/action_cable/server.rb +++ /dev/null @@ -1,19 +0,0 @@ -require 'eventmachine' -EventMachine.epoll if EventMachine.epoll? -EventMachine.kqueue if EventMachine.kqueue? - -module ActionCable - module Server - extend ActiveSupport::Autoload - - eager_autoload do - autoload :Base - autoload :Broadcasting - autoload :Connections - autoload :Configuration - - autoload :Worker - autoload :ActiveRecordConnectionManagement, 'action_cable/server/worker/active_record_connection_management' - end - end -end diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb deleted file mode 100644 index f1585dc776..0000000000 --- a/lib/action_cable/server/base.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'em-hiredis' - -module ActionCable - module Server - # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the rack process that starts the cable server, but - # also by the user to reach the RemoteConnections instead for finding and disconnecting connections across all servers. - # - # Also, this is the server instance used for broadcasting. See Broadcasting for details. - class Base - include ActionCable::Server::Broadcasting - include ActionCable::Server::Connections - - cattr_accessor(:config, instance_accessor: true) { ActionCable::Server::Configuration.new } - - def self.logger; config.logger; end - delegate :logger, to: :config - - def initialize - end - - # Called by rack to setup the server. - def call(env) - setup_heartbeat_timer - config.connection_class.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 - - # Gateway to RemoteConnections. See that class for details. - def remote_connections - @remote_connections ||= RemoteConnections.new(self) - end - - # The thread worker pool for handling all the connection work on this server. Default size is set by config.worker_pool_size. - def worker_pool - @worker_pool ||= ActionCable::Server::Worker.pool(size: config.worker_pool_size) - end - - # Requires and returns an hash of all the channel class constants keyed by name. - def channel_classes - @channel_classes ||= begin - config.channel_paths.each { |channel_path| require channel_path } - config.channel_class_names.each_with_object({}) { |name, hash| hash[name] = name.constantize } - end - end - - # The redis pubsub adapter used for all streams/broadcasting. - def pubsub - @pubsub ||= redis.pubsub - end - - # The EventMachine Redis instance used by the pubsub adapter. - def redis - @redis ||= EM::Hiredis.connect(config.redis[:url]).tap do |redis| - redis.on(:reconnect_failed) do - logger.info "[ActionCable] Redis reconnect failed." - # logger.info "[ActionCable] Redis reconnected. Closing all the open connections." - # @connections.map &:close - end - end - end - - # All the identifiers applied to the connection class associated with this server. - def connection_identifiers - config.connection_class.identifiers - 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 deleted file mode 100644 index 6e0fbae387..0000000000 --- a/lib/action_cable/server/broadcasting.rb +++ /dev/null @@ -1,54 +0,0 @@ -require 'redis' - -module ActionCable - module Server - # Broadcasting is how other parts of your application can send messages to the channel 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 shit fit for print' } - # - # # Client-side coffescript 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. It'll automatically be JSON encoded. - def broadcast(broadcasting, message) - broadcaster_for(broadcasting).broadcast(message) - end - - # Returns a broadcaster for a named broadcasting that can be reused. Useful when you have a object that - # may need multiple spots to transmit to a specific broadcasting over and over. - def broadcaster_for(broadcasting) - Broadcaster.new(self, broadcasting) - end - - # The redis instance used for broadcasting. Not intended for direct user use. - def broadcasting_redis - @broadcasting_redis ||= Redis.new(config.redis) - end - - private - class Broadcaster - attr_reader :server, :broadcasting - - def initialize(server, broadcasting) - @server, @broadcasting = server, broadcasting - end - - def broadcast(message) - server.logger.info "[ActionCable] Broadcasting to #{broadcasting}: #{message}" - server.broadcasting_redis.publish broadcasting, ActiveSupport::JSON.encode(message) - end - end - end - end -end diff --git a/lib/action_cable/server/configuration.rb b/lib/action_cable/server/configuration.rb deleted file mode 100644 index f7fcee019b..0000000000 --- a/lib/action_cable/server/configuration.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'active_support/core_ext/hash/indifferent_access' - -module ActionCable - module Server - # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak the configuration points - # in a Rails config initializer. - class Configuration - attr_accessor :logger, :log_tags - attr_accessor :connection_class, :worker_pool_size - attr_accessor :redis_path, :channels_path - attr_accessor :disable_request_forgery_protection, :allowed_request_origins - attr_accessor :url - - def initialize - @logger = Rails.logger - @log_tags = [] - - @connection_class = ApplicationCable::Connection - @worker_pool_size = 100 - - @redis_path = Rails.root.join('config/redis/cable.yml') - @channels_path = Rails.root.join('app/channels') - - @disable_request_forgery_protection = false - end - - def log_to_stdout - console = ActiveSupport::Logger.new($stdout) - console.formatter = @logger.formatter - console.level = @logger.level - - @logger.extend(ActiveSupport::Logger.broadcast(console)) - end - - def channel_paths - @channels ||= Dir["#{channels_path}/**/*_channel.rb"] - end - - def channel_class_names - @channel_class_names ||= channel_paths.collect do |channel_path| - Pathname.new(channel_path).basename.to_s.split('.').first.camelize - end - end - - def redis - @redis ||= config_for(redis_path).with_indifferent_access - end - - private - # FIXME: Extract this from Rails::Application in a way it can be used here. - def config_for(path) - if path.exist? - require "yaml" - require "erb" - (YAML.load(ERB.new(path.read).result) || {})[Rails.env] || {} - else - raise "Could not load configuration. No such file - #{path}" - end - rescue Psych::SyntaxError => e - raise "YAML syntax error occurred while parsing #{path}. " \ - "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \ - "Error: #{e.message}" - end - end - end -end - diff --git a/lib/action_cable/server/connections.rb b/lib/action_cable/server/connections.rb deleted file mode 100644 index 47dcea8c20..0000000000 --- a/lib/action_cable/server/connections.rb +++ /dev/null @@ -1,37 +0,0 @@ -module ActionCable - module Server - # Collection class for all the connections that's been established on this specific server. Remember, usually you'll run many cable servers, so - # you can't use this collection as an full list of all the connections established against your application. Use RemoteConnections for that. - # As such, this is primarily for internal use. - module Connections - 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 receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically - # disconnect. - def setup_heartbeat_timer - EM.next_tick do - @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do - EM.next_tick { connections.map(&:beat) } - end - end - end - - def open_connections_statistics - connections.map(&:statistics) - end - end - end -end diff --git a/lib/action_cable/server/worker.rb b/lib/action_cable/server/worker.rb deleted file mode 100644 index e063b2a2e1..0000000000 --- a/lib/action_cable/server/worker.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'celluloid' -require 'active_support/callbacks' - -module ActionCable - module Server - # Worker used by Server.send_async to do connection work in threads. Only for internal use. - class Worker - include ActiveSupport::Callbacks - include Celluloid - - attr_reader :connection - define_callbacks :work - include ActiveRecordConnectionManagement - - def invoke(receiver, method, *args) - @connection = receiver - - run_callbacks :work do - receiver.send method, *args - end - 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 - - def run_periodic_timer(channel, callback) - @connection = channel.connection - - run_callbacks :work do - callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback) - 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 deleted file mode 100644 index ecece4e270..0000000000 --- a/lib/action_cable/server/worker/active_record_connection_management.rb +++ /dev/null @@ -1,22 +0,0 @@ -module ActionCable - module Server - class Worker - # Clear active connections between units of work so the long-running channel or connection processes do not hoard connections. - module ActiveRecordConnectionManagement - extend ActiveSupport::Concern - - included do - if defined?(ActiveRecord::Base) - set_callback :work, :around, :with_database_connections - end - end - - def with_database_connections - connection.logger.tag(ActiveRecord::Base.logger) { yield } - ensure - ActiveRecord::Base.clear_active_connections! - end - end - end - end -end \ No newline at end of file diff --git a/lib/action_cable/version.rb b/lib/action_cable/version.rb deleted file mode 100644 index 4947029dcc..0000000000 --- a/lib/action_cable/version.rb +++ /dev/null @@ -1,3 +0,0 @@ -module ActionCable - VERSION = '0.0.3' -end \ No newline at end of file diff --git a/lib/actioncable.rb b/lib/actioncable.rb deleted file mode 100644 index f6df6fd063..0000000000 --- a/lib/actioncable.rb +++ /dev/null @@ -1,2 +0,0 @@ -# Pointer for auto-require -require 'action_cable' \ No newline at end of file diff --git a/lib/assets/javascripts/cable.coffee.erb b/lib/assets/javascripts/cable.coffee.erb deleted file mode 100644 index 25a9fc79c2..0000000000 --- a/lib/assets/javascripts/cable.coffee.erb +++ /dev/null @@ -1,12 +0,0 @@ -#= require_self -#= require cable/consumer - -@Cable = - INTERNAL: <%= ActionCable::INTERNAL.to_json %> - - createConsumer: (url = @getConfig("url")) -> - new Cable.Consumer url - - getConfig: (name) -> - element = document.head.querySelector("meta[name='action-cable-#{name}']") - element?.getAttribute("content") diff --git a/lib/assets/javascripts/cable/connection.coffee b/lib/assets/javascripts/cable/connection.coffee deleted file mode 100644 index b2abe8dcb2..0000000000 --- a/lib/assets/javascripts/cable/connection.coffee +++ /dev/null @@ -1,84 +0,0 @@ -# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. - -{message_types} = Cable.INTERNAL - -class Cable.Connection - @reopenDelay: 500 - - constructor: (@consumer) -> - @open() - - send: (data) -> - if @isOpen() - @webSocket.send(JSON.stringify(data)) - true - else - false - - open: => - if @webSocket and not @isState("closed") - throw new Error("Existing connection must be closed before opening") - else - @webSocket = new WebSocket(@consumer.url) - @installEventHandlers() - true - - close: -> - @webSocket?.close() - - reopen: -> - if @isState("closed") - @open() - else - try - @close() - finally - setTimeout(@open, @constructor.reopenDelay) - - isOpen: -> - @isState("open") - - # Private - - isState: (states...) -> - @getState() in states - - getState: -> - return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState - null - - installEventHandlers: -> - for eventName of @events - handler = @events[eventName].bind(this) - @webSocket["on#{eventName}"] = handler - return - - events: - message: (event) -> - {identifier, message, type} = JSON.parse(event.data) - - switch type - when message_types.confirmation - @consumer.subscriptions.notify(identifier, "connected") - when message_types.rejection - @consumer.subscriptions.reject(identifier) - else - @consumer.subscriptions.notify(identifier, "received", message) - - open: -> - @disconnected = false - @consumer.subscriptions.reload() - - close: -> - @disconnect() - - error: -> - @disconnect() - - disconnect: -> - return if @disconnected - @disconnected = true - @consumer.subscriptions.notifyAll("disconnected") - - toJSON: -> - state: @getState() diff --git a/lib/assets/javascripts/cable/connection_monitor.coffee b/lib/assets/javascripts/cable/connection_monitor.coffee deleted file mode 100644 index 435efcc361..0000000000 --- a/lib/assets/javascripts/cable/connection_monitor.coffee +++ /dev/null @@ -1,84 +0,0 @@ -# Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting -# revival reconnections if things go astray. Internal class, not intended for direct user manipulation. -class Cable.ConnectionMonitor - @pollInterval: - min: 3 - max: 30 - - @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) - - identifier: Cable.INTERNAL.identifiers.ping - - constructor: (@consumer) -> - @consumer.subscriptions.add(this) - @start() - - connected: -> - @reset() - @pingedAt = now() - delete @disconnectedAt - - disconnected: -> - @disconnectedAt = now() - - received: -> - @pingedAt = now() - - reset: -> - @reconnectAttempts = 0 - - start: -> - @reset() - delete @stoppedAt - @startedAt = now() - @poll() - document.addEventListener("visibilitychange", @visibilityDidChange) - - stop: -> - @stoppedAt = now() - document.removeEventListener("visibilitychange", @visibilityDidChange) - - poll: -> - setTimeout => - unless @stoppedAt - @reconnectIfStale() - @poll() - , @getInterval() - - getInterval: -> - {min, max} = @constructor.pollInterval - interval = 5 * Math.log(@reconnectAttempts + 1) - clamp(interval, min, max) * 1000 - - reconnectIfStale: -> - if @connectionIsStale() - @reconnectAttempts++ - unless @disconnectedRecently() - @consumer.connection.reopen() - - connectionIsStale: -> - secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold - - disconnectedRecently: -> - @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold - - visibilityDidChange: => - if document.visibilityState is "visible" - setTimeout => - if @connectionIsStale() or not @consumer.connection.isOpen() - @consumer.connection.reopen() - , 200 - - toJSON: -> - interval = @getInterval() - connectionIsStale = @connectionIsStale() - {@startedAt, @stoppedAt, @pingedAt, @reconnectAttempts, connectionIsStale, interval} - - now = -> - new Date().getTime() - - secondsSince = (time) -> - (now() - time) / 1000 - - clamp = (number, min, max) -> - Math.max(min, Math.min(max, number)) diff --git a/lib/assets/javascripts/cable/consumer.coffee b/lib/assets/javascripts/cable/consumer.coffee deleted file mode 100644 index 05a7398e79..0000000000 --- a/lib/assets/javascripts/cable/consumer.coffee +++ /dev/null @@ -1,31 +0,0 @@ -#= require cable/connection -#= require cable/connection_monitor -#= require cable/subscriptions -#= require cable/subscription - -# The Cable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, -# the Cable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. -# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription -# method. -# -# The following example shows how this can be setup: -# -# @App = {} -# App.cable = Cable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. -class Cable.Consumer - constructor: (@url) -> - @subscriptions = new Cable.Subscriptions this - @connection = new Cable.Connection this - @connectionMonitor = new Cable.ConnectionMonitor this - - send: (data) -> - @connection.send(data) - - inspect: -> - JSON.stringify(this, null, 2) - - toJSON: -> - {@url, @subscriptions, @connection, @connectionMonitor} diff --git a/lib/assets/javascripts/cable/subscription.coffee b/lib/assets/javascripts/cable/subscription.coffee deleted file mode 100644 index 5b024d4e15..0000000000 --- a/lib/assets/javascripts/cable/subscription.coffee +++ /dev/null @@ -1,68 +0,0 @@ -# A new subscription is created through the Cable.Subscriptions instance available on the consumer. -# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding -# Channel instance on the server side. -# -# An example demonstrates the basic functionality: -# -# App.appearance = App.cable.subscriptions.create "AppearanceChannel", -# connected: -> -# # Called once the subscription has been successfully completed -# -# appear: -> -# @perform 'appear', appearing_on: @appearingOn() -# -# away: -> -# @perform 'away' -# -# appearingOn: -> -# $('main').data 'appearing-on' -# -# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server -# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). -# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. -# -# This is how the server component would look: -# -# class AppearanceChannel < ApplicationCable::Channel -# def subscribed -# current_user.appear -# end -# -# def unsubscribed -# current_user.disappear -# end -# -# def appear(data) -# current_user.appear on: data['appearing_on'] -# end -# -# def away -# current_user.away -# end -# end -# -# The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. -# The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method. -class Cable.Subscription - constructor: (@subscriptions, params = {}, mixin) -> - @identifier = JSON.stringify(params) - extend(this, mixin) - @subscriptions.add(this) - @consumer = @subscriptions.consumer - - # Perform a channel action with the optional data passed as an attribute - perform: (action, data = {}) -> - data.action = action - @send(data) - - send: (data) -> - @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) - - unsubscribe: -> - @subscriptions.remove(this) - - extend = (object, properties) -> - if properties? - for key, value of properties - object[key] = value - object diff --git a/lib/assets/javascripts/cable/subscriptions.coffee b/lib/assets/javascripts/cable/subscriptions.coffee deleted file mode 100644 index 7955565f06..0000000000 --- a/lib/assets/javascripts/cable/subscriptions.coffee +++ /dev/null @@ -1,78 +0,0 @@ -# Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user -# us Cable.Subscriptions#create, and it should be called through the consumer like so: -# -# @App = {} -# App.cable = Cable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see Cable.Subscription. -class Cable.Subscriptions - constructor: (@consumer) -> - @subscriptions = [] - @history = [] - - create: (channelName, mixin) -> - channel = channelName - params = if typeof channel is "object" then channel else {channel} - new Cable.Subscription this, params, mixin - - # Private - - add: (subscription) -> - @subscriptions.push(subscription) - @notify(subscription, "initialized") - @sendCommand(subscription, "subscribe") - - remove: (subscription) -> - @forget(subscription) - - unless @findAll(subscription.identifier).length - @sendCommand(subscription, "unsubscribe") - - reject: (identifier) -> - for subscription in @findAll(identifier) - @forget(subscription) - @notify(subscription, "rejected") - - forget: (subscription) -> - @subscriptions = (s for s in @subscriptions when s isnt subscription) - - findAll: (identifier) -> - s for s in @subscriptions when s.identifier is identifier - - reload: -> - for subscription in @subscriptions - @sendCommand(subscription, "subscribe") - - notifyAll: (callbackName, args...) -> - for subscription in @subscriptions - @notify(subscription, callbackName, args...) - - notify: (subscription, callbackName, args...) -> - if typeof subscription is "string" - subscriptions = @findAll(subscription) - else - subscriptions = [subscription] - - for subscription in subscriptions - subscription[callbackName]?(args...) - - if callbackName in ["initialized", "connected", "disconnected", "rejected"] - {identifier} = subscription - @record(notification: {identifier, callbackName, args}) - - sendCommand: (subscription, command) -> - {identifier} = subscription - if identifier is Cable.INTERNAL.identifiers.ping - @consumer.connection.isOpen() - else - @consumer.send({command, identifier}) - - record: (data) -> - data.time = new Date() - @history = @history.slice(-19) - @history.push(data) - - toJSON: -> - history: @history - identifiers: (subscription.identifier for subscription in @subscriptions) diff --git a/test/channel/base_test.rb b/test/channel/base_test.rb deleted file mode 100644 index 580338b44a..0000000000 --- a/test/channel/base_test.rb +++ /dev/null @@ -1,148 +0,0 @@ -require 'test_helper' -require 'stubs/test_connection' -require 'stubs/room' - -class ActionCable::Channel::BaseTest < ActiveSupport::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 - - 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 - - private - def rm_rf - @last_action = [ :rm_rf ] - end - end - - setup do - @user = User.new "lifo" - @connection = TestConnection.new(@user) - @channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } - end - - test "should subscribe to a channel on initialize" do - assert_equal 1, @channel.room.id - end - - test "on subscribe callbacks" do - assert @channel.subscribed - end - - test "channel params" do - assert_equal({ id: 1 }, @channel.params) - end - - test "unsubscribing from a channel" do - assert @channel.room - assert @channel.subscribed? - - @channel.unsubscribe_from_channel - - assert ! @channel.room - assert ! @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 "transmitting data" do - @channel.perform_action 'action' => :get_latest - - expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "message" => { "data" => "latest" } - assert_equal expected, @connection.last_transmission - end - - test "subscription confirmation" do - expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" - assert_equal expected, @connection.last_transmission - end - -end diff --git a/test/channel/broadcasting_test.rb b/test/channel/broadcasting_test.rb deleted file mode 100644 index 1de04243e5..0000000000 --- a/test/channel/broadcasting_test.rb +++ /dev/null @@ -1,29 +0,0 @@ -require 'test_helper' -require 'stubs/test_connection' -require 'stubs/room' - -class ActionCable::Channel::BroadcastingTest < ActiveSupport::TestCase - class ChatChannel < ActionCable::Channel::Base - end - - setup do - @connection = TestConnection.new - end - - test "broadcasts_to" do - ActionCable.stubs(:server).returns mock().tap { |m| m.expects(:broadcast).with('action_cable:channel:broadcasting_test:chat:Room#1-Campfire', "Hello World") } - ChatChannel.broadcast_to(Room.new(1), "Hello World") - end - - test "broadcasting_for with an object" do - assert_equal "Room#1-Campfire", ChatChannel.broadcasting_for(Room.new(1)) - end - - test "broadcasting_for with an array" do - assert_equal "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 "hello", ChatChannel.broadcasting_for("hello") - end -end diff --git a/test/channel/naming_test.rb b/test/channel/naming_test.rb deleted file mode 100644 index 89ef6ad8b0..0000000000 --- a/test/channel/naming_test.rb +++ /dev/null @@ -1,10 +0,0 @@ -require 'test_helper' - -class ActionCable::Channel::NamingTest < ActiveSupport::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 deleted file mode 100644 index 1590a12f09..0000000000 --- a/test/channel/periodic_timers_test.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'test_helper' -require 'stubs/test_connection' -require 'stubs/room' - -class ActionCable::Channel::PeriodicTimersTest < ActiveSupport::TestCase - class ChatChannel < ActionCable::Channel::Base - periodically -> { ping }, every: 5 - periodically :send_updates, every: 1 - - private - def ping - end - end - - setup do - @connection = TestConnection.new - end - - test "periodic timers definition" do - timers = ChatChannel.periodic_timers - - assert_equal 2, timers.size - - first_timer = timers[0] - assert_kind_of Proc, first_timer[0] - assert_equal 5, first_timer[1][:every] - - second_timer = timers[1] - assert_equal :send_updates, second_timer[0] - assert_equal 1, second_timer[1][:every] - end - - test "timer start and stop" do - EventMachine::PeriodicTimer.expects(:new).times(2).returns(true) - channel = ChatChannel.new @connection, "{id: 1}", { id: 1 } - - channel.expects(:stop_periodic_timers).once - channel.unsubscribe_from_channel - end -end diff --git a/test/channel/rejection_test.rb b/test/channel/rejection_test.rb deleted file mode 100644 index aa93396d44..0000000000 --- a/test/channel/rejection_test.rb +++ /dev/null @@ -1,25 +0,0 @@ -require 'test_helper' -require 'stubs/test_connection' -require 'stubs/room' - -class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase - class SecretChannel < ActionCable::Channel::Base - def subscribed - reject if params[:id] > 0 - end - end - - setup do - @user = User.new "lifo" - @connection = TestConnection.new(@user) - end - - test "subscription rejection" do - @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } - @channel = SecretChannel.new @connection, "{id: 1}", { id: 1 } - - expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "reject_subscription" - assert_equal expected, @connection.last_transmission - end - -end diff --git a/test/channel/stream_test.rb b/test/channel/stream_test.rb deleted file mode 100644 index 5e4e01abbf..0000000000 --- a/test/channel/stream_test.rb +++ /dev/null @@ -1,80 +0,0 @@ -require 'test_helper' -require 'stubs/test_connection' -require 'stubs/room' - -class ActionCable::Channel::StreamTest < ActionCable::TestCase - class ChatChannel < ActionCable::Channel::Base - def subscribed - if params[:id] - @room = Room.new params[:id] - stream_from "test_room_#{@room.id}" - end - end - - def send_confirmation - transmit_subscription_confirmation - end - - end - - test "streaming start and stop" do - run_in_eventmachine do - connection = TestConnection.new - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1").returns stub_everything(:pubsub) } - channel = ChatChannel.new connection, "{id: 1}", { id: 1 } - - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe_proc) } - channel.unsubscribe_from_channel - end - end - - test "stream_for" do - run_in_eventmachine do - connection = TestConnection.new - EM.next_tick do - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:channel:stream_test:chat:Room#1-Campfire").returns stub_everything(:pubsub) } - end - - channel = ChatChannel.new connection, "" - channel.stream_for Room.new(1) - end - end - - test "stream_from subscription confirmation" do - EM.run do - connection = TestConnection.new - connection.expects(:pubsub).returns EM::Hiredis.connect.pubsub - - channel = ChatChannel.new connection, "{id: 1}", { id: 1 } - assert_nil connection.last_transmission - - EM::Timer.new(0.1) do - expected = ActiveSupport::JSON.encode "identifier" => "{id: 1}", "type" => "confirm_subscription" - assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation within 0.1s" - - EM.run_deferred_callbacks - EM.stop - end - end - end - - test "subscription confirmation should only be sent out once" do - EM.run do - connection = TestConnection.new - connection.stubs(:pubsub).returns EM::Hiredis.connect.pubsub - - channel = ChatChannel.new connection, "test_channel" - channel.send_confirmation - channel.send_confirmation - - EM.run_deferred_callbacks - - expected = ActiveSupport::JSON.encode "identifier" => "test_channel", "type" => "confirm_subscription" - assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation" - - assert_equal 1, connection.transmissions.size - EM.stop - end - end - -end diff --git a/test/connection/authorization_test.rb b/test/connection/authorization_test.rb deleted file mode 100644 index 68668b2835..0000000000 --- a/test/connection/authorization_test.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'test_helper' -require 'stubs/test_server' - -class ActionCable::Connection::AuthorizationTest < ActionCable::TestCase - class Connection < ActionCable::Connection::Base - attr_reader :websocket - - def connect - reject_unauthorized_connection - end - - def send_async(method, *args) - # Bypass Celluloid - send method, *args - end - end - - test "unauthorized connection" do - run_in_eventmachine do - server = TestServer.new - server.config.allowed_request_origins = %w( http://rubyonrails.com ) - - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', - 'HTTP_ORIGIN' => 'http://rubyonrails.com' - - connection = Connection.new(server, env) - connection.websocket.expects(:close) - - connection.process - end - end -end diff --git a/test/connection/base_test.rb b/test/connection/base_test.rb deleted file mode 100644 index da6041db4a..0000000000 --- a/test/connection/base_test.rb +++ /dev/null @@ -1,118 +0,0 @@ -require 'test_helper' -require 'stubs/test_server' - -class ActionCable::Connection::BaseTest < ActionCable::TestCase - class Connection < ActionCable::Connection::Base - attr_reader :websocket, :subscriptions, :message_buffer, :connected - - def connect - @connected = true - end - - def disconnect - @connected = false - end - - def send_async(method, *args) - # Bypass Celluloid - send method, *args - end - end - - setup do - @server = TestServer.new - @server.config.allowed_request_origins = %w( http://rubyonrails.com ) - end - - test "making a connection with invalid headers" do - run_in_eventmachine do - connection = ActionCable::Connection::Base.new(@server, Rack::MockRequest.env_for("/test")) - response = connection.process - assert_equal 404, response[0] - end - end - - test "websocket connection" do - run_in_eventmachine do - connection = open_connection - connection.process - - assert connection.websocket.possible? - assert connection.websocket.alive? - end - end - - test "rack response" do - run_in_eventmachine do - connection = open_connection - response = connection.process - - assert_equal [ -1, {}, [] ], response - end - end - - test "on connection open" do - run_in_eventmachine do - connection = open_connection - connection.process - - connection.websocket.expects(:transmit).with(regexp_matches(/\_ping/)) - connection.message_buffer.expects(:process!) - - # Allow EM to run on_open callback - EM.next_tick do - assert_equal [ connection ], @server.connections - assert connection.connected - end - end - end - - test "on connection close" do - run_in_eventmachine do - connection = open_connection - connection.process - - # Setup the connection - EventMachine.stubs(:add_periodic_timer).returns(true) - connection.send :on_open - assert connection.connected - - connection.subscriptions.expects(:unsubscribe_from_all) - connection.send :on_close - - assert ! connection.connected - assert_equal [], @server.connections - end - end - - test "connection statistics" do - run_in_eventmachine do - connection = open_connection - connection.process - - statistics = connection.statistics - - assert statistics[:identifier].blank? - assert_kind_of Time, statistics[:started_at] - assert_equal [], statistics[:subscriptions] - end - end - - test "explicitly closing a connection" do - run_in_eventmachine do - connection = open_connection - connection.process - - connection.websocket.expects(:close) - connection.close - end - end - - private - def open_connection - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket', - 'HTTP_ORIGIN' => 'http://rubyonrails.com' - - Connection.new(@server, env) - end -end diff --git a/test/connection/cross_site_forgery_test.rb b/test/connection/cross_site_forgery_test.rb deleted file mode 100644 index ede3057e30..0000000000 --- a/test/connection/cross_site_forgery_test.rb +++ /dev/null @@ -1,82 +0,0 @@ -require 'test_helper' -require 'stubs/test_server' - -class ActionCable::Connection::CrossSiteForgeryTest < ActionCable::TestCase - HOST = 'rubyonrails.com' - - class Connection < ActionCable::Connection::Base - def send_async(method, *args) - # Bypass Celluloid - send method, *args - end - end - - setup do - @server = TestServer.new - @server.config.allowed_request_origins = %w( http://rubyonrails.com ) - end - - teardown do - @server.config.disable_request_forgery_protection = false - @server.config.allowed_request_origins = [] - 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 - - 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 = Connection.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_ORIGIN' => origin - end -end diff --git a/test/connection/identifier_test.rb b/test/connection/identifier_test.rb deleted file mode 100644 index 02e6b21845..0000000000 --- a/test/connection/identifier_test.rb +++ /dev/null @@ -1,77 +0,0 @@ -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 - - test "connection identifier" do - run_in_eventmachine do - open_connection_with_stubbed_pubsub - assert_equal "User#lifo", @connection.connection_identifier - end - end - - test "should subscribe to internal channel on open and unsubscribe on close" do - run_in_eventmachine do - pubsub = mock('pubsub') - pubsub.expects(:subscribe).with('action_cable/User#lifo') - pubsub.expects(:unsubscribe_proc).with('action_cable/User#lifo', kind_of(Proc)) - - server = TestServer.new - server.stubs(:pubsub).returns(pubsub) - - open_connection server: server - close_connection - end - end - - test "processing disconnect message" do - run_in_eventmachine do - open_connection_with_stubbed_pubsub - - @connection.websocket.expects(:close) - message = ActiveSupport::JSON.encode('type' => 'disconnect') - @connection.process_internal_message message - end - end - - test "processing invalid message" do - run_in_eventmachine do - open_connection_with_stubbed_pubsub - - @connection.websocket.expects(:close).never - message = ActiveSupport::JSON.encode('type' => 'unknown') - @connection.process_internal_message message - end - end - - protected - def open_connection_with_stubbed_pubsub - server = TestServer.new - server.stubs(:pubsub).returns(stub_everything('pubsub')) - - open_connection server: server - end - - def open_connection(server:) - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(server, env) - - @connection.process - @connection.send :on_open - end - - def close_connection - @connection.send :on_close - end -end diff --git a/test/connection/multiple_identifiers_test.rb b/test/connection/multiple_identifiers_test.rb deleted file mode 100644 index 55a9f96cb3..0000000000 --- a/test/connection/multiple_identifiers_test.rb +++ /dev/null @@ -1,41 +0,0 @@ -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 - run_in_eventmachine do - open_connection_with_stubbed_pubsub - assert_equal "Room#my-room:User#lifo", @connection.connection_identifier - end - end - - protected - def open_connection_with_stubbed_pubsub - server = TestServer.new - server.stubs(:pubsub).returns(stub_everything('pubsub')) - - open_connection server: server - end - - def open_connection(server:) - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(server, env) - - @connection.process - @connection.send :on_open - end - - def close_connection - @connection.send :on_close - end -end diff --git a/test/connection/string_identifier_test.rb b/test/connection/string_identifier_test.rb deleted file mode 100644 index ab69df57b3..0000000000 --- a/test/connection/string_identifier_test.rb +++ /dev/null @@ -1,44 +0,0 @@ -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 - - def send_async(method, *args) - # Bypass Celluloid - send method, *args - end - end - - test "connection identifier" do - run_in_eventmachine do - open_connection_with_stubbed_pubsub - assert_equal "random-string", @connection.connection_identifier - end - end - - protected - def open_connection_with_stubbed_pubsub - @server = TestServer.new - @server.stubs(:pubsub).returns(stub_everything('pubsub')) - - open_connection - end - - def open_connection - env = Rack::MockRequest.env_for "/test", 'HTTP_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(@server, env) - - @connection.process - @connection.send :on_open - end - - def close_connection - @connection.send :on_close - end -end diff --git a/test/connection/subscriptions_test.rb b/test/connection/subscriptions_test.rb deleted file mode 100644 index 4f6760827e..0000000000 --- a/test/connection/subscriptions_test.rb +++ /dev/null @@ -1,116 +0,0 @@ -require 'test_helper' - -class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase - class Connection < ActionCable::Connection::Base - attr_reader :websocket - - def send_async(method, *args) - # Bypass Celluloid - send method, *args - 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 - end - - setup do - @server = TestServer.new - @server.stubs(:channel_classes).returns(ChatChannel.name => ChatChannel) - - @chat_identifier = ActiveSupport::JSON.encode(id: 1, channel: 'ActionCable::Connection::SubscriptionsTest::ChatChannel') - end - - test "subscribe command" do - run_in_eventmachine do - setup_connection - channel = subscribe_to_chat_channel - - assert_kind_of ChatChannel, channel - assert_equal 1, channel.room.id - end - end - - test "subscribe command without an identifier" do - run_in_eventmachine do - setup_connection - - @subscriptions.execute_command 'command' => 'subscribe' - assert @subscriptions.identifiers.empty? - end - end - - test "unsubscribe command" do - run_in_eventmachine do - setup_connection - subscribe_to_chat_channel - - channel = subscribe_to_chat_channel - channel.expects(:unsubscribe_from_channel) - - @subscriptions.execute_command 'command' => 'unsubscribe', 'identifier' => @chat_identifier - assert @subscriptions.identifiers.empty? - end - end - - test "unsubscribe command without an identifier" do - run_in_eventmachine do - setup_connection - - @subscriptions.execute_command 'command' => 'unsubscribe' - assert @subscriptions.identifiers.empty? - end - end - - test "message command" do - run_in_eventmachine 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 - end - - test "unsubscrib from all" do - run_in_eventmachine 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) - - channel1.expects(:unsubscribe_from_channel) - channel2.expects(:unsubscribe_from_channel) - - @subscriptions.unsubscribe_from_all - 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_CONNECTION' => 'upgrade', 'HTTP_UPGRADE' => 'websocket' - @connection = Connection.new(@server, env) - - @subscriptions = ActionCable::Connection::Subscriptions.new(@connection) - end -end diff --git a/test/stubs/global_id.rb b/test/stubs/global_id.rb deleted file mode 100644 index 334f0d03e8..0000000000 --- a/test/stubs/global_id.rb +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index cd66a0b687..0000000000 --- a/test/stubs/room.rb +++ /dev/null @@ -1,16 +0,0 @@ -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_connection.rb b/test/stubs/test_connection.rb deleted file mode 100644 index 384abc5e76..0000000000 --- a/test/stubs/test_connection.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'stubs/user' - -class TestConnection - attr_reader :identifiers, :logger, :current_user, :transmissions - - def initialize(user = User.new("lifo")) - @identifiers = [ :current_user ] - - @current_user = user - @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) - @transmissions = [] - end - - def transmit(data) - @transmissions << data - end - - def last_transmission - @transmissions.last - end -end diff --git a/test/stubs/test_server.rb b/test/stubs/test_server.rb deleted file mode 100644 index f9168f9b78..0000000000 --- a/test/stubs/test_server.rb +++ /dev/null @@ -1,15 +0,0 @@ -require 'ostruct' - -class TestServer - include ActionCable::Server::Connections - - attr_reader :logger, :config - - def initialize - @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) - @config = OpenStruct.new(log_tags: []) - end - - def send_async - end -end diff --git a/test/stubs/user.rb b/test/stubs/user.rb deleted file mode 100644 index a66b4f87d5..0000000000 --- a/test/stubs/user.rb +++ /dev/null @@ -1,15 +0,0 @@ -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/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index 935e50e900..0000000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,47 +0,0 @@ -require "rubygems" -require "bundler" - -gem 'minitest' -require "minitest/autorun" - -Bundler.setup -Bundler.require :default, :test - -require 'puma' -require 'em-hiredis' -require 'mocha/mini_test' - -require 'rack/mock' - -require 'action_cable' -ActiveSupport.test_order = :sorted - -# Require all the stubs and models -Dir[File.dirname(__FILE__) + '/stubs/*.rb'].each {|file| require file } - -require 'celluloid' -$CELLULOID_DEBUG = false -$CELLULOID_TEST = false -Celluloid.logger = Logger.new(StringIO.new) - -require 'faye/websocket' -class << Faye::WebSocket - remove_method :ensure_reactor_running - - # We don't want Faye to start the EM reactor in tests because it makes testing much harder. - # We want to be able to start and stop EM loop in tests to make things simpler. - def ensure_reactor_running - # no-op - end -end - -class ActionCable::TestCase < ActiveSupport::TestCase - def run_in_eventmachine - EM.run do - yield - - EM.run_deferred_callbacks - EM.stop - end - end -end diff --git a/test/worker_test.rb b/test/worker_test.rb deleted file mode 100644 index 69c4b6529d..0000000000 --- a/test/worker_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -require 'test_helper' - -class WorkerTest < ActiveSupport::TestCase - class Receiver - attr_accessor :last_action - - def run - @last_action = :run - end - - def process(message) - @last_action = [ :process, message ] - end - - def connection - end - end - - setup do - Celluloid.boot - - @worker = ActionCable::Server::Worker.new - @receiver = Receiver.new - end - - teardown do - @receiver.last_action = nil - end - - test "invoke" do - @worker.invoke @receiver, :run - assert_equal :run, @receiver.last_action - end - - test "invoke with arguments" do - @worker.invoke @receiver, :process, "Hello" - assert_equal [ :process, "Hello" ], @receiver.last_action - end - - test "running periodic timers with a proc" do - @worker.run_periodic_timer @receiver, @receiver.method(:run) - assert_equal :run, @receiver.last_action - end - - test "running periodic timers with a method" do - @worker.run_periodic_timer @receiver, :run - assert_equal :run, @receiver.last_action - end -end -- cgit v1.2.3