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.
@@ -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