cable_ready 4.5.0 → 5.0.0.pre3

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 +260 -161
  3. data/Gemfile.lock +141 -96
  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/cable_ready.gemspec +3 -2
  12. data/lib/cable_ready.rb +40 -5
  13. data/lib/cable_ready/broadcaster.rb +3 -4
  14. data/lib/cable_ready/cable_car.rb +17 -0
  15. data/lib/cable_ready/channel.rb +12 -32
  16. data/lib/cable_ready/channels.rb +4 -7
  17. data/lib/cable_ready/compoundable.rb +11 -0
  18. data/lib/cable_ready/config.rb +17 -5
  19. data/lib/cable_ready/identifiable.rb +30 -0
  20. data/lib/cable_ready/operation_builder.rb +80 -0
  21. data/lib/cable_ready/sanity_checker.rb +151 -0
  22. data/lib/cable_ready/stream_identifier.rb +13 -0
  23. data/lib/cable_ready/version.rb +1 -1
  24. data/lib/generators/cable_ready/channel_generator.rb +71 -0
  25. data/lib/generators/cable_ready/initializer_generator.rb +14 -0
  26. data/lib/generators/cable_ready/stream_from_generator.rb +43 -0
  27. data/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb +18 -0
  28. data/package.json +10 -5
  29. data/test/lib/cable_ready/cable_car_test.rb +50 -0
  30. data/test/lib/cable_ready/identifiable_test.rb +75 -0
  31. data/test/lib/cable_ready/operation_builder_test.rb +189 -0
  32. data/test/lib/generators/cable_ready/channel_generator_test.rb +157 -0
  33. data/test/support/generator_test_helpers.rb +28 -0
  34. data/test/test_helper.rb +15 -0
  35. data/yarn.lock +137 -127
  36. metadata +47 -8
  37. data/tags +0 -62
@@ -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/cable_ready.gemspec CHANGED
@@ -8,10 +8,10 @@ Gem::Specification.new do |gem|
8
8
  gem.version = CableReady::VERSION
9
9
  gem.authors = ["Nathan Hopkins"]
10
10
  gem.email = ["natehop@gmail.com"]
11
- gem.homepage = "https://github.com/hopsoft/cable_ready"
11
+ gem.homepage = "https://github.com/stimulusreflex/cable_ready"
12
12
  gem.summary = "Out-of-Band Server Triggered DOM Operations"
13
13
 
14
- gem.files = Dir["lib/**/*.rb", "app/assets/javascripts/cable_ready.js", "bin/*", "[A-Z]*"]
14
+ gem.files = Dir["lib/**/*.rb", "app/**/*.rb", "bin/*", "[A-Z]*"]
15
15
  gem.test_files = Dir["test/**/*.rb"]
16
16
 
17
17
  gem.add_dependency "rails", ">= 5.2"
@@ -23,4 +23,5 @@ Gem::Specification.new do |gem|
23
23
  gem.add_development_dependency "pry-nav"
24
24
  gem.add_development_dependency "rake"
25
25
  gem.add_development_dependency "standardrb"
26
+ gem.add_development_dependency "mocha"
26
27
  end
data/lib/cable_ready.rb CHANGED
@@ -1,20 +1,55 @@
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"
6
15
  require "cable_ready/config"
7
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"
8
23
 
9
24
  module CableReady
10
25
  class Engine < Rails::Engine
11
- end
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
+ response.content_type ||= Mime[:cable_ready]
34
+ render json: operations.dispatch
35
+ end
12
36
 
13
- def self.config
14
- CableReady::Config.instance
37
+ Mime::Type.register "application/vnd.cable-ready.json", :cable_ready
38
+ end
39
+ end
15
40
  end
16
41
 
17
- def self.configure
18
- yield config
42
+ class << self
43
+ def config
44
+ CableReady::Config.instance
45
+ end
46
+
47
+ def configure
48
+ yield config
49
+ end
50
+
51
+ def signed_stream_verifier
52
+ @signed_stream_verifier ||= ActiveSupport::MessageVerifier.new(config.verifier_key, digest: "SHA256", serializer: JSON)
53
+ end
19
54
  end
20
55
  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,47 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CableReady
