actioncable-next 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +17 -0
  5. data/lib/action_cable/channel/base.rb +335 -0
  6. data/lib/action_cable/channel/broadcasting.rb +50 -0
  7. data/lib/action_cable/channel/callbacks.rb +76 -0
  8. data/lib/action_cable/channel/naming.rb +28 -0
  9. data/lib/action_cable/channel/periodic_timers.rb +81 -0
  10. data/lib/action_cable/channel/streams.rb +213 -0
  11. data/lib/action_cable/channel/test_case.rb +329 -0
  12. data/lib/action_cable/connection/authorization.rb +18 -0
  13. data/lib/action_cable/connection/base.rb +165 -0
  14. data/lib/action_cable/connection/callbacks.rb +57 -0
  15. data/lib/action_cable/connection/identification.rb +51 -0
  16. data/lib/action_cable/connection/internal_channel.rb +50 -0
  17. data/lib/action_cable/connection/subscriptions.rb +124 -0
  18. data/lib/action_cable/connection/test_case.rb +294 -0
  19. data/lib/action_cable/deprecator.rb +9 -0
  20. data/lib/action_cable/engine.rb +98 -0
  21. data/lib/action_cable/gem_version.rb +19 -0
  22. data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
  23. data/lib/action_cable/remote_connections.rb +82 -0
  24. data/lib/action_cable/server/base.rb +163 -0
  25. data/lib/action_cable/server/broadcasting.rb +62 -0
  26. data/lib/action_cable/server/configuration.rb +75 -0
  27. data/lib/action_cable/server/connections.rb +44 -0
  28. data/lib/action_cable/server/socket/client_socket.rb +159 -0
  29. data/lib/action_cable/server/socket/message_buffer.rb +56 -0
  30. data/lib/action_cable/server/socket/stream.rb +117 -0
  31. data/lib/action_cable/server/socket/web_socket.rb +47 -0
  32. data/lib/action_cable/server/socket.rb +180 -0
  33. data/lib/action_cable/server/stream_event_loop.rb +119 -0
  34. data/lib/action_cable/server/tagged_logger_proxy.rb +46 -0
  35. data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
  36. data/lib/action_cable/server/worker.rb +75 -0
  37. data/lib/action_cable/subscription_adapter/async.rb +14 -0
  38. data/lib/action_cable/subscription_adapter/base.rb +39 -0
  39. data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
  40. data/lib/action_cable/subscription_adapter/inline.rb +40 -0
  41. data/lib/action_cable/subscription_adapter/postgresql.rb +130 -0
  42. data/lib/action_cable/subscription_adapter/redis.rb +257 -0
  43. data/lib/action_cable/subscription_adapter/subscriber_map.rb +80 -0
  44. data/lib/action_cable/subscription_adapter/test.rb +41 -0
  45. data/lib/action_cable/test_case.rb +13 -0
  46. data/lib/action_cable/test_helper.rb +163 -0
  47. data/lib/action_cable/version.rb +12 -0
  48. data/lib/action_cable.rb +81 -0
  49. data/lib/actioncable-next.rb +5 -0
  50. data/lib/rails/generators/channel/USAGE +19 -0
  51. data/lib/rails/generators/channel/channel_generator.rb +127 -0
  52. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  53. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  55. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  56. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  57. data/lib/rails/generators/channel/templates/javascript/index.js.tt +1 -0
  58. data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
  59. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  60. metadata +191 -0
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/rescuable"
6
+
7
+ module ActionCable
8
+ module Connection
9
+ # # Action Cable Connection Base
10
+ #
11
+ # For every WebSocket connection the Action Cable server accepts, a Connection
12
+ # object will be instantiated. This instance becomes the parent of all of the
13
+ # channel subscriptions that are created from there on. Incoming messages are
14
+ # then routed to these channel subscriptions based on an identifier sent by the
15
+ # Action Cable consumer. The Connection itself does not deal with any specific
16
+ # application logic beyond authentication and authorization.
17
+ #
18
+ # Here's a basic example:
19
+ #
20
+ # module ApplicationCable
21
+ # class Connection < ActionCable::Connection::Base
22
+ # identified_by :current_user
23
+ #
24
+ # def connect
25
+ # self.current_user = find_verified_user
26
+ # logger.add_tags current_user.name
27
+ # end
28
+ #
29
+ # def disconnect
30
+ # # Any cleanup work needed when the cable connection is cut.
31
+ # end
32
+ #
33
+ # private
34
+ # def find_verified_user
35
+ # User.find_by_identity(cookies.encrypted[:identity_id]) ||
36
+ # reject_unauthorized_connection
37
+ # end
38
+ # end
39
+ # end
40
+ #
41
+ # First, we declare that this connection can be identified by its current_user.
42
+ # This allows us to later be able to find all connections established for that
43
+ # current_user (and potentially disconnect them). You can declare as many
44
+ # identification indexes as you like. Declaring an identification means that an
45
+ # attr_accessor is automatically set for that key.
46
+ #
47
+ # Second, we rely on the fact that the WebSocket connection is established with
48
+ # the cookies from the domain being sent along. This makes it easy to use signed
49
+ # cookies that were set when logging in via a web interface to authorize the
50
+ # WebSocket connection.
51
+ #
52
+ # Finally, we add a tag to the connection-specific logger with the name of the
53
+ # current user to easily distinguish their messages in the log.
54
+ #
55
+ class Base
56
+ include Identification
57
+ include InternalChannel
58
+ include Authorization
59
+ include Callbacks
60
+ include ActiveSupport::Rescuable
61
+
62
+ attr_reader :subscriptions, :logger
63
+ private attr_reader :server, :socket
64
+
65
+ delegate :pubsub, :executor, :config, to: :server
66
+ delegate :env, :request, :protocol, :perform_work, to: :socket, allow_nil: true
67
+
68
+ def initialize(server, socket)
69
+ @server = server
70
+ @socket = socket
71
+
72
+ @logger = socket.logger
73
+ @subscriptions = Subscriptions.new(self)
74
+
75
+ @_internal_subscriptions = nil
76
+
77
+ @started_at = Time.now
78
+ end
79
+
80
+ # This method is called every time an Action Cable client establishes an underlying connection.
81
+ # Override it in your class to define authentication logic and
82
+ # populate connection identifiers.
83
+ def connect
84
+ end
85
+
86
+ # This method is called every time an Action Cable client disconnects.
87
+ # Override it in your class to cleanup the relevant application state (e.g., presence, online counts, etc.)
88
+ def disconnect
89
+ end
90
+
91
+ def handle_open
92
+ connect
93
+ subscribe_to_internal_channel
94
+ send_welcome_message
95
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
96
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false)
97
+ end
98
+
99
+ def handle_close
100
+ subscriptions.unsubscribe_from_all
101
+ unsubscribe_from_internal_channel
102
+
103
+ disconnect
104
+ end
105
+
106
+ def handle_channel_command(payload)
107
+ run_callbacks :command do
108
+ subscriptions.execute_command payload
109
+ end
110
+ rescue Exception => e
111
+ rescue_with_handler(e) || raise
112
+ end
113
+
114
+ alias_method :handle_incoming, :handle_channel_command
115
+
116
+ def transmit(data) # :nodoc:
117
+ socket.transmit(data)
118
+ end
119
+
120
+ # Close the connection.
121
+ def close(reason: nil, reconnect: true)
122
+ transmit(
123
+ type: ActionCable::INTERNAL[:message_types][:disconnect],
124
+ reason: reason,
125
+ reconnect: reconnect
126
+ )
127
+ socket.close
128
+ end
129
+
130
+ # Return a basic hash of statistics for the connection keyed with `identifier`,
131
+ # `started_at`, `subscriptions`, and `request_id`. This can be returned by a
132
+ # health check against the connection.
133
+ def statistics
134
+ {
135
+ identifier: connection_identifier,
136
+ started_at: @started_at,
137
+ subscriptions: subscriptions.identifiers,
138
+ request_id: env["action_dispatch.request_id"]
139
+ }
140
+ end
141
+
142
+ def beat
143
+ transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i
144
+ end
145
+
146
+ def inspect # :nodoc:
147
+ "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
148
+ end
149
+
150
+ private
151
+ # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
152
+ def cookies # :doc:
153
+ request.cookie_jar
154
+ end
155
+
156
+ def send_welcome_message
157
+ # Send welcome message to the internal connection monitor channel. This ensures
158
+ # the connection monitor state is reset after a successful websocket connection.
159
+ transmit type: ActionCable::INTERNAL[:message_types][:welcome]
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/callbacks"
6
+
7
+ module ActionCable
8
+ module Connection
9
+ # # Action Cable Connection Callbacks
10
+ #
11
+ # The [before_command](rdoc-ref:ClassMethods#before_command),
12
+ # [after_command](rdoc-ref:ClassMethods#after_command), and
13
+ # [around_command](rdoc-ref:ClassMethods#around_command) callbacks are invoked
14
+ # when sending commands to the client, such as when subscribing, unsubscribing,
15
+ # or performing an action.
16
+ #
17
+ # #### Example
18
+ #
19
+ # module ApplicationCable
20
+ # class Connection < ActionCable::Connection::Base
21
+ # identified_by :user
22
+ #
23
+ # around_command :set_current_account
24
+ #
25
+ # private
26
+ #
27
+ # def set_current_account
28
+ # # Now all channels could use Current.account
29
+ # Current.set(account: user.account) { yield }
30
+ # end
31
+ # end
32
+ # end
33
+ #
34
+ module Callbacks
35
+ extend ActiveSupport::Concern
36
+ include ActiveSupport::Callbacks
37
+
38
+ included do
39
+ define_callbacks :command
40
+ end
41
+
42
+ module ClassMethods
43
+ def before_command(*methods, &block)
44
+ set_callback(:command, :before, *methods, &block)
45
+ end
46
+
47
+ def after_command(*methods, &block)
48
+ set_callback(:command, :after, *methods, &block)
49
+ end
50
+
51
+ def around_command(*methods, &block)
52
+ set_callback(:command, :around, *methods, &block)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "set"
6
+
7
+ module ActionCable
8
+ module Connection
9
+ module Identification
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ class_attribute :identifiers, default: Set.new
14
+ end
15
+
16
+ module ClassMethods
17
+ # Mark a key as being a connection identifier index that can then be used to
18
+ # find the specific connection again later. Common identifiers are current_user
19
+ # and current_account, but could be anything, really.
20
+ #
21
+ # Note that anything marked as an identifier will automatically create a
22
+ # delegate by the same name on any channel instances created off the connection.
23
+ def identified_by(*identifiers)
24
+ Array(identifiers).each { |identifier| attr_accessor identifier }
25
+ self.identifiers += identifiers
26
+ end
27
+ end
28
+
29
+ # Return a single connection identifier that combines the value of all the
30
+ # registered identifiers into a single gid.
31
+ def connection_identifier
32
+ unless defined? @connection_identifier
33
+ @connection_identifier = connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") }
34
+ end
35
+
36
+ @connection_identifier
37
+ end
38
+
39
+ private
40
+ def connection_gid(ids)
41
+ ids.map do |o|
42
+ if o.respond_to? :to_gid_param
43
+ o.to_gid_param
44
+ else
45
+ o.to_s
46
+ end
47
+ end.sort.join(":")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Connection
7
+ # # Action Cable InternalChannel
8
+ #
9
+ # Makes it possible for the RemoteConnection to disconnect a specific
10
+ # connection.
11
+ module InternalChannel
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+ def internal_channel
16
+ "action_cable/#{connection_identifier}"
17
+ end
18
+
19
+ def subscribe_to_internal_channel
20
+ if connection_identifier.present?
21
+ callback = -> (message) { process_internal_message ActiveSupport::JSON.decode(message) }
22
+ @_internal_subscriptions ||= []
23
+ @_internal_subscriptions << [ internal_channel, callback ]
24
+
25
+ pubsub.subscribe(internal_channel, callback)
26
+ logger.info "Registered connection (#{connection_identifier})"
27
+ end
28
+ end
29
+
30
+ def unsubscribe_from_internal_channel
31
+ if @_internal_subscriptions.present?
32
+ @_internal_subscriptions.each { |channel, callback| pubsub.unsubscribe(channel, callback) }
33
+ end
34
+ end
35
+
36
+ def process_internal_message(message)
37
+ case message["type"]
38
+ when "disconnect"
39
+ logger.info "Removing connection (#{connection_identifier})"
40
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:remote], reconnect: message.fetch("reconnect", true))
41
+ end
42
+ rescue Exception => e
43
+ logger.error "There was an exception - #{e.class}(#{e.message})"
44
+ logger.error e.backtrace.join("\n")
45
+
46
+ close
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/core_ext/hash/indifferent_access"
6
+
7
+ module ActionCable
8
+ module Connection
9
+ # # Action Cable Connection Subscriptions
10
+ #
11
+ # Collection class for all the channel subscriptions established on a given
12
+ # connection. Responsible for routing incoming commands that arrive on the
13
+ # connection to the proper channel.
14
+ class Subscriptions # :nodoc:
15
+ class Error < StandardError; end
16
+
17
+ class AlreadySubscribedError < Error
18
+ def initialize(identifier)
19
+ super "Already subscribed to #{identifier}"
20
+ end
21
+ end
22
+
23
+ class ChannelNotFound < Error
24
+ def initialize(channel_id)
25
+ super "Channel not found: #{channel_id}"
26
+ end
27
+ end
28
+
29
+ class MalformedCommandError < Error
30
+ def initialize(data)
31
+ super "Malformed command: #{data.inspect}"
32
+ end
33
+ end
34
+
35
+ class UnknownCommandError < Error
36
+ def initialize(command)
37
+ super "Received unrecognized command: #{command}"
38
+ end
39
+ end
40
+
41
+ class UnknownSubscription < Error
42
+ def initialize(identifier)
43
+ "Unable to find subscription with identifier: #{identifier}"
44
+ end
45
+ end
46
+
47
+ def initialize(connection)
48
+ @connection = connection
49
+ @subscriptions = {}
50
+ end
51
+
52
+ def execute_command(data)
53
+ case data["command"]
54
+ when "subscribe" then add data
55
+ when "unsubscribe" then remove data
56
+ when "message" then perform_action data
57
+ else
58
+ raise UnknownCommandError, data["command"]
59
+ end
60
+ end
61
+
62
+ def add(data)
63
+ id_key = data["identifier"]
64
+
65
+ raise MalformedCommandError, data unless id_key.present?
66
+
67
+ raise AlreadySubscribedError, id_key if subscriptions.key?(id_key)
68
+
69
+ subscription = subscription_from_identifier(id_key)
70
+
71
+ if subscription
72
+ subscriptions[id_key] = subscription
73
+ subscription.subscribe_to_channel
74
+ else
75
+ id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
76
+ raise ChannelNotFound, id_options[:channel]
77
+ end
78
+ end
79
+
80
+ def remove(data)
81
+ logger.info "Unsubscribing from channel: #{data['identifier']}"
82
+ remove_subscription find(data)
83
+ end
84
+
85
+ def remove_subscription(subscription)
86
+ subscription.unsubscribe_from_channel
87
+ subscriptions.delete(subscription.identifier)
88
+ end
89
+
90
+ def perform_action(data)
91
+ find(data).perform_action ActiveSupport::JSON.decode(data["data"])
92
+ end
93
+
94
+ def identifiers
95
+ subscriptions.keys
96
+ end
97
+
98
+ def unsubscribe_from_all
99
+ subscriptions.each { |id, channel| remove_subscription(channel) }
100
+ end
101
+
102
+ private
103
+ attr_reader :connection, :subscriptions
104
+ delegate :logger, to: :connection
105
+
106
+ def find(data)
107
+ if subscription = subscriptions[data["identifier"]]
108
+ subscription
109
+ else
110
+ raise UnknownSubscription, data["identifier"]
111
+ end
112
+ end
113
+
114
+ def subscription_from_identifier(id_key)
115
+ id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
116
+ subscription_klass = id_options[:channel].safe_constantize
117
+
118
+ if subscription_klass && ActionCable::Channel::Base > subscription_klass
119
+ subscription_klass.new(connection, id_key, id_options)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end