react_on_rails_pro 17.0.0.rc.4 → 17.0.0.rc.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45123dd7dda25991484169e21920b5c3d5cda2e2d517c38c0a0e3a5c8220f802
4
- data.tar.gz: 6c3bb209b7fcbbd85a6cb2a5e9b035207ee50fb5466e087373536dde0dc638cc
3
+ metadata.gz: aa0bf219fe1d63080c7f653bd3496ea95f15a8e3a1a79a91dd6140de2f8415d9
4
+ data.tar.gz: 8d8ea4b6fd46b87c5411abbd9c01bd6e49ab9fa0bf026dfcf59cfa270a2e00c1
5
5
  SHA512:
6
- metadata.gz: 7ba6fa338ab17e3793d0cedf6eb230384435e8b993b66b2dc2bc584a40d847e7ad23d25d05eff69e05369fcda4db381a750d3d8530819549076c5987558fbf66
7
- data.tar.gz: d1abe0dd5e92fb32848bcef2bdad82c4083822157d77745f683a83764acd9670f205e9106f1c51c802a5f6caa1243628a8206ab28ee7f7362a3fe8ef72e9e60d
6
+ metadata.gz: 9e22fcd250f90618ccac39fabb7cbfecb60d502302c3cd1abd0d6bc3f9249c6552b0a25a9dcc235ef4d8c95c11223edc84eb9246bf67058f4ca70074756e9a8b
7
+ data.tar.gz: a6891194c45e31b9ce9a39a9dc31b5cb6807fa5a76bf64844aceaff5bbaacad0eefc9668ceeccdd5d7215c30739a37792918f8355a1094b0cd3339bda8921acd
@@ -21,13 +21,18 @@ spec:
21
21
  - path: /var/lib/postgresql/data
22
22
  recoveryPolicy: retain
23
23
  uri: 'scratch://postgres-vol'
24
- # Important that postgres does not scaling because disk storage is local to one server!
24
+ # Keep Postgres on one manual replica because disk storage is local to one server.
25
+ # Capacity AI may restart the workload when adjusting resources; for Postgres
26
+ # that can disrupt the database despite the retained local volume.
27
+ # Keep the single-replica manual posture explicit instead of relying on platform defaults.
25
28
  defaultOptions:
26
29
  autoscaling:
30
+ metric: disabled
31
+ minScale: 1
27
32
  maxScale: 1
28
33
  capacityAI: false
29
34
  # This firewall configuration corresponds to using a simple, hard-coded password for postgres
30
35
  # in the gvc.yml template.
31
36
  firewallConfig:
32
37
  internal:
33
- inboundAllowType: same-gvc
38
+ inboundAllowType: same-gvc
@@ -47,11 +47,15 @@ spec:
47
47
  - number: 3800
48
48
  protocol: http
49
49
  defaultOptions:
50
- # Start out like this for "test apps"
50
+ # Public/staging test apps keep one warm replica while Capacity AI right-sizes both workload
51
+ # containers (rails and node-renderer).
52
+ # Capacity AI may restart that replica when applying resource changes.
53
+ # For production or high-availability staging, increase maxScale and review Capacity AI suitability.
51
54
  autoscaling:
52
- # Max of 1 effectively disables autoscaling, so a like a Heroku dyno count of 1
55
+ metric: disabled
56
+ minScale: 1
53
57
  maxScale: 1
54
- capacityAI: false
58
+ capacityAI: true
55
59
  firewallConfig:
56
60
  external:
57
61
  # Default to allow public access to Rails server
@@ -22,7 +22,13 @@ spec:
22
22
  - number: 6379
23
23
  protocol: tcp
24
24
  defaultOptions:
25
+ # Capacity AI may restart the workload when adjusting resources; for Redis
26
+ # that can disrupt cache state and connections.
27
+ # Review cache restart tolerance before copying the Rails Capacity AI posture here.
28
+ # Keep the single-replica manual posture explicit instead of relying on platform defaults.
25
29
  autoscaling:
30
+ metric: disabled
31
+ minScale: 1
26
32
  maxScale: 1
