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.
- checksums.yaml +4 -4
- data/CLAUDE.md +4 -3
- data/CONTEXT.md +34 -27
- data/Gemfile.development_dependencies +1 -0
- data/Gemfile.lock +18 -3
- data/LICENSE_SETUP.md +42 -9
- data/README.md +12 -1
- data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +69 -95
- data/app/helpers/react_on_rails_pro_helper.rb +178 -45
- data/docs/adr/0001-boot-seed-rolling-deploy-bundles.md +28 -17
- data/lib/react_on_rails_pro/assets_precompile.rb +34 -20
- data/lib/react_on_rails_pro/async_props_emitter.rb +64 -1
- data/lib/react_on_rails_pro/cache.rb +1 -1
- data/lib/react_on_rails_pro/concerns/async_rendering.rb +11 -6
- data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +7 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +17 -9
- data/lib/react_on_rails_pro/configuration.rb +79 -11
- data/lib/react_on_rails_pro/engine.rb +11 -5
- data/lib/react_on_rails_pro/error.rb +5 -0
- data/lib/react_on_rails_pro/license_task_formatter.rb +1 -1
- data/lib/react_on_rails_pro/license_validator.rb +38 -38
- data/lib/react_on_rails_pro/open_telemetry.rb +210 -0
- data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +269 -35
- data/lib/react_on_rails_pro/renderer_artifact.rb +176 -0
- data/lib/react_on_rails_pro/renderer_artifact_support.rb +287 -0
- data/lib/react_on_rails_pro/renderer_cache_helpers.rb +58 -32
- data/lib/react_on_rails_pro/renderer_http_client.rb +134 -39
- data/lib/react_on_rails_pro/request.rb +249 -118
- data/lib/react_on_rails_pro/rolling_deploy/tarball.rb +48 -18
- data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +242 -93
- data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +77 -19
- data/lib/react_on_rails_pro/server_rendering_js_code.rb +48 -4
- data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +46 -7
- data/lib/react_on_rails_pro/stream_cache.rb +18 -0
- data/lib/react_on_rails_pro/stream_request.rb +9 -2
- data/lib/react_on_rails_pro/utils.rb +77 -17
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/lib/react_on_rails_pro.rb +1 -0
- data/sig/react_on_rails_pro/configuration.rbs +7 -0
- data/sig/react_on_rails_pro/open_telemetry.rbs +52 -0
- data/sig/react_on_rails_pro/renderer_http_client.rbs +18 -0
- metadata +7 -3
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
require "digest"
|
|
17
17
|
require "io/wait"
|
|
18
18
|
require "uri"
|
|
19
|
+
require_relative "open_telemetry"
|
|
19
20
|
require_relative "renderer_http_client"
|
|
20
21
|
require_relative "stream_request"
|
|
21
22
|
require_relative "async_props_emitter"
|
|
22
23
|
|
|
23
24
|
module ReactOnRailsPro
|
|
24
25
|
class Request # rubocop:disable Metrics/ClassLength
|
|
26
|
+
RAW_RENDER_CONTENT_TYPE = "application/vnd.react-on-rails.render-request+javascript"
|
|
27
|
+
RAW_RENDER_HEADER_PREFIX = "x-react-on-rails-pro-"
|
|
28
|
+
|
|
25
29
|
class UploadAssetsWaiter
|
|
26
30
|
def initialize
|
|
27
31
|
if Fiber.scheduler
|
|
@@ -86,13 +90,15 @@ module ReactOnRailsPro
|
|
|
86
90
|
end
|
|
87
91
|
end
|
|
88
92
|
|
|
89
|
-
def render_code(path, js_code, send_bundle)
|
|
93
|
+
def render_code(path, js_code, send_bundle, bundle_role: :server, artifacts: nil)
|
|
90
94
|
Rails.logger.info { "[ReactOnRailsPro] Perform rendering request #{path}" }
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
artifacts = resolve_upload_artifacts(artifacts, action_description: "uploading requested assets") if send_bundle
|
|
96
|
+
request_path = send_bundle ? retarget_render_path(path, artifacts, bundle_role) : path
|
|
97
|
+
form = form_with_code(js_code, send_bundle, artifacts:)
|
|
98
|
+
perform_render_request(request_path, js_code, form:, send_bundle:)
|
|
93
99
|
end
|
|
94
100
|
|
|
95
|
-
def render_code_as_stream(path, js_code, is_rsc_payload:, rsc_stream_observability: false)
|
|
101
|
+
def render_code_as_stream(path, js_code, is_rsc_payload:, rsc_stream_observability: false, artifacts: nil)
|
|
96
102
|
Rails.logger.info { "[ReactOnRailsPro] Perform rendering request as a stream #{path}" }
|
|
97
103
|
if is_rsc_payload && !ReactOnRailsPro.configuration.enable_rsc_support
|
|
98
104
|
raise ReactOnRailsPro::Error,
|
|
@@ -102,18 +108,16 @@ module ReactOnRailsPro
|
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
warn_cb = ->(request_time) { warn_if_slow_streaming_first_chunk(path, request_time) }
|
|
111
|
+
bundle_role = is_rsc_payload ? :rsc : :server
|
|
105
112
|
ReactOnRailsPro::StreamRequest.create(first_chunk_warn_callback: warn_cb) do |send_bundle, _tasks|
|
|
106
|
-
|
|
107
|
-
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
|
|
108
|
-
upload_assets
|
|
109
|
-
end
|
|
113
|
+
request_path, request_artifacts = prepare_streaming_request(path, send_bundle:, bundle_role:, artifacts:)
|
|
110
114
|
|
|
111
|
-
form = form_with_code(js_code, false, rsc_stream_observability:)
|
|
112
|
-
|
|
115
|
+
form = form_with_code(js_code, false, artifacts: request_artifacts, rsc_stream_observability:)
|
|
116
|
+
perform_render_request(request_path, js_code, form:, send_bundle: false, stream: true)
|
|
113
117
|
end
|
|
114
118
|
end
|
|
115
119
|
|
|
116
|
-
# Performs an incremental render request with bidirectional
|
|
120
|
+
# Performs an incremental render request with bidirectional streaming.
|
|
117
121
|
#
|
|
118
122
|
# ARCHITECTURE: This method orchestrates the async props flow:
|
|
119
123
|
#
|
|
@@ -130,13 +134,13 @@ module ReactOnRailsPro
|
|
|
130
134
|
# │ │ └── Sends NDJSON: {updateChunk} │
|
|
131
135
|
# │ │ │
|
|
132
136
|
# │ ... streaming HTML chunks ... │ 4. Block completes │
|
|
133
|
-
# │ │ output.close (
|
|
137
|
+
# │ │ output.close (request EOF) │
|
|
134
138
|
# └───────────────────────────────────┴─────────────────────────────────────┘
|
|
135
139
|
#
|
|
136
140
|
# WHY async task?
|
|
137
141
|
# - We need to return the response stream immediately so Rails can start sending HTML
|
|
138
142
|
# - The async_props_block runs concurrently, sending props as they become available
|
|
139
|
-
# - When the block finishes, we close the
|
|
143
|
+
# - When the block finishes, we close the request body
|
|
140
144
|
# - Node's handleRequestClosed then calls asyncPropsManager.endStream()
|
|
141
145
|
#
|
|
142
146
|
def render_code_with_incremental_updates(
|
|
@@ -145,51 +149,52 @@ module ReactOnRailsPro
|
|
|
145
149
|
async_props_block:,
|
|
146
150
|
pull_enabled: false,
|
|
147
151
|
push_props: nil,
|
|
148
|
-
|
|
152
|
+
is_rsc_payload: false,
|
|
153
|
+
rsc_stream_observability: false,
|
|
154
|
+
artifacts: nil
|
|
149
155
|
)
|
|
150
156
|
Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" }
|
|
151
157
|
|
|
152
|
-
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
153
158
|
if !push_props.nil? && !pull_enabled
|
|
154
159
|
raise ArgumentError, "push_props can only be provided when pull_enabled is true"
|
|
155
160
|
end
|
|
156
161
|
|
|
157
162
|
warn_cb = ->(request_time) { warn_if_slow_streaming_first_chunk(path, request_time) }
|
|
163
|
+
bundle_role = is_rsc_payload ? :rsc : :server
|
|
158
164
|
ReactOnRailsPro::StreamRequest.create(
|
|
159
165
|
first_chunk_warn_callback: warn_cb,
|
|
160
166
|
pull_enabled:
|
|
161
167
|
) do |send_bundle, tasks|
|
|
162
|
-
|
|
163
|
-
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
|
|
164
|
-
upload_assets
|
|
165
|
-
end
|
|
168
|
+
request_path, request_artifacts = prepare_streaming_request(path, send_bundle:, bundle_role:, artifacts:)
|
|
166
169
|
|
|
167
|
-
# Open a bidirectional
|
|
168
|
-
# output supports << (alias for write) and close (
|
|
170
|
+
# Open a bidirectional request using async-http's Writable body.
|
|
171
|
+
# output supports << (alias for write) and close (signals request EOF).
|
|
169
172
|
output, response = connection.post_bidi(
|
|
170
|
-
|
|
173
|
+
request_path,
|
|
171
174
|
headers: [["content-type", "application/x-ndjson"]]
|
|
172
175
|
)
|
|
173
176
|
|
|
174
|
-
emitter =
|
|
175
|
-
|
|
177
|
+
emitter = build_incremental_emitter(request_artifacts, output, pull_enabled:)
|
|
178
|
+
write_initial_incremental_request(
|
|
176
179
|
output,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
js_code,
|
|
181
|
+
emitter,
|
|
182
|
+
artifacts: request_artifacts,
|
|
183
|
+
pull_enabled:,
|
|
184
|
+
push_props:,
|
|
185
|
+
rsc_stream_observability:
|
|
181
186
|
)
|
|
182
187
|
|
|
183
|
-
# Send the initial render request as first NDJSON line
|
|
184
|
-
output << "#{initial_data.to_json}\n"
|
|
185
|
-
|
|
186
188
|
# Execute async props block in a separate fiber.
|
|
187
189
|
# This runs concurrently with the response streaming back to the client.
|
|
190
|
+
parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
|
|
188
191
|
tasks.push(Async::Task.current.async do
|
|
189
|
-
|
|
192
|
+
ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
|
|
193
|
+
async_props_block.call(emitter)
|
|
194
|
+
end
|
|
190
195
|
ensure
|
|
191
196
|
# When the block completes (or raises), close the output.
|
|
192
|
-
# This
|
|
197
|
+
# This signals request-body EOF, triggering Node's handleRequestClosed.
|
|
193
198
|
output.close
|
|
194
199
|
end)
|
|
195
200
|
|
|
@@ -197,35 +202,16 @@ module ReactOnRailsPro
|
|
|
197
202
|
end
|
|
198
203
|
end
|
|
199
204
|
|
|
200
|
-
def upload_assets
|
|
205
|
+
def upload_assets(artifacts: nil)
|
|
201
206
|
Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# Create a list of bundle timestamps to send to the node renderer
|
|
212
|
-
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
213
|
-
target_bundles = [pool.server_bundle_hash]
|
|
214
|
-
|
|
215
|
-
# Add RSC bundle if enabled
|
|
216
|
-
if ReactOnRailsPro.configuration.enable_rsc_support
|
|
217
|
-
rsc_bundle_path = ReactOnRailsPro::Utils.rsc_bundle_js_file_path
|
|
218
|
-
unless File.exist?(rsc_bundle_path)
|
|
219
|
-
raise ReactOnRailsPro::Error, "RSC bundle not found at #{rsc_bundle_path}. " \
|
|
220
|
-
"Please build your bundles before uploading assets."
|
|
221
|
-
end
|
|
222
|
-
target_bundles << pool.rsc_bundle_hash
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
assets_to_copy = assets_to_copy_for_upload
|
|
226
|
-
|
|
227
|
-
with_asset_upload_single_flight(upload_assets_single_flight_key(target_bundles, assets_to_copy)) do
|
|
228
|
-
form = form_with_assets_and_bundle(assets_to_copy:)
|
|
207
|
+
artifacts = resolve_upload_artifacts(artifacts, action_description: "uploading assets")
|
|
208
|
+
target_bundles = artifacts.map(&:id)
|
|
209
|
+
|
|
210
|
+
# Artifact IDs already bind every bundle and companion byte. Keying the
|
|
211
|
+
# single-flight upload by those IDs avoids re-reading live paths after
|
|
212
|
+
# the operation-scoped snapshot was constructed.
|
|
213
|
+
with_asset_upload_single_flight(upload_assets_single_flight_key(target_bundles, [])) do
|
|
214
|
+
form = form_with_assets_and_bundle(artifacts:)
|
|
229
215
|
# TODO: targetBundles is only kept for backward compatibility with older node renderers
|
|
230
216
|
# (protocol 2.0.0) that require it. The new node renderer derives target directories from
|
|
231
217
|
# the bundle_<hash> form keys and ignores this field. Remove at the next breaking version.
|
|
@@ -251,7 +237,8 @@ module ReactOnRailsPro
|
|
|
251
237
|
|
|
252
238
|
form_data["targetBundles"] = target_bundles
|
|
253
239
|
|
|
254
|
-
|
|
240
|
+
encoded_filename = URI.encode_www_form_component(filename)
|
|
241
|
+
response = perform_request("/asset-exists?filename=#{encoded_filename}", json: form_data)
|
|
255
242
|
JSON.parse(response.body)["exists"] == true
|
|
256
243
|
end
|
|
257
244
|
|
|
@@ -288,7 +275,9 @@ module ReactOnRailsPro
|
|
|
288
275
|
def retry_or_raise_transport_error(error, available_retries, path, error_type)
|
|
289
276
|
if available_retries.zero?
|
|
290
277
|
raise ReactOnRailsPro::Error,
|
|
291
|
-
"#{error_type} error on renderer request: #{path}.\
|
|
278
|
+
"#{error_type} error on renderer request: #{path}.\n" \
|
|
279
|
+
"#{ReactOnRailsPro::RendererHttpClient.transport_config_description}\n" \
|
|
280
|
+
"Original error:\n#{error}\n#{error.backtrace}"
|
|
292
281
|
end
|
|
293
282
|
Rails.logger.info do
|
|
294
283
|
"[ReactOnRailsPro] #{error_type} error when making a request to the Node Renderer. " \
|
|
@@ -331,103 +320,163 @@ module ReactOnRailsPro
|
|
|
331
320
|
"expected at most #{warn_timeout}."
|
|
332
321
|
end
|
|
333
322
|
|
|
334
|
-
def form_with_code(js_code, send_bundle, rsc_stream_observability: false)
|
|
335
|
-
|
|
323
|
+
def form_with_code(js_code, send_bundle, artifacts: nil, rsc_stream_observability: false)
|
|
324
|
+
artifacts ||= if send_bundle
|
|
325
|
+
ReactOnRailsPro::Utils.renderer_artifacts(action_description: "uploading requested assets")
|
|
326
|
+
end
|
|
327
|
+
form = common_form_data(artifacts:)
|
|
336
328
|
form["renderingRequest"] = js_code
|
|
337
329
|
form["rscStreamObservability"] = true if rsc_stream_observability
|
|
338
|
-
populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle
|
|
330
|
+
populate_form_with_bundle_and_assets(form, check_bundle: false, artifacts:) if send_bundle
|
|
339
331
|
form
|
|
340
332
|
end
|
|
341
333
|
|
|
342
|
-
def
|
|
343
|
-
|
|
334
|
+
def perform_render_request(path, js_code, form:, send_bundle:, stream: false)
|
|
335
|
+
if send_bundle
|
|
336
|
+
perform_request(path, form:, stream:)
|
|
337
|
+
else
|
|
338
|
+
perform_request(path, raw: raw_render_request(js_code, form), stream:)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
344
341
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
342
|
+
# The field list here must stay in sync with common_form_data / form_with_code and
|
|
343
|
+
# the Node renderer's normalizeRawRenderRequest (node renderer's worker.ts); an
|
|
344
|
+
# unmapped field raises ArgumentError below. Every value is sanitized because these
|
|
345
|
+
# body fields become raw HTTP header values.
|
|
346
|
+
def raw_render_request(js_code, form)
|
|
347
|
+
metadata = form.except("renderingRequest")
|
|
348
|
+
headers = [
|
|
349
|
+
["content-type", RAW_RENDER_CONTENT_TYPE],
|
|
350
|
+
["#{RAW_RENDER_HEADER_PREFIX}protocol-version",
|
|
351
|
+
sanitize_raw_render_header(metadata.delete("protocolVersion"))],
|
|
352
|
+
["#{RAW_RENDER_HEADER_PREFIX}gem-version", sanitize_raw_render_header(metadata.delete("gemVersion"))],
|
|
353
|
+
["#{RAW_RENDER_HEADER_PREFIX}rails-env", sanitize_raw_render_header(metadata.delete("railsEnv"))],
|
|
354
|
+
[
|
|
355
|
+
"#{RAW_RENDER_HEADER_PREFIX}dependency-bundle-timestamps",
|
|
356
|
+
sanitize_raw_render_header(JSON.generate(Array(metadata.delete("dependencyBundleTimestamps"))))
|
|
357
|
+
]
|
|
358
|
+
]
|
|
359
|
+
password = metadata.delete("password")
|
|
360
|
+
headers << ["authorization", "Bearer #{sanitize_raw_render_header(password)}"] if password
|
|
361
|
+
if metadata.key?("rscStreamObservability")
|
|
362
|
+
observability = sanitize_raw_render_header(metadata.delete("rscStreamObservability"))
|
|
363
|
+
headers << ["#{RAW_RENDER_HEADER_PREFIX}rsc-stream-observability", observability]
|
|
364
|
+
end
|
|
365
|
+
raise ArgumentError, "Unsupported raw render metadata: #{metadata.keys.join(', ')}" unless metadata.empty?
|
|
366
|
+
|
|
367
|
+
{ headers:, body: js_code }
|
|
368
|
+
end
|
|
352
369
|
|
|
353
|
-
|
|
370
|
+
def sanitize_raw_render_header(value)
|
|
371
|
+
value.to_s.tap do |header_value|
|
|
372
|
+
raise ArgumentError, "Renderer metadata headers cannot contain newlines" if header_value.match?(/[\r\n]/)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def populate_form_with_bundle_and_assets(form, check_bundle:, artifacts: nil, assets_to_copy: nil)
|
|
377
|
+
artifacts ||= ReactOnRailsPro::Utils.renderer_artifacts(action_description: "building renderer upload")
|
|
378
|
+
artifacts.each do |artifact|
|
|
354
379
|
add_bundle_to_form(
|
|
355
380
|
form,
|
|
356
|
-
bundle_path:
|
|
357
|
-
bundle_file_name:
|
|
358
|
-
bundle_hash:
|
|
359
|
-
check_bundle
|
|
381
|
+
bundle_path: artifact.bundle,
|
|
382
|
+
bundle_file_name: "#{artifact.id}.js",
|
|
383
|
+
bundle_hash: artifact.id,
|
|
384
|
+
check_bundle:,
|
|
385
|
+
bundle_body: artifact.bundle_body
|
|
360
386
|
)
|
|
361
387
|
end
|
|
362
388
|
|
|
363
|
-
|
|
389
|
+
if assets_to_copy
|
|
390
|
+
add_assets_to_form(form, assets_to_copy:)
|
|
391
|
+
else
|
|
392
|
+
artifact = artifacts.fetch(0)
|
|
393
|
+
add_assets_to_form(
|
|
394
|
+
form,
|
|
395
|
+
assets_to_copy: artifact.companions,
|
|
396
|
+
snapshot_bodies: artifact.companion_bodies
|
|
397
|
+
)
|
|
398
|
+
end
|
|
364
399
|
end
|
|
365
400
|
|
|
366
|
-
def add_bundle_to_form(form, bundle_path:, bundle_file_name:, bundle_hash:, check_bundle:)
|
|
367
|
-
|
|
401
|
+
def add_bundle_to_form(form, bundle_path:, bundle_file_name:, bundle_hash:, check_bundle:, bundle_body: nil)
|
|
402
|
+
if check_bundle && bundle_body.nil? && !File.exist?(bundle_path)
|
|
403
|
+
raise ReactOnRailsPro::Error, "Bundle not found #{bundle_path}"
|
|
404
|
+
end
|
|
368
405
|
|
|
369
406
|
form["bundle_#{bundle_hash}"] = {
|
|
370
|
-
body: get_form_body_for_file(bundle_path),
|
|
407
|
+
body: bundle_body || get_form_body_for_file(bundle_path),
|
|
371
408
|
content_type: "text/javascript",
|
|
372
409
|
filename: bundle_file_name
|
|
373
410
|
}
|
|
374
411
|
end
|
|
375
412
|
|
|
376
413
|
def assets_to_copy_for_upload
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
assets_to_copy << ReactOnRailsPro::Utils.react_client_manifest_file_path
|
|
381
|
-
assets_to_copy << ReactOnRailsPro::Utils.react_server_client_manifest_file_path
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
assets_to_copy
|
|
414
|
+
ReactOnRailsPro::Utils
|
|
415
|
+
.renderer_artifacts(action_description: "collecting renderer upload assets")
|
|
416
|
+
.fetch(0).companions.values
|
|
385
417
|
end
|
|
386
418
|
|
|
387
|
-
def add_assets_to_form(form, assets_to_copy:)
|
|
419
|
+
def add_assets_to_form(form, assets_to_copy:, snapshot_bodies: nil)
|
|
388
420
|
return form unless assets_to_copy.present?
|
|
389
421
|
|
|
390
|
-
assets_to_copy.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
next
|
|
395
|
-
end
|
|
422
|
+
assets = assets_to_copy.respond_to?(:each_pair) ? assets_to_copy.to_a : Array(assets_to_copy)
|
|
423
|
+
assets.each_with_index do |asset, idx|
|
|
424
|
+
add_asset_to_form(form, asset, idx, snapshot_bodies)
|
|
425
|
+
end
|
|
396
426
|
|
|
397
|
-
|
|
427
|
+
form
|
|
428
|
+
end
|
|
398
429
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
warn "[ReactOnRailsPro] Error uploading asset #{asset_path}: #{e}"
|
|
407
|
-
end
|
|
430
|
+
def add_asset_to_form(form, asset, index, snapshot_bodies)
|
|
431
|
+
basename, asset_path = asset.is_a?(Array) ? asset : [File.basename(asset.to_s), asset]
|
|
432
|
+
Rails.logger.info { "[ReactOnRailsPro] Uploading asset #{asset_path}" }
|
|
433
|
+
snapshot_body = snapshot_bodies&.fetch(basename, nil)
|
|
434
|
+
unless uploadable_asset?(asset_path, snapshot_body)
|
|
435
|
+
warn "Asset not found #{asset_path}"
|
|
436
|
+
return
|
|
408
437
|
end
|
|
409
438
|
|
|
410
|
-
form
|
|
439
|
+
form["assetsToCopy#{index}"] = {
|
|
440
|
+
body: asset_upload_body(asset_path, snapshot_body),
|
|
441
|
+
content_type: ReactOnRailsPro::Utils.mine_type_from_file_name(basename),
|
|
442
|
+
filename: basename
|
|
443
|
+
}
|
|
444
|
+
rescue StandardError => e
|
|
445
|
+
warn "[ReactOnRailsPro] Error uploading asset #{asset_path}: #{e}"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def uploadable_asset?(asset_path, snapshot_body)
|
|
449
|
+
snapshot_body || asset_path.is_a?(RendererArtifact::InlineCompanion) ||
|
|
450
|
+
http_url?(asset_path) || File.exist?(asset_path)
|
|
411
451
|
end
|
|
412
452
|
|
|
413
|
-
def
|
|
414
|
-
|
|
415
|
-
|
|
453
|
+
def asset_upload_body(asset_path, snapshot_body)
|
|
454
|
+
return snapshot_body if snapshot_body
|
|
455
|
+
return asset_path.body if asset_path.is_a?(RendererArtifact::InlineCompanion)
|
|
456
|
+
|
|
457
|
+
get_form_body_for_file(asset_path)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def form_with_assets_and_bundle(artifacts: nil, assets_to_copy: nil)
|
|
461
|
+
artifacts ||= ReactOnRailsPro::Utils.renderer_artifacts(action_description: "building renderer upload")
|
|
462
|
+
form = common_form_data(artifacts:)
|
|
463
|
+
populate_form_with_bundle_and_assets(form, check_bundle: true, artifacts:, assets_to_copy:)
|
|
416
464
|
form
|
|
417
465
|
end
|
|
418
466
|
|
|
419
|
-
def common_form_data
|
|
420
|
-
ReactOnRailsPro::Utils.common_form_data
|
|
467
|
+
def common_form_data(artifacts: nil)
|
|
468
|
+
ReactOnRailsPro::Utils.common_form_data(artifacts:)
|
|
421
469
|
end
|
|
422
470
|
|
|
423
471
|
def build_initial_incremental_request(
|
|
424
472
|
js_code,
|
|
425
473
|
emitter,
|
|
474
|
+
artifacts: nil,
|
|
426
475
|
pull_enabled: false,
|
|
427
476
|
push_props: nil,
|
|
428
477
|
rsc_stream_observability: false
|
|
429
478
|
)
|
|
430
|
-
data = common_form_data.merge(
|
|
479
|
+
data = common_form_data(artifacts:).merge(
|
|
431
480
|
renderingRequest: js_code,
|
|
432
481
|
onRequestClosedUpdateChunk: emitter.end_stream_chunk
|
|
433
482
|
)
|
|
@@ -439,6 +488,82 @@ module ReactOnRailsPro
|
|
|
439
488
|
data
|
|
440
489
|
end
|
|
441
490
|
|
|
491
|
+
def build_incremental_emitter(artifacts, output, pull_enabled:)
|
|
492
|
+
rsc_artifact = artifact_for_role(artifacts, :rsc)
|
|
493
|
+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
494
|
+
ReactOnRailsPro::AsyncPropsEmitter.new(
|
|
495
|
+
rsc_artifact&.id || pool.rsc_bundle_hash,
|
|
496
|
+
output,
|
|
497
|
+
pull_enabled:
|
|
498
|
+
)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def write_initial_incremental_request(output, js_code, emitter, **)
|
|
502
|
+
initial_data = build_initial_incremental_request(js_code, emitter, **)
|
|
503
|
+
output << "#{initial_data.to_json}\n"
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def retarget_render_path(path, artifacts, role)
|
|
507
|
+
match = path.match(%r{\A/bundles/[^/]+/(?<endpoint>render|incremental-render)/})
|
|
508
|
+
return path unless match
|
|
509
|
+
|
|
510
|
+
artifact = artifact_for_role(artifacts, role)
|
|
511
|
+
unless artifact
|
|
512
|
+
raise ReactOnRailsPro::Error,
|
|
513
|
+
"No renderer artifact is configured for role #{role.inspect} while retrying #{match[:endpoint]}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
path.sub(%r{\A/bundles/[^/]+/}, "/bundles/#{artifact.id}/")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def prepare_streaming_request(path, send_bundle:, bundle_role:, artifacts: nil)
|
|
520
|
+
return [path, artifacts] unless send_bundle
|
|
521
|
+
|
|
522
|
+
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
|
|
523
|
+
artifacts = resolve_upload_artifacts(artifacts, action_description: "uploading requested assets")
|
|
524
|
+
request_path = retarget_render_path(path, artifacts, bundle_role)
|
|
525
|
+
upload_assets(artifacts:)
|
|
526
|
+
[request_path, artifacts]
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def resolve_upload_artifacts(artifacts, action_description:)
|
|
530
|
+
return ReactOnRailsPro::Utils.renderer_artifacts(action_description:) unless artifacts
|
|
531
|
+
|
|
532
|
+
artifacts = Array(artifacts)
|
|
533
|
+
expected_ids = expected_artifact_ids(artifacts)
|
|
534
|
+
return artifacts unless expected_ids
|
|
535
|
+
|
|
536
|
+
current_artifacts = ReactOnRailsPro::Utils.renderer_artifacts(
|
|
537
|
+
action_description:,
|
|
538
|
+
roles: expected_ids.keys
|
|
539
|
+
)
|
|
540
|
+
current_ids = current_artifacts.to_h { |artifact| [artifact.role, artifact.id] }
|
|
541
|
+
return current_artifacts if current_ids == expected_ids && current_artifacts.length == expected_ids.length
|
|
542
|
+
|
|
543
|
+
raise ReactOnRailsPro::Error,
|
|
544
|
+
"Renderer artifacts changed after this render request was prepared; refusing to upload different bytes"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def expected_artifact_ids(artifacts)
|
|
548
|
+
identities = artifacts.select { |artifact| artifact.is_a?(RendererArtifact::Identity) }
|
|
549
|
+
return nil if identities.empty?
|
|
550
|
+
|
|
551
|
+
unless identities.length == artifacts.length
|
|
552
|
+
raise ReactOnRailsPro::Error, "Renderer artifact snapshots cannot mix identities and captured bodies"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
ids_by_role = identities.to_h { |identity| [identity.role, identity.id] }
|
|
556
|
+
if ids_by_role.length != identities.length
|
|
557
|
+
raise ReactOnRailsPro::Error, "Renderer artifact snapshot contains duplicate roles"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
ids_by_role
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def artifact_for_role(artifacts, role)
|
|
564
|
+
Array(artifacts).find { |artifact| artifact.role == role }
|
|
565
|
+
end
|
|
566
|
+
|
|
442
567
|
def upload_assets_single_flight_key(target_bundles, assets_to_copy)
|
|
443
568
|
[
|
|
444
569
|
"bundles",
|
|
@@ -451,6 +576,10 @@ module ReactOnRailsPro
|
|
|
451
576
|
end
|
|
452
577
|
|
|
453
578
|
def upload_asset_fingerprint(asset_path)
|
|
579
|
+
if asset_path.is_a?(RendererArtifact::InlineCompanion)
|
|
580
|
+
return "inline:#{asset_path.url}:#{Digest::SHA256.hexdigest(asset_path.body)}"
|
|
581
|
+
end
|
|
582
|
+
|
|
454
583
|
path = asset_path.to_s
|
|
455
584
|
return "url:#{path}" if http_url?(path)
|
|
456
585
|
return "missing:#{path}" unless File.exist?(path)
|
|
@@ -543,7 +672,7 @@ module ReactOnRailsPro
|
|
|
543
672
|
|
|
544
673
|
def create_connection
|
|
545
674
|
url = ReactOnRailsPro.configuration.renderer_url
|
|
546
|
-
Rails.logger.
|
|
675
|
+
Rails.logger.debug do
|
|
547
676
|
"[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
|
|
548
677
|
end
|
|
549
678
|
|
|
@@ -551,7 +680,8 @@ module ReactOnRailsPro
|
|
|
551
680
|
origin: url,
|
|
552
681
|
pool_size: ReactOnRailsPro.configuration.renderer_http_pool_size,
|
|
553
682
|
connect_timeout: ReactOnRailsPro.configuration.renderer_http_pool_timeout,
|
|
554
|
-
read_timeout: ReactOnRailsPro.configuration.ssr_timeout
|
|
683
|
+
read_timeout: ReactOnRailsPro.configuration.ssr_timeout,
|
|
684
|
+
force_http2: ReactOnRailsPro.configuration.renderer_http_force_http2
|
|
555
685
|
)
|
|
556
686
|
rescue StandardError => e
|
|
557
687
|
message = <<~MSG
|
|
@@ -560,6 +690,7 @@ module ReactOnRailsPro
|
|
|
560
690
|
renderer_http_pool_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_timeout}
|
|
561
691
|
renderer_http_pool_warn_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_warn_timeout}
|
|
562
692
|
renderer_http_keep_alive_timeout = #{ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout}
|
|
693
|
+
renderer_http_force_http2 = #{ReactOnRailsPro.configuration.renderer_http_force_http2}
|
|
563
694
|
renderer_url = #{url}
|
|
564
695
|
Be sure to use a url that contains the protocol of http or https.
|
|
565
696
|
Original error is
|
|
@@ -20,6 +20,7 @@ require "tempfile"
|
|
|
20
20
|
require "zlib"
|
|
21
21
|
|
|
22
22
|
require "react_on_rails_pro/error"
|
|
23
|
+
require "react_on_rails_pro/renderer_artifact"
|
|
23
24
|
|
|
24
25
|
module ReactOnRailsPro
|
|
25
26
|
module RollingDeploy
|
|
@@ -51,17 +52,23 @@ module ReactOnRailsPro
|
|
|
51
52
|
# file doesn't blow heap before the running total trips the size cap.
|
|
52
53
|
EXTRACT_CHUNK_SIZE = 64 * 1024
|
|
53
54
|
|
|
54
|
-
# Path-safety regex for tar entries.
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
# Path-safety regex for tar entries. Reuse RendererArtifact's companion
|
|
56
|
+
# contract while keeping the transport-specific TarWriter byte limit at
|
|
57
|
+
# this boundary. The shared pattern rejects
|
|
58
|
+
# slash, backslash, colon, NUL/ASCII controls, and the special `.` / `..`
|
|
59
|
+
# directory entries while permitting ordinary filename characters such
|
|
60
|
+
# as spaces, `@`, `%`, Unicode, and leading dots or hyphens. The `./`
|
|
61
|
+
# prefix permitted by tar is normalised away before this match runs.
|
|
62
|
+
# RubyGems TarWriter stores a flat name in a 100-byte header field.
|
|
63
|
+
ENTRY_NAME_PATTERN = ReactOnRailsPro::RendererArtifact::SAFE_COMPANION_NAME_PATTERN
|
|
64
|
+
ENTRY_NAME_MAX_BYTES = 100
|
|
65
|
+
|
|
66
|
+
def safe_entry_name?(value)
|
|
67
|
+
name = value.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
68
|
+
name.valid_encoding? &&
|
|
69
|
+
name.bytesize <= ENTRY_NAME_MAX_BYTES &&
|
|
70
|
+
ReactOnRailsPro::RendererArtifact.safe_companion_name?(name)
|
|
71
|
+
end
|
|
65
72
|
|
|
66
73
|
# Compose a gzipped tarball from the given entries and yield the
|
|
67
74
|
# resulting Tempfile. The temp file is removed when the block returns.
|
|
@@ -158,10 +165,23 @@ module ReactOnRailsPro
|
|
|
158
165
|
|
|
159
166
|
def validate_compose_entries!(entries)
|
|
160
167
|
entries.each do |name, path|
|
|
161
|
-
|
|
168
|
+
raw_name = name.to_s
|
|
169
|
+
utf8_name = raw_name.dup.force_encoding(Encoding::UTF_8)
|
|
170
|
+
unless utf8_name.valid_encoding?
|
|
171
|
+
raise ReactOnRailsPro::Error,
|
|
172
|
+
"Tarball entry name #{raw_name.inspect} is not valid UTF-8."
|
|
173
|
+
end
|
|
174
|
+
if utf8_name.bytesize > ENTRY_NAME_MAX_BYTES
|
|
175
|
+
raise ReactOnRailsPro::Error,
|
|
176
|
+
"Tarball entry name #{raw_name.inspect} exceeds the maximum of " \
|
|
177
|
+
"#{ENTRY_NAME_MAX_BYTES} UTF-8 bytes."
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
unless safe_entry_name?(utf8_name)
|
|
162
181
|
raise ReactOnRailsPro::Error,
|
|
163
182
|
"Tarball entry name #{name.inspect} is not a safe basename. " \
|
|
164
|
-
"Allowed: flat
|
|
183
|
+
"Allowed: flat basenames up to #{ENTRY_NAME_MAX_BYTES} UTF-8 bytes without slashes, " \
|
|
184
|
+
"backslashes, colons, or control characters."
|
|
165
185
|
end
|
|
166
186
|
|
|
167
187
|
raise ReactOnRailsPro::Error, "Tarball source path for #{name.inspect} is blank" if path.to_s.empty?
|
|
@@ -192,14 +212,24 @@ module ReactOnRailsPro
|
|
|
192
212
|
def safe_entry_name!(entry)
|
|
193
213
|
# `TarReader` exposes the entry name verbatim; tar archives commonly
|
|
194
214
|
# prefix entries with `./`. Strip that prefix before the pattern check
|
|
195
|
-
# so a legitimate `./bundle.js` doesn't get rejected
|
|
196
|
-
#
|
|
215
|
+
# so a legitimate `./bundle.js` doesn't get rejected. The shared flat-
|
|
216
|
+
# basename contract still rejects subdirectories and traversal names.
|
|
197
217
|
raw = entry.full_name.to_s
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
# Ruby 3.3 tags tar header bytes as ASCII-8BIT even when they contain a
|
|
219
|
+
# valid UTF-8 filename. The wire contract uses UTF-8 names: retag valid
|
|
220
|
+
# bytes before matching/writing, and reject malformed input instead of
|
|
221
|
+
# passing ambiguous bytes to the filesystem.
|
|
222
|
+
name = raw.dup.force_encoding(Encoding::UTF_8)
|
|
223
|
+
unless name.valid_encoding?
|
|
224
|
+
raise ReactOnRailsPro::Error,
|
|
225
|
+
"Rolling-deploy tarball entry name #{raw.inspect} is not valid UTF-8."
|
|
226
|
+
end
|
|
227
|
+
name = name.delete_prefix("./")
|
|
228
|
+
unless safe_entry_name?(name)
|
|
200
229
|
raise ReactOnRailsPro::Error,
|
|
201
230
|
"Rolling-deploy tarball entry name #{raw.inspect} is not a safe basename. " \
|
|
202
|
-
"Allowed: flat
|
|
231
|
+
"Allowed: flat basenames up to #{ENTRY_NAME_MAX_BYTES} UTF-8 bytes without slashes, " \
|
|
232
|
+
"backslashes, colons, control characters, or `.` / `..`."
|
|
203
233
|
end
|
|
204
234
|
name
|
|
205
235
|
end
|