react_on_rails_pro 17.0.1 → 17.1.0.rc.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +4 -3
  3. data/CONTEXT.md +34 -27
  4. data/Gemfile.development_dependencies +1 -0
  5. data/Gemfile.lock +18 -3
  6. data/LICENSE_SETUP.md +42 -9
  7. data/README.md +12 -1
  8. data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +69 -95
  9. data/app/helpers/react_on_rails_pro_helper.rb +178 -45
  10. data/docs/adr/0001-boot-seed-rolling-deploy-bundles.md +28 -17
  11. data/lib/react_on_rails_pro/assets_precompile.rb +34 -20
  12. data/lib/react_on_rails_pro/async_props_emitter.rb +64 -1
  13. data/lib/react_on_rails_pro/cache.rb +1 -1
  14. data/lib/react_on_rails_pro/concerns/async_rendering.rb +11 -6
  15. data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +7 -0
  16. data/lib/react_on_rails_pro/concerns/stream.rb +17 -9
  17. data/lib/react_on_rails_pro/configuration.rb +79 -11
  18. data/lib/react_on_rails_pro/engine.rb +11 -5
  19. data/lib/react_on_rails_pro/error.rb +5 -0
  20. data/lib/react_on_rails_pro/license_task_formatter.rb +1 -1
  21. data/lib/react_on_rails_pro/license_validator.rb +38 -38
  22. data/lib/react_on_rails_pro/open_telemetry.rb +210 -0
  23. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +269 -35
  24. data/lib/react_on_rails_pro/renderer_artifact.rb +176 -0
  25. data/lib/react_on_rails_pro/renderer_artifact_support.rb +287 -0
  26. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +58 -32
  27. data/lib/react_on_rails_pro/renderer_http_client.rb +134 -39
  28. data/lib/react_on_rails_pro/request.rb +249 -118
  29. data/lib/react_on_rails_pro/rolling_deploy/tarball.rb +48 -18
  30. data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +242 -93
  31. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +77 -19
  32. data/lib/react_on_rails_pro/server_rendering_js_code.rb +48 -4
  33. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +46 -7
  34. data/lib/react_on_rails_pro/stream_cache.rb +18 -0
  35. data/lib/react_on_rails_pro/stream_request.rb +9 -2
  36. data/lib/react_on_rails_pro/utils.rb +77 -17
  37. data/lib/react_on_rails_pro/version.rb +1 -1
  38. data/lib/react_on_rails_pro.rb +1 -0
  39. data/sig/react_on_rails_pro/configuration.rbs +7 -0
  40. data/sig/react_on_rails_pro/open_telemetry.rbs +52 -0
  41. data/sig/react_on_rails_pro/renderer_http_client.rbs +18 -0
  42. metadata +7 -3
@@ -13,6 +13,8 @@
13
13
  # For licensing terms:
14
14
  # https://github.com/shakacode/react_on_rails/blob/main/REACT-ON-RAILS-PRO-LICENSE.md
15
15
 
16
+ require_relative "../open_telemetry"
17
+
16
18
  module ReactOnRailsPro
17
19
  # AsyncRendering enables concurrent rendering of multiple React components.
18
20
  # When enabled, async_react_component calls will execute their HTTP requests
@@ -54,14 +56,17 @@ module ReactOnRailsPro
54
56
  def wrap_in_async_react_context
55
57
  require "async"
56
58
  require "async/barrier"
59
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
57
60
 
58
61
  Sync do
59
- @react_on_rails_async_barrier = Async::Barrier.new
60
- yield
61
- check_for_unresolved_async_components
62
- ensure
63
- @react_on_rails_async_barrier&.stop
64
- @react_on_rails_async_barrier = nil
62
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
63
+ @react_on_rails_async_barrier = Async::Barrier.new
64
+ yield
65
+ check_for_unresolved_async_components
66
+ ensure
67
+ @react_on_rails_async_barrier&.stop
68
+ @react_on_rails_async_barrier = nil
69
+ end
65
70
  end
66
71
  end
67
72
 
@@ -24,6 +24,8 @@ module ReactOnRailsPro
24
24
 
25
25
  def rsc_payload
26
26
  @rsc_payload_component_name = rsc_payload_component_name
27
+ return head :forbidden unless rsc_payload_authorized?(@rsc_payload_component_name)
28
+
27
29
  @rsc_payload_component_props =
