Class: SolidObjects::ActorChannel
- Inherits:
-
ActionCable::Channel::Base
- Object
- ActionCable::Channel::Base
- SolidObjects::ActorChannel
- Defined in:
- lib/solid_objects/actor_channel.rb,
sig/generated/lib/solid_objects/actor_channel.rbs
Constant Summary collapse
- REJECT_REASONS =
{ UnknownActorType => "unregistered_actor_type", InvalidStreamToken => "invalid_stream_token", InvalidComponentToken => "invalid_component_token", JSON::ParserError => "malformed_component_registration", KeyError => "missing_subscription_parameter" }.freeze
Instance Attribute Summary collapse
-
#component_subscriptions ⇒ Object
readonly
Returns the value of attribute component_subscriptions.
-
#payload_names ⇒ Object
readonly
Returns the value of attribute payload_names.
-
#reference ⇒ Object
readonly
Returns the value of attribute reference.
-
#scalar_observables ⇒ Object
readonly
Returns the value of attribute scalar_observables.
Instance Method Summary collapse
- #newer_payload_revision?(snapshot) ⇒ Boolean
-
#payload_authorization_context(name) ⇒ Object
Resolves the Cable connection to whatever the application uses as an authorization subject, so a payload block and
authorize_querysee the same object a controller render would pass. - #receive_broadcast(stream) ⇒ void
- #refresh_outdated_components(snapshot) ⇒ void
-
#reject_and_report(reason, actor_type:, actor_id:, error: nil) ⇒ void
The exception message stays out of the payload, because a component or payload error can carry actor state into logs.
- #reject_reason(error) ⇒ String
- #scalar_observable_names(snapshot) ⇒ Array[String]
- #subscribed ⇒ void
-
#transmit_state_payload(snapshot, name) ⇒ Boolean
A payload is one subscriber's view of one name.
- #transmit_state_payloads(snapshot) ⇒ void
- #validate_payload_names! ⇒ void
- #validate_scalar_observables! ⇒ void
Instance Attribute Details
#component_subscriptions ⇒ Object (readonly)
Returns the value of attribute component_subscriptions.
62 63 64 |
# File 'lib/solid_objects/actor_channel.rb', line 62 def component_subscriptions @component_subscriptions end |
#payload_names ⇒ Object (readonly)
Returns the value of attribute payload_names.
62 63 64 |
# File 'lib/solid_objects/actor_channel.rb', line 62 def payload_names @payload_names end |
#reference ⇒ Object (readonly)
Returns the value of attribute reference.
62 63 64 |
# File 'lib/solid_objects/actor_channel.rb', line 62 def reference @reference end |
#scalar_observables ⇒ Object (readonly)
Returns the value of attribute scalar_observables.
62 63 64 |
# File 'lib/solid_objects/actor_channel.rb', line 62 def scalar_observables @scalar_observables end |
Instance Method Details
#newer_payload_revision?(snapshot) ⇒ Boolean
165 166 167 168 169 170 |
# File 'lib/solid_objects/actor_channel.rb', line 165 def newer_payload_revision?(snapshot) current = @payload_revision return true unless current (current <=> [ snapshot.instance_id, snapshot.revision ]) == -1 end |
#payload_authorization_context(name) ⇒ Object
Resolves the Cable connection to whatever the application uses as an
authorization subject, so a payload block and authorize_query see the
same object a controller render would pass.
157 158 159 160 161 162 |
# File 'lib/solid_objects/actor_channel.rb', line 157 def (name) callable = SolidObjects.configuration. return callable.call(connection:) unless CallableKeywords.accepts?(callable, :payload_name) callable.call(connection:, payload_name: name) end |
#receive_broadcast(stream) ⇒ void
This method returns an undefined value.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/solid_objects/actor_channel.rb', line 88 def receive_broadcast(stream) invalidation = TurboStreamRenderer.invalidation(stream) revision_only = invalidation && invalidation.fetch("observable_name") == PayloadBroadcast::REVISION_OBSERVABLE if !revision_only && (!invalidation || scalar_observables.nil? || scalar_observables.include?(invalidation.fetch("observable_name"))) transmit stream end return unless invalidation component_subscriptions .refreshes_for(invalidation) .each { |refresh| transmit refresh } transmit_state_payloads(ActorSnapshot.new(reference)) end |
#refresh_outdated_components(snapshot) ⇒ void
This method returns an undefined value.
188 189 190 191 192 |
# File 'lib/solid_objects/actor_channel.rb', line 188 def refresh_outdated_components(snapshot) component_subscriptions .reconnect_refreshes(snapshot) .each { |refresh| transmit refresh } end |
#reject_and_report(reason, actor_type:, actor_id:, error: nil) ⇒ void
This method returns an undefined value.
The exception message stays out of the payload, because a component or payload error can carry actor state into logs.
70 71 72 73 74 75 76 77 78 79 |
# File 'lib/solid_objects/actor_channel.rb', line 70 def reject_and_report(reason, actor_type:, actor_id:, error: nil) SolidObjects.instrument( :"subscription.rejected", reason:, actor_type:, actor_id:, error_class: error&.class&.name ) reject end |
#reject_reason(error) ⇒ String
82 83 84 85 |
# File 'lib/solid_objects/actor_channel.rb', line 82 def reject_reason(error) match = REJECT_REASONS.find { |error_class, _| error.is_a?(error_class) } match ? match.last : "invalid_subscription" end |
#scalar_observable_names(snapshot) ⇒ Array[String]
212 213 214 215 216 217 218 219 |
# File 'lib/solid_objects/actor_channel.rb', line 212 def scalar_observable_names(snapshot) return scalar_observables if scalar_observables definition = snapshot.actor_class.definition definition.observables.keys.filter_map do |name| name.to_s if definition.broadcasts_observable_value?(name) end end |
#subscribed ⇒ void
This method returns an undefined value.
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/solid_objects/actor_channel.rb', line 16 def subscribed identity = StreamToken.verify(params.fetch("token")) actor_type = identity.fetch("actor_type") actor_id = identity.fetch("actor_id") SolidObjects.registry.fetch(actor_type) = SolidObjects.configuration..call( actor_type:, actor_id:, authorization_context: connection ) unless return reject_and_report("unauthorized", actor_type:, actor_id:) end @reference = Reference.new(actor_type:, actor_id:) @scalar_observables = identity["observables"] @payload_names = identity["payloads"] validate_scalar_observables! validate_payload_names! @component_subscriptions = ComponentSubscriptions.parse( params["components"], reference: ) stream_from StreamName.for(reference), coder: ActiveSupport::JSON do |stream| receive_broadcast(stream) end snapshot = ActorSnapshot.new(reference) scalar_observable_names(snapshot).each do |name| transmit TurboStreamRenderer.observable_value( reference:, name:, value: snapshot.observable_value(name) ) end refresh_outdated_components(snapshot) transmit_state_payloads(snapshot) rescue KeyError, JSON::ParserError, InvalidStreamToken, InvalidComponentToken, UnknownActorType => error reject_and_report(reject_reason(error), actor_type:, actor_id:, error:) end |
#transmit_state_payload(snapshot, name) ⇒ Boolean
A payload is one subscriber's view of one name. Letting it raise through here would reject the subscription or abandon the rest of a broadcast, so a failure is confined to the payload that caused it and reported. The exception message is deliberately not instrumented: a payload block reads actor state, so its message is the one place subscriber state could leak into logs.
Returns whether this revision was settled for the name. An unauthorized payload is settled: the decision is stable, so retrying it would only re-deliver its authorized siblings.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/solid_objects/actor_channel.rb', line 132 def transmit_state_payload(snapshot, name) payload = PayloadBroadcast.new( snapshot:, name:, authorization_context: (name) ).call transmit TurboStreamRenderer.state_payload(payload) true rescue Unauthorized true rescue => error SolidObjects.instrument( :payload_broadcast_failed, actor_type: reference.actor_type, actor_id: reference.actor_id, payload_name: name, error_class: error.class.name ) false end |
#transmit_state_payloads(snapshot) ⇒ void
This method returns an undefined value.
107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/solid_objects/actor_channel.rb', line 107 def transmit_state_payloads(snapshot) return if payload_names.nil? || payload_names.empty? return unless newer_payload_revision?(snapshot) # The watermark records what the subscriber has, so a revision with a # failed payload must not advance it: dedup would skip every later # attempt at that revision and the actor may not mutate again for a long # time. Every name is still attempted before the decision is made. attempts = payload_names.map { |name| transmit_state_payload(snapshot, name) } return if attempts.any?(false) @payload_revision = [ snapshot.instance_id, snapshot.revision ] end |
#validate_payload_names! ⇒ void
This method returns an undefined value.
173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/solid_objects/actor_channel.rb', line 173 def validate_payload_names! return unless payload_names broadcasts = SolidObjects .registry .fetch(reference.actor_type) .definition .payload_broadcasts unknown = payload_names.find { |name| !broadcasts.key?(name.to_sym) } return unless unknown raise InvalidStreamToken, "unknown payload broadcast #{unknown.inspect}" end |
#validate_scalar_observables! ⇒ void
This method returns an undefined value.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/solid_objects/actor_channel.rb', line 195 def validate_scalar_observables! return unless scalar_observables definition = SolidObjects .registry .fetch(reference.actor_type) .definition unknown = scalar_observables.find do |name| !definition.observables.key?(name.to_sym) || !definition.broadcasts_observable_value?(name) end return unless unknown raise InvalidStreamToken, "unknown scalar observable #{unknown.inspect}" end |