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.
- checksums.yaml +4 -4
- data/.controlplane/rails.yml +2 -2
- data/CLAUDE.md +10 -0
- data/CONTRIBUTING.md +2 -2
- data/Gemfile.development_dependencies +20 -13
- data/Gemfile.lock +12 -28
- data/Rakefile +0 -5
- data/app/helpers/react_on_rails_pro_helper.rb +28 -1
- data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
- data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
- data/lib/react_on_rails_pro/configuration.rb +114 -17
- data/lib/react_on_rails_pro/engine.rb +10 -0
- data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
- data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
- data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
- data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
- data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
- data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
- data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
- data/lib/react_on_rails_pro/request.rb +135 -8
- data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
- data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
- data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
- data/lib/react_on_rails_pro/stream_request.rb +42 -49
- data/lib/react_on_rails_pro/utils.rb +7 -9
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/lib/react_on_rails_pro.rb +8 -0
- data/lib/tasks/assets.rake +36 -3
- data/rakelib/run_rspec.rake +6 -6
- data/react_on_rails_pro.gemspec +9 -4
- data/sig/react_on_rails_pro/configuration.rbs +2 -0
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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)
|