4
- class Channel
5
- attr_reader :identifier, :enqueued_operations
6
-
7
- def initialize(identifier)
8
- @identifier = identifier
9
- reset
10
- CableReady.config.operation_names.each { |name| add_operation_method name }
11
-
12
- config_observer = self
13
- CableReady.config.add_observer config_observer, :add_operation_method
14
- ObjectSpace.define_finalizer self, -> { CableReady.config.delete_observer config_observer }
15
- end
4
+ class Channel < OperationBuilder
5
+ attr_reader :identifier
16
6
 
17
7
  def broadcast(clear: true)
18
- ActionCable.server.broadcast identifier, {"cableReady" => true, "operations" => broadcastable_operations}
19
- reset if clear
8
+ ActionCable.server.broadcast identifier, {"cableReady" => true, "operations" => operations_payload}
9
+ reset! if clear
20
10
  end
21
11
 
22
12
  def broadcast_to(model, clear: true)
23
- identifier.broadcast_to model, {"cableReady" => true, "operations" => broadcastable_operations}
24
- reset if clear
13
+ identifier.broadcast_to model, {"cableReady" => true, "operations" => operations_payload}
14
+ reset! if clear
25
15
  end
26
16
 
27
- def add_operation_method(name)
28
- return if respond_to?(name)
29
- singleton_class.public_send :define_method, name, ->(options = {}) {
30
- enqueued_operations[name.to_s] << options.stringify_keys
31
- self # supports operation chaining
32
- }
33
- end
34
-
35
- private
36
-
37
- def reset
38
- @enqueued_operations = Hash.new { |hash, key| hash[key] = [] }
17
+ def broadcast_later(clear: true)
18
+ CableReadyBroadcastJob.perform_later(identifier: identifier, operations: operations_payload)
19
+ reset! if clear
39
20
  end
40
21
 
41
- def broadcastable_operations
42
- enqueued_operations
43
- .select { |_, list| list.present? }
44
- .deep_transform_keys! { |key| key.to_s.camelize(:lower) }
22
+ def broadcast_later_to(model, clear: true)
23
+ CableReadyBroadcastJob.perform_later(identifier: identifier.name, operations: operations_payload, model: model)
24
+ reset! if clear
45
25
  end
46
26
  end
47
27
  end
@@ -1,22 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thread/local"
4
- require_relative "channel"
5
-
6
3
  module CableReady
7
4
  # This class is a thread local singleton: CableReady::Channels.instance
8
5
  # SEE: https://github.com/socketry/thread-local/tree/master/guides/getting-started
9
6
  class Channels
7
+ include Compoundable
10
8
  extend Thread::Local
11
9
 
12
- attr_accessor :operations
13
-
14
10
  def initialize
15
11
  @channels = {}
16
- @operations = {}
17
12
  end
18
13
 
19
- def [](identifier)
14
+ def [](*keys)
15
+ keys.select!(&:itself)
16
+ identifier = keys.many? || (keys.one? && keys.first.is_a?(ActiveRecord::Base)) ? compound(keys) : keys.pop
20
17
  @channels[identifier] ||= CableReady::Channel.new(identifier)
21
18
  end
22
19
 
@@ -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
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
- require "observer"
5
- require "singleton"
6
-
7
3
  module CableReady
8
4
  # This class is a process level singleton shared by all threads: CableReady::Config.instance
9
5
  class Config
@@ -11,9 +7,22 @@ module CableReady
11
7
  include Observable
12
8
  include Singleton
13
9
 
10
+ attr_accessor :on_failed_sanity_checks, :on_new_version_available
11
+ attr_writer :verifier_key
12
+
14
13
  def initialize
15
14
  super
16
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")
17
26
  end
18
27
 
19
28
  def operation_names
@@ -33,6 +42,7 @@ module CableReady
33
42
  append
34
43
  clear_storage
35
44
  console_log
45
+ console_table
36
46
  dispatch_event
37
47
  go
38
48
  graft
@@ -42,9 +52,10 @@ module CableReady
42
52
  morph
43
53
  notification
44
54
  outer_html
45
- play_sound
46
55
  prepend
47
56
  push_state
57
+ redirect_to
58
+ reload
48
59
  remove
49
60
  remove_attribute
50
61
  remove_css_class
@@ -56,6 +67,7 @@ module CableReady
56
67
  set_cookie
57
68
  set_dataset_property
58
69
  set_focus
70
+ set_meta
59
71
  set_property
60
72
  set_storage_item
