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
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