upkeep-rails 0.1.6

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_verifier"
4
+ require "securerandom"
5
+
6
+ module Upkeep
7
+ module Rails
8
+ module ActivationToken
9
+ PURPOSE = "upkeep-subscription-activation"
10
+
11
+ module_function
12
+
13
+ def generate(subscription)
14
+ subscription_id = subscription.respond_to?(:id) ? subscription.id : subscription
15
+ verifier.generate(
16
+ { "subscription_id" => subscription_id.to_s },
17
+ purpose: PURPOSE,
18
+ expires_in: Upkeep::Rails.configuration.activation_token_expires_in
19
+ )
20
+ end
21
+
22
+ def valid_for_subscription?(token, subscription_id)
23
+ payload = verifier.verified(token.to_s, purpose: PURPOSE)
24
+ token_subscription_id(payload) == subscription_id.to_s
25
+ end
26
+
27
+ def token_subscription_id(payload)
28
+ return unless payload.respond_to?(:[])
29
+
30
+ payload["subscription_id"] || payload[:subscription_id]
31
+ end
32
+
33
+ def verifier
34
+ rails_message_verifier || fallback_verifier
35
+ end
36
+
37
+ def rails_message_verifier
38
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application)
39
+ return unless ::Rails.application.respond_to?(:message_verifier)
40
+
41
+ ::Rails.application.message_verifier(PURPOSE)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ def fallback_verifier
47
+ @fallback_verifier ||= ActiveSupport::MessageVerifier.new(fallback_secret)
48
+ end
49
+
50
+ def fallback_secret
51
+ @fallback_secret ||= SecureRandom.hex(64)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+ require "active_support/notifications"
5
+
6
+ module Upkeep
7
+ module Rails
8
+ module Cable
9
+ class Channel < ::ActionCable::Channel::Base
10
+ SUBSCRIBE_NOTIFICATION = "subscribe_channel.upkeep"
11
+
12
+ def subscribed
13
+ if ActiveSupport::Notifications.notifier.listening?(SUBSCRIBE_NOTIFICATION)
14
+ instrumented_subscribe
15
+ else
16
+ subscribe_without_instrumentation
17
+ end
18
+ end
19
+
20
+ def unsubscribed
21
+ Upkeep::Rails.subscriptions.unregister(subscription_id)
22
+ rescue KeyError
23
+ nil
24
+ end
25
+
26
+ private
27
+
28
+ def instrumented_subscribe
29
+ payload = { subscription_id: safe_subscription_id }
30
+ ActiveSupport::Notifications.instrument(SUBSCRIBE_NOTIFICATION, payload) do
31
+ subscribe_without_instrumentation(payload: payload)
32
+ end
33
+ end
34
+
35
+ def subscribe_without_instrumentation(payload: nil)
36
+ id = subscription_id
37
+ payload[:subscription_id] = id if payload
38
+ unless measure(payload, :activation_token_ms) { valid_activation_token?(id) }
39
+ return reject_upkeep_subscription(payload, "invalid_activation_token")
40
+ end
41
+
42
+ subscription = measure(payload, :fetch_ms) { Upkeep::Rails.subscriptions.fetch(id) }
43
+ authorized = measure(payload, :authorization_ms) { authorized_subscription?(subscription) }
44
+ unless authorized
45
+ return reject_upkeep_subscription(payload, "unauthorized_identity")
46
+ end
47
+
48
+ measure(payload, :activation_ms) { Upkeep::Rails.subscriptions.activate(id) }
49
+ stream_count = measure(payload, :stream_attach_ms) { attach_streams(subscription) }
50
+ payload[:stream_count] = stream_count if payload
51
+ rescue KeyError
52
+ reject_upkeep_subscription(payload, missing_activation_token? ? "missing_activation_token" : "missing_subscription")
53
+ rescue UnidentifiedSubscriber
54
+ reject_upkeep_subscription(payload, "unidentified_subscriber")
55
+ end
56
+
57
+ def reject_upkeep_subscription(payload, reason)
58
+ payload[:rejected] = true if payload
59
+ payload[:reject_reason] = reason if payload
60
+ log_subscription_rejection(reason)
61
+ reject
62
+ end
63
+
64
+ def attach_streams(subscription)
65
+ stream_from stream_name_for(subscription)
66
+ count = 1
67
+ shared_stream_names_for(subscription).each do |stream_name|
68
+ stream_from stream_name
69
+ count += 1
70
+ end
71
+ count
72
+ end
73
+
74
+ def measure(payload, key)
75
+ return yield unless payload
76
+
77
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ yield
79
+ ensure
80
+ payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3) if payload && started_at
81
+ end
82
+
83
+ def safe_subscription_id
84
+ subscription_id
85
+ rescue KeyError
86
+ nil
87
+ end
88
+
89
+ def subscription_id
90
+ params.fetch(:subscription_id)
91
+ end
92
+
93
+ def activation_token
94
+ params.fetch(:activation_token)
95
+ rescue KeyError
96
+ @missing_activation_token = true
97
+ raise
98
+ end
99
+
100
+ def missing_activation_token?
101
+ @missing_activation_token == true
102
+ end
103
+
104
+ def valid_activation_token?(id)
105
+ ActivationToken.valid_for_subscription?(activation_token, id)
106
+ end
107
+
108
+ def log_subscription_rejection(reason)
109
+ return unless reason == "missing_activation_token"
110
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
111
+
112
+ ::Rails.logger.warn(
113
+ "[upkeep] subscription rejected: missing activation_token. " \
114
+ "If this started after upgrading upkeep-rails, refresh app/javascript/upkeep/subscription.js from the install generator."
115
+ )
116
+ end
117
+
118
+ def authorized_subscription?(subscription)
119
+ return true if anonymous_public_subscription?(subscription)
120
+ return true unless metadata_value(subscription, :identity_mode)
121
+
122
+ SubscriberIdentity.derive_for_subscription(connection, subscription).subscriber_id == subscription.subscriber_id
123
+ end
124
+
125
+ def anonymous_public_subscription?(subscription)
126
+ metadata_value(subscription, :identity_mode) == SubscriberIdentity::ANONYMOUS_PUBLIC_MODE
127
+ end
128
+
129
+ def stream_name_for(subscription)
130
+ metadata_value(subscription, :stream_name) || subscription.metadata.fetch(:stream_name)
131
+ end
132
+
133
+ def shared_stream_names_for(subscription)
134
+ metadata_value(subscription, :shared_stream_names) || []
135
+ end
136
+
137
+ def metadata_value(subscription, key)
138
+ subscription.metadata[key] || subscription.metadata[key.to_s]
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module Upkeep
8
+ module Rails
9
+ module Cable
10
+ class UnidentifiedSubscriber < StandardError; end
11
+
12
+ Identity = Data.define(:subscriber_id, :stream_name, :components)
13
+ Decision = Data.define(:mode, :anonymous, :deopt_reason, :identity_sources, :identity_names)
14
+
15
+ class SubscribeContext
16
+ def initialize(connection)
17
+ @action_cable_connection = connection
18
+ end
19
+
20
+ def session
21
+ action_cable_request.session
22
+ end
23
+
24
+ def cookies
25
+ action_cable_request.cookies
26
+ end
27
+
28
+ def method_missing(name, *args, &block)
29
+ if action_cable_connection.respond_to?(name)
30
+ action_cable_connection.public_send(name, *args, &block)
31
+ else
32
+ super
33
+ end
34
+ end
35
+
36
+ def respond_to_missing?(name, include_private = false)
37
+ action_cable_connection.respond_to?(name, false) || super
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :action_cable_connection
43
+
44
+ def action_cable_request
45
+ action_cable_connection.__send__(:request)
46
+ end
47
+ end
48
+
49
+ module SubscriberIdentity
50
+ ANONYMOUS_PUBLIC_MODE = "anonymous_public"
51
+ IDENTIFIED_MODE = "identified"
52
+ DECLARATION_REQUIRED_SOURCES = %w[Current.user current_attribute warden_user].freeze
53
+
54
+ module_function
55
+
56
+ def derive(connection)
57
+ derive_all(connection).last
58
+ end
59
+
60
+ def derive_all(connection)
61
+ components = subscribe_components(connection, configuration.identity_definitions)
62
+ raise UnidentifiedSubscriber, "ActionCable connection has no declared Upkeep identities" if components.empty?
63
+
64
+ [for_components(components)]
65
+ end
66
+
67
+ def derive_for_subscription(connection, subscription)
68
+ names = metadata_value(subscription, :identity_names) || []
69
+ definitions = names.map { |name| configuration.identity_definition(name) }
70
+ components = subscribe_components(connection, definitions)
71
+ raise UnidentifiedSubscriber, "ActionCable connection did not resolve subscription identities #{names.inspect}" if components.empty?
72
+
73
+ for_components(components)
74
+ end
75
+
76
+ def derive_from_request(request, recorder:, decision: decision_for(request, recorder: recorder))
77
+ components = if decision.anonymous
78
+ anonymous_components
79
+ else
80
+ recorder_components(recorder)
81
+ end
82
+
83
+ if components.empty?
84
+ raise UnidentifiedSubscriber,
85
+ "subscription has identity dependencies but no declared Upkeep identity mapping"
86
+ end
87
+
88
+ for_components(components)
89
+ end
90
+
91
+ def decision_for(_request = nil, recorder:)
92
+ configured_dependencies = configured_identity_dependencies(recorder)
93
+ undeclared_dependencies = undeclared_identity_dependencies(recorder)
94
+
95
+ if configured_dependencies.any?
96
+ Decision.new(
97
+ IDENTIFIED_MODE,
98
+ false,
99
+ "identity_dependencies_present",
100
+ configured_dependencies.map { |definition, _dependency| definition.source.to_s }.uniq.sort,
101
+ configured_dependencies.map { |definition, _dependency| definition.name.to_s }.uniq.sort
102
+ )
103
+ elsif undeclared_dependencies.any?
104
+ Decision.new(
105
+ IDENTIFIED_MODE,
106
+ false,
107
+ "identity_setup_required",
108
+ undeclared_dependencies.map { |dependency| dependency.source.to_s }.uniq.sort,
109
+ []
110
+ )
111
+ else
112
+ Decision.new(
113
+ ANONYMOUS_PUBLIC_MODE,
114
+ true,
115
+ nil,
116
+ [],
117
+ []
118
+ )
119
+ end
120
+ end
121
+
122
+ def identifier_components(connection)
123
+ identifiers = Array(connection.identifiers)
124
+ identifiers.map { |name| component_for(name, connection.public_send(name)) }
125
+ end
126
+
127
+ def for_identifiers(identifiers)
128
+ for_components(identifiers.map { |name, value| component_for(name, value) })
129
+ end
130
+
131
+ def for_components(components)
132
+ canonical_bytes = JSON.generate(components.sort_by { |component| component.fetch(:name) })
133
+ subscriber_id = "action_cable:#{Digest::SHA256.hexdigest(canonical_bytes)}"
134
+
135
+ Identity.new(
136
+ subscriber_id,
137
+ Delivery::ActionCableAdapter.stream_name_for(subscriber_id),
138
+ components
139
+ )
140
+ end
141
+
142
+ def recorder_components(recorder)
143
+ components_by_name = Hash.new { |hash, key| hash[key] = [] }
144
+ configured_identity_dependencies(recorder).each do |definition, dependency|
145
+ component = component_for_dependency(definition, dependency)
146
+ components_by_name[definition.name] << component if component
147
+ end
148
+
149
+ components_by_name.map do |name, components|
150
+ unique_components = components.uniq
151
+ if unique_components.size > 1
152
+ raise UnidentifiedSubscriber, "captured identity :#{name} changed during request"
153
+ end
154
+
155
+ unique_components.first
156
+ end.compact
157
+ end
158
+
159
+ def anonymous_components
160
+ [ scalar_component(:anonymous_public_subscription, SecureRandom.uuid) ]
161
+ end
162
+
163
+ def identity_dependencies(recorder)
164
+ return [] unless recorder
165
+
166
+ recorder.graph.dependency_nodes
167
+ .map(&:payload)
168
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
169
+ .uniq(&:cache_key)
170
+ end
171
+
172
+ def configured_identity_dependencies(recorder)
173
+ definitions = configuration.identity_definitions
174
+ return [] if definitions.empty?
175
+
176
+ identity_dependencies(recorder).flat_map do |dependency|
177
+ definitions.select { |definition| definition.matches_dependency?(dependency) }
178
+ .reject { |definition| definition.absent_dependency?(dependency) }
179
+ .map { |definition| [definition, dependency] }
180
+ end
181
+ end
182
+
183
+ def undeclared_identity_dependencies(recorder)
184
+ identity_dependencies(recorder).select do |dependency|
185
+ declaration_required_dependency?(dependency) &&
186
+ configured_identity_dependencies_for_dependency(dependency).empty?
187
+ end
188
+ end
189
+
190
+ def configured_identity_dependencies_for_dependency(dependency)
191
+ configuration.identity_definitions.select { |definition| definition.matches_dependency?(dependency) }
192
+ end
193
+
194
+ def declaration_required_dependency?(dependency)
195
+ DECLARATION_REQUIRED_SOURCES.include?(dependency.source.to_s)
196
+ end
197
+
198
+ def component_for_dependency(definition, dependency)
199
+ if definition.absent_dependency?(dependency)
200
+ raise UnidentifiedSubscriber, "captured identity :#{definition.name} from #{definition.source_label} is absent"
201
+ end
202
+
203
+ identity_component(definition.name, dependency.key.fetch(:value))
204
+ end
205
+
206
+ def subscribe_components(connection, definitions)
207
+ definitions.filter_map do |definition|
208
+ value = call_subscribe_block(definition, connection)
209
+ if definition.absent?(value)
210
+ raise UnidentifiedSubscriber, "subscribe identity :#{definition.name} from #{definition.source_label} is absent"
211
+ end
212
+
213
+ identity_component(definition.name, subscribe_identity_value(definition, value))
214
+ end
215
+ end
216
+
217
+ def call_subscribe_block(definition, connection)
218
+ block = definition.subscribe_block
219
+ context = SubscribeContext.new(connection)
220
+ block.arity == 1 ? block.call(context) : context.instance_exec(&block)
221
+ end
222
+
223
+ def subscribe_identity_value(definition, value)
224
+ case definition.source
225
+ when :session, :cookie
226
+ Dependencies.private_fingerprint(value)
227
+ else
228
+ canonical_identity_value(value)
229
+ end
230
+ end
231
+
232
+ def identity_component(name, value)
233
+ {
234
+ name: name.to_s,
235
+ kind: "identity",
236
+ value: normalize_component_value(value)
237
+ }
238
+ end
239
+
240
+ def canonical_identity_value(value)
241
+ case value
242
+ when nil, true, false, Numeric, String, Symbol
243
+ value
244
+ when Array
245
+ value.map { |item| canonical_identity_value(item) }
246
+ when Hash
247
+ value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical_identity_value(value.fetch(key))] }
248
+ else
249
+ model_identity = Dependencies.model_identity(value)
250
+ return model_identity if model_identity
251
+
252
+ return { global_id: value.to_gid_param } if value.respond_to?(:to_gid_param)
253
+
254
+ raise UnidentifiedSubscriber, "identity value #{value.class.name} has no canonical identity"
255
+ end
256
+ end
257
+
258
+ def normalize_component_value(value)
259
+ case value
260
+ when Hash
261
+ value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, normalize_component_value(value.fetch(key))] }
262
+ when Array
263
+ value.map { |item| normalize_component_value(item) }
264
+ when Symbol
265
+ value.to_s
266
+ else
267
+ value
268
+ end
269
+ end
270
+
271
+ def model_component(name, identity)
272
+ return unless identity.is_a?(Hash) && identity[:model] && identity[:id]
273
+
274
+ {
275
+ name: name.to_s,
276
+ kind: "model",
277
+ model: identity.fetch(:model),
278
+ id: identity.fetch(:id).to_s
279
+ }
280
+ end
281
+
282
+ def component_for(name, value)
283
+ raise UnidentifiedSubscriber, "ActionCable identifier #{name} is nil" if value.nil?
284
+
285
+ if active_record?(value)
286
+ active_record_component(name, value)
287
+ elsif scalar?(value)
288
+ scalar_component(name, value)
289
+ elsif value.respond_to?(:to_gid_param)
290
+ global_id_component(name, value)
291
+ else
292
+ raise UnidentifiedSubscriber, "ActionCable identifier #{name} has no canonical identity"
293
+ end
294
+ end
295
+
296
+ def active_record?(value)
297
+ defined?(::ActiveRecord::Base) && value.is_a?(::ActiveRecord::Base)
298
+ end
299
+
300
+ def active_record_component(name, record)
301
+ raise UnidentifiedSubscriber, "ActionCable identifier #{name} is an unsaved record" unless record.id
302
+
303
+ model_component(name, model: record.class.name, id: record.id)
304
+ end
305
+
306
+ def scalar?(value)
307
+ value.is_a?(String) ||
308
+ value.is_a?(Symbol) ||
309
+ value.is_a?(Integer) ||
310
+ value == true ||
311
+ value == false
312
+ end
313
+
314
+ def scalar_component(name, value)
315
+ {
316
+ name: name.to_s,
317
+ kind: "scalar",
318
+ class: value.class.name,
319
+ value: value.to_s
320
+ }
321
+ end
322
+
323
+ def global_id_component(name, value)
324
+ {
325
+ name: name.to_s,
326
+ kind: "global_id",
327
+ value: value.to_gid_param
328
+ }
329
+ end
330
+
331
+ def metadata_value(subscription, key)
332
+ subscription.metadata[key] || subscription.metadata[key.to_s]
333
+ end
334
+
335
+ def configuration
336
+ Upkeep::Rails.configuration
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cable/subscriber_identity"
4
+ require_relative "cable/channel"
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+
6
+ module Upkeep
7
+ module Rails
8
+ module ClientSubscription
9
+ CHANNEL = "Upkeep::Rails::Cable::Channel"
10
+
11
+ module_function
12
+
13
+ def inject(html, identity:, subscription:)
14
+ marker = marker_for(identity: identity, subscription: subscription)
15
+ insert_before_closing("body", html, marker) ||
16
+ "#{html}#{marker}"
17
+ end
18
+
19
+ def marker_for(identity:, subscription:)
20
+ payload = JSON.generate(
21
+ channel: CHANNEL,
22
+ subscription_id: subscription.id,
23
+ activation_token: ActivationToken.generate(subscription),
24
+ stream_name: identity.stream_name
25
+ ).gsub("</", '<\/')
26
+
27
+ id = "upkeep-subscription-source-#{subscription.id}"
28
+
29
+ [
30
+ %(<upkeep-subscription-source id="#{CGI.escapeHTML(id)}" ),
31
+ %(data-upkeep-subscription data-turbo-temporary>),
32
+ CGI.escapeHTML(payload),
33
+ %(</upkeep-subscription-source>)
34
+ ].join
35
+ end
36
+
37
+ def insert_before_closing(tag, html, marker)
38
+ index = html.rindex(%(</#{tag}>)) || html.rindex(%(</#{tag.upcase}>))
39
+ return unless index
40
+
41
+ "#{html[0...index]}#{marker}#{html[index..]}"
42
+ end
43
+ end
44
+ end
45
+ end