27
33
  capacityAI: false
28
34
  # This firewall configuration corresponds to using no password for Redis in the gvc.yml template.
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (17.0.0.rc.4)
12
+ react_on_rails (17.0.0.rc.6)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,7 +20,7 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (17.0.0.rc.4)
23
+ react_on_rails_pro (17.0.0.rc.6)
24
24
  addressable
25
25
  async (>= 2.29)
26
26
  async-http (~> 0.95)
@@ -28,7 +28,7 @@ PATH
28
28
  io-endpoint (~> 0.17.0)
29
29
  jwt (>= 2.5, < 4)
30
30
  rainbow
31
- react_on_rails (= 17.0.0.rc.4)
31
+ react_on_rails (= 17.0.0.rc.6)
32
32
 
33
33
  GEM
34
34
  remote: https://rubygems.org/
@@ -28,15 +28,40 @@ module ReactOnRailsPro
28
28
  # Global variables in Node.js VM persist across requests, causing data leakage.
29
29
  # sharedExecutionContext is scoped to a single HTTP request (ExecutionContext).
30
30
  #
31
- # @example Usage in view
31
+ # PULL MODE:
32
+ # When pull_enabled is true, React components can request props lazily via
33
+ # getProp(). Those requests arrive as propRequest chunks on the response stream.
34
+ # `pull_requests` exposes an Async::Queue that yields prop names as they arrive.
35
+ # The user's block can dequeue and resolve them dynamically.
36
+ #
37
+ # @example Push-only usage (existing)
32
38
  # stream_react_component_with_async_props("Dashboard") do |emit|
33
- # emit.call("users", User.all.to_a) # Sends immediately
34
- # emit.call("posts", Post.recent.to_a) # Sends when ready
39
+ # emit.call("users", User.all.to_a)
40
+ # emit.call("posts", Post.recent.to_a)
41
+ # end
42
+ #
43
+ # @example Pull mode usage
44
+ # stream_react_component_with_async_props("Dashboard", push_props: %w[stats]) do |emit|
45
+ # emit.call("stats", compute_stats)
46
+ # while (prop_name = emit.pull_requests.dequeue)
47
+ # emit.call(prop_name, fetch_prop(prop_name))
48
+ # end
35
49
  # end
36
50
  class AsyncPropsEmitter
37
- def initialize(bundle_timestamp, request_stream)
51
+ SANITIZED_REJECTION_REASON = "Async prop rejected by server"
52
+
53
+ attr_reader :pull_requests
54
+
55
+ def initialize(bundle_timestamp, request_stream, pull_enabled: false)
38
56
  @bundle_timestamp = bundle_timestamp
39
57
  @request_stream = request_stream
58
+ @pushed_props = Set.new
59
+ @pull_enabled = pull_enabled
60
+ @pull_requests = PullRequestQueue.new(@pushed_props) if pull_enabled
61
+ end
62
+
63
+ def pull_enabled?
64
+ @pull_enabled
40
65
  end
41
66
 
42
67
  # Sends an async prop to the Node renderer.
@@ -45,17 +70,35 @@ module ReactOnRailsPro
45
70
  def call(prop_name, prop_value)
46
71
  update_chunk = generate_update_chunk(prop_name, prop_value)
47
72
  @request_stream << "#{update_chunk.to_json}\n"
73
+ @pushed_props.add(prop_name)
48
74
  rescue StandardError => e
75
+ # Continue streaming: one failed async prop write should not abort the
76
+ # entire render. The prop is not marked as pushed unless the write
77
+ # succeeds, so pull mode can request it again instead of silently hanging.
49
78
  Rails.logger.error do
50
79
  backtrace = e.backtrace&.first(5)&.join("\n")
51
80
  "[ReactOnRailsPro::AsyncProps] Failed to send async prop '#{prop_name}': " \
52
81
  "#{e.class} - #{e.message}\n#{backtrace}"
53
82
  end
54
- # Continue - don't abort entire render because one prop failed
55
83
  end
56
84
 
