live_cable 0.3.0 → 0.4.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +567 -0
  3. data/README.md +14 -2
  4. data/app/assets/javascript/controllers/live_controller.js +51 -7
  5. data/app/assets/javascript/live_cable.js +2 -0
  6. data/app/assets/javascript/loading.js +58 -6
  7. data/app/assets/javascript/subscriptions.js +292 -22
  8. data/app/channels/live_channel.rb +51 -29
  9. data/app/helpers/live_cable_helper.rb +31 -2
  10. data/lib/live_cable/action_cable_connection.rb +4 -1
  11. data/lib/live_cable/component/broadcasting.rb +37 -6
  12. data/lib/live_cable/component/dispatching.rb +36 -0
  13. data/lib/live_cable/component/lifecycle.rb +1 -1
  14. data/lib/live_cable/component/reactive_variables.rb +33 -5
  15. data/lib/live_cable/component/rendering.rb +30 -14
  16. data/lib/live_cable/component/streaming.rb +35 -4
  17. data/lib/live_cable/component.rb +4 -3
  18. data/lib/live_cable/connection/broadcasting.rb +15 -3
  19. data/lib/live_cable/connection/component_management.rb +12 -8
  20. data/lib/live_cable/connection/error_handling.rb +28 -7
  21. data/lib/live_cable/connection/messaging.rb +40 -52
  22. data/lib/live_cable/connection/state_management.rb +13 -16
  23. data/lib/live_cable/connection.rb +33 -0
  24. data/lib/live_cable/container.rb +17 -11
  25. data/lib/live_cable/defaults_signer.rb +95 -0
  26. data/lib/live_cable/delegation/array.rb +27 -1
  27. data/lib/live_cable/delegation/hash.rb +55 -1
  28. data/lib/live_cable/delegation/methods.rb +25 -1
  29. data/lib/live_cable/delegation/model.rb +12 -0
  30. data/lib/live_cable/delegator.rb +54 -6
  31. data/lib/live_cable/dispatch.rb +54 -0
  32. data/lib/live_cable/engine.rb +3 -1
  33. data/lib/live_cable/forbidden.rb +7 -0
  34. data/lib/live_cable/identity_digest.rb +27 -0
  35. data/lib/live_cable/model_observer.rb +37 -0
  36. data/lib/live_cable/observer_tracking.rb +10 -1
  37. data/lib/live_cable/predicate_builder.rb +13 -0
  38. data/lib/live_cable/render_context.rb +82 -8
  39. data/lib/live_cable/rendering/controller_renderer.rb +26 -0
  40. data/lib/live_cable/rendering/dependency_visitor.rb +68 -24
  41. data/lib/live_cable/rendering/handler.rb +1 -1
  42. data/lib/live_cable/rendering/method_analyzer.rb +18 -4
  43. data/lib/live_cable/rendering/method_collector.rb +13 -5
  44. data/lib/live_cable/rendering/partial.rb +13 -1
  45. data/lib/live_cable/rendering/partial_renderer.rb +59 -7
  46. data/lib/live_cable/rendering/render_result.rb +5 -0
  47. data/lib/live_cable/rendering/renderer.rb +18 -14
  48. data/lib/live_cable/rendering/view_buffer.rb +21 -0
  49. data/lib/live_cable/testing/render_state.rb +5 -3
  50. data/lib/live_cable/testing/test_channel.rb +6 -0
  51. data/lib/live_cable/testing/test_component.rb +3 -3
  52. data/lib/live_cable/testing.rb +9 -5
  53. data/lib/live_cable/version.rb +1 -1
  54. data/lib/live_cable.rb +32 -3
  55. metadata +9 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'monitor'
4
+
3
5
  module LiveCable
4
6
  class Connection
5
7
  include ComponentManagement
@@ -14,6 +16,37 @@ module LiveCable
14
16
  @request = request
15
17
  @containers = Hash.new { |hash, key| hash[key] = Container.new }
16
18
  @components = {}
