react_on_rails_pro 16.6.0 → 16.7.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/.controlplane/rails.yml +2 -2
  3. data/CLAUDE.md +10 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/Gemfile.development_dependencies +20 -13
  6. data/Gemfile.lock +12 -28
  7. data/Rakefile +0 -5
  8. data/app/helpers/react_on_rails_pro_helper.rb +28 -1
  9. data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
  10. data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
  11. data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
  12. data/lib/react_on_rails_pro/configuration.rb +114 -17
  13. data/lib/react_on_rails_pro/engine.rb +10 -0
  14. data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
  15. data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
  16. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
  17. data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
  18. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
  19. data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
  20. data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
  21. data/lib/react_on_rails_pro/request.rb +135 -8
  22. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
  23. data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
  24. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
  25. data/lib/react_on_rails_pro/stream_request.rb +42 -49
  26. data/lib/react_on_rails_pro/utils.rb +7 -9
  27. data/lib/react_on_rails_pro/version.rb +1 -1
  28. data/lib/react_on_rails_pro.rb +8 -0
  29. data/lib/tasks/assets.rake +36 -3
  30. data/rakelib/run_rspec.rake +6 -6
  31. data/react_on_rails_pro.gemspec +9 -4
  32. data/sig/react_on_rails_pro/configuration.rbs +2 -0
  33. metadata +35 -22