57
- # Generates the chunk that should be executed when the request stream closes
58
- # This tells the asyncPropsManager to end the stream
85
+ # Rejects an async prop on the Node side so React can show an error boundary.
86
+ def reject(prop_name, reason)
87
+ update_chunk = generate_reject_chunk(prop_name, reason)
88
+ @request_stream << "#{update_chunk.to_json}\n"
89
+ # Once the reject chunk is written, Ruby treats the prop as settled too.
90
+ # That keeps duplicate pull requests filtered even if the JS manager is recreated.
91
+ @pushed_props.add(prop_name)
92
+ rescue StandardError => e
93
+ Rails.logger.error do
94
+ backtrace = e.backtrace&.first(5)&.join("\n")
95
+ "[ReactOnRailsPro::AsyncProps] Failed to reject async prop '#{prop_name}': " \
96
+ "#{e.class} - #{e.message}\n#{backtrace}"
97
+ end
98
+ end
99
+
100
+ # Generates the chunk that should be executed when the request stream closes.
101
+ # This tells the asyncPropsManager to end the stream.
59
102
  def end_stream_chunk
60
103
  {
61
104
  bundleTimestamp: @bundle_timestamp,
@@ -63,6 +106,12 @@ module ReactOnRailsPro
63
106
  }
64
107
  end
65
108
 
109
+ # Called by stream_request when the response stream signals render complete.
110
+ # Closes the pull_requests queue so dequeue returns nil.
111
+ def render_complete!
112
+ @pull_requests&.close
113
+ end
114
+
66
115
  private
67
116
 
68
117
  def generate_update_chunk(prop_name, value)
@@ -72,6 +121,13 @@ module ReactOnRailsPro
72
121
  }
73
122
  end
74
123
 
124
+ def generate_reject_chunk(prop_name, reason)
125
+ {
126
+ bundleTimestamp: @bundle_timestamp,
127
+ updateChunk: generate_reject_prop_js(prop_name, reason)
128
+ }
129
+ end
130
+
75
131
  def generate_set_prop_js(prop_name, value)
76
132
  <<~JS.strip
