cable_ready 4.4.3 → 5.0.0.pre1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +238 -155
  3. data/Gemfile.lock +144 -100
  4. data/LATEST +1 -0
  5. data/README.md +13 -13
  6. data/Rakefile +8 -2
  7. data/app/channels/cable_ready/stream.rb +12 -0
  8. data/app/helpers/cable_ready_helper.rb +11 -0
  9. data/app/jobs/cable_ready_broadcast_job.rb +14 -0
  10. data/bin/standardize +1 -1
  11. data/lib/cable_ready.rb +41 -0
  12. data/lib/cable_ready/broadcaster.rb +3 -4
  13. data/lib/cable_ready/cable_car.rb +17 -0
  14. data/lib/cable_ready/channel.rb +14 -36
  15. data/lib/cable_ready/channels.rb +22 -65
  16. data/lib/cable_ready/compoundable.rb +11 -0
  17. data/lib/cable_ready/config.rb +78 -0
  18. data/lib/cable_ready/identifiable.rb +19 -0
  19. data/lib/cable_ready/operation_builder.rb +69 -0
  20. data/lib/cable_ready/sanity_checker.rb +151 -0
  21. data/lib/cable_ready/stream_identifier.rb +13 -0
  22. data/lib/cable_ready/version.rb +1 -1
  23. data/lib/generators/cable_ready/channel_generator.rb +71 -0
  24. data/lib/generators/cable_ready/initializer_generator.rb +14 -0
  25. data/lib/generators/cable_ready/stream_from_generator.rb +43 -0
  26. data/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb +18 -0
  27. data/test/lib/cable_ready/cable_car_test.rb +28 -0
  28. data/test/lib/cable_ready/identifiable_test.rb +75 -0
  29. data/test/lib/cable_ready/operation_builder_test.rb +128 -0
  30. data/test/lib/generators/cable_ready/channel_generator_test.rb +157 -0
  31. data/test/support/generator_test_helpers.rb +28 -0
  32. data/test/test_helper.rb +15 -0
  33. metadata +66 -15
  34. data/cable_ready.gemspec +0 -25
  35. data/package.json +0 -36
  36. data/tags +0 -57
  37. data/yarn.lock +0 -2552
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CableReadyBroadcastJob < (defined?(ActiveJob::Base) ? ActiveJob::Base : Object)
4
+ include CableReady::Broadcaster
5
+ queue_as :default if defined?(ActiveJob::Base)
6
+
7
+ def perform(identifier:, operations:, model: nil)
8
+ if model.present?
9
+ cable_ready[identifier.safe_constantize].apply!(operations).broadcast_to(model)
10
+ else
11
+ cable_ready[identifier].apply!(operations).broadcast
12
+ end
13
+ end
14
+ end
data/bin/standardize CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  bundle exec magic_frozen_string_literal
4
4
  bundle exec standardrb --fix