@@ -0,0 +1,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "react_on_rails_pro/renderer_cache_helpers"
5
+ require "securerandom"
6
+ require "timeout"
7
+
8
+ module ReactOnRailsPro
9
+ # Seeds previous deploy bundle hashes into the Node Renderer cache so that
10
+ # during a rolling deploy, new renderer instances can serve requests for
11
+ # bundles referenced by draining Rails instances without hitting the 410
12
+ # retry path.
13
+ #
14
+ # Discovery:
15
+ # * ENV["PREVIOUS_BUNDLE_HASHES"] (comma-separated) — overrides adapter discovery.
16
+ # * ReactOnRailsPro.configuration.rolling_deploy_adapter#previous_bundle_hashes — the default.
17
+ #
18
+ # Retrieval:
19
+ # * rolling_deploy_adapter#fetch(hash) must return a Hash with keys
20
+ # :bundle (String path to the bundle file) and :assets (Array<String>
21
+ # of companion asset file paths). Returns nil if the bundle is
22
+ # unavailable.
23
+ #
24
+ # Protocol model: each hash is one bundle's cache entry. Adapters
25
+ # advertise separate hashes for server and RSC bundles; the stager
26
+ # stages each hash at <cache>/<hash>/<hash>.js independently.
27
+ #
28
+ # Missing previous bundles degrade gracefully (warn + continue) because
29
+ # the runtime 410-retry path is still a valid fallback — a failed
30
+ # rolling-deploy seed is less catastrophic than a failed *current*
31
+ # bundle seed.
32
+ module RollingDeployCacheStager # rubocop:disable Metrics/ModuleLength
33
+ # Duplicated in react_on_rails/lib/react_on_rails/doctor.rb as a hardcoded
34
+ # fallback when the Pro gem isn't loaded. The cross-package equality is
35
+ # asserted in spec/dummy/spec/rolling_deploy_cache_stager_spec.rb so a
36
+ # change here fails that spec instead of silently drifting past the doctor
37
+ # probe. Note: `Timeout.timeout` interrupts the discovery call at a quasi-
38
+ # random thread-execution point. The bundled reference adapters use pure-
39
+ # Ruby HTTP clients that release the GIL, so the interrupt is safe. Adapter
40
+ # authors using native-extension or FFI-backed clients should add their own
41
+ # SDK-level `open_timeout` / `read_timeout` rather than rely solely on this
42
+ # outer wrapper. Same caveat applies to `FETCH_TIMEOUT_SECONDS` below and
43
+ # the upload timeout in `assets_precompile.rb`.
44
+ DISCOVERY_TIMEOUT_SECONDS = 10
45
+ # Per-hash fetch budget during pre-seeding. Large cross-region stores may
46
+ # need adapters to keep fetches comfortably under this limit.
47
+ FETCH_TIMEOUT_SECONDS = 30
48
+ # Age threshold for sweeping leftover `.staging-*` / `.previous-*` dirs.
49
+ # Any temp dir older than this is assumed to be from a crashed or abandoned
50
+ # prior run and safe to remove. If `assets:precompile` itself routinely
51
+ # takes longer than one hour on a persistent-volume deploy (uncommon),
52
+ # raise this so a concurrent seeder's still-in-use staging dir is not
53
+ # swept mid-operation. The degradation is graceful regardless — the racing
54
+ # `replace_bundle_directory` rolls back cleanly on `ENOENT`.
55
+ STALE_TEMP_DIR_TTL_SECONDS = 3600
56
+ # Match temp dirs created by `temporary_bundle_directory` (and the analogous
57
+ # `.previous-` backup suffix in `replace_bundle_directory`). The 8-hex
58
+ # random suffix defeats false positives where a real bundle hash happens
59
+ # to end with `.staging-<digits>-<short hex>`. PID is `\d+` rather than
60
+ # `\d{4,}` because container deployments (Docker, Kubernetes) commonly run
61
+ # the seeding process as PID 1; a stricter floor would silently leave
62
+ # PID-1 staging dirs in the cache to accumulate forever.
63
+ TEMPORARY_DIRECTORY_PATTERN = /\.(?:staging|previous)-\d+-[0-9a-f]{8,}\z/
64
+
65
+ def self.call(cache_dir:, current_hashes:, mode:)
66
+ adapter = ReactOnRailsPro.configuration.rolling_deploy_adapter
67
+ return handle_missing_adapter unless adapter
68
+
69
+ sweep_stale_temporary_directories(cache_dir)
70
+ hashes = resolve_previous_hashes(adapter, current_hashes)
71
+ if hashes.empty?
72
+ puts "[ReactOnRailsPro] No previous bundle hashes to seed for rolling deploy."
73
+ return
74
+ end
75
+
76
+ # Create the cache root once we know we have at least one hash to stage.
77
+ # bundle_directory then resolves real paths against an existing dir without
78
+ # needing to mutate the filesystem itself.
79
+ FileUtils.mkdir_p(cache_dir)
80
+ normalized_cache_dir = File.realpath(cache_dir)
81
+ puts "[ReactOnRailsPro] Seeding previous bundle hashes for rolling deploy: #{hashes.inspect}"
82
+ hashes.each { |hash| seed_previous_hash(adapter, hash, normalized_cache_dir, mode) }
83
+ end
84
+
85
+ def self.handle_missing_adapter
86
+ env_override = ENV["PREVIOUS_BUNDLE_HASHES"].to_s.strip
87
+ return nil if env_override.empty?
88
+
89
+ # PREVIOUS_BUNDLE_HASHES overrides *discovery*; the adapter is still required
90
+ # to fetch the actual bundle files. Refuse to proceed rather than raise a raw
91
+ # NoMethodError on the nil adapter.
92
+ warn "[ReactOnRailsPro] PREVIOUS_BUNDLE_HASHES=#{env_override.inspect} is set but no " \
93
+ "rolling_deploy_adapter is configured. Rolling-deploy seeding requires both. " \
94
+ "Set config.rolling_deploy_adapter to enable. Skipping previous-hash seeding."
95
+ nil
96
+ end
97
+ private_class_method :handle_missing_adapter
98
+
99
+ # Bundle hashes are used as directory names under the renderer cache path
100
+ # (<cache>/<hash>/<hash>.js). Reject path separators, `.` / `..`, and any
101
+ # leading dot. `bundle_directory`'s `start_with?` guard already prevents
102
+ # path traversal, but a leading-dot hash (e.g. `.hidden`) would still
103
+ # create a hidden cache subdirectory invisible to `ls`, surprising
104
+ # operators who count bundle-hash entries during incident response.
105
+ SAFE_HASH_PATTERN = /\A(?!\.)[A-Za-z0-9_.-]+\z/
106
+
107
+ def self.resolve_previous_hashes(adapter, current_hashes)
108
+ explicit = ENV["PREVIOUS_BUNDLE_HASHES"].to_s.split(",").map(&:strip).reject(&:empty?)
109
+ hashes = if explicit.any?
110
+ sanitize_hashes(explicit, source_label: "PREVIOUS_BUNDLE_HASHES")
111
+ else
112
+ sanitize_hashes(
113
+ fetch_hashes_from_adapter(adapter),
114
+ source_label: "rolling_deploy_adapter#previous_bundle_hashes"
115
+ )
116
+ end
117
+ # Deduplicate within the previous-hash list first so a duplicate entry can't
118
+ # fail later and trigger `seed_previous_hash`'s rollback on a directory an
119
+ # earlier successful stage already populated. Then subtract current-build
120
+ # hashes so we don't re-fetch what we just staged.
121
+ hashes.uniq - Array(current_hashes).map(&:to_s)
122
+ end
123
+ private_class_method :resolve_previous_hashes
124
+
125
+ def self.fetch_hashes_from_adapter(adapter)
126
+ Timeout.timeout(DISCOVERY_TIMEOUT_SECONDS) { Array(adapter.previous_bundle_hashes) }
127
+ rescue Timeout::Error
128
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#previous_bundle_hashes timed out after " \
129
+ "#{DISCOVERY_TIMEOUT_SECONDS}s. Skipping previous-hash seeding."
130
+ []
131
+ rescue StandardError => e
132
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#previous_bundle_hashes raised #{e.class}: " \
133
+ "#{e.message}. Skipping previous-hash seeding."
134
+ []
135
+ end
136
+ private_class_method :fetch_hashes_from_adapter
137
+
138
+ def self.seed_previous_hash(adapter, hash, cache_dir, mode)
139
+ staging_dir = nil
140
+ payload = fetch_payload(adapter, hash)
141
+ return if payload.nil?
142
+
143
+ bundle_dir = bundle_directory(cache_dir, hash)
144
+ staging_dir = temporary_bundle_directory(bundle_dir)
145
+ # Create the staging dir explicitly so a permission error here surfaces with a
146
+ # clear "Failed to seed previous bundle hash" attribution rather than as a
147
+ # downstream copy/symlink failure inside `stage_previous_file`.
148
+ FileUtils.mkdir_p(staging_dir)
149
+ stage_previous_file(
150
+ payload[:bundle],
151
+ File.join(staging_dir, "#{hash}.js"),
152
+ bundle_dir,
153
+ mode,
154
+ "Seeded previous bundle file"
155
+ )
156
+
157
+ Array(payload[:assets]).each do |asset_path|
158
+ stage_previous_file(
159
+ asset_path,
160
+ File.join(staging_dir, File.basename(asset_path)),
161
+ bundle_dir,
162
+ mode,
163
+ "Seeded previous asset"
164
+ )
165
+ end
166
+
167
+ replace_bundle_directory(staging_dir, bundle_dir)
168
+ staging_dir = nil
169
+ puts "[ReactOnRailsPro] Seeded previous bundle hash #{hash} at #{bundle_dir}."
170
+ rescue StandardError => e
171
+ # Remove only files created by this attempt. If the hash directory was
172
+ # already valid from an earlier seed on a persistent cache volume, keep it
173
+ # available rather than evicting it because this refresh failed.
174
+ FileUtils.rm_rf(staging_dir) if staging_dir
175
+ warn "[ReactOnRailsPro] Failed to seed previous bundle hash #{hash}: #{e.class}: #{e.message}. " \
176
+ "Rolled back this attempt's partially-staged files. Runtime 410-retry remains the fallback."
177
+ end
178
+ private_class_method :seed_previous_hash
179
+
180
+ def self.fetch_payload(adapter, hash)
181
+ payload = Timeout.timeout(FETCH_TIMEOUT_SECONDS) { adapter.fetch(hash) }
182
+ if payload.nil?
183
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) returned nil. " \
184
+ "Runtime 410-retry path remains available as fallback."
185
+ return nil
186
+ end
187
+
188
+ asset_paths = Array(payload[:assets]).map(&:to_s)
189
+ return nil unless valid_bundle_payload?(payload, hash)
190
+ return nil unless valid_required_rsc_payload?(asset_paths, hash)
191
+ return nil unless valid_asset_payload?(asset_paths, hash)
192
+
193
+ # Attribute PackerUtils / RendererCacheHelpers failures to the loadable-stats
194
+ # lookup rather than letting the outer adapter#fetch rescue blame the adapter
195
+ # for an internal framework error (manifest absent, malformed, etc.).
196
+ begin
197
+ warn_if_missing_loadable_stats(asset_paths, hash)
198
+ rescue StandardError => e
199
+ warn "[ReactOnRailsPro] Could not check loadable-stats.json for #{hash.inspect}: " \
200
+ "#{e.class}: #{e.message}. Continuing with the seeded payload."
201
+ end
202
+ payload
203
+ rescue Timeout::Error
204
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) timed out after " \
205
+ "#{FETCH_TIMEOUT_SECONDS}s. Skipping this hash."
206
+ nil
207
+ rescue StandardError => e
208
+ # Keep adapter-fetch attribution here instead of letting the outer rescue
209
+ # in `seed_previous_hash` rewrite the message as a generic staging failure —
210
+ # `bundle_dir` is still nil at this point, so nothing has been staged.
211
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) raised #{e.class}: " \
212
+ "#{e.message}. Skipping this hash."
213
+ nil
214
+ end
215
+ private_class_method :fetch_payload
216
+
217
+ def self.valid_bundle_payload?(payload, hash)
218
+ return true if payload[:bundle] && File.file?(payload[:bundle])
219
+
220
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) returned payload without " \
221
+ "a valid :bundle file path. Skipping this hash."
222
+ false
223
+ end
224
+ private_class_method :valid_bundle_payload?
225
+
226
+ def self.valid_asset_payload?(asset_paths, hash)
227
+ # Stricter than upload-side filtering: a partial payload in the store means
228
+ # every subsequent seed for this hash would produce a broken hydration
229
+ # chain, so reject the whole hash rather than staging an incomplete set.
230
+ invalid_assets = asset_paths.reject { |asset_path| File.file?(asset_path) }
231
+ return true if invalid_assets.empty?
232
+
233
+ missing_assets, non_file_assets = invalid_assets.partition { |asset_path| !File.exist?(asset_path) }
234
+ warn_missing_asset_payload(hash, missing_assets)
235
+ warn_non_file_asset_payload(hash, non_file_assets)
236
+ false
237
+ end
238
+ private_class_method :valid_asset_payload?
239
+
240
+ # Split the missing payload into required-RSC and non-required buckets and
241
+ # warn on each populated bucket. When both are missing, logging only the
242
+ # required-RSC bucket (the previous behavior) hid non-required missing
243
+ # entries from operators debugging a skipped hash.
244
+ def self.warn_missing_asset_payload(hash, missing_assets)
245
+ return if missing_assets.empty?
246
+
247
+ required_basenames = required_rsc_asset_basenames
248
+ missing_required, missing_non_required =
249
+ missing_assets.partition { |path| required_basenames.include?(File.basename(path)) }
250
+
251
+ if missing_required.any?
252
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) returned missing required RSC " \
253
+ "asset path(s): #{missing_required.map { |p| File.basename(p) }.inspect}. Skipping this hash."
254
+ end
255
+
256
+ return if missing_non_required.empty?
257
+
258
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) returned non-required asset " \
259
+ "path(s) that do not exist: #{missing_non_required.inspect}. Adapter contract requires only " \
260
+ "existing file paths. Skipping this hash to avoid staging an incomplete bundle directory."
261
+ end
262
+ private_class_method :warn_missing_asset_payload
263
+
264
+ def self.warn_non_file_asset_payload(hash, non_file_assets)
265
+ return if non_file_assets.empty?
266
+
267
+ non_file_required = required_rsc_asset_basenames & non_file_assets.map { |path| File.basename(path) }
268
+ if non_file_required.any?
269
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) returned non-file required RSC " \
270
+ "asset path(s): #{non_file_required.inspect}. Skipping this hash."
271
+ end
272
+
273
+ non_file_non_required =
274
+ non_file_assets.reject { |path| non_file_required.include?(File.basename(path)) }
275
+ return if non_file_non_required.empty?
276
+
277
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) returned non-required asset " \
278
+ "path(s) that are not files: #{non_file_non_required.inspect}. Adapter contract requires only " \
279
+ "existing file paths. Skipping this hash to avoid staging an incomplete bundle directory."
280
+ end
281
+ private_class_method :warn_non_file_asset_payload
282
+
283
+ # Only checks that the required RSC basenames *appear* in the payload's asset
284
+ # list. Existence and file-ness of those paths on disk are validated downstream
285
+ # by `valid_asset_payload?`, which attributes any missing required RSC files
286
+ # via `warn_missing_asset_payload`. Splitting the two passes lets each warning
287
+ # attribute the failure mode (contract gap vs. dangling path) accurately.
288
+ def self.valid_required_rsc_payload?(asset_paths, hash)
289
+ missing = required_rsc_asset_basenames - asset_paths.map { |path| File.basename(path) }
290
+ return true if missing.empty?
291
+
292
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#fetch(#{hash.inspect}) is missing required RSC " \
293
+ "companion asset(s): #{missing.inspect}. Skipping this hash."
294
+ false
295
+ end
296
+ private_class_method :valid_required_rsc_payload?
297
+
298
+ def self.warn_if_missing_loadable_stats(asset_paths, hash)
299
+ return if asset_paths.map { |path| File.basename(path) }.include?("loadable-stats.json")
300
+ # Skip the warning for builds that legitimately don't produce loadable-stats.json
301
+ # (single-chunk apps without code-splitting). The local build's collect_assets
302
+ # only attaches the file when it exists, so the previous-hash payload absence is
303
+ # consistent — warning would just be noise on every rolling deploy.
304
+ return unless ReactOnRailsPro::RendererCacheHelpers.loadable_stats_asset_path
305
+
306
+ warn "[ReactOnRailsPro] WARNING: rolling_deploy_adapter#fetch(#{hash.inspect}) is missing loadable-stats.json. " \
307
+ "Client hydration may break for requests served by this previous bundle hash."
308
+ end
309
+ private_class_method :warn_if_missing_loadable_stats
310
+
311
+ def self.required_rsc_asset_basenames
312
+ RendererCacheHelpers.required_rsc_asset_basenames
313
+ end
314
+ private_class_method :required_rsc_asset_basenames
315
+
316
+ def self.stage_previous_file(src, dest, bundle_dir, mode, log_prefix)
317
+ stage_mode = cache_local_source?(src, bundle_dir) ? :copy : mode
318
+ stage_file(src, dest, stage_mode, log_prefix)
319
+ end
320
+ private_class_method :stage_previous_file
321
+
322
+ # In symlink mode, a payload source inside the same target bundle dir would
323
+ # become self-referential after the staging dir is promoted into place.
324
+ # Copy those cache-local files instead; external payload sources still use
325
+ # the caller-requested mode.
326
+ def self.cache_local_source?(src, bundle_dir)
327
+ return false unless File.directory?(bundle_dir)
328
+
329
+ bundle_dir_realpath = File.realpath(bundle_dir)
330
+ source_realpath = File.realpath(src)
331
+ source_realpath.start_with?("#{bundle_dir_realpath}#{File::SEPARATOR}")
332
+ rescue Errno::ENOENT
333
+ false
334
+ end
335
+ private_class_method :cache_local_source?
336
+
337
+ def self.stage_file(src, dest, mode, log_prefix)
338
+ RendererCacheHelpers.stage_file(src, dest, mode, log_prefix: log_prefix)
339
+ end
340
+ private_class_method :stage_file
341
+
342
+ # No-ops when `cache_dir` does not exist yet. On Docker-style deploys (cache
343
+ # rebuilt from an immutable image layer each release) that's the desired
344
+ # behavior. On persistent-volume deploys where the cache survives across
345
+ # releases but `cache_dir` was wiped between runs, orphaned `.staging-*` /
346
+ # `.previous-*` dirs from a prior run would re-appear when the volume is
347
+ # remounted under a fresh, empty `cache_dir` — they'll be swept on the next
348
+ # successful seeding pass once `cache_dir` is recreated, not this one.
349
+ def self.sweep_stale_temporary_directories(cache_dir)
350
+ return unless Dir.exist?(cache_dir)
351
+
352
+ Dir.children(cache_dir).each do |entry|
353
+ next unless entry.match?(TEMPORARY_DIRECTORY_PATTERN)
354
+
355
+ remove_stale_temporary_directory(File.join(cache_dir, entry))
356
+ end
357
+ rescue StandardError => e
358
+ # Dir.children uses opendir(2) — a process that can stat cache_dir but
359
+ # not read it (e.g. wrong group/umask from a prior deploy step) raises
360
+ # Errno::EACCES here. Per the module's degrade-gracefully contract, a
361
+ # failed sweep must not break assets:precompile.
362
+ warn "[ReactOnRailsPro] Could not sweep stale rolling-deploy temp directories in #{cache_dir}: " \
363
+ "#{e.class}: #{e.message}. Continuing."
364
+ end
365
+ private_class_method :sweep_stale_temporary_directories
366
+
367
+ def self.remove_stale_temporary_directory(path)
368
+ stat = File.lstat(path)
369
+ return unless stat.directory?
370
+ return unless stat.mtime < Time.now - STALE_TEMP_DIR_TTL_SECONDS
371
+
372
+ FileUtils.rm_rf(path)
373
+ warn "[ReactOnRailsPro] Removed stale rolling-deploy temp directory #{path}."
374
+ rescue StandardError => e
375
+ warn "[ReactOnRailsPro] Could not remove stale rolling-deploy temp directory #{path}: " \
376
+ "#{e.class}: #{e.message}."
377
+ end
378
+ private_class_method :remove_stale_temporary_directory
379
+
380
+ def self.temporary_bundle_directory(bundle_dir)
381
+ "#{bundle_dir}.staging-#{Process.pid}-#{SecureRandom.hex(6)}"
382
+ end
383
+ private_class_method :temporary_bundle_directory
384
+
385
+ # Even after SAFE_HASH_PATTERN, a hash like `release.staging-1-abcdef12345678`
386
+ # is allowed by the character class but also matches TEMPORARY_DIRECTORY_PATTERN
387
+ # — staging that hash would create a directory the next sweep silently evicts.
388
+ # Webpack content hashes are pure hex and won't collide, but the protocol is
389
+ # open to user-supplied adapters whose hashes could embed dots (OCI tags,
390
+ # human-readable release names, etc.). Reject both buckets in one pass so the
391
+ # warning lists every offending hash for the operator at the source.
392
+ def self.sanitize_hashes(hash_values, source_label:)
393
+ hashes = Array(hash_values).map { |value| value.to_s.strip }.reject(&:empty?)
394
+ invalid = (hashes.grep_v(SAFE_HASH_PATTERN) + hashes.grep(TEMPORARY_DIRECTORY_PATTERN)).uniq
395
+ if invalid.any?
396
+ warn "[ReactOnRailsPro] #{source_label} returned invalid hash values (rejected): #{invalid.inspect}. " \
397
+ "Hashes must consist of alphanumeric characters, hyphens, underscores, and dots; " \
398
+ "may not begin with a dot or be `.`/`..`; and must not resemble a renderer-cache " \
399
+ "staging-dir suffix (e.g., `.staging-1-deadbeef12`)."
400
+ end
401
+ hashes - invalid
402
+ end
403
+ private_class_method :sanitize_hashes
404
+
405
+ def self.bundle_directory(normalized_cache_dir, hash)
406
+ normalized_candidate = File.expand_path(File.join(normalized_cache_dir, hash))
407
+
408
+ # Require the candidate to be a *subdirectory* of the cache root, not the
409
+ # cache root itself. `sanitize_hashes` already rejects `""` / `.` / `..`,
410
+ # so the equality case is unreachable today; enforcing `start_with?` only
411
+ # keeps staging safe even if sanitization ever regressed (a bundle landing
412
+ # directly at `<cache>/<hash>.js` instead of `<cache>/<hash>/<hash>.js`
413
+ # would break the renderer's lookup layout silently).
414
+ return normalized_candidate if normalized_candidate.start_with?("#{normalized_cache_dir}#{File::SEPARATOR}")
415
+
416
+ raise ReactOnRailsPro::Error,
417
+ "Refusing to stage rolling-deploy bundle hash #{hash.inspect} outside renderer cache dir " \
418
+ "#{normalized_cache_dir.inspect}."
419
+ end
420
+ private_class_method :bundle_directory
421
+
422
+ # There is a brief window between the two `mv` calls below where `bundle_dir`
423
+ # does not exist on disk. A renderer lookup for this hash during that window
424
+ # would miss the cache and fall back to the runtime 410-retry path — a single
425
+ # cold-start, not a correctness regression. On Linux/same-filesystem deploys
426
+ # `File.rename` is atomic at the kernel level, so the window is sub-millisecond.
427
+ # On cross-filesystem or NFS mounts, `FileUtils.mv` falls back to copy+delete
428
+ # and the window can widen to seconds. The trade-off is intentional: this
429
+ # design favors full-replacement atomicity (no half-staged dir ever observed)
430
+ # over zero-downtime swap, since the 410-retry fallback bounds the worst case.
431
+ def self.replace_bundle_directory(staging_dir, bundle_dir)
432
+ backup_dir = nil
433
+ # `File.exist?` follows symlinks, so a dangling symlink left from an
434
+ # interrupted prior seed would report `false` and skip the backup. The
435
+ # `File.symlink?` clause backs the symlink up too, so a later
436
+ # `restore_previous_bundle_directory` has something to restore if the
437
+ # promotion below fails.
438
+ if File.exist?(bundle_dir) || File.symlink?(bundle_dir)
439
+ backup_dir = "#{bundle_dir}.previous-#{Process.pid}-#{SecureRandom.hex(6)}"
440
+ FileUtils.mv(bundle_dir, backup_dir)
441
+ end
442
+
443
+ # Catch the straightforward race where a concurrent writer recreates
444
+ # bundle_dir before promotion starts. The rescue below restores the backup
445
+ # so the previous good copy remains servable. Note: this `File.exist?`
446
+ # check is itself a TOCTOU window — a concurrent writer could recreate
447
+ # `bundle_dir` between this check and `FileUtils.mv` below. On Linux that
448
+ # produces the nested-staging-dir case handled afterward; on macOS/BSD
449
+ # the kernel raises `ENOTEMPTY` instead, which is caught by the outer
450
+ # `rescue StandardError` and triggers `restore_previous_bundle_directory`.
451
+ # Both detection paths are correct.
452
+ if File.exist?(bundle_dir)
453
+ raise ReactOnRailsPro::Error,
454
+ "Concurrent writer recreated #{bundle_dir} between backup and promote; aborting promotion"
455
+ end
456
+
457
+ FileUtils.mv(staging_dir, bundle_dir)
458
+ nested_staging_dir = File.join(bundle_dir, File.basename(staging_dir))
459
+ # Detect the specific race where bundle_dir was recreated after the
460
+ # existence check above but before FileUtils.mv ran, causing mv to nest
461
+ # staging_dir inside the racing dir instead of renaming it.
462
+ if File.directory?(nested_staging_dir)
463
+ # The racing directory is now the authoritative cache entry. Remove only
464
+ # this attempt's nested staging copy; restore leaves the racing directory
465
+ # intact and the old backup is swept later.
466
+ FileUtils.rm_rf(nested_staging_dir)
467
+ raise ReactOnRailsPro::Error,
468
+ "Concurrent writer recreated #{bundle_dir} before promotion completed; aborting promotion"
469
+ end
470
+ puts "[ReactOnRailsPro] Staged previous bundle hash into #{bundle_dir}"
471
+ remove_previous_bundle_backup(backup_dir)
472
+ rescue StandardError
473
+ restore_previous_bundle_directory(backup_dir, bundle_dir)
474
+ raise
475
+ end
476
+ private_class_method :replace_bundle_directory
477
+
478
+ def self.remove_previous_bundle_backup(backup_dir)
479
+ return unless backup_dir
480
+
481
+ FileUtils.rm_rf(backup_dir)
482
+ rescue StandardError => e
483
+ warn "[ReactOnRailsPro] Could not remove stale rolling-deploy backup directory #{backup_dir}: " \
484
+ "#{e.class}: #{e.message}. It will be swept on a later run."
485
+ end
486
+ private_class_method :remove_previous_bundle_backup
487
+
488
+ def self.restore_previous_bundle_directory(backup_dir, bundle_dir)
489
+ return unless backup_dir && File.exist?(backup_dir)
490
+
491
+ if File.exist?(bundle_dir)
492
+ warn "[ReactOnRailsPro] Cannot restore previous rolling-deploy bundle directory because #{bundle_dir} " \
493
+ "already exists. Leaving that concurrent writer's directory intact; #{backup_dir} will be swept later."
494
+ return
495
+ end
496
+
497
+ FileUtils.mv(backup_dir, bundle_dir)
498
+ remove_nested_backup_directory(backup_dir, bundle_dir)
499
+ rescue StandardError => e
500
+ warn "[ReactOnRailsPro] Could not restore previous rolling-deploy bundle directory #{backup_dir} " \
501
+ "to #{bundle_dir}: #{e.class}: #{e.message}. Runtime 410-retry remains the fallback."
502
+ end
503
+ private_class_method :restore_previous_bundle_directory
504
+
505
+ def self.remove_nested_backup_directory(backup_dir, bundle_dir)
506
+ nested_backup_dir = File.join(bundle_dir, File.basename(backup_dir))
507
+ return unless File.directory?(nested_backup_dir)
508
+
509
+ FileUtils.rm_rf(nested_backup_dir)
510
+ warn "[ReactOnRailsPro] Cannot restore previous rolling-deploy bundle directory because #{bundle_dir} " \
511
+ "was recreated during restore. Leaving that concurrent writer's directory intact; removed nested " \
512
+ "backup directory #{nested_backup_dir}."
513
+ end
514
+ private_class_method :remove_nested_backup_directory
515
+ end
516
+ end
@@ -36,20 +36,55 @@ module ReactOnRailsPro
36
36
  renderingRequest,