77
133
  (function(){
@@ -81,6 +137,25 @@ module ReactOnRailsPro
81
137
  JS
82
138
  end
83
139
 
140
+ def generate_reject_prop_js(prop_name, reason)
141
+ <<~JS.strip
142
+ (function(){
143
+ var asyncPropsManager = ReactOnRails.getOrCreateAsyncPropsManager(sharedExecutionContext);
144
+ asyncPropsManager.rejectProp(#{prop_name.to_json}, #{sanitized_rejection_reason(reason).to_json});
145
+ })()
146
+ JS
147
+ end
148
+
149
+ # Always return the generic message regardless of the internal reason. Raw
150
+ # Rails-side details such as SQL errors, file paths, or credentials must not
151
+ # reach the browser. The raw reason is still emitted to debug logs for
152
+ # operators; keep it below info level because staging log aggregators may
153
+ # persist those details.
154
+ def sanitized_rejection_reason(reason)
155
+ Rails.logger.debug { "[ReactOnRailsPro::AsyncProps] Prop rejected (internal reason): #{reason}" }
156
+ SANITIZED_REJECTION_REASON
157
+ end
158
+
84
159
  def generate_end_stream_js
85
160
  <<~JS.strip
86
161
  (function(){
@@ -90,4 +165,46 @@ module ReactOnRailsPro
90
165
  JS
91
166
  end
92
167
  end
168
+
169
+ # Queue of prop names requested by React (pull mode).
170
+ # Wraps Async::Queue with automatic filtering of already-pushed props.
171
+ # dequeue returns nil after the queue is closed (render complete).
172
+ class PullRequestQueue
173
+ def initialize(pushed_props)
174
+ @queue = Async::Queue.new
175
+ @pushed_props = pushed_props
176
+ @closed = false
177
+ end
178
+
179
+ # Enqueue a propRequest from the Node renderer.
180
+ # Silently drops requests for props that have already been pushed.
181
+ def enqueue(prop_name)
182
+ # @pushed_props is mutated by AsyncPropsEmitter#call. In fiber-concurrent
183
+ # code this has a narrow TOCTOU window; duplicate requests are filtered on
184
+ # the TypeScript side via AsyncPropsManager's pullRequested flag.
185
+ return if @closed || @pushed_props.include?(prop_name)
186
+
187
+ @queue.enqueue(prop_name)
188
+ rescue Async::Queue::ClosedError
189
+ # Queue closed between the @closed guard and enqueue; safe to ignore.
190
+ end
191
+
192
+ # Blocks until a prop name is available, or returns nil if closed.
193
+ def dequeue
194
+ @queue.dequeue
195
+ rescue Async::Queue::ClosedError
196
+ nil
197
+ end
198
+
199
+ def close
200
+ return if @closed
201
+
202
+ @queue.close
203
+ @closed = true
204
+ end
205
+
206
+ def closed?
207
+ @closed
208
+ end
209
+ end
93
210
  end
@@ -87,13 +87,19 @@ module ReactOnRailsPro
87
87
  # - When the block finishes, we close the output (END_STREAM flag)
88
88
  # - Node's handleRequestClosed then calls asyncPropsManager.endStream()
89
89
  #
90
- def render_code_with_incremental_updates(path, js_code, async_props_block:)
90
+ def render_code_with_incremental_updates(path, js_code, async_props_block:, pull_enabled: false, push_props: nil)
91
91
  Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" }
92
92
 
93
93
  pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
94
+ if !push_props.nil? && !pull_enabled
95
+ raise ArgumentError, "push_props can only be provided when pull_enabled is true"
96
+ end
94
97
 
95
98
  warn_cb = ->(request_time) { warn_if_slow_streaming_first_chunk(path, request_time) }
96
- ReactOnRailsPro::StreamRequest.create(first_chunk_warn_callback: warn_cb) do |send_bundle, tasks|
99
+ ReactOnRailsPro::StreamRequest.create(
100
+ first_chunk_warn_callback: warn_cb,
101
+ pull_enabled:
102
+ ) do |send_bundle, tasks|
97
103
  if send_bundle
98
104
  Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
99
105
  upload_assets
@@ -106,10 +112,14 @@ module ReactOnRailsPro
106
112
  headers: [["content-type", "application/x-ndjson"]]
107
113
  )
108
114
 
109
- # Create emitter output has the same interface as the old HTTPX request
110
- # object (<< for writing, close for END_STREAM), so AsyncPropsEmitter works unchanged.
111
- emitter = ReactOnRailsPro::AsyncPropsEmitter.new(pool.rsc_bundle_hash, output)
112
- initial_data = build_initial_incremental_request(js_code, emitter)
115
+ emitter = ReactOnRailsPro::AsyncPropsEmitter.new(
116
+ pool.rsc_bundle_hash,
117
+ output,
118
+ pull_enabled:
119
+ )
120
+ initial_data = build_initial_incremental_request(
121
+ js_code, emitter, pull_enabled:, push_props:
122
+ )
113
123
 
114
124
  # Send the initial render request as first NDJSON line
115
125
  output << "#{initial_data.to_json}\n"
@@ -124,7 +134,7 @@ module ReactOnRailsPro
124
134
  output.close
125
135
  end)
126
136
 
127
- response
137
+ { pull_result: true, response:, emitter: pull_enabled ? emitter : nil }
128
138
  end
129
139
  end
130
140
 
@@ -342,11 +352,16 @@ module ReactOnRailsPro
342
352
  ReactOnRailsPro::Utils.common_form_data
343
353
  end
344
354
 
345
- def build_initial_incremental_request(js_code, emitter)
346
- common_form_data.merge(
355
+ def build_initial_incremental_request(js_code, emitter, pull_enabled: false, push_props: nil)
356
+ data = common_form_data.merge(
347
357
  renderingRequest: js_code,
348
358
  onRequestClosedUpdateChunk: emitter.end_stream_chunk
349
359
  )
360
+ if pull_enabled
361
+ data[:pullEnabled] = true
362
+ data[:pushProps] = Array(push_props)
363
+ end
364
+ data
350
365
  end
351
366
 
352
367
  def create_connection
@@ -72,10 +72,16 @@ module ReactOnRailsPro
72
72
  if async_props_block
73
73
  # Use incremental rendering when async props block is provided
74
74
  path = prepare_incremental_render_path(js_code, render_options)
75
+ push_props = render_options.internal_option(:push_props)
76
+ # Pull mode is enabled whenever push_props is set, including [] for pure pull.
77
+ # nil means push-only mode with no bidirectional prop-request channel.
78
+ pull_enabled = !push_props.nil?
75
79
  ReactOnRailsPro::Request.render_code_with_incremental_updates(
76
80
  path,
77
81
  js_code,
78
- async_props_block:
82
+ async_props_block:,
83
+ pull_enabled:,
84
+ push_props:
79
85
  )
80
86
  else
81
87
  # Use standard streaming when no async props block
@@ -110,17 +110,21 @@ module ReactOnRailsPro
110
110
  end
111
111
 
112
112
  class StreamRequest
113
- def http_status
114
- @status
115
- end
113
+ MAX_PULL_PROP_NAME_LENGTH = 256
114
+ # Keep aligned with ReactOnRails::LengthPrefixedParser::CONTROL_MESSAGE_TYPES,
115
+ # which parses these same control frames from the shared wire format.
116
+ CONTROL_MESSAGE_TYPES = %w[propRequest renderComplete].freeze
117
+ private_constant :CONTROL_MESSAGE_TYPES
116
118
 
117
- def http_status_recorded?
118
- @status_recorded
119
- end
119
+ def http_status = @status
120
+
121
+ def http_status_recorded? = @status_recorded
120
122
 
121
- def initialize(first_chunk_warn_callback: nil, &request_block)
123
+ def initialize(first_chunk_warn_callback: nil, pull_enabled: false, &request_block)
122
124
  @request_executor = request_block
123
125
  @first_chunk_warn_callback = first_chunk_warn_callback
126
+ @pull_enabled = pull_enabled
127
+ @emitter = nil
124
128
  @status = nil
125
129
  @status_recorded = false
126
130
  end
@@ -133,8 +137,8 @@ module ReactOnRailsPro
133
137
  Sync { consume_with_bundle_reupload(&block) }
134
138
  end
135
139
 
136
- def self.create(first_chunk_warn_callback: nil, &request_block)
137
- StreamDecorator.new(new(first_chunk_warn_callback:, &request_block))
140
+ def self.create(first_chunk_warn_callback: nil, pull_enabled: false, &request_block)
141
+ StreamDecorator.new(new(first_chunk_warn_callback:, pull_enabled:, &request_block))
138
142
  end
139
143
 
140
144
  private
@@ -151,9 +155,19 @@ module ReactOnRailsPro
151
155
  # response attempt.
152
156
  reset_response_status
153
157
  @received_first_chunk = false
154
- stream_response = @request_executor.call(send_bundle, tasks)
155
-
156
- process_response_chunks(stream_response, &block)
158
+ stream_response, @emitter = normalize_executor_result(@request_executor.call(send_bundle, tasks))
159
+
160
+ begin
161
+ process_response_chunks(stream_response, &block)
162
+ ensure
163
+ # renderComplete control messages normally close this queue earlier.
164
+ # This safety net covers parser errors, stream aborts, and timeouts
165
+ # before renderComplete arrives. The user's pull_requests.dequeue loop
166
+ # then exits with nil; any in-flight emit.call is protected by
167
+ # AsyncPropsEmitter#call's rescue.
168
+ @emitter&.render_complete!
169
+ @emitter = nil
170
+ end
157
171
  break
158
172
  rescue ReactOnRailsPro::RendererHttpClient::HTTPError => e
159
173
  stop_tasks(tasks) if retrying_with_bundle_upload?(e, send_bundle)
@@ -182,12 +196,9 @@ module ReactOnRailsPro
182
196
  record_status(stream_response) unless @status_recorded
183
197
  next if stream_response.error?
184
198
 
185
- unless @received_first_chunk
186
- @received_first_chunk = true
187
- @first_chunk_warn_callback&.call(Time.now - request_start_time)
188
- end
189
-
190
- parser.feed(chunk, &block)
199
+ yielded_content = parse_and_route_chunk(parser, chunk, &block)
200
+ # Control messages are protocol bookkeeping, so they do not satisfy the slow-first-chunk marker.
201
+ record_first_chunk(request_start_time) if yielded_content
191
202
  end
192
203
  record_status(stream_response) unless @status_recorded
193
204
  parser.flush
@@ -199,6 +210,50 @@ module ReactOnRailsPro
199
210
  raise
200
211
  end
201
212
 
213
+ # Expected shapes from callers:
214
+ # render_code_with_incremental_updates => { pull_result: true, response:, emitter: }
215
+ # render_code_as_stream => bare response object (pre-pull mode)
216
+ def normalize_executor_result(result)
217
+ # Incremental rendering returns a named result so unrelated Array-like responses are never
218
+ # mistaken for [response, emitter]. Older renderers still return only response. The
219
+ # :pull_result key is the protocol discriminator, so future response-shaped hashes are safe.
220
+ return result.values_at(:response, :emitter) if result.is_a?(Hash) && result.key?(:pull_result)
221
+
222
+ [result, nil]
223
+ end
224
+
225
+ def record_first_chunk(request_start_time)
226
+ return if @received_first_chunk
227
+
228
+ @received_first_chunk = true
229
+ @first_chunk_warn_callback&.call(Time.now - request_start_time)
230
+ end
231
+
232
+ def parse_and_route_chunk(parser, chunk, &)
233
+ yielded_content = false
234
+ parser.feed(chunk) do |parsed|
235
+ next route_control_message(parsed) if CONTROL_MESSAGE_TYPES.include?(parsed["messageType"])
236
+
237
+ yield parsed
238
+ yielded_content = true
239
+ end
240
+ yielded_content
241
+ end
242
+
243
+ def route_control_message(parsed)
244
+ return unless @emitter
245
+
246
+ case parsed["messageType"]
247
+ when "propRequest"
248
+ prop_name = parsed["propName"]
249
+ @emitter.pull_requests&.enqueue(prop_name) if prop_name.is_a?(String) &&
250
+ !prop_name.empty? &&
251
+ prop_name.length <= MAX_PULL_PROP_NAME_LENGTH
252
+ when "renderComplete"
253
+ @emitter.render_complete!
254
+ end
255
+ end
256
+
202
257
  # Retrying after first chunk would duplicate content in the page.
203
258
  def raise_or_retry_streaming_transport_error(error, available_retries)
204
259
  error_type = error.is_a?(ReactOnRailsPro::RendererHttpClient::TimeoutError) ? "Time out" : "Connection"
@@ -14,6 +14,6 @@
14
14
  # https://github.com/shakacode/react_on_rails/blob/main/REACT-ON-RAILS-PRO-LICENSE.md
15
15
 
16
16
  module ReactOnRailsPro
17
- VERSION = "17.0.0.rc.4"
17
+ VERSION = "17.0.0.rc.6"
18
18
  PROTOCOL_VERSION = "2.0.0"
19
19
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react_on_rails_pro
3
3
  version: !ruby/object:Gem::Version
4
- version: 17.0.0.rc.4
4
+ version: 17.0.0.rc.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
@@ -119,14 +119,14 @@ dependencies:
119
119
  requirements:
120
120
  - - '='
121
121
  - !ruby/object:Gem::Version
122
- version: 17.0.0.rc.4
122
+ version: 17.0.0.rc.6
123
123
  type: :runtime
124
124
  prerelease: false
125
125
  version_requirements: !ruby/object:Gem::Requirement
126
126
  requirements:
127
127
  - - '='
128
128
  - !ruby/object:Gem::Version
129
- version: 17.0.0.rc.4
129
+ version: 17.0.0.rc.6
130
130
  - !ruby/object:Gem::Dependency
131
131
  name: bundler
132
132
  requirement: !ruby/object:Gem::Requirement