live_cable 0.0.1
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 +7 -0
- data/app/assets/javascript/controllers/live_controller.js +123 -0
- data/app/assets/javascript/live_cable_blessing.js +20 -0
- data/app/assets/javascript/subscriptions.js +191 -0
- data/app/channels/live_channel.rb +45 -0
- data/app/helpers/live_cable_helper.rb +70 -0
- data/config/importmap.rb +5 -0
- data/lib/live_cable/component.rb +255 -0
- data/lib/live_cable/connection.rb +205 -0
- data/lib/live_cable/container.rb +103 -0
- data/lib/live_cable/csrf_checker.rb +20 -0
- data/lib/live_cable/delegation/array.rb +81 -0
- data/lib/live_cable/delegation/hash.rb +38 -0
- data/lib/live_cable/delegation/methods.rb +37 -0
- data/lib/live_cable/delegation/model.rb +12 -0
- data/lib/live_cable/delegator.rb +114 -0
- data/lib/live_cable/engine.rb +23 -0
- data/lib/live_cable/error.rb +5 -0
- data/lib/live_cable/model_observer.rb +13 -0
- data/lib/live_cable/observer.rb +44 -0
- data/lib/live_cable/observer_tracking.rb +71 -0
- data/lib/live_cable/render_context.rb +39 -0
- data/lib/live_cable.rb +47 -0
- metadata +63 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
class Component
|
|
5
|
+
include ActiveSupport::Rescuable
|
|
6
|
+
|
|
7
|
+
class_attribute :is_compound, default: false
|
|
8
|
+
class_attribute :shared_variables, default: []
|
|
9
|
+
class_attribute :reactive_variables, default: []
|
|
10
|
+
class_attribute :shared_reactive_variables, default: []
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def compound
|
|
14
|
+
self.is_compound = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def actions(*names)
|
|
18
|
+
@allowed_actions = names.map!(&:to_sym).freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def allowed_actions
|
|
22
|
+
@allowed_actions || []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reactive(variable, initial_value = nil, shared: false)
|
|
26
|
+
list_name = shared ? :shared_reactive_variables : :reactive_variables
|
|
27
|
+
current = (public_send(list_name) || []).dup
|
|
28
|
+
public_send("#{list_name}=", current << variable)
|
|
29
|
+
|
|
30
|
+
if shared
|
|
31
|
+
shared_reactive_variables << variable
|
|
32
|
+
else
|
|
33
|
+
reactive_variables << variable
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
create_reactive_variables(variable, initial_value, shared: shared)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shared(variable, initial_value = nil)
|
|
40
|
+
self.shared_variables = (shared_variables || []).dup << variable
|
|
41
|
+
|
|
42
|
+
create_reactive_variables(variable, initial_value, shared: true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def component_string
|
|
46
|
+
name.underscore.delete_prefix('live/')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def component_id(id)
|
|
50
|
+
"#{component_string}/#{id}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def create_reactive_variables(variable, initial_value, shared: false)
|
|
56
|
+
define_method(variable) do
|
|
57
|
+
container_name = shared ? Connection::SHARED_CONTAINER : live_id
|
|
58
|
+
|
|
59
|
+
if live_connection
|
|
60
|
+
return live_connection.get(container_name, self, variable, initial_value)
|
|
61
|
+
elsif prerender_container.key?(variable)
|
|
62
|
+
return prerender_container[variable]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return if initial_value.nil?
|
|
66
|
+
|
|
67
|
+
if initial_value.arity.positive?
|
|
68
|
+
initial_value.call(self)
|
|
69
|
+
else
|
|
70
|
+
initial_value.call
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
define_method("#{variable}=") do |value|
|
|
75
|
+
container_name = shared ? Connection::SHARED_CONTAINER : live_id
|
|
76
|
+
|
|
77
|
+
if live_connection
|
|
78
|
+
live_connection.set(container_name, variable, value)
|
|
79
|
+
else
|
|
80
|
+
prerender_container[variable] = value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attr_reader :rendered, :defaults
|
|
87
|
+
|
|
88
|
+
def initialize(id)
|
|
89
|
+
@live_id = self.class.component_id(id)
|
|
90
|
+
@rendered = false
|
|
91
|
+
@subscribed = false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def broadcast(data)
|
|
95
|
+
ActionCable.server.broadcast(channel_name, data)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def render
|
|
99
|
+
@rendered = true
|
|
100
|
+
ApplicationController.renderer.render(self, layout: false)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def broadcast_subscribe
|
|
104
|
+
broadcast({ _status: 'subscribed', id: live_id })
|
|
105
|
+
@subscribed = true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def broadcast_destroy
|
|
109
|
+
broadcast({ _status: 'destroy' })
|
|
110
|
+
@subscribed = false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_broadcast
|
|
114
|
+
before_render
|
|
115
|
+
broadcast(_refresh: render)
|
|
116
|
+
after_render
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def status
|
|
120
|
+
subscribed? ? 'subscribed' : 'disconnected'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def subscribed?
|
|
124
|
+
@subscribed
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def destroy
|
|
128
|
+
broadcast_destroy
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Lifecycle hooks - override in subclasses to add custom behavior
|
|
132
|
+
def connected
|
|
133
|
+
# Called when the component is first subscribed to the channel
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def disconnected
|
|
137
|
+
# Called when the component is unsubscribed from the channel
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def before_render
|
|
141
|
+
# Called before each render/broadcast
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def after_render
|
|
145
|
+
# Called after each render/broadcast
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
attr_accessor :live_connection, :channel
|
|
149
|
+
|
|
150
|
+
def live_id
|
|
151
|
+
@live_id ||= SecureRandom.uuid
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def channel_name
|
|
155
|
+
"#{live_connection.channel_name}/#{live_id}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def to_partial_path
|
|
159
|
+
base = self.class.name.underscore
|
|
160
|
+
|
|
161
|
+
if self.class.is_compound
|
|
162
|
+
"#{base}/#{template_state}"
|
|
163
|
+
else
|
|
164
|
+
base
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def template_state
|
|
169
|
+
'component'
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_in(view_context)
|
|
173
|
+
# @TODO: Figure out where to put this
|
|
174
|
+
ActionView::Base.annotate_rendered_view_with_filenames = false
|
|
175
|
+
|
|
176
|
+
view, render_context = view_context.with_render_context(self) do
|
|
177
|
+
view_context.render(template: to_partial_path, layout: false, locals:)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if @previous_render_context
|
|
181
|
+
destroyed = @previous_render_context.children - render_context.children
|
|
182
|
+
|
|
183
|
+
destroyed.each(&:destroy)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
@previous_render_context = render_context
|
|
187
|
+
|
|
188
|
+
view
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def all_reactive_variables
|
|
192
|
+
self.class.reactive_variables + self.class.shared_reactive_variables
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def dirty(*variables)
|
|
196
|
+
variables.each do |variable|
|
|
197
|
+
unless all_reactive_variables.include?(variable)
|
|
198
|
+
raise Error, "Invalid reactive variable: #{variable}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
container_name = self.class.reactive_variables.include?(variable) ? live_id : Connection::SHARED_CONTAINER
|
|
202
|
+
|
|
203
|
+
live_connection.dirty(container_name, variable)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def defaults=(defaults)
|
|
208
|
+
# Don't set defaults more than once
|
|
209
|
+
return if defined?(@defaults)
|
|
210
|
+
|
|
211
|
+
@defaults = (defaults || {}).symbolize_keys
|
|
212
|
+
keys = all_reactive_variables & @defaults.keys
|
|
213
|
+
|
|
214
|
+
keys.each do |key|
|
|
215
|
+
public_send("#{key}=", @defaults[key])
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Allow the component to access the identified_by methods from the connection
|
|
220
|
+
def respond_to_missing?(method_name, _include_private = false)
|
|
221
|
+
return false unless channel
|
|
222
|
+
|
|
223
|
+
channel.connection.identifiers.include?(method_name)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def method_missing(method_name, *, &)
|
|
227
|
+
channel.connection.public_send(method_name, *, &)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
def stream_from(channel_name, callback = nil, coder: nil, &block)
|
|
233
|
+
channel.stream_from(channel_name, coder:) do |payload|
|
|
234
|
+
callback ||= block
|
|
235
|
+
callback.call(payload)
|
|
236
|
+
|
|
237
|
+
live_connection.broadcast_changeset
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def prerender_container
|
|
242
|
+
@prerender_container ||= {}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def locals
|
|
246
|
+
identifiers = channel ? channel.connection.identifiers.to_a : []
|
|
247
|
+
|
|
248
|
+
(all_reactive_variables | (self.class.shared_variables || []) | identifiers).
|
|
249
|
+
to_h { |v| [v, public_send(v)] }.
|
|
250
|
+
merge(
|
|
251
|
+
component: self
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
class Connection
|
|
5
|
+
attr_reader :session_id
|
|
6
|
+
|
|
7
|
+
SHARED_CONTAINER = '_shared'
|
|
8
|
+
|
|
9
|
+
def initialize(request)
|
|
10
|
+
@request = request
|
|
11
|
+
@session_id = SecureRandom.uuid
|
|
12
|
+
@containers = {} # @todo Use Hash.new with a proc to make a container / hash
|
|
13
|
+
@components = {}
|
|
14
|
+
@channels = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get_component(id)
|
|
18
|
+
components[id]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_component(component)
|
|
22
|
+
component.live_connection = self
|
|
23
|
+
components[component.live_id] = component
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def remove_component(component)
|
|
27
|
+
components.delete(component.live_id)
|
|
28
|
+
containers.delete(component.live_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set_channel(component, channel)
|
|
32
|
+
@channels[component] = channel
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_channel(component)
|
|
36
|
+
@channels[component]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def remove_channel(component)
|
|
40
|
+
@channels.delete(component)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def get(container_name, component, variable, initial_value)
|
|
44
|
+
containers[container_name] ||= Container.new
|
|
45
|
+
containers[container_name][variable] ||= process_initial_value(component, variable, initial_value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def set(container_name, variable, value)
|
|
49
|
+
dirty(container_name, variable)
|
|
50
|
+
|
|
51
|
+
containers[container_name] ||= Container.new
|
|
52
|
+
containers[container_name][variable] = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def receive(component, data)
|
|
56
|
+
check_csrf_token(data)
|
|
57
|
+
reset_changeset
|
|
58
|
+
|
|
59
|
+
return unless data['messages'].present?
|
|
60
|
+
|
|
61
|
+
data['messages'].each do |message|
|
|
62
|
+
action(component, message)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
broadcast_changeset
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def action(component, data)
|
|
69
|
+
params = parse_params(data)
|
|
70
|
+
|
|
71
|
+
if data['_action']
|
|
72
|
+
action = data['_action']&.to_sym
|
|
73
|
+
|
|
74
|
+
if action == :_reactive
|
|
75
|
+
return reactive(component, data)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless component.class.allowed_actions.include?(action)
|
|
79
|
+
raise LiveCable::Error, "Unauthorized action: #{action}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
method = component.method(action)
|
|
83
|
+
|
|
84
|
+
if method.arity.positive?
|
|
85
|
+
method.call(params)
|
|
86
|
+
else
|
|
87
|
+
method.call
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
handle_error(component, e)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reactive(component, data)
|
|
95
|
+
unless component.all_reactive_variables.include?(data['name'].to_sym)
|
|
96
|
+
raise Error, "Invalid reactive variable: #{data['name']}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
component.public_send("#{data['name']}=", data['value'])
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
handle_error(component, e)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def channel_name
|
|
105
|
+
"live_#{session_id}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def dirty(container_name, *variables)
|
|
109
|
+
containers[container_name] ||= Container.new
|
|
110
|
+
containers[container_name].mark_dirty(*variables)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def broadcast_changeset
|
|
114
|
+
# Use a copy of the components since new ones can get added while rendering
|
|
115
|
+
# and causes an issue here.
|
|
116
|
+
components.values.dup.each do |component|
|
|
117
|
+
container = containers[component.live_id]
|
|
118
|
+
if container&.changed?
|
|
119
|
+
component.render_broadcast
|
|
120
|
+
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
shared_changeset = containers[SHARED_CONTAINER]&.changeset || []
|
|
125
|
+
|
|
126
|
+
if (component.shared_reactive_variables || []).intersect?(shared_changeset)
|
|
127
|
+
component.render_broadcast
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
attr_reader :request
|
|
135
|
+
|
|
136
|
+
# @return [Hash<String, Container>]
|
|
137
|
+
attr_reader :containers
|
|
138
|
+
|
|
139
|
+
# @return [Hash<String, Component>]
|
|
140
|
+
attr_reader :components
|
|
141
|
+
|
|
142
|
+
def check_csrf_token(data)
|
|
143
|
+
session = request.session
|
|
144
|
+
return unless session[:_csrf_token]
|
|
145
|
+
|
|
146
|
+
token = data['_csrf_token']
|
|
147
|
+
unless csrf_checker.valid?(session, token)
|
|
148
|
+
raise LiveCable::Error, 'Invalid CSRF token'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def csrf_checker
|
|
153
|
+
@csrf_checker ||= LiveCable::CsrfChecker.new(request)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def process_initial_value(component, variable, initial_value)
|
|
157
|
+
case initial_value
|
|
158
|
+
when nil
|
|
159
|
+
nil
|
|
160
|
+
when Proc
|
|
161
|
+
args = []
|
|
162
|
+
args << component if initial_value.arity.positive?
|
|
163
|
+
|
|
164
|
+
initial_value.call(*args)
|
|
165
|
+
else
|
|
166
|
+
raise Error, "Initial value for \":#{variable}\" must be a proc or nil"
|
|
167
|
+
end
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
handle_error(component, e)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def parse_params(data)
|
|
173
|
+
params = data['params'] || ''
|
|
174
|
+
|
|
175
|
+
ActionController::Parameters.new(
|
|
176
|
+
ActionDispatch::ParamBuilder.from_pairs(
|
|
177
|
+
ActionDispatch::QueryParser.each_pair(params)
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def reset_changeset
|
|
183
|
+
containers.each_value(&:reset_changeset)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def handle_error(component, error)
|
|
187
|
+
html = <<~HTML
|
|
188
|
+
<details>
|
|
189
|
+
<summary style="color: #f00; cursor: pointer">
|
|
190
|
+
<strong>#{component.class.name}</strong> - #{error.class.name}: #{error.message}
|
|
191
|
+
</summary>
|
|
192
|
+
<small>
|
|
193
|
+
<ol>
|
|
194
|
+
#{error.backtrace&.map { "<li>#{it}</li>" }&.join("\n")}
|
|
195
|
+
</ol>
|
|
196
|
+
</small>
|
|
197
|
+
</details>
|
|
198
|
+
HTML
|
|
199
|
+
|
|
200
|
+
component.broadcast(_refresh: html)
|
|
201
|
+
ensure
|
|
202
|
+
raise(error)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
# Storage for reactive variable values with automatic change tracking.
|
|
5
|
+
#
|
|
6
|
+
# A Container is a Hash subclass that stores the values of reactive variables
|
|
7
|
+
# for a single component instance. It automatically wraps supported types
|
|
8
|
+
# (Arrays, Hashes, ActiveRecord models) in Delegators that track mutations.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# container = Container.new
|
|
12
|
+
# container[:username] = "john" # Stored as-is
|
|
13
|
+
# container[:tags] = ['ruby', 'rails'] # Wrapped in Delegator
|
|
14
|
+
# container[:tags] << 'rspec' # Automatically marks :tags as dirty
|
|
15
|
+
#
|
|
16
|
+
# Architecture:
|
|
17
|
+
# - Each component instance has its own Container (identified by live_id)
|
|
18
|
+
# - Shared reactive variables use a special SHARED_CONTAINER
|
|
19
|
+
# - During message processing, mutations are tracked in a changeset
|
|
20
|
+
# - After message processing, components with dirty changesets are re-rendered
|
|
21
|
+
# - Changesets are reset after broadcasting updates
|
|
22
|
+
#
|
|
23
|
+
# Change Tracking:
|
|
24
|
+
# 1. Value is stored via []=
|
|
25
|
+
# 2. If value is an Array/Hash/Model, it's wrapped in a Delegator
|
|
26
|
+
# 3. Delegator attaches observers to the value
|
|
27
|
+
# 4. When value is mutated, observers mark the variable as dirty
|
|
28
|
+
# 5. Component re-renders if changeset contains the variable
|
|
29
|
+
class Container < Hash
|
|
30
|
+
# @param args [Array] Arguments passed to Hash.new
|
|
31
|
+
def initialize(...)
|
|
32
|
+
super
|
|
33
|
+
@changeset = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Store a value in the container, automatically wrapping supported types
|
|
37
|
+
# in Delegators for change tracking.
|
|
38
|
+
#
|
|
39
|
+
# @param key [Symbol] The reactive variable name
|
|
40
|
+
# @param value [Object] The value to store
|
|
41
|
+
# @return [Object] The stored value (possibly wrapped in a Delegator)
|
|
42
|
+
#
|
|
43
|
+
# @example Storing different types
|
|
44
|
+
# container[:count] = 0 # Number - stored as-is
|
|
45
|
+
# container[:tags] = ['ruby'] # Array - wrapped in Delegator
|
|
46
|
+
# container[:user] = User.new # ActiveRecord - wrapped in Delegator
|
|
47
|
+
def []=(key, value)
|
|
48
|
+
# ActiveRecord models get observers attached directly
|
|
49
|
+
if value.class < ModelObserver
|
|
50
|
+
value.add_live_cable_observer(observer, key)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# If value is already a Delegator, add observer and store as-is
|
|
54
|
+
if value.is_a?(Delegator)
|
|
55
|
+
value.add_live_cable_observer(observer, key)
|
|
56
|
+
super
|
|
57
|
+
else
|
|
58
|
+
# Wrap supported types in Delegators for change tracking
|
|
59
|
+
super(key, Delegator.create_if_supported(value, key, observer))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Mark one or more variables as dirty (changed).
|
|
64
|
+
# Dirty variables will trigger a re-render of their component.
|
|
65
|
+
#
|
|
66
|
+
# @param variables [Array<Symbol>] Variable names to mark as dirty
|
|
67
|
+
# @return [void]
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# container.mark_dirty(:username, :email)
|
|
71
|
+
def mark_dirty(*variables)
|
|
72
|
+
@changeset |= variables # Union operator keeps values unique
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the list of variables that have changed during this message cycle.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<Symbol>] List of dirty variable names
|
|
78
|
+
attr_reader :changeset
|
|
79
|
+
|
|
80
|
+
# Check if any variables have changed.
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] true if changeset is not empty
|
|
83
|
+
def changed?
|
|
84
|
+
!@changeset.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Clear the changeset after broadcasting updates.
|
|
88
|
+
# Called by Connection after all components have been re-rendered.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def reset_changeset
|
|
92
|
+
@changeset = []
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get or create the observer for this container.
|
|
96
|
+
# Each container has exactly one observer instance.
|
|
97
|
+
#
|
|
98
|
+
# @return [LiveCable::Observer] The observer instance
|
|
99
|
+
def observer
|
|
100
|
+
@observer ||= Observer.new(self)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
class CsrfChecker
|
|
5
|
+
include ActiveSupport::Configurable
|
|
6
|
+
include ActionController::RequestForgeryProtection
|
|
7
|
+
|
|
8
|
+
def initialize(request)
|
|
9
|
+
@request = request
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def valid?(session, token)
|
|
13
|
+
valid_authenticity_token?(session, token)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
attr_reader :request
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
module Delegation
|
|
5
|
+
module Array
|
|
6
|
+
extend Methods
|
|
7
|
+
extend Enumerable
|
|
8
|
+
|
|
9
|
+
GETTER_METHODS = %i[
|
|
10
|
+
[]
|
|
11
|
+
chunk
|
|
12
|
+
collect
|
|
13
|
+
compact
|
|
14
|
+
cycle
|
|
15
|
+
drop
|
|
16
|
+
drop_while
|
|
17
|
+
filter
|
|
18
|
+
find_all
|
|
19
|
+
first
|
|
20
|
+
flatten
|
|
21
|
+
grep
|
|
22
|
+
grep_v
|
|
23
|
+
group_by
|
|
24
|
+
last
|
|
25
|
+
map
|
|
26
|
+
reject
|
|
27
|
+
reverse
|
|
28
|
+
rotate
|
|
29
|
+
select
|
|
30
|
+
shuffle
|
|
31
|
+
slice
|
|
32
|
+
sort
|
|
33
|
+
sort_by
|
|
34
|
+
take
|
|
35
|
+
take_while
|
|
36
|
+
transpose
|
|
37
|
+
uniq
|
|
38
|
+
zip
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
MUTATIVE_METHODS = %i[
|
|
42
|
+
[]=
|
|
43
|
+
<<
|
|
44
|
+
clear
|
|
45
|
+
compact!
|
|
46
|
+
concat
|
|
47
|
+
delete
|
|
48
|
+
delete_at
|
|
49
|
+
delete_if
|
|
50
|
+
fill
|
|
51
|
+
flatten!
|
|
52
|
+
insert
|
|
53
|
+
keep_if
|
|
54
|
+
map!
|
|
55
|
+
pop
|
|
56
|
+
push
|
|
57
|
+
reject!
|
|
58
|
+
replace
|
|
59
|
+
reverse!
|
|
60
|
+
rotate!
|
|
61
|
+
select!
|
|
62
|
+
shift
|
|
63
|
+
shuffle!
|
|
64
|
+
slice!
|
|
65
|
+
sort!
|
|
66
|
+
sort_by!
|
|
67
|
+
uniq!
|
|
68
|
+
unshift
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
decorate_getters GETTER_METHODS
|
|
72
|
+
decorate_mutators MUTATIVE_METHODS
|
|
73
|
+
|
|
74
|
+
def each(&)
|
|
75
|
+
__getobj__.each do |v|
|
|
76
|
+
yield create_delegator(v)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
module Delegation
|
|
5
|
+
module Hash
|
|
6
|
+
extend Methods
|
|
7
|
+
|
|
8
|
+
MUTATIVE_METHODS = %i[
|
|
9
|
+
[]=
|
|
10
|
+
clear
|
|
11
|
+
compact!
|
|
12
|
+
deep_merge!
|
|
13
|
+
deep_stringify_keys!
|
|
14
|
+
deep_transform_keys!
|
|
15
|
+
deep_transform_values!
|
|
16
|
+
delete
|
|
17
|
+
delete_if
|
|
18
|
+
except!
|
|
19
|
+
extract!
|
|
20
|
+
keep_if
|
|
21
|
+
merge!
|
|
22
|
+
rehash
|
|
23
|
+
reject!
|
|
24
|
+
reverse_merge!
|
|
25
|
+
select!
|
|
26
|
+
shift
|
|
27
|
+
stringify_keys!
|
|
28
|
+
symbolize_keys!
|
|
29
|
+
transform_keys!
|
|
30
|
+
transform_values!
|
|
31
|
+
update
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
decorate_getter :[]
|
|
35
|
+
decorate_mutators MUTATIVE_METHODS
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|