28
30
  begin
29
31
  rsc_payload_component_props
@@ -58,6 +60,11 @@ module ReactOnRailsPro
58
60
 
59
61
  private
60
62
 
63
+ def rsc_payload_authorized?(component_name)
64
+ authorizer = ReactOnRailsPro.configuration.rsc_payload_authorizer
65
+ authorizer.nil? || authorizer.call(self, component_name)
66
+ end
67
+
61
68
  def rsc_payload_component_props
62
69
  return {} if params[:props].blank?
63
70
 
@@ -15,6 +15,7 @@
15
15
 
16
16
  require "English"
17
17
  require "erb/util"
18
+ require_relative "../open_telemetry"
18
19
 
19
20
  module ReactOnRailsPro
20
21
  module StreamCacheWrites
@@ -66,8 +67,10 @@ module ReactOnRailsPro
66
67
  extend ActiveSupport::Concern
67
68
  RENDERER_SERVER_TIMING_COLLECTOR_KEY = :react_on_rails_pro_rsc_stream_renderer_server_timing_entries
68
69
  RENDERER_SERVER_TIMING_ATTEMPT_COLLECTOR_KEY = :react_on_rails_pro_rsc_stream_renderer_server_timing_attempt
70
+ MAX_BROWSER_PERFORMANCE_MARK_FALLBACK_ENTRIES = 200
69
71
  private_constant :RENDERER_SERVER_TIMING_COLLECTOR_KEY
70
72
  private_constant :RENDERER_SERVER_TIMING_ATTEMPT_COLLECTOR_KEY
73
+ private_constant :MAX_BROWSER_PERFORMANCE_MARK_FALLBACK_ENTRIES
71
74
 
72
75
  included do
73
76
  include ActionController::Live
@@ -221,16 +224,19 @@ module ReactOnRailsPro
221
224
  require_streaming_dependencies
222
225
  warn_on_non_html_formats_without_content_type(render_options[:formats], content_type)
223
226
  initialize_rsc_stream_observability_state(rsc_stream_observability)
227
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
224
228
 
225
229
  Sync do |parent_task|
226
- ReactOnRailsPro::Stream.with_renderer_server_timing_collector(renderer_server_timing_collector_for_stream) do
227
- stream_view_containing_react_components_in_sync(
228
- parent_task,
229
- template:,
230
- close_stream_at_end:,
231
- content_type:,
232
- render_options:
233
- )
230
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
231
+ ReactOnRailsPro::Stream.with_renderer_server_timing_collector(renderer_server_timing_collector_for_stream) do
232
+ stream_view_containing_react_components_in_sync(
233
+ parent_task,
234
+ template:,
235
+ close_stream_at_end:,
236
+ content_type:,
237
+ render_options:
238
+ )
239
+ end
234
240
  end
235
241
  end
236
242
  ensure