61
73
  set_style
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableReady
4
+ module Identifiable
5
+ def dom_id(record, prefix = nil)
6
+ return record.to_dom_selector if record.respond_to?(:to_dom_selector)
7
+
8
+ prefix = prefix.to_s.strip if prefix
9
+
10
+ id = if record.respond_to?(:to_dom_id)
11
+ record.to_dom_id
12
+ elsif record.is_a?(ActiveRecord::Relation)
13
+ [prefix, record.model_name.plural].compact.join("_")
14
+ elsif record.is_a?(ActiveRecord::Base)
15
+ ActionView::RecordIdentifier.dom_id(record, prefix)
16
+ else
17
+ [prefix, record.to_s.strip].compact.join("_")
18
+ end
19
+
20
+ "##{id}".squeeze("#").strip.downcase
21
+ end
22
+
23
+ def identifiable?(obj)
24
+ obj.respond_to?(:to_dom_selector) ||
25
+ obj.respond_to?(:to_dom_id) ||
26
+ obj.is_a?(ActiveRecord::Relation) ||
27
+ obj.is_a?(ActiveRecord::Base)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,80 @@
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
+ if args.one? && args.first.respond_to?(:to_operation_options) && [Array, Hash].include?(args.first.to_operation_options.class)
28
+ case args.first.to_operation_options
29
+ when Array
30
+ selector, options = nil, args.first.to_operation_options
31
+ .select { |e| e.is_a?(Symbol) && args.first.respond_to?("to_#{e}".to_sym) }
32
+ .each_with_object({}) { |option, memo| memo[option.to_s] = args.first.send("to_#{option}".to_sym) }
33
+ when Hash
34
+ selector, options = nil, args.first.to_operation_options
35
+ else
36
+ raise TypeError, ":to_operation_options returned an #{args.first.to_operation_options.class.name}. Must be an Array or Hash."
37
+ end
38
+ else
39
+ selector, options = nil, args.first || {} # 1 or 0 params
40
+ selector, options = options, {} unless options.is_a?(Hash) # swap if only selector provided
41
+ selector, options = args[0, 2] if args.many? # 2 or more params
42
+ options.stringify_keys!
43
+ options.each { |key, value| options[key] = value.send("to_#{key}".to_sym) if value.respond_to?("to_#{key}".to_sym) }
44
+ end
45
+ options["selector"] = selector if selector && options.exclude?("selector")
46
+ options["selector"] = previous_selector if previous_selector && options.exclude?("selector")
47
+ if options.include?("selector")
48
+ @previous_selector = options["selector"]
49
+ options["selector"] = identifiable?(previous_selector) ? dom_id(previous_selector) : previous_selector
50
+ end
51
+ options["operation"] = name.to_s.camelize(:lower)
52
+ @enqueued_operations << options
53
+ self
54
+ }
55
+ end
56
+
57
+ def to_json(*args)
58
+ @enqueued_operations.to_json(*args)
59
+ end
60
+
61
+ def apply!(operations = "[]")
62
+ operations = begin
63
+ JSON.parse(operations.is_a?(String) ? operations : operations.to_json)
64
+ rescue JSON::ParserError
65
+ {}
66
+ end
67
+ @enqueued_operations.push(operations)
68
+ self
69
+ end
70
+
71
+ def operations_payload
72
+ @enqueued_operations.map { |operation| operation.deep_transform_keys! { |key| key.to_s.camelize(:lower) } }
73
+ end
74
+
75
+ def reset!
76
+ @enqueued_operations = []
77
+ @previous_selector = nil
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CableReady::SanityChecker
4
+ LATEST_VERSION_FORMAT = /^(\d+\.\d+\.\d+)$/
5
+ NODE_VERSION_FORMAT = /(\d+\.\d+\.\d+.*):/
6
+ JSON_VERSION_FORMAT = /(\d+\.\d+\.\d+.*)"/
7
+
8
+ class << self
9
+ def check!
10
+ return if ENV["SKIP_SANITY_CHECK"]
11
+ return if CableReady.config.on_failed_sanity_checks == :ignore
12
+ return if called_by_generate_config?
13
+ return if called_by_rake?
14
+
15
+ instance = new
16
+ instance.check_package_versions_match
17
+ instance.check_new_version_available
18
+ end
19
+
20
+ private
21
+
22
+ def called_by_generate_config?
23
+ ARGV.include?("cable_ready:initializer")
24
+ end
25
+
26
+ def called_by_rake?
27
+ File.basename($PROGRAM_NAME) == "rake"
28
+ end
29
+ end
30
+
31
+ def check_package_versions_match
32
+ if npm_version.nil?
33
+ warn_and_exit <<~WARN
34
+ 👉 Can't locate the cable_ready npm package.
35
+
36
+ yarn add cable_ready@#{gem_version}
37
+
38
+ Either add it to your package.json as a dependency or use "yarn link cable_ready" if you are doing development.
39
+ WARN
40
+ end
41
+
42
+ if package_version_mismatch?
43
+ warn_and_exit <<~WARN
44
+ 👉 The cable_ready npm package version (#{npm_version}) does not match the Rubygem version (#{gem_version}).
45
+
46
+ To update the cable_ready npm package:
47
+
48
+ yarn upgrade cable_ready@#{gem_version}
49
+ WARN
50
+ end
51
+ end
52
+
53
+ def check_new_version_available
54
+ return if CableReady.config.on_new_version_available == :ignore
55
+ return if Rails.env.development? == false
56
+ return if using_preview_release?
57
+ begin
58
+ latest_version = URI.open("https://raw.githubusercontent.com/stimulusreflex/cable_ready/master/LATEST", open_timeout: 1, read_timeout: 1).read.strip
59
+ if latest_version != CableReady::VERSION
60
+ puts <<~WARN
61
+
62
+ 👉 There is a new version of CableReady available!
63
+ Current: #{CableReady::VERSION} Latest: #{latest_version}
64
+
65
+ If you upgrade, it is very important that you update BOTH Gemfile and package.json
66
+ Then, run `bundle install && yarn install` to update to #{latest_version}.
67
+
68
+ WARN
69
+ exit if CableReady.config.on_new_version_available == :exit
70
+ end
71
+ rescue
72
+ puts "👉 CableReady #{CableReady::VERSION} update check skipped: connection timeout"
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def package_version_mismatch?
79
+ npm_version != gem_version
80
+ end
81
+
82
+ def using_preview_release?
83
+ preview = CableReady::VERSION.match?(LATEST_VERSION_FORMAT) == false
84
+ puts "👉 CableReady #{CableReady::VERSION} update check skipped: pre-release build" if preview
85
+ preview
86
+ end
87
+
88
+ def gem_version
89
+ @_gem_version ||= CableReady::VERSION.gsub(".pre", "-pre")
90
+ end
91
+
92
+ def npm_version
93
+ @_npm_version ||= find_npm_version
94
+ end
95
+
96
+ def find_npm_version
97
+ if (match = search_file(package_json_path, regex: /version/))
98
+ match[JSON_VERSION_FORMAT, 1]
99
+ elsif (match = search_file(yarn_lock_path, regex: /^cable_ready/))
100
+ match[NODE_VERSION_FORMAT, 1]
101
+ end
102
+ end
103
+
104
+ def search_file(path, regex:)
105
+ return if File.exist?(path) == false
106
+ File.foreach(path).grep(regex).first
107
+ end
108
+
109
+ def package_json_path
110
+ Rails.root.join("node_modules", "cable_ready", "package.json")
111
+ end
112
+
113
+ def yarn_lock_path
114
+ Rails.root.join("yarn.lock")
115
+ end
116
+
117
+ def initializer_missing?
118
+ File.exist?(Rails.root.join("config", "initializers", "cable_ready.rb")) == false
119
+ end
120
+
121
+ def warn_and_exit(text)
122
+ puts
123
+ puts "Heads up! 🔥"
124
+ puts
125
+ puts text
126
+ puts
127
+ if CableReady.config.on_failed_sanity_checks == :exit
128
+ puts <<~INFO
129
+ To ignore any warnings and start the application anyway, you can set the SKIP_SANITY_CHECK environment variable:
130
+
131
+ SKIP_SANITY_CHECK=true rails
132
+
133
+ To do this permanently, add the following directive to the CableReady initializer:
134
+
135
+ CableReady.configure do |config|
136
+ config.on_failed_sanity_checks = :warn
137
+ end
138
+
139
+ INFO
140
+ if initializer_missing?
141
+ puts <<~INFO
142
+ You can create a CableReady initializer with the command:
143
+
144
+ bundle exec rails generate cable_ready:initializer
145
+
146
+ INFO
147
+ end
148
+ exit false if Rails.env.test? == false
149
+ end
150
+ end
151
+ end