37
37
  rscBundleHash: #{ReactOnRailsPro::Utils.rsc_bundle_hash.to_json},
38
38
  }
39
- if (typeof generateRSCPayload !== 'function') {
40
- globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
41
- const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
42
- const propsString = JSON.stringify(props);
43
- const newRenderingRequest = renderingRequest.replace(
44
- /\\(\\s*\\)\\s*$/,
45
- function() { return `(${JSON.stringify(componentName)}, ${propsString})`; }
46
- );
47
- return runOnOtherBundle(rscBundleHash, newRenderingRequest);
48
- }
39
+ const runOnOtherBundle = globalThis.runOnOtherBundle;
40
+ const generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
41
+ const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
42
+ const propsString = JSON.stringify(props);
43
+ const newRenderingRequest = renderingRequest.replace(
44
+ /\\(\\s*\\)\\s*$/,
45
+ function() { return `(${JSON.stringify(componentName)}, ${propsString})`; }
46
+ );
47
+ return runOnOtherBundle(rscBundleHash, newRenderingRequest);
49
48
  }
50
49
  JS
51
50
  end
52
51
 
52
+ # Generates JavaScript code for async props setup when incremental rendering is enabled.
53
+ #
54
+ # This code runs DURING the initial render request, BEFORE the component renders.
55
+ # It sets up the infrastructure that allows:
56
+ # 1. Component to call `getReactOnRailsAsyncProp("propName")` → returns a Promise
57
+ # 2. Update chunks to call `asyncPropsManager.setProp("propName", value)` → resolves the Promise
58
+ #
59
+ # WHY isRSCBundle CHECK?
60
+ # - Async props only work with React Server Components (RSC)
61
+ # - RSC bundle has `addAsyncPropsCapabilityToComponentProps` method
62
+ # - Server bundle (non-RSC) doesn't support this pattern
63
+ #
64
+ # RACE CONDITION HANDLING:
65
+ # - Uses getOrCreateAsyncPropsManager internally for lazy initialization
66
+ # - If initial render runs first: creates manager, stores in sharedExecutionContext
67
+ # - If update chunk arrives first: creates manager via getOrCreateAsyncPropsManager
68
+ # - Both share the same manager via sharedExecutionContext
69
+ #
70
+ # WHY sharedExecutionContext?
71
+ # - The asyncPropManager needs to be accessible by update chunks that arrive later
72
+ # - Update chunks run in the same ExecutionContext, so they can retrieve it
73
+ # - sharedExecutionContext is NOT global - it's scoped to this HTTP request
74
+ #
75
+ # @param render_options [Object] Options that control the rendering behavior
76
+ # @return [String] JavaScript code that sets up AsyncPropsManager or empty string
77
+ def async_props_setup_js(render_options)
78
+ return "" unless render_options.internal_option(:async_props_block)
79
+
80
+ <<-JS
81
+ if (ReactOnRails.isRSCBundle) {
82
+ var { props: propsWithAsyncProps } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps, sharedExecutionContext);
83
+ usedProps = propsWithAsyncProps;
84
+ }
85
+ JS
86
+ end
87
+
53
88
  # Main rendering function that generates JavaScript code for server-side rendering