19
+ @monitor = Monitor.new
20
+ end
21
+
22
+ # Run a block holding this connection's lock.
23
+ #
24
+ # One Connection is shared by every component on a socket, but ActionCable
25
+ # runs a socket's work - each incoming message, each subscribe and
26
+ # unsubscribe, each stream_from callback - as separate jobs on a worker
27
+ # pool shared by the whole server, with nothing keeping two jobs for the
28
+ # same socket apart. Every such entry point holds this lock for its whole
29
+ # duration, action and render included, so a stream callback can't land
30
+ # between an action's write and the push that follows it.
31
+ #
32
+ # A Monitor rather than a Mutex because the calls nest: receive ->
33
+ # broadcast_changeset -> add_component all take it again on one thread.
34
+ # Only ever one connection's lock per job - never take another
35
+ # connection's while holding this one.
36
+ def synchronize(&)
37
+ @monitor.synchronize(&)
38
+ end
39
+
40
+ # A fresh view for one socket render. The controller behind it is kept,
41
+ # and built again when a code reload replaces ApplicationController.
42
+ #
43
+ # @return [ActionView::Base]
44
+ def view_context
45
+ unless @controller.instance_of?(ApplicationController)
46
+ @controller = Rendering::ControllerRenderer.controller_for(ApplicationController)
47
+ end
48
+
49
+ @controller.view_context
17
50
  end
18
51
 
19
52
  private
@@ -18,7 +18,7 @@ module LiveCable
18
18
  # - Shared reactive variables use a special SHARED_CONTAINER
19
19
  # - During message processing, mutations are tracked in a changeset
20
20
  # - After message processing, components with dirty changesets are re-rendered
21
- # - Changesets are reset after broadcasting updates
21
+ # - Changesets are reset when the next message or stream callback starts
22
22
  #
23
23
  # Change Tracking:
24
24
  # 1. Value is stored via []=
@@ -46,11 +46,12 @@ module LiveCable
46
46
  # container[:user] = User.new # ActiveRecord - wrapped in Delegator
47
47
  def []=(key, value)
48
48
  # Remove observer from the old value so it stops notifying this container
49
- self[key].try(:remove_live_cable_observer, observer, key)
49
+ remove_observer_from(self[key], key)
50
50
 
51
- # ActiveRecord models get observers attached directly
52
- if value.class < ModelObserver
53
- value.add_live_cable_observer(observer, key)
51
+ # ActiveRecord models get observers attached directly, even when stored wrapped
52
+ record = value.is_a?(Delegator) ? value.__getobj__ : value
53
+ if record.class < ModelObserver
54
+ record.add_live_cable_observer(observer, key)
54
55
  end
55
56
 
56
57
  # If value is already a Delegator, add observer and store as-is
@@ -59,7 +60,7 @@ module LiveCable
59
60
  super
60
61
  else
61
62
  # Wrap supported types in Delegators for change tracking
62
- super(key, Delegator.create_if_supported(value, key, observer))
63
+ super(key, Delegator.create_if_supported(Delegator.unwrap(value), key, observer))
63
64
  end
64
65
  end
65
66
 
@@ -87,8 +88,8 @@ module LiveCable
87
88
  !@changeset.empty?
88
89
  end
89
90
 
90
- # Clear the changeset after broadcasting updates.
91
- # Called by Connection after all components have been re-rendered.
91
+ # Clear the changeset before tracking a new round of changes.
92
+ # Called by Connection before each message or stream callback.
92
93
  #
93
94
  # @return [void]
94
95
  def reset_changeset
@@ -114,9 +115,7 @@ module LiveCable
114
115
  # Note: We only remove this specific observer, not all observers,
115
116
  # because the same object might be shared across multiple containers
116
117
  each do |variable, value|
117
- if value.respond_to?(:remove_live_cable_observer)
118
- value.remove_live_cable_observer(observer, variable)
119
- end
118
+ remove_observer_from(value, variable)
120
119
  end
121
120
 
122
121
  # Clear the container's data
@@ -124,5 +123,12 @@ module LiveCable
124
123
  @changeset&.clear
125
124
  @observer = nil
126
125
  end
