cable_ready 4.5.0 → 5.0.0.pre3

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