react_on_rails_pro 17.0.0.rc.8 → 17.0.0.rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6013b40c199b7e3a943a2b2d77321d8f5481d9f6e5a63b9d02d8a52e1d91753b
4
- data.tar.gz: e33168cb4f032e3ea0fcd9668eef969e0769dac6b9fb6d62da622205f9e1f9eb
3
+ metadata.gz: f76154cd40917f3cb62526a57ec696e6a9efedc600c99c62a8cdffd18afd30d5
4
+ data.tar.gz: 63a43241e6c89df22486834d03451a125282273fbf40ebd17efe2982316de860
5
5
  SHA512:
6
- metadata.gz: c99af25077139ada7aa948a235c0020f95ae007ce0c4294bf73a4f12e561d63dd6138bb3f5b5e13cd52deffdc4c8b3d1fa5b8c892df47adde5fcd40d4a947762
7
- data.tar.gz: 40d17fa6a656718d015ab0195974a135d199ea11c6db12b63ac4e3be5636bcf531fcc103908c1625facd68728e2f0ddc855d4c02b6fa4b6c52f123604ea7c6b4
6
+ metadata.gz: 41997c4eb40dbe7858f8ae734b11d4a9f12840eb022e94e00ead20ea6afe12be1949f840347379f46b7452f68b41b9c076737ea9c721fba0e772c4501f82806f
7
+ data.tar.gz: 34a525de9302065a00826c4042b41dcd82d8b97e3d5024200618b3f1eb62b04edb3924a3e4527e487484a7c05233225563ee26817159ed352249df4aed466bef
data/CONTEXT.md ADDED
@@ -0,0 +1,135 @@
1
+ # React on Rails Pro — Rolling Deploy
2
+
3
+ How the Node Renderer bundle cache is warmed so that during a rolling deploy —
4
+ when old and new app versions run side by side — no SSR request pays the
5
+ cold-bundle penalty. Covers the seeding mechanism and how it interacts with the
6
+ deploy pipeline (build vs. release, staging-to-production promotion).
7
+
8
+ ## Language
9
+
10
+ **Rolling deploy**:
11
+ The window where old (draining) and new app instances run side by side, both
12
+ sending SSR requests to the Node Renderer fleet.
13
+
14
+ **Draining bundle**:
15
+ The bundle hash that draining old Rails instances still request during the
16
+ overlap; the new renderer fleet must serve it warm.
17
+ _Avoid_: "old bundle" (old relative to what? — the draining bundle is defined by
18
+ what is live-and-draining in _this environment_, not by build order).
19
+
20
+ **Pre-seed**:
21
+ Staging previous bundle hashes into the new renderer cache before it serves
22
+ traffic, so **draining bundle** requests hit warm cache instead of the **410
23
+ fallback**.
24
+ _Avoid_: warmup (means the per-request lazy cache fill, the opposite of this).
25
+
26
+ **Pre-seed source**:
27
+ The deployment(s) the pre-seed pulls bundles from — resolved from that
28
+ environment's `rolling_deploy_previous_urls` (HTTP adapter) at the moment the
29
+ seed runs. The source is whatever environment's config is in effect _when and
30
+ where the seed executes_.
31
+
32
+ **Build-time seed**:
33
+ Pre-seed baked into the image during `assets:precompile`. Uses the _building_
34
+ environment's config and a _snapshot_ of its then-live bundle. Frozen into the
35
+ image layer.
36
+
37
+ **Release-time seed** (a.k.a. **boot seed**):
38
+ Pre-seed run by the _target_ environment's renderer container at container boot
39
+ via `rake react_on_rails_pro:pre_seed_renderer_cache`, resolving that
40
+ environment's _actually-live_ bundle at that moment. Readiness-gated.
41
+ _Avoid_: "runtime seed" (ambiguous with the per-request 410 path).
42
+
43
+ **Multi-source seed**:
44
+ A build-time seed whose **pre-seed source** is a _list_ of endpoints (the
45
+ built-in HTTP adapter's `rolling_deploy_previous_urls` accepts more than one).
46
+ Staging seeds from both staging and production so the promoted image is born
47
+ prod-ready.
48
+
49
+ **Promotion model**:
50
+ Production is deployed by promoting the _exact staging image_, not by building a
51
+ fresh production image. Consequence: the image's **build-time seed** used the
52
+ _staging_ pipeline's config and a snapshot taken at staging-build time — never
53
+ production's.
54
+
55
+ **410 fallback**:
56
+ The self-healing but slow per-request cold path: cache miss → `410 Gone` → Rails
57
+ ships the bundle to the renderer → retry, repeating per request until cached.
58
+ The thing pre-seeding exists to avoid.
59
+
60
+ **Seed correctness (R1)**:
61
+ The requirement that the new renderer fleet holds the exact **draining bundle**
62
+ for _this_ environment at _this_ release.
63
+
64
+ **Deploy ordering (R2)**:
65
+ The requirement that the new renderer fleet is live and cache-warm _before_ new
66
+ Rails takes traffic, and old renderers stay up until old Rails drains. Also
67
+ called **renderer-before-Rails**.
68
+
69
+ ## Relationships
70
+
71
+ - A **rolling deploy** has one **draining bundle** per bundle kind (server, and
72
+ RSC when enabled) that the new renderer fleet must serve warm.
73
+ - **Pre-seed** eliminates the **410 fallback** for the **draining bundle**;
74
+ without it, every draining-bundle request pays the cold path.
75
+ - **Seed correctness (R1)** and **Deploy ordering (R2)** are independent and
76
+ _both_ required. R2 alone (a delay) cannot rescue a wrong seed; R1 alone
77
+ cannot rescue Rails cutting over before the renderer is warm.
78
+ - Under the **promotion model**, a **build-time seed** _cannot_ satisfy R1: it
79
+ runs in the staging pipeline against a staging snapshot, but production's
80
+ **draining bundle** is only known at the (later, sometimes-skipped) promotion.
81
+ - Fix: set staging's **pre-seed source** to **production**, so the promoted
82
+ image is born prod-ready (build-time seed = prod's live bundle). This bounds
83
+ the blast radius but goes stale across _two pending promotions_.
84
+ - The residual staleness window (two images built before either is promoted) is
85
+ closed by a **release-time seed** at promotion, which resolves production's
86
+ actually-live **draining bundle**.
87
+ - Staging seeding prod bundles means staging's _own_ rolling deploys cold-miss
88
+ (staging drains staging's-previous, which is not seeded). Accepted: staging
89
+ SSR performance during deploys is not a goal.
90
+
91
+ ### Three layers of defense
92
+
93
+ Correctness is layered; each layer bounds the failure the prior one leaves open.
94
+
95
+ 1. **Multi-source build-time seed** — the image is born prod-ready; failure floor
96
+ if the boot seed can't reach the live endpoint. (Correct for a single pending
97
+ promotion.)
98
+ 2. **Boot seed (release-time)** — the correctness path: pulls the _actually-live_
99
+ **draining bundle** at container start, closing the two-pending-promotions
100
+ window. Subsumes layer 1's correctness; layer 1 remains only as a fallback.
101
+ 3. **Deploy ordering (R2)** — renderer live+warm before Rails; readiness gates on
102
+ the boot seed _completing_ (not succeeding — a failed seed degrades to the 410
103
+ fallback rather than wedging the deploy).
104
+
105
+ **Interdependency:** the **boot seed** returns the correct **draining bundle**
106
+ _only because_ the renderer boots before Rails (R2). Before new Rails is live,
107
+ the environment's live endpoint is still served by the draining old pods, so it
108
+ advertises the draining hash. If Rails cut over first, the live endpoint would
109
+ advertise the _new_ hash and the boot seed would miss the very bundle it needs.
110
+ Layer 2 depends on Layer 3.
111
+
112
+ ## Example dialogue
113
+
114
+ > **Dev:** "We promote the staging image to prod, and pre-seed is on. Why is
115
+ > prod SSR still slow after a rolling deploy?"
116
+ > **Domain expert:** "Because the **build-time seed** ran in the staging
117
+ > pipeline — the image holds staging's previous bundle, not prod's **draining
118
+ > bundle**. The seed was correct for the wrong environment."
119
+ > **Dev:** "So if staging's **pre-seed source** points at production, the
120
+ > promoted image is already prod-ready?"
121
+ > **Domain expert:** "For a single pending promotion, yes. Two pending
122
+ > promotions still go stale — that's why promotion also runs a **release-time
123
+ > seed** against prod's live bundle."
124
+ > **Dev:** "And that's enough?"
125
+ > **Domain expert:** "That's **R1**. You still need **R2** — the new renderer
126
+ > fleet live and warm before Rails cuts over — or you get the 410 storm anyway."
127
+
128
+ ## Flagged ambiguities
129
+
130
+ - "old bundle" was used to mean both the build-predecessor and the live-draining
131
+ bundle — resolved: the term of record is **draining bundle**, defined by what
132
+ is live in the target environment, not by build order.
133
+ - "the delay" (R2) was initially read as a fix for the slow-SSR report — resolved:
134
+ the report is an R1 (wrong-seed) failure; the delay is a necessary but separate
135
+ R2 concern.
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (17.0.0.rc.8)
12
+ react_on_rails (17.0.0.rc.9)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,14 +20,14 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (17.0.0.rc.8)
23
+ react_on_rails_pro (17.0.0.rc.9)
24
24
  async (>= 2.29)
25
25
  async-http (~> 0.95)
26
26
  execjs (~> 2.9)
27
27
  io-endpoint (~> 0.17.0)
28
28
  jwt (>= 2.5, < 4)
29
29
  nokogiri (>= 1.12, < 2)
30
- react_on_rails (= 17.0.0.rc.8)
30
+ react_on_rails (= 17.0.0.rc.9)
31
31
 
32
32
  GEM
33
33
  remote: https://rubygems.org/
@@ -1260,8 +1260,14 @@ module ReactOnRailsProHelper
1260
1260
  render_options = create_render_options(react_component_name, options)
1261
1261
  json_stream = server_rendered_react_component(render_options)
1262
1262
  json_stream.transform do |chunk|
1263
- html = chunk.delete("html") || ""
1264
- metadata = chunk.to_json
1263
+ # Read `html` without removing it. This chunk may be owned by StreamCache,
1264
+ # which buffers a reference to it and writes it to Rails.cache after the
1265
+ # stream completes. Mutating it here (e.g. `chunk.delete("html")`) would
1266
+ # tear the payload out of the buffered Hash, so prerender caching would
1267
+ # persist an empty payload and every cache hit would serve zero bytes.
1268
+ # See https://github.com/shakacode/react_on_rails/issues/4550.
1269
+ html = chunk["html"] || ""
1270
+ metadata = chunk.except("html").to_json
1265
1271
  content_bytes = html.bytesize.to_s(16).rjust(8, "0")
1266
1272
  "#{metadata}\t#{content_bytes}\n#{html}".html_safe
1267
1273
  end
@@ -0,0 +1,38 @@
1
+ # Seed rolling-deploy bundles at renderer boot, not only at build
2
+
3
+ **Status:** accepted
4
+
5
+ When the production image is produced by **promoting the staging image**
6
+ (`upstream: hichee-staging` in Control Plane), a build-time pre-seed cannot know
7
+ production's live **draining bundle**: it runs in the staging pipeline against a
8
+ staging snapshot, and promotion happens later and is sometimes skipped. We
9
+ therefore have the **node-renderer container pull the target environment's
10
+ actually-live bundle at boot** (`rake react_on_rails_pro:pre_seed_renderer_cache`,
11
+ readiness-gated) as the correctness path, and keep the build-time seed as a
12
+ fallback floor.
13
+
14
+ ## Considered options
15
+
16
+ - **Build-time seed only (status quo).** Rejected: structurally wrong under the
17
+ promotion model — the image holds staging's previous bundle, and even a
18
+ multi-source build-time seed (staging + production) goes stale across two
19
+ pending promotions.
20
+ - **Build a dedicated production image instead of promoting staging.** Rejected:
21
+ fights the promotion model (the point of promotion is shipping the exact
22
+ artifact tested on staging) and still goes stale if a prod image is ever built
23
+ ahead of its release.
24
+ - **Boot seed only.** Rejected in favor of belt-and-suspenders: a boot seed that
25
+ can't reach the live endpoint would fall all the way back to the per-request
26
+ 410 path. Keeping the build-time multi-source seed bounds that failure to a
27
+ small, rare miss.
28
+
29
+ ## Consequences
30
+
31
+ - Correctness of the boot seed **depends on deploy ordering (R2)**: the renderer
32
+ must boot before Rails, so the environment's live endpoint still advertises the
33
+ draining hash. If Rails cut over first, the boot seed would fetch the new hash
34
+ and miss the draining one.
35
+ - Readiness gates on the boot seed **completing, not succeeding** — a failed seed
36
+ degrades to the 410 fallback rather than wedging the deploy.
37
+ - Staging seeds production bundles, so staging's own rolling deploys cold-miss.
38
+ Accepted: staging SSR performance during deploys is not a goal.
@@ -38,7 +38,7 @@ module ReactOnRailsPro
38
38
  remote_bundle_cache_adapter: Configuration::DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER,
39
39
  rolling_deploy_adapter: Configuration::DEFAULT_ROLLING_DEPLOY_ADAPTER,
40
40
  rolling_deploy_token: Configuration::DEFAULT_ROLLING_DEPLOY_TOKEN,
41
- rolling_deploy_previous_url: Configuration::DEFAULT_ROLLING_DEPLOY_PREVIOUS_URL,
41
+ rolling_deploy_previous_urls: Configuration::DEFAULT_ROLLING_DEPLOY_PREVIOUS_URLS,
42
42
  rolling_deploy_mount_path: Configuration::DEFAULT_ROLLING_DEPLOY_MOUNT_PATH,
43
43
  ssr_timeout: Configuration::DEFAULT_SSR_TIMEOUT,
44
44
  ssr_pre_hook_js: nil,
@@ -79,7 +79,7 @@ module ReactOnRailsPro
79
79
  DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER = nil
80
80
  DEFAULT_ROLLING_DEPLOY_ADAPTER = nil
81
81
  DEFAULT_ROLLING_DEPLOY_TOKEN = nil
82
- DEFAULT_ROLLING_DEPLOY_PREVIOUS_URL = nil
82
+ DEFAULT_ROLLING_DEPLOY_PREVIOUS_URLS = nil
83
83
  DEFAULT_ROLLING_DEPLOY_MOUNT_PATH = "/react_on_rails_pro/rolling_deploy"
84
84
  # Minimum bearer-token length when using the built-in HTTP rolling-deploy adapter.
85
85
  # 32 chars matches SecureRandom.hex(16) and rules out obviously low-entropy values
@@ -112,7 +112,7 @@ module ReactOnRailsPro
112
112
  :renderer_http_pool_timeout, :renderer_http_pool_warn_timeout,
113
113
  :dependency_globs, :excluded_dependency_globs, :rendering_returns_promises,
114
114
  :remote_bundle_cache_adapter, :rolling_deploy_adapter,
115
- :rolling_deploy_token, :rolling_deploy_previous_url, :rolling_deploy_mount_path,
115
+ :rolling_deploy_token, :rolling_deploy_previous_urls, :rolling_deploy_mount_path,
116
116
  :ssr_pre_hook_js, :assets_to_copy,
117
117
  :renderer_request_retry_limit, :throw_js_errors, :ssr_timeout,
118
118
  :profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support,
@@ -201,7 +201,7 @@ module ReactOnRailsPro
201
201
  tracing: nil,
202
202
  dependency_globs: nil, excluded_dependency_globs: nil, rendering_returns_promises: nil,
203
203
  remote_bundle_cache_adapter: nil, rolling_deploy_adapter: nil,
204
- rolling_deploy_token: nil, rolling_deploy_previous_url: nil,
204
+ rolling_deploy_token: nil, rolling_deploy_previous_urls: nil,
205
205
  rolling_deploy_mount_path: nil,
206
206
  ssr_pre_hook_js: nil, assets_to_copy: nil,
207
207
  renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil,
@@ -229,7 +229,7 @@ module ReactOnRailsPro
229
229
  self.remote_bundle_cache_adapter = remote_bundle_cache_adapter
230
230
  self.rolling_deploy_adapter = rolling_deploy_adapter
231
231
  self.rolling_deploy_token = rolling_deploy_token
232
- self.rolling_deploy_previous_url = rolling_deploy_previous_url
232
+ self.rolling_deploy_previous_urls = rolling_deploy_previous_urls
233
233
  # Constructor nil/blank means "use the default"; configure-block assignment
234
234
  # can still set nil/blank later to opt out of the engine auto-mount.
235
235
  self.rolling_deploy_mount_path = rolling_deploy_mount_path.presence || DEFAULT_ROLLING_DEPLOY_MOUNT_PATH
@@ -407,8 +407,8 @@ module ReactOnRailsPro
407
407
 
408
408
  # Only fires when the user has selected the built-in HTTP adapter. Custom
409
409
  # adapters that re-use the new config knobs for their own reasons are not
410
- # forced through this validation. We do not validate previous_url here:
411
- # an unset URL is a valid "discovery off; this is the upload-only side of
410
+ # forced through this validation. We do not validate previous_urls here:
411
+ # an unset list is a valid "discovery off; this is the upload-only side of
412
412
  # a one-way deploy" mode (the adapter returns [] for previous_bundle_hashes).
413
413
  def validate_rolling_deploy_http_adapter_config
414
414
  return unless rolling_deploy_http_adapter?
@@ -38,11 +38,19 @@ module ReactOnRailsPro
38
38
  # Configuration (see docs/pro/rolling-deploy-adapters.md):
39
39
  #
40
40
  # ReactOnRailsPro.configure do |config|
41
- # config.rolling_deploy_adapter = ReactOnRailsPro::RollingDeployAdapters::Http
42
- # config.rolling_deploy_token = ENV.fetch("ROLLING_DEPLOY_TOKEN")
43
- # config.rolling_deploy_previous_url = ENV["ROLLING_DEPLOY_PREVIOUS_URL"]
41
+ # config.rolling_deploy_adapter = ReactOnRailsPro::RollingDeployAdapters::Http
42
+ # config.rolling_deploy_token = ENV.fetch("ROLLING_DEPLOY_TOKEN")
43
+ # config.rolling_deploy_previous_urls = ENV["ROLLING_DEPLOY_PREVIOUS_URLS"]
44
44
  # end
45
45
  #
46
+ # `rolling_deploy_previous_urls` accepts a single URL string, a comma-
47
+ # separated string, or an Array of URL strings. Discovery unions the bundle
48
+ # hashes advertised by every endpoint; fetch tries each endpoint in order and
49
+ # returns the first that has the requested hash. Seeding from more than one
50
+ # endpoint (e.g. staging + production) lets an image built in one environment
51
+ # and promoted to another carry the promotion target's draining bundle. See
52
+ # docs/pro/rolling-deploy-adapters.md#promotion-deploys-need-a-release-time-boot-seed.
53
+ #
46
54
  # Error contract matches the rolling_deploy_adapter protocol: every
47
55
  # exception is caught and reported as a warning so a failed seed degrades
48
56
  # to the runtime 410-retry fallback rather than failing the build.
@@ -81,19 +89,59 @@ module ReactOnRailsPro
81
89
 
82
90
  class << self
83
91
  def previous_bundle_hashes
84
- base = configured_previous_url
85
- return [] if base.nil?
92
+ bases = configured_previous_urls
93
+ return [] if bases.empty?
86
94
 
87
95
  if token_missing?
88
96
  return warn_and_return("rolling_deploy_token is not configured; skipping manifest fetch",
89
97
  [])
90
98
  end
91
99
 
92
- response = http_get(
93
- URI("#{base}/manifest"),
94
- read_timeout: MANIFEST_READ_TIMEOUT_SECONDS
95
- )
96
- return warn_and_return("manifest returned HTTP #{response.code}", []) unless response.is_a?(Net::HTTPSuccess)
100
+ # Union across every configured endpoint. A single endpoint failing
101
+ # (down, 404, malformed manifest) yields [] for that base and does not
102
+ # abort discovery for the others — the point of multiple endpoints is
103
+ # resilience, so one unreachable source must not blank the rest.
104
+ bases.flat_map { |base| manifest_hashes(base) }.uniq
105
+ end
106
+
107
+ def fetch(bundle_hash)
108
+ return nil if hash_invalid?(bundle_hash)
109
+
110
+ bases = configured_previous_urls
111
+ return nil if bases.empty?
112
+
113
+ if token_missing?
114
+ return warn_and_return("rolling_deploy_token is not configured; skipping fetch(#{bundle_hash.inspect})",
115
+ nil)
116
+ end
117
+
118
+ # Try each endpoint in order; return the first that has the hash. A
119
+ # given hash is content-addressed, so whichever endpoint serves it
120
+ # returns identical bytes — first hit wins.
121
+ bases.each do |base|
122
+ payload = fetch_from(base, bundle_hash)
123
+ return payload if payload
124
+ end
125
+ nil
126
+ end
127
+
128
+ # Intentional no-op. The running Rails server IS the artifact store —
129
+ # bundle + companion assets are already on local disk where the
130
+ # mountable BundlesController will serve them on the next deploy's
131
+ # build CI. Documented in docs/pro/rolling-deploy-adapters.md.
132
+ def upload(_bundle_hash, bundle:, assets:)
133
+ # See class doc above.
134
+ end
135
+
136
+ private
137
+
138
+ # One endpoint's manifest hashes, or [] on any failure so a single bad
139
+ # endpoint cannot abort discovery across the others.
140
+ def manifest_hashes(base)
141
+ response = http_get(URI("#{base}/manifest"), read_timeout: MANIFEST_READ_TIMEOUT_SECONDS)
142
+ unless response.is_a?(Net::HTTPSuccess)
143
+ return warn_and_return("manifest at #{base} returned HTTP #{response.code}", [])
144
+ end
97
145
 
98
146
  parsed = JSON.parse(response.body)
99
147
  # Filter manifest hashes through SAFE_HASH_PATTERN before returning
@@ -107,19 +155,13 @@ module ReactOnRailsPro
107
155
  .reject(&:empty?)
108
156
  .grep(ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN)
109
157
  rescue StandardError => e
110
- warn_and_return("previous_bundle_hashes failed: #{e.class}: #{e.message}", [])
158
+ warn_and_return("previous_bundle_hashes for #{base} failed: #{e.class}: #{e.message}", [])
111
159
  end
112
160
 
113
- def fetch(bundle_hash)
114
- base = configured_previous_url
115
- return nil if base.nil?
116
- return nil if hash_invalid?(bundle_hash)
117
-
118
- if token_missing?
119
- return warn_and_return("rolling_deploy_token is not configured; skipping fetch(#{bundle_hash.inspect})",
120
- nil)
121
- end
122
-
161
+ # Fetch one hash from one endpoint. Returns the payload Hash or nil.
162
+ # Owns the temp dir lifecycle so a failed attempt cleans up before the
163
+ # caller tries the next endpoint into the same (hash-keyed) directory.
164
+ def fetch_from(base, bundle_hash)
123
165
  dir = bundle_dir(bundle_hash)
124
166
  FileUtils.mkdir_p(dir)
125
167
 
@@ -131,36 +173,37 @@ module ReactOnRailsPro
131
173
  result
132
174
  rescue StandardError => e
133
175
  cleanup_and_return(dir, nil) if dir
134
- warn_and_return("fetch(#{bundle_hash.inspect}) failed: #{e.class}: #{e.message}", nil)
176
+ warn_and_return("fetch(#{bundle_hash.inspect}) from #{base} failed: #{e.class}: #{e.message}", nil)
135
177
  end
136
178
 
137
- # Intentional no-op. The running Rails server IS the artifact store —
138
- # bundle + companion assets are already on local disk where the
139
- # mountable BundlesController will serve them on the next deploy's
140
- # build CI. Documented in docs/pro/rolling-deploy-adapters.md.
141
- def upload(_bundle_hash, bundle:, assets:)
142
- # See class doc above.
179
+ # Normalize the configured previous URL(s) into a de-duplicated list of
180
+ # scheme-validated base URLs. Accepts a single string, a comma-separated
181
+ # string, or an Array (whose entries may themselves be comma-separated —
182
+ # convenient for `ENV["ROLLING_DEPLOY_PREVIOUS_URLS"]`).
183
+ def configured_previous_urls
184
+ raw = ReactOnRailsPro.configuration.rolling_deploy_previous_urls
185
+ Array(raw)
186
+ .flat_map { |entry| entry.to_s.split(",") }
187
+ .map(&:strip)
188
+ .reject(&:empty?)
189
+ .uniq
190
+ .filter_map { |url| normalize_previous_url(url) }
143
191
  end
144
192
 
145
- private
146
-
147
- def configured_previous_url
148
- url = ReactOnRailsPro.configuration.rolling_deploy_previous_url.to_s.strip
149
- return nil if url.empty?
150
-
193
+ def normalize_previous_url(url)
151
194
  uri = URI.parse(url.chomp("/"))
152
195
  unless %w[http https].include?(uri.scheme)
153
196
  Rails.logger.warn(
154
- "#{LOG_PREFIX} rolling_deploy_previous_url has unsupported scheme " \
155
- "#{uri.scheme.inspect}; expected http or https. Skipping discovery."
197
+ "#{LOG_PREFIX} rolling_deploy_previous_urls entry #{url.inspect} has unsupported scheme " \
198
+ "#{uri.scheme.inspect}; expected http or https. Skipping this entry."
156
199
  )
157
200
  return nil
158
201
  end
159
202
  uri.to_s
160
203
  rescue URI::InvalidURIError => e
161
204
  Rails.logger.warn(
162
- "#{LOG_PREFIX} rolling_deploy_previous_url is not a valid URI: #{e.message}. " \
163
- "Skipping discovery."
205
+ "#{LOG_PREFIX} rolling_deploy_previous_urls entry #{url.inspect} is not a valid URI: " \
206
+ "#{e.message}. Skipping this entry."
164
207
  )
165
208
  nil
166
209
  end
@@ -330,7 +373,7 @@ module ReactOnRailsPro
330
373
  # by surfacing the cleartext-token risk in build CI logs.
331
374
  #
332
375
  # Loopback hosts are intentionally exempt so developers running a
333
- # local Rails server for `rolling_deploy_previous_url` during
376
+ # local Rails server for `rolling_deploy_previous_urls` during
334
377
  # development don't see noise on every build CI rehearsal — the
335
378
  # token never leaves the host in that case.
336
379
  LOOPBACK_HOST_PATTERN = /\A(localhost|127(?:\.\d{1,3}){3}|::1|\[::1\])\z/
@@ -65,7 +65,14 @@ module ReactOnRailsPro
65
65
 
66
66
  buffered_chunks = []
67
67
  @upstream_stream.each_chunk do |chunk|
68
- buffered_chunks << chunk
68
+ # Snapshot the chunk before handing it downstream. A downstream consumer
69
+ # receives the same object we buffer here, and this buffered array is
70
+ # persisted to Rails.cache after the stream completes. If a consumer
71
+ # mutates its chunk (e.g. deleting a key while framing), an un-duped
72
+ # buffer would persist that mutation and serve a corrupted cache entry.
73
+ # A shallow dup keeps the cached copy intact regardless of the consumer.
74
+ # See https://github.com/shakacode/react_on_rails/issues/4550.
75
+ buffered_chunks << chunk.dup
69
76
  yield(chunk)
70
77
  end
71
78
  Rails.cache.write(@cache_key, buffered_chunks, @cache_options || {})
@@ -14,6 +14,6 @@
14
14
  # https://github.com/shakacode/react_on_rails/blob/main/REACT-ON-RAILS-PRO-LICENSE.md
15
15
 
16
16
  module ReactOnRailsPro
17
- VERSION = "17.0.0.rc.8"
17
+ VERSION = "17.0.0.rc.9"
18
18
  PROTOCOL_VERSION = "2.0.0"
19
19
  end
@@ -34,7 +34,7 @@ module ReactOnRailsPro
34
34
  DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER: nil
35
35
  DEFAULT_ROLLING_DEPLOY_ADAPTER: nil
36
36
  DEFAULT_ROLLING_DEPLOY_TOKEN: nil
37
- DEFAULT_ROLLING_DEPLOY_PREVIOUS_URL: nil
37
+ DEFAULT_ROLLING_DEPLOY_PREVIOUS_URLS: nil
38
38
  DEFAULT_ROLLING_DEPLOY_MOUNT_PATH: String
39
39
  DEFAULT_RENDERER_REQUEST_RETRY_LIMIT: Integer
40
40
  DEFAULT_THROW_JS_ERRORS: bool
@@ -70,7 +70,7 @@ module ReactOnRailsPro
70
70
  attr_accessor remote_bundle_cache_adapter: Module?
71
71
  attr_accessor rolling_deploy_adapter: Module?
72
72
  attr_accessor rolling_deploy_token: String?
73
- attr_accessor rolling_deploy_previous_url: String?
73
+ attr_accessor rolling_deploy_previous_urls: (String | Array[String])?
74
74
  attr_accessor rolling_deploy_mount_path: String?
75
75
  attr_accessor ssr_pre_hook_js: String?
76
76
  attr_accessor assets_to_copy: Array[String]?
@@ -106,7 +106,7 @@ module ReactOnRailsPro
106
106
  ?remote_bundle_cache_adapter: Module?,
107
107
  ?rolling_deploy_adapter: Module?,
108
108
  ?rolling_deploy_token: String?,
109
- ?rolling_deploy_previous_url: String?,
109
+ ?rolling_deploy_previous_urls: (String | Array[String])?,
110
110
  ?rolling_deploy_mount_path: String?,
111
111
  ?ssr_pre_hook_js: String?,
112
112
  ?assets_to_copy: Array[String]?,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react_on_rails_pro
3
3
  version: !ruby/object:Gem::Version
4
- version: 17.0.0.rc.8
4
+ version: 17.0.0.rc.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
@@ -111,14 +111,14 @@ dependencies:
111
111
  requirements:
112
112
  - - '='
113
113
  - !ruby/object:Gem::Version
114
- version: 17.0.0.rc.8
114
+ version: 17.0.0.rc.9
115
115
  type: :runtime
116
116
  prerelease: false
117
117
  version_requirements: !ruby/object:Gem::Requirement
118
118
  requirements:
119
119
  - - '='
120
120
  - !ruby/object:Gem::Version
121
- version: 17.0.0.rc.8
121
+ version: 17.0.0.rc.9
122
122
  - !ruby/object:Gem::Dependency
123
123
  name: bundler
124
124
  requirement: !ruby/object:Gem::Requirement
@@ -194,6 +194,7 @@ files:
194
194
  - AGENTS.md
195
195
  - CHANGELOG.md
196
196
  - CLAUDE.md
197
+ - CONTEXT.md
197
198
  - CONTRIBUTING.md
198
199
  - Gemfile
199
200
  - Gemfile.development_dependencies
@@ -208,6 +209,7 @@ files:
208
209
  - app/helpers/react_on_rails_pro_helper.rb
209
210
  - app/views/react_on_rails_pro/rsc_payload.text.erb
210
211
  - babel.config.js
212
+ - docs/adr/0001-boot-seed-rolling-deploy-bundles.md
211
213
  - lib/react_on_rails_pro.rb
212
214
  - lib/react_on_rails_pro/assets_precompile.rb
213
215
  - lib/react_on_rails_pro/async_props_emitter.rb