126
+
127
+ private
128
+
129
+ def remove_observer_from(value, key)
130
+ value.try(:remove_live_cable_observer, observer, key)
131
+ value.__getobj__.try(:remove_live_cable_observer, observer, key) if value.is_a?(Delegator)
132
+ end
127
133
  end
128
134
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ # Signs and verifies the reactive-variable defaults that travel from a
5
+ # server-rendered page to the client and back on subscribe.
6
+ #
7
+ # Defaults originate on the server in the `live(...)` helper, but they are
8
+ # emitted into the DOM as a `live-defaults` attribute and re-sent by the
9
+ # client when its ActionCable subscription connects. Because that value
10
+ # round-trips through the browser, it cannot be trusted: without signing, a
11
+ # user could edit the attribute (or craft the subscribe frame) to set *any*
12
+ # reactive variable at subscribe time - including ones deliberately not
13
+ # marked `writable:`, such as a record id, price, or tenant key.
14
+ #
15
+ # The blob is signed with a key derived from secret_key_base and bound to the
16
+ # component's live_id, so a tampered or replayed value verifies as invalid
17
+ # and is treated as no defaults at all.
18
+ module DefaultsSigner
19
+ PURPOSE = 'live_cable/defaults'
20
+
21
+ module_function
22
+
23
+ # Equal defaults always sign to the same blob, so the client can compare
24
+ # blobs to tell whether a component's defaults have changed.
25
+ #
26
+ # @param defaults [Hash] The reactive-variable defaults to sign
27
+ # @param live_id [String] The component instance the defaults belong to
28
+ # @return [String] A signed, tamper-evident blob
29
+ def sign(defaults, live_id)
30
+ defaults = (defaults || {}).as_json.sort.to_h
31
+
32
+ verifier.generate({ 'live_id' => live_id, 'defaults' => defaults }, purpose: PURPOSE)
33
+ end
34
+
35
+ # The defaults as the component gets them back from the page on
36
+ # subscribe, so a prerender can render with the same values. In
37
+ # development and test, warns about any default that changes on the way.
38
+ #
39
+ # @param defaults [Hash]
40
+ # @param component_class [Class] Named in the warning
41
+ # @return [Hash] String-keyed, JSON-native defaults
42
+ def round_trip(defaults, component_class)
43
+ defaults ||= {}
44
+ json = JSON.parse(JSON.generate(defaults.as_json))
45
+
46
+ warn_about_changed(defaults, json, component_class) if Rails.env.local?
47
+
48
+ json
49
+ end
50
+
51
+ # @param blob [String, nil] A blob previously produced by {sign}
52
+ # @param live_id [String] The component instance the blob must be bound to
53
+ # @return [Hash] The verified defaults, or an empty hash when the blob is
54
+ # blank, not a string, tampered with, or bound to a different component
55
+ def verify(blob, live_id)
56
+ return {} unless blob.is_a?(String) && blob.present?
57
+
58
+ data = verifier.verify(blob, purpose: PURPOSE)
59
+ return {} unless data.is_a?(Hash) && data['live_id'] == live_id
60
+
61
+ data['defaults'] || {}
62
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
63
+ {}
64
+ end
65
+
66
+ def warn_about_changed(defaults, json, component_class)
67
+ defaults.each do |key, value|
68
+ next if json[key.to_s] == value
69
+
70
+ LiveCable.warn_once(
71
+ "[LiveCable] #{component_class.name} default :#{key} (#{value.class}) is not JSON-native, so it " \
72
+ 'reaches the component as its JSON form. Pass strings, numbers, booleans, nil, or arrays and ' \
73
+ 'string-keyed hashes of those - an id rather than a record, which the component loads itself.'
74
+ )
75
+ end
76
+ end
77
+
78
+ # @return [ActiveSupport::MessageVerifier]
79
+ def verifier
80
+ # JSON serializer keeps the payload to simple, safe types (no Marshal).
81
+ ActiveSupport::MessageVerifier.new(secret, serializer: JSON, url_safe: true)
82
+ end
83
+
84
+ # A key derived for this purpose alone, as Rails' own verifiers do, rather
85
+ # than secret_key_base itself - which would make these signatures
86
+ # interchangeable with anything else HMAC'd under the raw secret.
87
+ def secret
88
+ if Rails.application&.secret_key_base.blank?
89
+ raise LiveCable::Error, 'secret_key_base is required to sign LiveCable defaults'
90
+ end
91
+
92
+ Rails.application.key_generator.generate_key(PURPOSE)
93
+ end
94
+ end
95
+ end
@@ -4,16 +4,19 @@ module LiveCable
4
4
  module Delegation
