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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +567 -0
- data/README.md +14 -2
- data/app/assets/javascript/controllers/live_controller.js +51 -7
- data/app/assets/javascript/live_cable.js +2 -0
- data/app/assets/javascript/loading.js +58 -6
- data/app/assets/javascript/subscriptions.js +292 -22
- data/app/channels/live_channel.rb +51 -29
- data/app/helpers/live_cable_helper.rb +31 -2
- data/lib/live_cable/action_cable_connection.rb +4 -1
- data/lib/live_cable/component/broadcasting.rb +37 -6
- data/lib/live_cable/component/dispatching.rb +36 -0
- data/lib/live_cable/component/lifecycle.rb +1 -1
- data/lib/live_cable/component/reactive_variables.rb +33 -5
- data/lib/live_cable/component/rendering.rb +30 -14
- data/lib/live_cable/component/streaming.rb +35 -4
- data/lib/live_cable/component.rb +4 -3
- data/lib/live_cable/connection/broadcasting.rb +15 -3
- data/lib/live_cable/connection/component_management.rb +12 -8
- data/lib/live_cable/connection/error_handling.rb +28 -7
- data/lib/live_cable/connection/messaging.rb +40 -52
- data/lib/live_cable/connection/state_management.rb +13 -16
- data/lib/live_cable/connection.rb +33 -0
- data/lib/live_cable/container.rb +17 -11
- data/lib/live_cable/defaults_signer.rb +95 -0
- data/lib/live_cable/delegation/array.rb +27 -1
- data/lib/live_cable/delegation/hash.rb +55 -1
- data/lib/live_cable/delegation/methods.rb +25 -1
- data/lib/live_cable/delegation/model.rb +12 -0
- data/lib/live_cable/delegator.rb +54 -6
- data/lib/live_cable/dispatch.rb +54 -0
- data/lib/live_cable/engine.rb +3 -1
- data/lib/live_cable/forbidden.rb +7 -0
- data/lib/live_cable/identity_digest.rb +27 -0
- data/lib/live_cable/model_observer.rb +37 -0
- data/lib/live_cable/observer_tracking.rb +10 -1
- data/lib/live_cable/predicate_builder.rb +13 -0
- data/lib/live_cable/render_context.rb +82 -8
- data/lib/live_cable/rendering/controller_renderer.rb +26 -0
- data/lib/live_cable/rendering/dependency_visitor.rb +68 -24
- data/lib/live_cable/rendering/handler.rb +1 -1
- data/lib/live_cable/rendering/method_analyzer.rb +18 -4
- data/lib/live_cable/rendering/method_collector.rb +13 -5
- data/lib/live_cable/rendering/partial.rb +13 -1
- data/lib/live_cable/rendering/partial_renderer.rb +59 -7
- data/lib/live_cable/rendering/render_result.rb +5 -0
- data/lib/live_cable/rendering/renderer.rb +18 -14
- data/lib/live_cable/rendering/view_buffer.rb +21 -0
- data/lib/live_cable/testing/render_state.rb +5 -3
- data/lib/live_cable/testing/test_channel.rb +6 -0
- data/lib/live_cable/testing/test_component.rb +3 -3
- data/lib/live_cable/testing.rb +9 -5
- data/lib/live_cable/version.rb +1 -1
- data/lib/live_cable.rb +32 -3
- 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
|
data/lib/live_cable/container.rb
CHANGED
|
@@ -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
|
|
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]
|
|
49
|
+
remove_observer_from(self[key], key)
|
|
50
50
|
|
|
51
|
-
# ActiveRecord models get observers attached directly
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
91
|
-
# Called by Connection
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/live_cable/delegator.rb
CHANGED
|
@@ -48,12 +48,14 @@ module LiveCable
|
|
|
48
48
|
def initialize(value)
|
|
49
49
|
super
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
data/lib/live_cable/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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,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
|