54
89
  # @param props_string [String] JSON string of props to pass to the React component
55
90
  # @param rails_context [String] JSON string of Rails context data
@@ -88,6 +123,7 @@ module ReactOnRailsPro
88
123
  #{ssr_pre_hook_js}
89
124
  #{redux_stores}
90
125
  var usedProps = typeof props === 'undefined' ? #{props_string} : props;
126
+ #{async_props_setup_js(render_options)}
91
127
  return ReactOnRails[#{render_function_name}]({
92
128
  name: componentName,
93
129
  domNodeId: #{render_options.dom_id.to_json},
@@ -96,6 +132,7 @@ module ReactOnRailsPro
96
132
  railsContext: railsContext,
97
133
  throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
98
134
  renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
135
+ generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined,
99
136
  });
100
137
  })()
101
138
  JS
@@ -53,12 +53,26 @@ module ReactOnRailsPro
53
53
  end
54
54
 
55
55
  def eval_streaming_js(js_code, render_options)
56
- path = prepare_render_path(js_code, render_options)
57
- ReactOnRailsPro::Request.render_code_as_stream(
58
- path,
59
- js_code,
60
- is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
61
- )
56
+ is_rsc_payload = ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
57
+ async_props_block = render_options.internal_option(:async_props_block)
58
+
59
+ if async_props_block
60
+ # Use incremental rendering when async props block is provided
61
+ path = prepare_incremental_render_path(js_code, render_options)
62
+ ReactOnRailsPro::Request.render_code_with_incremental_updates(
63
+ path,
64
+ js_code,
65
+ async_props_block: async_props_block
66
+ )
67
+ else
68
+ # Use standard streaming when no async props block
69
+ path = prepare_render_path(js_code, render_options)
70
+ ReactOnRailsPro::Request.render_code_as_stream(
71
+ path,
72
+ js_code,
73
+ is_rsc_payload: is_rsc_payload
74
+ )
75
+ end
62
76
  end