5
5
  module Array
6
6
  extend Methods
7
- extend Enumerable
8
7
 
9
8
  GETTER_METHODS = %i[
10
9
  []
10
+ at
11
11
  chunk
12
12
  collect
13
13
  compact
14
14
  cycle
15
+ detect
16
+ dig
15
17
  drop
16
18
  drop_while
19
+ fetch
17
20
  filter
18
21
  find
19
22
  find_all
@@ -24,9 +27,14 @@ module LiveCable
24
27
  group_by
25
28
  last
26
29
  map
30
+ max
31
+ max_by
32
+ min
33
+ min_by
27
34
  reject
28
35
  reverse
29
36
  rotate
37
+ sample
30
38
  select
31
39
  shuffle
32
40
  slice
@@ -39,21 +47,36 @@ module LiveCable
39
47
  zip
40
48
  ].freeze
41
49
 
50
+ ITERATOR_METHODS = %i[
51
+ each_cons
52
+ each_slice
53
+ each_with_index
54
+ each_with_object
55
+ reverse_each
56
+ ].freeze
57
+
42
58
  MUTATIVE_METHODS = %i[
43
59
  []=
44
60
  <<
61
+ append
45
62
  clear
63
+ collect!
46
64
  compact!
65
+ compact_blank!
47
66
  concat
48
67
  delete
49
68
  delete_at
50
69
  delete_if
70
+ extract!
71
+ extract_options!
51
72
  fill
73
+ filter!
52
74
  flatten!
53
75
  insert
54
76
  keep_if
55
77
  map!
56
78
  pop
79
+ prepend
57
80
  push
58
81
  reject!
59
82
  replace
@@ -70,9 +93,12 @@ module LiveCable
70
93
  ].freeze
71
94
 
72
95
  decorate_getters GETTER_METHODS
96
+ decorate_iterators ITERATOR_METHODS
73
97
  decorate_mutators MUTATIVE_METHODS
74
98
 
75
99
  def each(&)
100
+ return to_enum(:each) { size } unless block_given?
101
+
76
102
  __getobj__.each do |v|
77
103
  yield create_delegator(v)
78
104
  end
@@ -5,34 +5,88 @@ module LiveCable
5
5
  module Hash
6
6
  extend Methods
7
7
 
8
+ GETTER_METHODS = %i[
9
+ []
10
+ dig
11
+ fetch
12
+ ].freeze
13
+
14
+ COLLECTION_GETTER_METHODS = %i[
15
+ fetch_values
16
+ values
17
+ values_at
18
+ ].freeze
19
+
20
+ ITERATOR_METHODS = %i[
21
+ detect
22
+ each_with_index
23
+ each_with_object
24
+ find
25
+ ].freeze
26
+
8
27
  MUTATIVE_METHODS = %i[
9
28
  []=
10
29
  clear
11
30
  compact!
31
+ compact_blank!
32
+ compare_by_identity
12
33
  deep_merge!
13
34
  deep_stringify_keys!
35
+ deep_symbolize_keys!
14
36
  deep_transform_keys!
15
37
  deep_transform_values!
38
+ default=
39
+ default_proc=
16
40
  delete
17
41
  delete_if
18
42
  except!
19
43
  extract!
44
+ filter!
20
45
  keep_if
21
46
  merge!
22
47
  rehash
23
48
  reject!
49
+ replace
24
50
  reverse_merge!
51
+ reverse_update
25
52
  select!
26
53
  shift
54
+ slice!
55
+ store
27
56
  stringify_keys!
28
57
  symbolize_keys!
58
+ to_options!
29
59
  transform_keys!
30
60
  transform_values!
31
61
  update
62
+ with_defaults!
32
63
  ].freeze
