actioncable-next 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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