5
- yarn run prettier-standard ./javascript/*.js
5
+ yarn format
data/lib/cable_ready.rb CHANGED
@@ -1,11 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/engine"
4
+ require "open-uri"
5
+ require "active_record"
6
+ require "action_view"
4
7
  require "active_support/all"
8
+ require "thread/local"
9
+ require "monitor"
10
+ require "observer"
11
+ require "singleton"
5
12
  require "cable_ready/version"
13
+ require "cable_ready/identifiable"
14
+ require "cable_ready/operation_builder"
15
+ require "cable_ready/config"
6
16
  require "cable_ready/broadcaster"
17
+ require "cable_ready/sanity_checker"
18
+ require "cable_ready/compoundable"
19
+ require "cable_ready/channel"
20
+ require "cable_ready/channels"
21
+ require "cable_ready/cable_car"
22
+ require "cable_ready/stream_identifier"
7
23
 
8
24
  module CableReady
9
25
  class Engine < Rails::Engine
26
+ initializer "cable_ready.sanity_check" do
27
+ SanityChecker.check! unless Rails.env.production?
28
+ end
29
+
30
+ initializer "renderer" do
31
+ ActiveSupport.on_load(:action_controller) do
32
+ ActionController::Renderers.add :operations do |operations, options|
33
+ render json: operations.dispatch
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ class << self
40
+ def config
41
+ CableReady::Config.instance
42
+ end
43
+
44
+ def configure
45
+ yield config
46
+ end
47
+
48
+ def signed_stream_verifier
49
+ @signed_stream_verifier ||= ActiveSupport::MessageVerifier.new(config.verifier_key, digest: "SHA256", serializer: JSON)
50
+ end
10
51
  end
11
52
  end
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "channels"
4
-
5
3
  module CableReady
6
4
  module Broadcaster
5
+ include Identifiable
7
6
  extend ::ActiveSupport::Concern
8
7
 
9
8
  def cable_ready
10
9
  CableReady::Channels.instance
11
10
  end
12
11
 
13
- def dom_id(record, prefix = nil)
14
- "##{ActionView::RecordIdentifier.dom_id(record, prefix)}"
12
+ def cable_car
13
+ CableReady::CableCar.instance
15
14
  end
16
15
  end
17
16
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableReady
4
+ class CableCar < OperationBuilder
5
+ extend Thread::Local
6
+
7
+ def initialize
8
+ super "CableCar"
9
+ end
10
+
11
+ def dispatch(clear: true)
12
+ payload = operations_payload
13
+ reset! if clear
14
+ payload
15
+ end
16
+ end
17
+ end
@@ -1,49 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CableReady
4
- class Channel
5
- attr_reader :identifier, :operations, :available_operations
4
+ class Channel < OperationBuilder
5
+ attr_reader :identifier
6
6
 
7
- def initialize(identifier, available_operations)
8
- @identifier = identifier
9
- @available_operations = available_operations
10
- reset
11
- available_operations.each do |available_operation, implementation|
12
- define_singleton_method available_operation, &implementation
13
- end
7
+ def broadcast(clear: true)
8
+ ActionCable.server.broadcast identifier, {"cableReady" => true, "operations" => operations_payload}
9
+ reset! if clear
14
10
  end
15
11
 
16
- def channel_broadcast(clear)
17
- operations.select! { |_, list| list.present? }
18
- operations.deep_transform_keys! { |key| key.to_s.camelize(:lower) }
19
- ActionCable.server.broadcast identifier, {"cableReady" => true, "operations" => operations}
20
- reset if clear
12
+ def broadcast_to(model, clear: true)
13
+ identifier.broadcast_to model, {"cableReady" => true, "operations" => operations_payload}
14
+ reset! if clear
21
15
  end
22
16
 
23
- def channel_broadcast_to(model, clear)
24
- operations.select! { |_, list| list.present? }
25
- operations.deep_transform_keys! { |key| key.to_s.camelize(:lower) }
26
- identifier.broadcast_to model, {"cableReady" => true, "operations" => operations}
27
- reset if clear
17
+ def broadcast_later(clear: true)
18
+ CableReadyBroadcastJob.perform_later(identifier: identifier, operations: operations_payload)
19
+ reset! if clear
28
20
  end
29
21
 
30
- def broadcast(clear = true)
31
- CableReady::Channels.instance.broadcast(identifier, clear: clear)
32
- end
33
-
34
- def broadcast_to(model, clear = true)
35
- CableReady::Channels.instance.broadcast_to(model, identifier, clear: clear)
36
- end
37
-
38
- private
39
-
40
- def enqueue_operation(key, options)
41
- operations[key] << options
42
- self
43
- end
44
-
45
- def reset
46
- @operations = Hash.new { |hash, operation| hash[operation] = [] }
22
+ def broadcast_later_to(model, clear: true)
23
+ CableReadyBroadcastJob.perform_later(identifier: identifier.name, operations: operations_payload, model: model)
24
+ reset! if clear
47
25
  end
48
26
  end
49
27
  end
@@ -1,83 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "channel"
4
-
5
3
  module CableReady
4
+ # This class is a thread local singleton: CableReady::Channels.instance
5
+ # SEE: https://github.com/socketry/thread-local/tree/master/guides/getting-started
6
6
  class Channels
7
- include Singleton
8
- attr_accessor :operations
9
-
10
- def self.configure
11
- yield CableReady::Channels.instance if block_given?
12
- end
7
+ include Compoundable
8
+ extend Thread::Local
13
9
 
14
10
  def initialize
15
11
  @channels = {}
16
- @operations = {}
17
- %i[
18
- add_css_class
19
- console_log
20
- dispatch_event
21
- inner_html
22
- insert_adjacent_html
23
- insert_adjacent_text
24
- morph
25
- notification
26
- outer_html
27
- push_state
28
- remove
29
- remove_attribute
30
- remove_css_class
31
- set_attribute
32
- set_cookie
33
- set_dataset_property
34
- set_focus
35
- set_property
36
- set_style
37
- set_styles
38
- set_value
39
- text_content
40
- ].each do |operation|
41
- add_operation operation
42
- end
43
- end
44
-
45
- def add_operation(operation, &implementation)
46
- @operations[operation] = implementation || ->(options = {}) { enqueue_operation(operation, options) }
47
12
  end
48
13
 
49
- def [](identifier)
50
- @channels[identifier] ||= CableReady::Channel.new(identifier, operations)
14
+ def [](*keys)
15
+ keys.select!(&:itself)
16
+ identifier = keys.many? || (keys.one? && keys.first.is_a?(ActiveRecord::Base)) ? compound(keys) : keys.pop
17
+ @channels[identifier] ||= CableReady::Channel.new(identifier)
51
18
  end
52
19
 
53
20
  def broadcast(*identifiers, clear: true)
54
- mutex.synchronize do
55
- @channels.values
56
- .reject { |channel| identifiers.any? && identifiers.exclude?(channel.identifier) }
57
- .select { |channel| channel.identifier.is_a?(String) }
58
- .tap do |channels|
59
- channels.each { |channel| @channels[channel.identifier].channel_broadcast(clear) }
60
- channels.each { |channel| @channels.except!(channel.identifier) if clear }
61
- end
62
- end
21
+ @channels.values
22
+ .reject { |channel| identifiers.any? && identifiers.exclude?(channel.identifier) }
23
+ .select { |channel| channel.identifier.is_a?(String) }
24
+ .tap do |channels|
25
+ channels.each { |channel| @channels[channel.identifier].broadcast(clear: clear) }
26
+ channels.each { |channel| @channels.except!(channel.identifier) if clear }
27
+ end
63
28
  end
64
29
 
65
30
  def broadcast_to(model, *identifiers, clear: true)
66
- mutex.synchronize do
67
- @channels.values
68
- .reject { |channel| identifiers.any? && identifiers.exclude?(channel.identifier) }
69
- .reject { |channel| channel.identifier.is_a?(String) }
70
- .tap do |channels|
71
- channels.each { |channel| @channels[channel.identifier].channel_broadcast_to(model, clear) }
72
- channels.each { |channel| @channels.except!(channel.identifier) if clear }
73
- end
74
- end
75
- end
76
-
77
- private
78
-
79
- def mutex
80
- @mutex ||= Mutex.new
31
+ @channels.values
32
+ .reject { |channel| identifiers.any? && identifiers.exclude?(channel.identifier) }
33
+ .reject { |channel| channel.identifier.is_a?(String) }
34
+ .tap do |channels|
35
+ channels.each { |channel| @channels[channel.identifier].broadcast_to(model, clear: clear) }
36
+ channels.each { |channel| @channels.except!(channel.identifier) if clear }
37
+ end
81
38
  end
82
39
  end
83
40
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableReady
4
+ module Compoundable
5
+ def compound(keys)
6
+ keys.map { |key|
7
+ key.class < ActiveRecord::Base ? key.to_global_id.to_s : key.to_s
8
+ }.join(":")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableReady
4
+ # This class is a process level singleton shared by all threads: CableReady::Config.instance
5
+ class Config
6
+ include MonitorMixin
7
+ include Observable
8
+ include Singleton
9
+
10
+ attr_accessor :on_failed_sanity_checks, :on_new_version_available
11
+ attr_writer :verifier_key
12
+
13
+ def initialize
14
+ super
15
+ @operation_names = Set.new(default_operation_names)
16
+ @on_failed_sanity_checks = :exit
17
+ @on_new_version_available = :ignore
18
+ end
19
+
20
+ def observers
21
+ @observer_peers&.keys || []
22
+ end
23
+
24
+ def verifier_key
25
+ @verifier_key || Rails.application.key_generator.generate_key("cable_ready/verifier_key")
26
+ end
27
+
28
+ def operation_names
29
+ @operation_names.to_a
30
+ end
31
+
32
+ def add_operation_name(name)
33
+ synchronize do
34
+ @operation_names << name.to_sym
35
+ notify_observers name.to_sym
36
+ end
37
+ end
38
+
39
+ def default_operation_names
40
+ Set.new(%i[
41
+ add_css_class
42
+ append
43
+ clear_storage
44
+ console_log
45
+ console_table
46
+ dispatch_event
47
+ go
48
+ graft
49
+ inner_html
50
+ insert_adjacent_html
51
+ insert_adjacent_text
52
+ morph
53
+ notification
54
+ outer_html
55
+ prepend
56
+ push_state
57
+ remove
58
+ remove_attribute
59
+ remove_css_class
60
+ remove_storage_item
61
+ replace
62
+ replace_state
63
+ scroll_into_view
64
+ set_attribute
65
+ set_cookie
66
+ set_dataset_property
67
+ set_focus
68
+ set_meta
69
+ set_property
70
+ set_storage_item
71
+ set_style
72
+ set_styles
73
+ set_value
74
+ text_content
75
+ ]).freeze
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableReady
4
+ module Identifiable
5
+ def dom_id(record, prefix = nil)
6
+ prefix = prefix.to_s.strip if prefix
7
+
8
+ id = if record.is_a?(ActiveRecord::Relation)
9
+ [prefix, record.model_name.plural].compact.join("_")
10
+ elsif record.is_a?(ActiveRecord::Base)
11
+ ActionView::RecordIdentifier.dom_id(record, prefix)
12
+ else
13
+ [prefix, record.to_s.strip].compact.join("_")
14
+ end
15
+
16
+ "##{id}".squeeze("#").strip
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableReady
4
+ class OperationBuilder
5
+ include Identifiable
6
+ attr_reader :identifier, :previous_selector
7
+
8
+ def self.finalizer_for(identifier)
9
+ proc {
10
+ channel = CableReady.config.observers.find { |o| o.try(:identifier) == identifier }
11
+ CableReady.config.delete_observer channel if channel
12
+ }
13
+ end
14
+
15
+ def initialize(identifier)
16
+ @identifier = identifier
17
+
18
+ reset!
19
+ CableReady.config.operation_names.each { |name| add_operation_method name }
20
+ CableReady.config.add_observer self, :add_operation_method
21
+ ObjectSpace.define_finalizer self, self.class.finalizer_for(identifier)
22
+ end
23
+
24
+ def add_operation_method(name)
25
+ return if respond_to?(name)
26
+ singleton_class.public_send :define_method, name, ->(*args) {
27
+ selector, options = nil, args.first || {} # 1 or 0 params
28
+ selector, options = options, {} unless options.is_a?(Hash) # swap if only selector provided
29
+ selector, options = args[0, 2] if args.many? # 2 or more params
30
+ options.stringify_keys!
31
+ options["selector"] = selector if selector && options.exclude?("selector")
32
+ options["selector"] = previous_selector if previous_selector && options.exclude?("selector")
33
+ if options.include?("selector")
34
+ @previous_selector = options["selector"]
35
+ options["selector"] = previous_selector.is_a?(ActiveRecord::Base) || previous_selector.is_a?(ActiveRecord::Relation) ? dom_id(previous_selector) : previous_selector
36
+ end
37
+ @enqueued_operations[name.to_s] << options
38
+ self
39
+ }
40
+ end
41
+
42
+ def to_json(*args)
43
+ @enqueued_operations.to_json(*args)
44
+ end
45
+
46
+ def apply!(operations = "{}")
47
+ operations = begin
48
+ JSON.parse(operations.is_a?(String) ? operations : operations.to_json)
49
+ rescue JSON::ParserError
50
+ {}
51
+ end
52
+ operations.each do |name, operation|
53
+ operation.each do |enqueued_operation|
54
+ @enqueued_operations[name.to_s] << enqueued_operation
55
+ end
56
+ end
57
+ self
58
+ end
59
+
60
+ def operations_payload
61
+ @enqueued_operations.select { |_, list| list.present? }.deep_transform_keys { |key| key.to_s.camelize(:lower) }
62
+ end
63
+
64
+ def reset!
65
+ @enqueued_operations = Hash.new { |hash, key| hash[key] = [] }
66
+ @previous_selector = nil
67
+ end
68
+ end
69
+ end