33
64
 
34
- decorate_getter :[]
65
+ decorate_getters GETTER_METHODS
66
+ decorate_collection_getters COLLECTION_GETTER_METHODS
67
+ decorate_iterators ITERATOR_METHODS
35
68
  decorate_mutators MUTATIVE_METHODS
69
+
70
+ # Like Hash#each, yields the key and value separately only to blocks that
71
+ # take more than one argument and a [key, value] pair to the rest.
72
+ def each_pair(&block)
73
+ return to_enum(:each_pair) { size } unless block
74
+
75
+ split = block.arity > 1
76
+
77
+ __getobj__.each_pair do |key, value|
78
+ value = create_delegator(value)
79
+
80
+ split ? yield(key, value) : yield([key, value])
81
+ end
82
+ end
83
+ alias each each_pair
84
+
85
+ def each_value
86
+ return to_enum(:each_value) { size } unless block_given?
87
+
88
+ __getobj__.each_value { |value| yield create_delegator(value) }
89
+ end
36
90
  end
37
91
  end
38
92
  end
@@ -19,6 +19,30 @@ module LiveCable
19
19
  end
20
20
  end
21
21
 
22
+ # A new collection is only wrapped when it holds something trackable, so
23
+ # a plain list of scalars stays a real Array.
24
+ def decorate_collection_getters(methods)
25
+ methods.each do |method|
26
+ define_method(method) do |*pos, **kwargs, &block|
27
+ result = __getobj__.method(method).call(*pos, **kwargs, &block)
28
+
29
+ result.any? { |value| Delegator.supported?(value) } ? create_delegator(result) : result
30
+ end
31
+ end
32
+ end
33
+
34
+ # Runs Enumerable's implementation against the delegator, so elements
35
+ # arrive through its wrapping #each.
36
+ def decorate_iterators(methods)
37
+ methods.each do |method|
38
+ iterator = ::Enumerable.instance_method(method)
39
+
40
+ define_method(method) do |*args, &block|
41
+ iterator.bind_call(self, *args, &block)
42
+ end
43
+ end
44
+ end
45
+
22
46
  def decorate_mutators(methods)
23
47
  methods.each do |method|
24
48
  decorate_mutator(method)
@@ -29,7 +53,7 @@ module LiveCable
29
53
  define_method(method) do |*pos, **kwargs, &block|
30
54
  notify_live_cable_observers
31
55
 
32
- __getobj__.method(method).call(*pos, **kwargs, &block)
56
+ __getobj__.method(method).call(*Delegator.unwrap(pos), **Delegator.unwrap(kwargs), &block)
33
57
  end
34
58
  end
35
59
  end
@@ -7,6 +7,18 @@ module LiveCable
7
7
 
8
8
  decorate_mutator :assign_attributes
9
9
  decorate_mutator :update
10
+
11
+ # Setters, toggle!, reload and the like reach the record through here
12
+ def method_missing(...) # rubocop:disable Style/MissingRespondToMissing -- SimpleDelegator defines it
13
+ record = __getobj__
14
+ writes = record.live_cable_writes
15
+
16
+ begin
17
+ super
18
+ ensure
19
+ notify_live_cable_observers unless record.live_cable_writes == writes
20
+ end
21
+ end
10
22
  end
11
23
  end
12
24
  end
@@ -48,12 +48,14 @@ module LiveCable
48
48
  def initialize(value)
49
49
  super
50
50
 
51
- # Extend with the appropriate delegation module based on value's type
52
- Delegation::SUPPORTED.each do |klass, delegator|
53
- if value.is_a?(klass)
54
- extend delegator
55
- end
56
- end
51
+ extend_delegation_modules
52
+ end
53
+
54
+ # dup doesn't copy the singleton class, so the copy needs its modules again
55
+ def initialize_dup(other)
56
+ super
57
+
58
+ extend_delegation_modules
57
59
  end
