cable_ready 4.4.3 → 5.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
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