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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a2a646309358247e05b84be4246c044bfae6f289eab7776e5821d6c54e8b1f00
4
+ data.tar.gz: d2ec2b1f743af72cdc61a8be7c41e80778cff79d9335baa059fbd791272cf07a
5
+ SHA512:
6
+ metadata.gz: 8ff34021af6ec2c0e69676ba92fb64f2f1c9a9141db355eaa1f16fc7e9e7f3e4689b79efd86ebada9fd698e9b76bf95a214969198feb0c4334bbebb86a0597ec
7
+ data.tar.gz: aed1822bc56226c76a02f6d9a95f3bc915fe429d3345bd3dbbc964ef6e384337347ed0496427b609788846951409042accb001ca157519f746997d512665b0d3
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Change log
2
+
3
+ ## main
4
+
5
+ - Initial extraction.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2024 Vladimir Dementyev
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Action Cable Next
2
+
3
+ This gem provides the functionality of the _server adapterization_ PR: [rails/rails#50979](https://github.com/rails/rails/pull/50979).
4
+
5
+ See the PR description for more information on the purpose of this refactoring.
6
+
7
+ ## Usage
8
+
9
+ Add this line to your application's Gemfile **before Rails or Action Cable**:
10
+
11
+ ```ruby
12
+ gem "actioncable-next"
13
+
14
+ gem "rails", "~> 7.0"
15
+ ```
16
+
17
+ Then, you can use Action Cable as before. Under the hood, the new implementation would be used.
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "set"
6
+ require "active_support/rescuable"
7
+ require "active_support/parameter_filter"
8
+
9
+ module ActionCable
10
+ module Channel
11
+ # # Action Cable Channel Base
12
+ #
13
+ # The channel provides the basic structure of grouping behavior into logical
14
+ # units when communicating over the WebSocket connection. You can think of a
15
+ # channel like a form of controller, but one that's capable of pushing content
16
+ # to the subscriber in addition to simply responding to the subscriber's direct
17
+ # requests.
18
+ #
19
+ # Channel instances are long-lived. A channel object will be instantiated when
20
+ # the cable consumer becomes a subscriber, and then lives until the consumer
21
+ # disconnects. This may be seconds, minutes, hours, or even days. That means you
22
+ # have to take special care not to do anything silly in a channel that would
23
+ # balloon its memory footprint or whatever. The references are forever, so they
24
+ # won't be released as is normally the case with a controller instance that gets
25
+ # thrown away after every request.
26
+ #
27
+ # Long-lived channels (and connections) also mean you're responsible for
28
+ # ensuring that the data is fresh. If you hold a reference to a user record, but
29
+ # the name is changed while that reference is held, you may be sending stale
30
+ # data if you don't take precautions to avoid it.
31
+ #
32
+ # The upside of long-lived channel instances is that you can use instance
33
+ # variables to keep reference to objects that future subscriber requests can
34
+ # interact with. Here's a quick example:
35
+ #
36
+ # class ChatChannel < ApplicationCable::Channel
37
+ # def subscribed
38
+ # @room = Chat::Room[params[:room_number]]
39
+ # end
40
+ #
41
+ # def speak(data)
42
+ # @room.speak data, user: current_user
43
+ # end
44
+ # end
45
+ #
46
+ # The #speak action simply uses the Chat::Room object that was created when the
47
+ # channel was first subscribed to by the consumer when that subscriber wants to
48
+ # say something in the room.
49
+ #
50
+ # ## Action processing
51
+ #
52
+ # Unlike subclasses of ActionController::Base, channels do not follow a RESTful
53
+ # constraint form for their actions. Instead, Action Cable operates through a
54
+ # remote-procedure call model. You can declare any public method on the channel
55
+ # (optionally taking a `data` argument), and this method is automatically
56
+ # exposed as callable to the client.
57
+ #
58
+ # Example:
59
+ #
60
+ # class AppearanceChannel < ApplicationCable::Channel
61
+ # def subscribed
62
+ # @connection_token = generate_connection_token
63
+ # end
64
+ #
65
+ # def unsubscribed
66
+ # current_user.disappear @connection_token
67
+ # end
68
+ #
69
+ # def appear(data)
70
+ # current_user.appear @connection_token, on: data['appearing_on']
71
+ # end
72
+ #
73
+ # def away
74
+ # current_user.away @connection_token
75
+ # end
76
+ #
77
+ # private
78
+ # def generate_connection_token
79
+ # SecureRandom.hex(36)
80
+ # end
81
+ # end
82
+ #
83
+ # In this example, the subscribed and unsubscribed methods are not callable
84
+ # methods, as they were already declared in ActionCable::Channel::Base, but
85
+ # `#appear` and `#away` are. `#generate_connection_token` is also not callable,
86
+ # since it's a private method. You'll see that appear accepts a data parameter,
87
+ # which it then uses as part of its model call. `#away` does not, since it's
88
+ # simply a trigger action.
89
+ #
90
+ # Also note that in this example, `current_user` is available because it was
91
+ # marked as an identifying attribute on the connection. All such identifiers
92
+ # will automatically create a delegation method of the same name on the channel
93
+ # instance.
94
+ #
95
+ # ## Rejecting subscription requests
96
+ #
97
+ # A channel can reject a subscription request in the #subscribed callback by
98
+ # invoking the #reject method:
99
+ #
100
+ # class ChatChannel < ApplicationCable::Channel
101
+ # def subscribed
102
+ # @room = Chat::Room[params[:room_number]]
103
+ # reject unless current_user.can_access?(@room)
104
+ # end
105
+ # end
106
+ #
107
+ # In this example, the subscription will be rejected if the `current_user` does
108
+ # not have access to the chat room. On the client-side, the `Channel#rejected`
109
+ # callback will get invoked when the server rejects the subscription request.
110
+ class Base
111
+ include Callbacks
112
+ include PeriodicTimers
113
+ include Streams
114
+ include Naming
115
+ include Broadcasting
116
+ include ActiveSupport::Rescuable
117
+
118
+ attr_reader :params, :connection, :identifier
119
+ delegate :logger, to: :connection
120
+
121
+ class << self
122
+ # A list of method names that should be considered actions. This includes all
123
+ # public instance methods on a channel, less any internal methods (defined on
124
+ # Base), adding back in any methods that are internal, but still exist on the
125
+ # class itself.
126
+ #
127
+ # #### Returns
128
+ # * `Set` - A set of all methods that should be considered actions.
129
+ def action_methods
130
+ @action_methods ||= begin
131
+ # All public instance methods of this class, including ancestors
132
+ methods = (public_instance_methods(true) -
133
+ # Except for public instance methods of Base and its ancestors
134
+ ActionCable::Channel::Base.public_instance_methods(true) +
135
+ # Be sure to include shadowed public instance methods of this class
136
+ public_instance_methods(false)).uniq.map(&:to_s)
137
+ methods.to_set
138
+ end
139
+ end
140
+
141
+ private
142
+ # action_methods are cached and there is sometimes need to refresh them.
143
+ # ::clear_action_methods! allows you to do that, so next time you run
144
+ # action_methods, they will be recalculated.
145
+ def clear_action_methods! # :doc:
146
+ @action_methods = nil
147
+ end
148
+
149
+ # Refresh the cached action_methods when a new action_method is added.
150
+ def method_added(name) # :doc:
151
+ super
152
+ clear_action_methods!
153
+ end
154
+ end
155
+
156
+ def initialize(connection, identifier, params = {})
157
+ @connection = connection
158
+ @identifier = identifier
159
+ @params = params
160
+
161
+ # When a channel is streaming via pubsub, we want to delay the confirmation
162
+ # transmission until pubsub subscription is confirmed.
163
+ #
164
+ # The counter starts at 1 because it's awaiting a call to #subscribe_to_channel
165
+ @defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1)
166
+
167
+ @reject_subscription = nil
168
+ @subscription_confirmation_sent = nil
169
+
170
+ delegate_connection_identifiers
171
+ end
172
+
173
+ # Extract the action name from the passed data and process it via the channel.
174
+ # The process will ensure that the action requested is a public method on the
175
+ # channel declared by the user (so not one of the callbacks like #subscribed).
176
+ def perform_action(data)
177
+ action = extract_action(data)
178
+
179
+ if processable_action?(action)
180
+ payload = { channel_class: self.class.name, action: action, data: data }
181
+ ActiveSupport::Notifications.instrument("perform_action.action_cable", payload) do
182
+ dispatch_action(action, data)
183
+ end
184
+ else
185
+ logger.error "Unable to process #{action_signature(action, data)}"
186
+ end
187
+ end
188
+
189
+ # This method is called after subscription has been added to the connection and
190
+ # confirms or rejects the subscription.
191
+ def subscribe_to_channel
192
+ run_callbacks :subscribe do
193
+ subscribed
194
+ end
195
+
196
+ reject_subscription if subscription_rejected?
197
+ ensure_confirmation_sent
198
+ end
199
+
200
+ # Called by the cable connection when it's cut, so the channel has a chance to
201
+ # cleanup with callbacks. This method is not intended to be called directly by
202
+ # the user. Instead, override the #unsubscribed callback.
203
+ def unsubscribe_from_channel # :nodoc:
204
+ run_callbacks :unsubscribe do
205
+ unsubscribed
206
+ end
207
+ end
208
+
209
+ private
210
+ # Called once a consumer has become a subscriber of the channel. Usually the
211
+ # place to set up any streams you want this channel to be sending to the
212
+ # subscriber.
213
+ def subscribed # :doc:
214
+ # Override in subclasses
215
+ end
216
+
217
+ # Called once a consumer has cut its cable connection. Can be used for cleaning
218
+ # up connections or marking users as offline or the like.
219
+ def unsubscribed # :doc:
220
+ # Override in subclasses
221
+ end
222
+
223
+ # Transmit a hash of data to the subscriber. The hash will automatically be
224
+ # wrapped in a JSON envelope with the proper channel identifier marked as the
225
+ # recipient.
226
+ def transmit(data, via: nil) # :doc:
227
+ logger.debug do
228
+ status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"
229
+ status += " (via #{via})" if via
230
+ status
231
+ end
232
+
233
+ payload = { channel_class: self.class.name, data: data, via: via }
234
+ ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do
235
+ connection.transmit identifier: @identifier, message: data
236
+ end
237
+ end
238
+
239
+ def ensure_confirmation_sent # :doc:
240
+ return if subscription_rejected?
241
+ @defer_subscription_confirmation_counter.decrement
242
+ transmit_subscription_confirmation unless defer_subscription_confirmation?
243
+ end
244
+
245
+ def defer_subscription_confirmation! # :doc:
246
+ @defer_subscription_confirmation_counter.increment
247
+ end
248
+
249
+ def defer_subscription_confirmation? # :doc:
250
+ @defer_subscription_confirmation_counter.value > 0
251
+ end
252
+
253
+ def subscription_confirmation_sent? # :doc:
254
+ @subscription_confirmation_sent
255
+ end
256
+
257
+ def reject # :doc:
258
+ @reject_subscription = true
259
+ end
260
+
261
+ def subscription_rejected? # :doc:
262
+ @reject_subscription
263
+ end
264
+
265
+ def delegate_connection_identifiers
266
+ connection.identifiers.each do |identifier|
267
+ define_singleton_method(identifier) do
268
+ connection.send(identifier)
269
+ end
270
+ end
271
+ end
272
+
273
+ def extract_action(data)
274
+ (data["action"].presence || :receive).to_sym
275
+ end
276
+
277
+ def processable_action?(action)
278
+ self.class.action_methods.include?(action.to_s) unless subscription_rejected?
279
+ end
280
+
281
+ def dispatch_action(action, data)
282
+ logger.debug action_signature(action, data)
283
+
284
+ if method(action).arity == 1
285
+ public_send action, data
286
+ else
287
+ public_send action
288
+ end
289
+ rescue Exception => exception
290
+ rescue_with_handler(exception) || raise
291
+ end
292
+
293
+ def action_signature(action, data)
294
+ (+"#{self.class.name}##{action}").tap do |signature|
295
+ arguments = data.except("action")
296
+
297
+ if arguments.any?
298
+ arguments = parameter_filter.filter(arguments)
299
+ signature << "(#{arguments.inspect})"
300
+ end
301
+ end
302
+ end
303
+
304
+ def parameter_filter
305
+ @parameter_filter ||= ActiveSupport::ParameterFilter.new(connection.config.filter_parameters)
306
+ end
307
+
308
+ def transmit_subscription_confirmation
309
+ unless subscription_confirmation_sent?
310
+ logger.debug "#{self.class.name} is transmitting the subscription confirmation"
311
+
312
+ ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name, identifier: @identifier) do
313
+ connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]
314
+ @subscription_confirmation_sent = true
315
+ end
316
+ end
317
+ end
318
+
319
+ def reject_subscription
320
+ connection.subscriptions.remove_subscription self
321
+ transmit_subscription_rejection
322
+ end
323
+
324
+ def transmit_subscription_rejection
325
+ logger.debug "#{self.class.name} is transmitting the subscription rejection"
326
+
327
+ ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name, identifier: @identifier) do
328
+ connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
334
+
335
+ ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/core_ext/object/to_param"
6
+
7
+ module ActionCable
8
+ module Channel
9
+ module Broadcasting
10
+ extend ActiveSupport::Concern
11
+
12
+ module ClassMethods
13
+ # Broadcast a hash to a unique broadcasting for this `model` in this channel.
14
+ def broadcast_to(model, message)
15
+ ActionCable.server.broadcast(broadcasting_for(model), message)
16
+ end
17
+
18
+ # Returns a unique broadcasting identifier for this `model` in this channel:
19
+ #
20
+ # CommentsChannel.broadcasting_for("all") # => "comments:all"
21
+ #
22
+ # You can pass any object as a target (e.g. Active Record model), and it would
23
+ # be serialized into a string under the hood.
24
+ def broadcasting_for(model)
25
+ serialize_broadcasting([ channel_name, model ])
26
+ end
27
+
28
+ private
29
+ def serialize_broadcasting(object) # :nodoc:
30
+ case
31
+ when object.is_a?(Array)
32
+ object.map { |m| serialize_broadcasting(m) }.join(":")
33
+ when object.respond_to?(:to_gid_param)
34
+ object.to_gid_param
35
+ else
36
+ object.to_param
37
+ end
38
+ end
39
+ end
40
+
41
+ def broadcasting_for(model)
42
+ self.class.broadcasting_for(model)
43
+ end
44
+
45
+ def broadcast_to(model, message)
46
+ self.class.broadcast_to(model, message)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/callbacks"
6
+
7
+ module ActionCable
8
+ module Channel
9
+ # # Action Cable Channel Callbacks
10
+ #
11
+ # Action Cable Channel provides callback hooks that are invoked during the life
12
+ # cycle of a channel:
13
+ #
14
+ # * [before_subscribe](rdoc-ref:ClassMethods#before_subscribe)
15
+ # * [after_subscribe](rdoc-ref:ClassMethods#after_subscribe) (aliased as
16
+ # [on_subscribe](rdoc-ref:ClassMethods#on_subscribe))
17
+ # * [before_unsubscribe](rdoc-ref:ClassMethods#before_unsubscribe)
18
+ # * [after_unsubscribe](rdoc-ref:ClassMethods#after_unsubscribe) (aliased as
19
+ # [on_unsubscribe](rdoc-ref:ClassMethods#on_unsubscribe))
20
+ #
21
+ #
22
+ # #### Example
23
+ #
24
+ # class ChatChannel < ApplicationCable::Channel
25
+ # after_subscribe :send_welcome_message, unless: :subscription_rejected?
26
+ # after_subscribe :track_subscription
27
+ #
28
+ # private
29
+ # def send_welcome_message
30
+ # broadcast_to(...)
31
+ # end
32
+ #
33
+ # def track_subscription
34
+ # # ...
35
+ # end
36
+ # end
37
+ #
38
+ module Callbacks
39
+ extend ActiveSupport::Concern
40
+ include ActiveSupport::Callbacks
41
+
42
+ included do
43
+ define_callbacks :subscribe
44
+ define_callbacks :unsubscribe
45
+ end
46
+
47
+ module ClassMethods
48
+ def before_subscribe(*methods, &block)
49
+ set_callback(:subscribe, :before, *methods, &block)
50
+ end
51
+
52
+ # This callback will be triggered after the Base#subscribed method is called,
53
+ # even if the subscription was rejected with the Base#reject method.
54
+ #
55
+ # To trigger the callback only on successful subscriptions, use the
56
+ # Base#subscription_rejected? method:
57
+ #
58
+ # after_subscribe :my_method, unless: :subscription_rejected?
59
+ #
60
+ def after_subscribe(*methods, &block)
61
+ set_callback(:subscribe, :after, *methods, &block)
62
+ end
63
+ alias_method :on_subscribe, :after_subscribe
64
+
65
+ def before_unsubscribe(*methods, &block)
66
+ set_callback(:unsubscribe, :before, *methods, &block)
67
+ end
68
+
69
+ def after_unsubscribe(*methods, &block)
70
+ set_callback(:unsubscribe, :after, *methods, &block)
71
+ end
72
+ alias_method :on_unsubscribe, :after_unsubscribe
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Channel
7
+ module Naming
8
+ extend ActiveSupport::Concern
9
+
10
+ module ClassMethods
11
+ # Returns the name of the channel, underscored, without the `Channel` ending. If
12
+ # the channel is in a namespace, then the namespaces are represented by single
13
+ # colon separators in the channel name.
14
+ #
15
+ # ChatChannel.channel_name # => 'chat'
16
+ # Chats::AppearancesChannel.channel_name # => 'chats:appearances'
17
+ # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
18
+ def channel_name
19
+ @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore
20
+ end
21
+ end
22
+
23
+ def channel_name
24
+ self.class.channel_name
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Channel
7
+ module PeriodicTimers
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :periodic_timers, instance_reader: false, default: []
12
+
13
+ after_subscribe :start_periodic_timers
14
+ after_unsubscribe :stop_periodic_timers
15
+ end
16
+
17
+ module ClassMethods
18
+ # Periodically performs a task on the channel, like updating an online user
19
+ # counter, polling a backend for new status messages, sending regular
20
+ # "heartbeat" messages, or doing some internal work and giving progress updates.
21
+ #
22
+ # Pass a method name or lambda argument or provide a block to call. Specify the
23
+ # calling period in seconds using the `every:` keyword argument.
24
+ #
25
+ # periodically :transmit_progress, every: 5.seconds
26
+ #
27
+ # periodically every: 3.minutes do
28
+ # transmit action: :update_count, count: current_count
29
+ # end
30
+ #
31
+ def periodically(callback_or_method_name = nil, every:, &block)
32
+ callback =
33
+ if block_given?
34
+ raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name
35
+ block
36
+ else
37
+ case callback_or_method_name
38
+ when Proc
39
+ callback_or_method_name
40
+ when Symbol
41
+ -> { __send__ callback_or_method_name }
42
+ else
43
+ raise ArgumentError, "Expected a Symbol method name or a Proc, got #{callback_or_method_name.inspect}"
44
+ end
45
+ end
46
+
47
+ unless every.kind_of?(Numeric) && every > 0
48
+ raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}"
49
+ end
50
+
51
+ self.periodic_timers += [[ callback, every: every ]]
52
+ end
53
+ end
54
+
55
+ private
56
+ def active_periodic_timers
57
+ @active_periodic_timers ||= []
58
+ end
59
+
60
+ def start_periodic_timers
61
+ self.class.periodic_timers.each do |callback, options|
62
+ active_periodic_timers << start_periodic_timer(callback, every: options.fetch(:every))
63
+ end
64
+ end
65
+
66
+ def start_periodic_timer(timer_callback, every:)
67
+ # A callback must be executed within the channel context
68
+ callback = -> { instance_exec(&timer_callback) }
69
+
70
+ connection.executor.timer(every) do
71
+ connection.perform_work callback, :call
72
+ end
73
+ end
74
+
75
+ def stop_periodic_timers
76
+ active_periodic_timers.each { |timer| timer.shutdown }
77
+ active_periodic_timers.clear
78
+ end
79
+ end
80
+ end
81
+ end