58
60
 
59
61
  # Factory method to create a Delegator only if the value's type is supported.
@@ -73,6 +75,30 @@ module LiveCable
73
75
  value
74
76
  end
75
77
 
78
+ # Replaces every Delegator in value, value included, with the object it
79
+ # wraps, copying an Array or Hash only when something inside it changes.
80
+ #
81
+ # @param value [Object]
82
+ # @return [Object]
83
+ def self.unwrap(value, unwrapped = {}.compare_by_identity)
84
+ value = value.__getobj__ while value.is_a?(Delegator)
85
+ return unwrapped[value] if unwrapped.key?(value)
86
+
87
+ unwrapped[value] = value
88
+ copy = nil
89
+ replace = lambda do |key, child|
90
+ plain = unwrap(child, unwrapped)
91
+ (copy ||= value.dup)[key] = plain unless plain.equal?(child)
92
+ end
93
+
94
+ case value
95
+ when ::Array then value.each_with_index { |child, index| replace.call(index, child) }
96
+ when ::Hash then value.each_pair { |key, child| replace.call(key, child) }
97
+ end
98
+
99
+ unwrapped[value] = copy || value
100
+ end
101
+
76
102
  # Check if a value's type can be wrapped in a Delegator.
77
103
  #
78
104
  # @param value [Object] The value to check
@@ -81,8 +107,27 @@ module LiveCable
81
107
  Delegation::SUPPORTED.keys.any? { |c| value.is_a?(c) }
82
108
  end
83
109
 
110
+ # SimpleDelegator compares its target with `other` as given, and
111
+ # ActiveRecord's == rejects anything that isn't an instance of the model.
112
+ def ==(other)
113
+ equal?(other) || __getobj__ == (other.is_a?(Delegator) ? other.__getobj__ : other)
114
+ end
115
+
116
+ def eql?(other)
117
+ equal?(other) || __getobj__.eql?(other.is_a?(Delegator) ? other.__getobj__ : other)
118
+ end
119
+
84
120
  private
85
121
 
122
+ # Extend with the appropriate delegation module based on the value's type
123
+ def extend_delegation_modules
124
+ Delegation::SUPPORTED.each do |klass, delegator|
125
+ if __getobj__.is_a?(klass)
126
+ extend delegator
127
+ end
128
+ end
129
+ end
130
+
86
131
  # Create a new Delegator for nested values (e.g., nested arrays/hashes).
87
132
  # Propagates all observers from the parent delegator to the child.
88
133
  #
@@ -94,6 +139,9 @@ module LiveCable
94
139
  # inner = outer[0] # Returns a Delegator wrapping ['inner']
95
140
  # inner << 'new' # Notifies same observers as outer
96
141
  def create_delegator(value)
142
+ # A Delegator stored inside raw data carries some other variable's observers
143
+ value = value.__getobj__ while value.is_a?(Delegator)
144
+
97
145
  return value unless self.class.supported?(value)
98
146
 
99
147
  # Create new delegator and propagate all observers from parent
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ # A client message on its way to a component: an action call or a
5
+ # live-reactive write. Exposed as Component#current_dispatch while the
6
+ # component's before_dispatch callbacks, and the action or write, run.
7
+ class Dispatch
8
+ # @return [Symbol] :action or :reactive
9
+ attr_reader :kind
10
+
11
+ # @return [Symbol] The action or reactive variable name
12
+ attr_reader :name
13
+
14
+ # @return [Object] The value a reactive write assigns; nil for an action
15
+ attr_reader :value
16
+
17
+ # @param data [Hash] One message from the client's batch
18
+ # @return [LiveCable::Dispatch]
19
+ def self.from_message(data)
20
+ if data['_action'].to_s == '_reactive'
21
+ new(:reactive, data['name'], value: data['value'])
22
+ else
23
+ new(:action, data['_action'], query: data['params'])
24
+ end
25
+ end
26
+
27
+ def initialize(kind, name, value: nil, query: nil)
28
+ @kind = kind
29
+ @name = name.to_s.to_sym
30
+ @value = value
31
+ @query = query
32
+ end
33
+
34
+ def action?
35
+ kind == :action
36
+ end
37
+
38
+ def reactive?
39
+ kind == :reactive
40
+ end
41
+
42
+ # @return [ActionController::Parameters, nil] The action's params; nil for
43
+ # a reactive write
44
+ def params
45
+ return unless action?
46
+
47
+ @params ||= ActionController::Parameters.new(
48
+ ActionDispatch::ParamBuilder.from_pairs(
49
+ ActionDispatch::QueryParser.each_pair(@query || '')
50
+ )
51
+ )
52
+ end
53
+ end
54
+ end
@@ -9,7 +9,7 @@ module LiveCable
9
9
  if live_component_dir.directory?