63
77
 
64
78
  def eval_js(js_code, render_options, send_bundle: false)
@@ -97,16 +111,27 @@ module ReactOnRailsPro
97
111
  end
98
112
 
99
113
  def prepare_render_path(js_code, render_options)
114
+ # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
115
+ # From the request path
116
+ # path = "/bundles/#{@bundle_hash}/render"
117
+ build_render_path(js_code, render_options, "render")
118
+ end
119
+
120
+ def prepare_incremental_render_path(js_code, render_options)
121
+ build_render_path(js_code, render_options, "incremental-render")
122
+ end
123
+
124
+ private
125
+
126
+ def build_render_path(js_code, render_options, endpoint)
100
127
  ReactOnRailsPro::ServerRenderingPool::ProRendering
101
128
  .set_request_digest_on_render_options(js_code, render_options)
102
129
 
103
130
  rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support
104
131
  is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming?
105
132
  bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash
106
- # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
107
- # From the request path
108
- # path = "/bundles/#{@bundle_hash}/render"
109
- "/bundles/#{bundle_hash}/render/#{render_options.request_digest}"
133
+
134
+ "/bundles/#{bundle_hash}/#{endpoint}/#{render_options.request_digest}"
110
135
  end
111
136
 
112
137
  def fallback_exec_js(js_code, render_options, error)