@@ -429,7 +435,9 @@ module ReactOnRailsPro
429
435
  try{perf.mark(#{mark_name_json});entry.fallback="mark-detail-unavailable";}
430
436
  catch(fallbackError){entry.fallback="performance-mark-unavailable";}}
431
437
  else{entry.fallback="performance-mark-unavailable";}
432
- (self.REACT_ON_RAILS_PERFORMANCE_MARKS=self.REACT_ON_RAILS_PERFORMANCE_MARKS||[]).push(entry);
438
+ var queue=self.REACT_ON_RAILS_PERFORMANCE_MARKS=self.REACT_ON_RAILS_PERFORMANCE_MARKS||[];
439
+ queue.push(entry);if(queue.length>#{MAX_BROWSER_PERFORMANCE_MARK_FALLBACK_ENTRIES}){
440
+ queue.splice(0,queue.length-#{MAX_BROWSER_PERFORMANCE_MARK_FALLBACK_ENTRIES});}
433
441
  })()</script>
434
442
  HTML
435
443
  end
@@ -31,7 +31,9 @@ module ReactOnRailsPro
31
31
  renderer_http_pool_timeout: Configuration::DEFAULT_RENDERER_HTTP_POOL_TIMEOUT,
32
32
  renderer_http_pool_warn_timeout: Configuration::DEFAULT_RENDERER_HTTP_POOL_WARN_TIMEOUT,
33
33
  renderer_http_keep_alive_timeout: Configuration::DEFAULT_RENDERER_HTTP_KEEP_ALIVE_TIMEOUT,
34
+ renderer_http_force_http2: Configuration::DEFAULT_RENDERER_HTTP_FORCE_HTTP2,
34
35
  renderer_password: nil,
36
+ license_token: nil,
35
37
  tracing: Configuration::DEFAULT_TRACING,
36
38
  dependency_globs: Configuration::DEFAULT_DEPENDENCY_GLOBS,
37
39
  excluded_dependency_globs: Configuration::DEFAULT_EXCLUDED_DEPENDENCY_GLOBS,
@@ -50,6 +52,7 @@ module ReactOnRailsPro
50
52
  raise_non_shell_server_rendering_errors: Configuration::DEFAULT_RAISE_NON_SHELL_SERVER_RENDERING_ERRORS,
51
53
  enable_rsc_support: Configuration::DEFAULT_ENABLE_RSC_SUPPORT,
52
54
  rsc_payload_generation_url_path: Configuration::DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH,
55
+ rsc_payload_authorizer: nil,
53
56
  rsc_bundle_js_file: Configuration::DEFAULT_RSC_BUNDLE_JS_FILE,
54
57
  react_client_manifest_file: Configuration::DEFAULT_REACT_CLIENT_MANIFEST_FILE,
55
58
  react_server_client_manifest_file: Configuration::DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE,
@@ -63,14 +66,16 @@ module ReactOnRailsPro
63
66
  DEFAULT_RENDERER_URL = "http://localhost:3800"
64
67
  DEFAULT_RENDERER_METHOD = "ExecJS"
65
68
  DEFAULT_RENDERER_FALLBACK_EXEC_JS = true
66
- # Maximum concurrent HTTP/2 streams per persistent client. When Fiber.scheduler is available,
67
- # clients are reused across requests within the same scheduler, making this limit effective.
68
- # Without a scheduler, clients are per-request and this limits streams on that single request.
69
+ # Maximum concurrent renderer connections per persistent client. With HTTP/1.1, each connection serves one
70
+ # request at a time, so this is also the request-concurrency cap for a client shared by a long-lived scheduler.
71
+ # Without a Fiber.scheduler, streaming renders use an ephemeral client and non-streaming renders use a persistent
72
+ # per-thread client; this bounds connections within each of those clients.
69
73
  DEFAULT_RENDERER_HTTP_POOL_SIZE = 10
70
74
  # TCP connect timeout. Request and response processing are still bounded by ssr_timeout.
71
75
  DEFAULT_RENDERER_HTTP_POOL_TIMEOUT = 5
72
76
  DEFAULT_RENDERER_HTTP_POOL_WARN_TIMEOUT = 0.25
73
77
  DEFAULT_RENDERER_HTTP_KEEP_ALIVE_TIMEOUT = 30
78
+ DEFAULT_RENDERER_HTTP_FORCE_HTTP2 = true
74
79
  DEFAULT_SSR_TIMEOUT = 5
75
80
  DEFAULT_PRERENDER_CACHING = false
76
81
  DEFAULT_TRACING = false
@@ -95,6 +100,7 @@ module ReactOnRailsPro
95
100
  DEFAULT_RSC_BUNDLE_JS_FILE = "rsc-bundle.js"
96
101
  DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
97
102
  DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json"
103
+ RSC_PAYLOAD_AUTHORIZER_POSITIONAL_PARAMS = %i[req opt].freeze
98
104
  DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE = 64
99
105
  # Ceiling TTL for a tag->cache-key index entry when the tagged cache entry
100
106
  # has no :expires_in of its own (see ReactOnRailsPro::Cache::TagIndex).
@@ -107,7 +113,7 @@ module ReactOnRailsPro
107
113
  ROLLING_DEPLOY_UPLOAD_ALL_KEYWORD_PARAMS = %i[keyrest].freeze
108
114
  ROLLING_DEPLOY_UPLOAD_REQUIRED_KEYWORDS = %i[bundle assets].freeze
109
115
 
110
- attr_accessor :renderer_url, :renderer_password, :tracing,
116
+ attr_accessor :renderer_url, :renderer_password, :license_token, :tracing,
111
117
  :server_renderer, :renderer_use_fallback_exec_js, :prerender_caching,
112
118
  :renderer_http_pool_timeout, :renderer_http_pool_warn_timeout,
113
119
  :dependency_globs, :excluded_dependency_globs, :rendering_returns_promises,
@@ -120,7 +126,8 @@ module ReactOnRailsPro
120
126
  :react_server_client_manifest_file
121
127
 
122
128
  attr_reader :concurrent_component_streaming_buffer_size, :renderer_http_keep_alive_timeout,
123
- :renderer_http_pool_size, :cache_tag_index_expires_in, :cache_tag_index_max_keys
129
+ :renderer_http_pool_size, :renderer_http_force_http2, :cache_tag_index_expires_in,
130
+ :cache_tag_index_max_keys, :rsc_payload_authorizer
124
131
 
125
132
  # Sets how long tag->key index entries live (see Cache::TagIndex).
126
133
  #
@@ -164,11 +171,12 @@ module ReactOnRailsPro
164
171
  @concurrent_component_streaming_buffer_size = value
165
172
  end
166
173
 
167
- # Sets the maximum concurrent HTTP/2 streams per persistent client.
174
+ # Sets the maximum concurrent renderer connections per persistent client.
168
175
  #
169
176
  # When Fiber.scheduler is available (e.g., inside Sync {} blocks), HTTP clients are
170
177
  # reused across requests within the same scheduler context, making this limit effective
171
- # for connection pooling. Without a scheduler, clients are created per-request.
178
+ # for connection pooling. Without a Fiber.scheduler, streaming renders use an ephemeral
179
+ # client and non-streaming renders use a persistent per-thread client.
172
180
  #
173
181
  # @param value [Integer, nil] A positive integer or nil (uses default)
174
182
  # @raise [ReactOnRailsPro::Error] if value is not a positive integer or nil
@@ -194,10 +202,40 @@ module ReactOnRailsPro
194
202
  @renderer_http_keep_alive_timeout = value
195
203
  end
196
204
 
197
- def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, # rubocop:disable Metrics/AbcSize
205
+ # Sets whether cleartext renderer connections force HTTP/2 prior knowledge (h2c).
206
+ #
207
+ # Set this to false only when the Node Renderer is also configured for HTTP/1.1. Direct HTTP/1.1 connections can
208
+ # stream in both directions, but async props are not supported through request-buffering or half-duplex proxies.
209
+ #
210
+ # @param value [Boolean] true to force h2c for http URLs, false to use HTTP/1.1
211
+ # @raise [ReactOnRailsPro::Error] if value is not true or false
212
+ def renderer_http_force_http2=(value)
213
+ unless [true, false].include?(value)
214
+ raise ReactOnRailsPro::Error, "config.renderer_http_force_http2 must be true or false"
215
+ end
216
+
217
+ @renderer_http_force_http2 = value
218
+ end
219
+
220
+ def rsc_payload_authorizer=(value)
221
+ unless value.nil? || value.respond_to?(:call)
222
+ raise ReactOnRailsPro::Error, "config.rsc_payload_authorizer must be nil or respond to #call"
223
+ end
224
+
225
+ if value && !rsc_payload_authorizer_signature_valid?(value)
226
+ raise ReactOnRailsPro::Error,
227
+ "config.rsc_payload_authorizer must accept call(controller, component_name) without required keywords"
228
+ end
229
+
230
+ @rsc_payload_authorizer = value
231
+ end
232
+
233
+ def initialize(renderer_url: nil, renderer_password: nil, license_token: nil, # rubocop:disable Metrics/AbcSize
234
+ server_renderer: nil,
198
235
  renderer_use_fallback_exec_js: nil, prerender_caching: nil,
199
236
  renderer_http_pool_size: nil, renderer_http_pool_timeout: nil,
200
237
  renderer_http_pool_warn_timeout: nil, renderer_http_keep_alive_timeout: nil,
238
+ renderer_http_force_http2: DEFAULT_RENDERER_HTTP_FORCE_HTTP2,
201
239
  tracing: nil,
202
240
  dependency_globs: nil, excluded_dependency_globs: nil, rendering_returns_promises: nil,
203
241
  remote_bundle_cache_adapter: nil, rolling_deploy_adapter: nil,
@@ -206,7 +244,7 @@ module ReactOnRailsPro
206
244
  ssr_pre_hook_js: nil, assets_to_copy: nil,
207
245
  renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil,
208
246
  profile_server_rendering_js_code: nil, raise_non_shell_server_rendering_errors: nil,
209
- enable_rsc_support: nil, rsc_payload_generation_url_path: nil,
247
+ enable_rsc_support: nil, rsc_payload_generation_url_path: nil, rsc_payload_authorizer: nil,
210
248
  rsc_bundle_js_file: nil, react_client_manifest_file: nil,
211
249
  react_server_client_manifest_file: nil,
212
250
  concurrent_component_streaming_buffer_size: DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE,
@@ -214,6 +252,7 @@ module ReactOnRailsPro
214
252
  cache_tag_index_max_keys: DEFAULT_CACHE_TAG_INDEX_MAX_KEYS)
215
253
  self.renderer_url = renderer_url
216
254
  self.renderer_password = renderer_password
255
+ self.license_token = license_token
217
256
  self.server_renderer = server_renderer
218
257
  self.renderer_use_fallback_exec_js = renderer_use_fallback_exec_js
219
258
  self.prerender_caching = prerender_caching
@@ -222,6 +261,7 @@ module ReactOnRailsPro
222
261
  self.renderer_http_pool_warn_timeout = renderer_http_pool_warn_timeout
223
262
  # Initial assignment applies the default constructor value; warn only when users set this deprecated config.
224
263
  assign_initial_renderer_http_keep_alive_timeout(renderer_http_keep_alive_timeout)
264
+ self.renderer_http_force_http2 = renderer_http_force_http2
225
265
  self.tracing = tracing
226
266
  self.rendering_returns_promises = server_renderer == "NodeRenderer" ? rendering_returns_promises : false
227
267
  self.dependency_globs = dependency_globs
@@ -242,6 +282,7 @@ module ReactOnRailsPro
242
282
  self.raise_non_shell_server_rendering_errors = raise_non_shell_server_rendering_errors
243
283
  self.enable_rsc_support = enable_rsc_support
244
284
  self.rsc_payload_generation_url_path = rsc_payload_generation_url_path
285
+ self.rsc_payload_authorizer = rsc_payload_authorizer
245
286
  self.rsc_bundle_js_file = rsc_bundle_js_file
246
287
  self.react_client_manifest_file = react_client_manifest_file
247
288
  self.react_server_client_manifest_file = react_server_client_manifest_file
@@ -318,6 +359,33 @@ module ReactOnRailsPro
318
359
 
319
360
  private
320
361
 
362
+ def rsc_payload_authorizer_signature_valid?(authorizer)
363
+ parameters = rsc_payload_authorizer_parameters(authorizer)
364
+ return false if parameters.any? { |type, _name| type == :keyreq }
365
+
366
+ # A non-lambda Proc intentionally ignores surplus positional arguments,
367
+ # unlike lambdas, Methods, and callable service objects.
368
+ return true if authorizer.is_a?(Proc) && !authorizer.lambda?
369
+
370
+ strict_rsc_payload_authorizer_signature_valid?(parameters)
371
+ end
372
+
373
+ def rsc_payload_authorizer_parameters(authorizer)
374
+ return authorizer.parameters if authorizer.is_a?(Proc) || authorizer.is_a?(Method)
375
+
376
+ authorizer.method(:call).parameters
377
+ end
378
+
379
+ def strict_rsc_payload_authorizer_signature_valid?(parameters)
380
+ required_positionals = parameters.count { |type, _name| type == :req }
381
+ accepted_positionals = parameters.count do |type, _name|
382
+ RSC_PAYLOAD_AUTHORIZER_POSITIONAL_PARAMS.include?(type)
383
+ end
384
+ accepts_rest = parameters.any? { |type, _name| type == :rest }
385
+
386
+ required_positionals <= 2 && (accepted_positionals >= 2 || accepts_rest)
387
+ end
388
+
321
389
  def assign_initial_renderer_http_pool_size(value)
322
390
  validate_renderer_http_pool_size(value)
323
391
  @renderer_http_pool_size = value
@@ -407,8 +475,8 @@ module ReactOnRailsPro
407
475
 
408
476
  # Only fires when the user has selected the built-in HTTP adapter. Custom
409
477
  # adapters that re-use the new config knobs for their own reasons are not
410
- # forced through this validation. We do not validate previous_urls here:
411
- # an unset list is a valid "discovery off; this is the upload-only side of
478
+ # forced through this validation. We do not validate previous_url here:
479
+ # an unset URL is a valid "discovery off; this is the upload-only side of
412
480
  # a one-way deploy" mode (the adapter returns [] for previous_bundle_hashes).
413
481
  def validate_rolling_deploy_http_adapter_config
414
482
  return unless rolling_deploy_http_adapter?
@@ -79,9 +79,13 @@ module ReactOnRailsPro
79
79
  when :expired
80
80
  expiration = ReactOnRailsPro::LicenseValidator.license_expiration
81
81
  expired_on = expiration ? " (expired on #{expiration.strftime('%Y-%m-%d')})" : ""
82
- log_license_issue("License has expired#{expired_on}", "Renew your license at #{LICENSE_URL}")
82
+ log_license_issue(
83
+ "License has expired#{expired_on}",
84
+ "Renew your license at #{LICENSE_URL}",
85
+ warn_outside_production: true
86
+ )
83
87
  when :invalid
84
- log_license_issue("Invalid license", "Get a license at #{LICENSE_URL}")
88
+ log_license_issue("Invalid license", "Get a license at #{LICENSE_URL}", warn_outside_production: true)
85
89
  end
86
90
  end
87
91
 
@@ -109,7 +113,7 @@ module ReactOnRailsPro
109
113
 
110
114
  message += " Attribution required for this license type." if attribution_required
111
115
 
112
- Rails.logger.info message
116
+ Rails.logger.debug message
113
117
  end
114
118
 
115
119
  def plan_display_name(plan)
@@ -126,15 +130,17 @@ module ReactOnRailsPro
126
130
  end
127
131
  end
128
132
 
129
- def log_license_issue(issue, action)
133
+ def log_license_issue(issue, action, warn_outside_production: false)
130
134
  prefix = "[React on Rails Pro] #{issue}."
131
135
 
132
136
  if Rails.env.production?
133
137
  warning = "Production Use of React on Rails Pro requires a valid license. " \
134
138
  "If this deployment is Production Use, #{action}"
135
139
  Rails.logger.warn "#{prefix} #{warning}"
140
+ elsif warn_outside_production
141
+ Rails.logger.warn "#{prefix} #{action}"
136
142
  else
137
- Rails.logger.info "#{prefix} No license required for development/test environments."
143
+ Rails.logger.debug "#{prefix} No license required for development/test environments."
138
144
  end
139
145
  end
140
146
  end
@@ -24,4 +24,9 @@ module ReactOnRailsPro
24
24
  "This is unexpected behavior."
25
25
  end
26
26
  end
27
+
28
+ # Distinguishes a missing renderer bundle from other artifact-construction
29
+ # failures so cache-key callers can preserve the established missing-RSC
30
+ # fallback without swallowing unrelated configuration or companion errors.
31
+ class MissingRendererBundleError < Error; end
27
32
  end
@@ -54,7 +54,7 @@ module ReactOnRailsPro
54
54
  return unless status == :missing
55
55
 
56
56
  puts ""
57
- puts "No license found. Set REACT_ON_RAILS_PRO_LICENSE"
57
+ puts "No license found. Set config.license_token or REACT_ON_RAILS_PRO_LICENSE"
58
58
  end
59
59
 
60
60
  def print_details(result, info)
@@ -41,55 +41,35 @@ module ReactOnRailsPro
41
41
  # See: https://bugs.ruby-lang.org/issues/20875
42
42
  LICENSE_MUTEX = Mutex.new
43
43
 
44
+ # Explicit tokens receive distinct cache keys so later application configuration
45
+ # cannot reuse ENV/default metadata. The ENV/default path intentionally retains
46
+ # one process-lifetime key and the existing reset-required semantics.
47
+ ENV_LICENSE_CACHE_KEY = Object.new.freeze
48
+
44
49
  class << self
45
50
  # Returns the current license status (never raises, never logs)
46
51
  # Thread-safe: uses Mutex to prevent race conditions during initialization
47
52
  # @return [Symbol] One of :valid, :expired, :invalid, :missing
48
53
  def license_status
49
- return @license_status if defined?(@license_status)
50
-
51
- LICENSE_MUTEX.synchronize do
52
- # Double-check pattern: another thread may have set it while we waited
53
- return @license_status if defined?(@license_status)
54
-
55
- @license_status = determine_license_status
56
- end
54
+ fetch_cached_license_value(:@license_status) { determine_license_status }
57
55
  end
58
56
 
59
57
  # Returns the license expiration time if available
60
58
  # @return [Time, nil] The expiration time or nil if not available
61
59
  def license_expiration
62
- return @license_expiration if defined?(@license_expiration)
63
-
64
- LICENSE_MUTEX.synchronize do
65
- return @license_expiration if defined?(@license_expiration)
66
-
67
- @license_expiration = determine_license_expiration
68
- end
60
+ fetch_cached_license_value(:@license_expiration) { determine_license_expiration }
69
61
  end
70
62
 
71
63
  # Returns the organization name from the license if available
72
64
  # @return [String, nil] The organization name or nil if not available
73
65
  def license_organization
74
- return @license_organization if defined?(@license_organization)
75
-
76
- LICENSE_MUTEX.synchronize do
77
- return @license_organization if defined?(@license_organization)
78
-
79
- @license_organization = determine_license_organization
80
- end
66
+ fetch_cached_license_value(:@license_organization) { determine_license_organization }
81
67
  end
82
68
 
83
69
  # Returns the license plan type if available
84
70
  # @return [String, nil] The plan type (e.g., "paid", "startup") or nil if not available
85
71
  def license_plan
86
- return @license_plan if defined?(@license_plan)
87
-
88
- LICENSE_MUTEX.synchronize do
89
- return @license_plan if defined?(@license_plan)
90
-
91
- @license_plan = determine_license_plan
92
- end
72
+ fetch_cached_license_value(:@license_plan) { determine_license_plan }
93
73
  end
94
74
 
95
75
  # Returns whether the product-level attribution is required.
@@ -97,13 +77,7 @@ module ReactOnRailsPro
97
77
  # A license JWT cannot waive the requirement.
98
78
  # @return [Boolean] True if attribution is required
99
79
  def attribution_required?
100
- return @attribution_required if defined?(@attribution_required)
101
-
102
- LICENSE_MUTEX.synchronize do
103
- return @attribution_required if defined?(@attribution_required)
104
-
105
- @attribution_required = determine_attribution_required
106
- end
80
+ fetch_cached_license_value(:@attribution_required) { determine_attribution_required }
107
81
  end
108
82
 
109
83
  # Returns license information for use in helpers and components
@@ -131,6 +105,30 @@ module ReactOnRailsPro
131
105
 
132
106
  private
133
107
 
108
+ def fetch_cached_license_value(cache_variable)
109
+ key = license_cache_key
110
+ if instance_variable_defined?(cache_variable)
111
+ cached = instance_variable_get(cache_variable)
112
+ return cached[:value] if cached[:key] == key
113
+ end
114
+
115
+ LICENSE_MUTEX.synchronize do
116
+ key = license_cache_key
117
+ if instance_variable_defined?(cache_variable)
118
+ cached = instance_variable_get(cache_variable)
119
+ return cached[:value] if cached[:key] == key
120
+ end
121
+
122
+ value = yield
123
+ instance_variable_set(cache_variable, { key:, value: }.freeze)
124
+ value
125
+ end
126
+ end
127
+
128
+ def license_cache_key
129
+ ReactOnRailsPro.configuration.license_token&.strip.presence || ENV_LICENSE_CACHE_KEY
130
+ end
131
+
134
132
  # Determines the license status by loading, decoding, and validating
135
133
  # @return [Symbol] The license status
136
134
  def determine_license_status
@@ -219,10 +217,12 @@ module ReactOnRailsPro
219
217
  true
220
218
  end
221
219
 
222
- # Loads license string from environment variable
220
+ # Loads the license string from explicit configuration, then the environment.
221
+ # Blank configured values fall through to preserve the environment-variable default.
223
222
  # @return [String, nil] License string or nil if not found
224
223
  def load_license_string
225
- ENV.fetch("REACT_ON_RAILS_PRO_LICENSE", nil)&.strip.presence
224
+ configured_token = ReactOnRailsPro.configuration.license_token&.strip.presence
225
+ configured_token || ENV.fetch("REACT_ON_RAILS_PRO_LICENSE", nil)&.strip.presence
226
226
  end
227
227
 
228
228
  # Decodes and verifies the JWT license