10
10
  Rails.autoloaders.main.push_dir(live_component_dir, namespace: Live)
11
11
  else
12
- warn("[LiveCable Warning] #{live_component_dir} does not exist for components.")
12
+ LiveCable.warn_once("[LiveCable] #{live_component_dir} does not exist; no components will be autoloaded.")
13
13
  end
14
14
 
15
15
  # Add LiveCable to importmap (skip when using jsbundling/npm)
@@ -35,6 +35,8 @@ module LiveCable
35
35
  initializer 'live_cable.active_record' do
36
36
  ActiveSupport.on_load :active_record do
37
37
  include ModelObserver
38
+
39
+ ActiveRecord::PredicateBuilder.prepend(PredicateBuilder)
38
40
  end
39
41
  end
40
42
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ # A message asked for an action or reactive write the component doesn't
5
+ # expose. Never offered to rescue_from.
6
+ class Forbidden < Error; end
7
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ # Digests the values that identify who a page was rendered for, so the
5
+ # client can tell when they change between pages without learning them.
6
+ #
7
+ # Keyed with a key derived from secret_key_base: a plain hash of a user id
8
+ # could be matched by hashing guessed ids.
9
+ module IdentityDigest
10
+ PURPOSE = 'live_cable/identity'
11
+
12
+ module_function
13
+
14
+ # @param values [Array<Object>] Records are identified by their GlobalID,
15
+ # as ActionCable identifies a connection; anything else by its to_s
16
+ # @return [String] A hex digest
17
+ def digest(*values)
18
+ payload = values.map { |value| value.respond_to?(:to_gid_param) ? value.to_gid_param : value.to_s }
19
+
20
+ OpenSSL::HMAC.hexdigest('SHA256', key, JSON.generate(payload))
21
+ end
22
+
23
+ def key
24
+ Rails.application.key_generator.generate_key(PURPOSE)
25
+ end
26
+ end
27
+ end
@@ -4,10 +4,47 @@ module LiveCable
4
4
  module ModelObserver
5
5
  include ObserverTracking
6
6
 
7
+ def ==(other)
8
+ super(other.is_a?(Delegator) ? other.__getobj__ : other)
9
+ end
10
+ alias eql? ==
11
+
7
12
  def _write_attribute(...)
8
13
  notify_live_cable_observers
9
14
 
10
15
  super
11
16
  end
17
+
18
+ def write_attribute(...)
19
+ notify_live_cable_observers
20
+
21
+ super
22
+ end
23
+
24
+ def update_columns(...)
25
+ super.tap { notify_live_cable_observers }
26
+ end
27
+
28
+ def reload(...)
29
+ super.tap { notify_live_cable_observers }
30
+ end
31
+
32
+ # Catches in-place changes to serialized and JSON attributes once saved
33
+ def changes_applied(...)
34
+ super.tap { notify_live_cable_observers if saved_changes? }
35
+ end
36
+
37
+ # Lets a Delegator around this record see that a call it forwarded wrote to it
38
+ def live_cable_writes
39
+ @live_cable_writes || 0
40
+ end
41
+
42
+ private
43
+
44
+ def notify_live_cable_observers
45
+ @live_cable_writes = live_cable_writes + 1
46
+
47
+ super
48
+ end
12
49
  end
13
50
  end