react_on_rails_pro 17.0.1 → 17.1.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +4 -3
  3. data/CONTEXT.md +34 -27
  4. data/Gemfile.development_dependencies +1 -0
  5. data/Gemfile.lock +18 -3
  6. data/LICENSE_SETUP.md +42 -9
  7. data/README.md +12 -1
  8. data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +69 -95
  9. data/app/helpers/react_on_rails_pro_helper.rb +178 -45
  10. data/docs/adr/0001-boot-seed-rolling-deploy-bundles.md +28 -17
  11. data/lib/react_on_rails_pro/assets_precompile.rb +34 -20
  12. data/lib/react_on_rails_pro/async_props_emitter.rb +64 -1
  13. data/lib/react_on_rails_pro/cache.rb +1 -1
  14. data/lib/react_on_rails_pro/concerns/async_rendering.rb +11 -6
  15. data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +7 -0
  16. data/lib/react_on_rails_pro/concerns/stream.rb +17 -9
  17. data/lib/react_on_rails_pro/configuration.rb +79 -11
  18. data/lib/react_on_rails_pro/engine.rb +11 -5
  19. data/lib/react_on_rails_pro/error.rb +5 -0
  20. data/lib/react_on_rails_pro/license_task_formatter.rb +1 -1
  21. data/lib/react_on_rails_pro/license_validator.rb +38 -38
  22. data/lib/react_on_rails_pro/open_telemetry.rb +210 -0
  23. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +269 -35
  24. data/lib/react_on_rails_pro/renderer_artifact.rb +176 -0
  25. data/lib/react_on_rails_pro/renderer_artifact_support.rb +287 -0
  26. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +58 -32
  27. data/lib/react_on_rails_pro/renderer_http_client.rb +134 -39
  28. data/lib/react_on_rails_pro/request.rb +249 -118
  29. data/lib/react_on_rails_pro/rolling_deploy/tarball.rb +48 -18
  30. data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +242 -93
  31. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +77 -19
  32. data/lib/react_on_rails_pro/server_rendering_js_code.rb +48 -4
  33. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +46 -7
  34. data/lib/react_on_rails_pro/stream_cache.rb +18 -0
  35. data/lib/react_on_rails_pro/stream_request.rb +9 -2
  36. data/lib/react_on_rails_pro/utils.rb +77 -17
  37. data/lib/react_on_rails_pro/version.rb +1 -1
  38. data/lib/react_on_rails_pro.rb +1 -0
  39. data/sig/react_on_rails_pro/configuration.rbs +7 -0
  40. data/sig/react_on_rails_pro/open_telemetry.rbs +52 -0
  41. data/sig/react_on_rails_pro/renderer_http_client.rbs +18 -0
  42. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06ec910f84772d46edd2e91e28078de7bdbb3b4819251130bb4b70774429b545
4
- data.tar.gz: fb97eae8a07604947973de8b65c2f0a73bb63cd46c84e36d6ea113431041672e
3
+ metadata.gz: 17f3b1f4dbed1f84fc13ee3f3a34720bdbd3dd32d04d758584e4fd1d8d90dd1d
4
+ data.tar.gz: 2708693a5d1cb985b18a5620955000b4b5e9491f84cdaa8a19c00441e411e0a2
5
5
  SHA512:
6
- metadata.gz: c843929a0ec6a3e1193dcac3da081666f8a2176a3ae7e0f0e23751e3bacfda89066423349c72b56d63753e4b07eb5044018825079762e1248efbbfa66a029e81
7
- data.tar.gz: d50cd4919cc961c7384bf79a74b5b3bd345866d1fabaaaf94d196436a71a1eb7752b76e6727191c2fb9d59ecd0070c7451ca98b693563071da53c47da1688423
6
+ metadata.gz: 59c9879ece288731279d23dee866571cf7c370512e60d189b55767d3b780bb976d5f5125a2bf654fbf2a1fb3fc469395d3354e8b23f6b7db1bb9571f56c651d6
7
+ data.tar.gz: 275bd2a78f52a9a5557ad6e72790d645b44aee7b95ce7678fdca725acacc494943da1b1b531343a3b494b92898fa78834278b85f0c97ac5ee9e06a5d16090579
data/CLAUDE.md CHANGED
@@ -88,10 +88,11 @@ Order matters. If the base package isn't published first, the chain breaks.
88
88
 
89
89
  ### License Validation
90
90
 
91
- `ReactOnRailsPro::LicenseValidator` runs on engine startup via JWT validation.
91
+ `ReactOnRailsPro::LicenseValidator` runs after Rails initialization via JWT validation.
92
92
 
93
- - License key: `REACT_ON_RAILS_PRO_LICENSE` environment variable
94
- - Expired licenses cause startup failures in dummy app
93
+ - Rails token sources: `config.license_token`, then `REACT_ON_RAILS_PRO_LICENSE`
94
+ - Node renderer token sources: `licenseToken`, then `REACT_ON_RAILS_PRO_LICENSE`
95
+ - Missing, invalid, and expired licenses are logged without blocking startup
95
96
  - License is checked in Pro engine initializer (`lib/react_on_rails_pro/engine.rb`)
96
97
 
97
98
  ## Pro CI Workflows
data/CONTEXT.md CHANGED
@@ -35,22 +35,31 @@ environment's config and a _snapshot_ of its then-live bundle. Frozen into the
35
35
  image layer.
36
36
 
37
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.
38
+ Pre-seed run by a Ruby-capable release, init, or startup step via
39
+ `bundle exec rake react_on_rails_pro:pre_seed_renderer_cache`, resolving the target
40
+ environment's _actually-live_ bundle. A combined Ruby+Node image can run it
41
+ before Node; a Node-only renderer needs a Ruby-capable step with the promoted
42
+ app artifact/config. That step and the renderer must mount the same writable
43
+ shared volume at `RENDERER_SERVER_BUNDLE_CACHE_PATH`, or copy/sync the completed
44
+ cache into the renderer before it starts. Renderer start/readiness waits for
45
+ completion. Without a Ruby-capable step that makes the completed cache available
46
+ to the renderer, use multi-source fallback plus 410 recovery or a combined
47
+ shape.
41
48
  _Avoid_: "runtime seed" (ambiguous with the per-request 410 path).
42
49
 
43
50
  **Multi-source seed**:
44
51
  A build-time seed whose **pre-seed source** is a _list_ of endpoints (the
45
52
  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.
53
+ Staging can carry bundles then advertised by both staging and production, which
54
+ improves fallback warmth but cannot determine a later production drain.
48
55
 
49
56
  **Promotion model**:
50
57
  Production is deployed by promoting the _exact staging image_, not by building a
51
58
  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.
59
+ _staging_ pipeline's config and snapshots taken at staging-build time. A
60
+ multi-source configuration can include production's then-live bundle, but it
61
+ still cannot know the production bundle that will be draining at a later
62
+ promotion.
54
63
 
55
64
  **410 fallback**:
56
65
  The self-healing but slow per-request cold path: cache miss → `410 Gone` → Rails
@@ -75,35 +84,33 @@ called **renderer-before-Rails**.
75
84
  - **Seed correctness (R1)** and **Deploy ordering (R2)** are independent and
76
85
  _both_ required. R2 alone (a delay) cannot rescue a wrong seed; R1 alone
77
86
  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_.
87
+ - Under the **promotion model**, a **build-time seed** cannot satisfy R1 for
88
+ every promotion: it runs against snapshots taken in the staging pipeline, but
89
+ production's **draining bundle** is only known at the later promotion.
90
+ - Set staging's **pre-seed source** to both **staging and production**, so the
91
+ image carries each environment's then-live bundle. This keeps staging's own
92
+ rolling deploys warm and improves fallback warmth, but still goes stale across
93
+ _two pending promotions_.
84
94
  - The residual staleness window (two images built before either is promoted) is
85
95
  closed by a **release-time seed** at promotion, which resolves production's
86
96
  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
97
 
91
98
  ### Three layers of defense
92
99
 
93
100
  Correctness is layered; each layer bounds the failure the prior one leaves open.
94
101
 
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.)
102
+ 1. **Multi-source build-time seed** — carries then-advertised bundles as a
103
+ failure floor if the boot seed cannot reach the live endpoint.
98
104
  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.
105
+ **draining bundle** before renderer readiness, closing the
106
+ two-pending-promotions window. Layer 1 remains only as a fallback.
101
107
  3. **Deploy ordering (R2)** — renderer live+warm before Rails; readiness gates on
102
108
  the boot seed _completing_ (not succeeding — a failed seed degrades to the 410
103
109
  fallback rather than wedging the deploy).
104
110
 
105
111
  **Interdependency:** the **boot seed** returns the correct **draining bundle**
106
- _only because_ the renderer boots before Rails (R2). Before new Rails is live,
112
+ only when its release-time step completes before renderer readiness and Rails
113
+ cutover (R2). Before new Rails is live,
107
114
  the environment's live endpoint is still served by the draining old pods, so it
108
115
  advertises the draining hash. If Rails cut over first, the live endpoint would
109
116
  advertise the _new_ hash and the boot seed would miss the very bundle it needs.
@@ -116,11 +123,11 @@ Layer 2 depends on Layer 3.
116
123
  > **Domain expert:** "Because the **build-time seed** ran in the staging
117
124
  > pipeline — the image holds staging's previous bundle, not prod's **draining
118
125
  > 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."
126
+ > **Dev:** "So does staging's **pre-seed source** including staging and
127
+ > production guarantee the promoted image has prod's draining bundle?"
128
+ > **Domain expert:** "No. It carries then-advertised bundles and improves the
129
+ > fallback, but two pending promotions can still go stale. The **release-time
130
+ > seed** against prod's live bundle establishes correctness."
124
131
  > **Dev:** "And that's enough?"
125
132
  > **Domain expert:** "That's **R1**. You still need **R2** — the new renderer
126
133
  > fleet live and warm before Rails cuts over — or you get the 410 storm anyway."
@@ -89,6 +89,7 @@ group :test do
89
89
  gem "equivalent-xml"
90
90
  gem "generator_spec"
91
91
  gem "launchy"
92
+ gem "opentelemetry-sdk", require: false
92
93
  gem "rspec_junit_formatter"
93
94
  gem "rspec-rails"
94
95
  gem "rspec-retry"
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (17.0.1)
12
+ react_on_rails (17.1.0.rc.0)
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.1)
23
+ react_on_rails_pro (17.1.0.rc.0)
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.1)
30
+ react_on_rails (= 17.1.0.rc.0)
31
31
 
32
32
  GEM
33
33
  remote: https://rubygems.org/
@@ -256,6 +256,20 @@ GEM
256
256
  racc (~> 1.4)
257
257
  nokogiri (1.19.4-x86_64-linux-gnu)
258
258
  racc (~> 1.4)
259
+ opentelemetry-api (1.11.0)
260
+ logger
261
+ opentelemetry-common (0.25.1)
262
+ opentelemetry-api (~> 1.0)
263
+ opentelemetry-registry (0.6.3)
264
+ opentelemetry-api (~> 1.1)
265
+ opentelemetry-sdk (1.13.0)
266
+ logger
267
+ opentelemetry-api (~> 1.1)
268
+ opentelemetry-common (~> 0.20)
269
+ opentelemetry-registry (~> 0.2)
270
+ opentelemetry-semantic_conventions
271
+ opentelemetry-semantic_conventions (1.43.0)
272
+ opentelemetry-api (~> 1.0)
259
273
  ostruct (0.6.3)
260
274
  package_json (0.2.0)
261
275
  pg (1.5.6)
@@ -481,6 +495,7 @@ DEPENDENCIES
481
495
  net-http
482
496
  net-imap
483
497
  net-smtp
498
+ opentelemetry-sdk
484
499
  ostruct
485
500
  pg
486
501
  pry (>= 0.14.1)
data/LICENSE_SETUP.md CHANGED
@@ -51,7 +51,21 @@ This change allows your application to start even with license issues, giving yo
51
51
 
52
52
  ## Installation
53
53
 
54
- ### Environment Variable (Required)
54
+ ### Rails Application Configuration
55
+
56
+ Rails applications can read the token from Rails credentials or another application-owned secret provider:
57
+
58
+ ```ruby
59
+ # config/initializers/react_on_rails_pro.rb
60
+ ReactOnRailsPro.configure do |config|
61
+ config.license_token = Rails.application.credentials.dig(:react_on_rails_pro, :license_token)
62
+ end
63
+ ```
64
+
65
+ Explicit nonblank configuration takes precedence over `REACT_ON_RAILS_PRO_LICENSE`. Blank configured values fall
66
+ through to the environment variable.
67
+
68
+ ### Environment Variable Alternative
55
69
 
56
70
  Set the `REACT_ON_RAILS_PRO_LICENSE` environment variable:
57
71
 
@@ -72,8 +86,23 @@ heroku config:set REACT_ON_RAILS_PRO_LICENSE="your_token"
72
86
  # Add to your CI environment variables if needed
73
87
  ```
74
88
 
75
- Configure your license token via the `REACT_ON_RAILS_PRO_LICENSE` environment variable.
76
- Never commit license tokens to version control.
89
+ Never commit license tokens to version control. Use Rails credentials, environment variables, or another secure secret
90
+ manager.
91
+
92
+ ### Standalone Node Renderer Configuration
93
+
94
+ The standalone Node renderer is a separate process and cannot read Rails credentials. Configure it independently using
95
+ its `licenseToken` option or the same environment-variable fallback:
96
+
97
+ ```js
98
+ reactOnRailsProNodeRenderer({
99
+ // Application-defined secret-manager integration:
100
+ licenseToken: loadLicenseTokenFromYourSecretManager(),
101
+ });
102
+ ```
103
+
104
+ Explicit nonblank `licenseToken` configuration takes precedence over `REACT_ON_RAILS_PRO_LICENSE`; blank or omitted
105
+ configuration falls back to the environment. Token values are masked in the renderer's sanitized configuration logs.
77
106
 
78
107
  ## License Validation and Signals
79
108
 
@@ -94,10 +123,12 @@ When no license is present, the application runs in **unlicensed mode**. This is
94
123
 
95
124
  No license setup is needed for development. Developers can install and use React on Rails Pro immediately.
96
125
 
97
- For production deployments, configure a paid license via the `REACT_ON_RAILS_PRO_LICENSE` environment variable.
126
+ For production deployments, configure a paid license through `config.license_token` or
127
+ `REACT_ON_RAILS_PRO_LICENSE`. Configure a standalone Node renderer separately when you use one.
98
128
 
99
129
  > Migration note: `config/react_on_rails_pro_license.key` is no longer read.
100
- > If you used that file previously, move the token to `REACT_ON_RAILS_PRO_LICENSE`.
130
+ > If you used that file previously, move the token to `config.license_token` or
131
+ > `REACT_ON_RAILS_PRO_LICENSE`.
101
132
 
102
133
  ### For CI/CD
103
134
 
@@ -259,7 +290,9 @@ window.railsContext.rorPro;
259
290
 
260
291
  ### Warning: "No license found"
261
292
 
262
- This is expected behavior in development, test, and CI environments. The application will run in unlicensed mode. For production, ensure the `REACT_ON_RAILS_PRO_LICENSE` environment variable is set.
293
+ This is expected behavior in development, test, and CI environments. The application will run in unlicensed mode. For
294
+ production, ensure the license is available through application configuration or `REACT_ON_RAILS_PRO_LICENSE` in each
295
+ process that validates it.
263
296
 
264
297
  ### Error: "Invalid license signature"
265
298
 
@@ -279,7 +312,7 @@ This is expected behavior in development, test, and CI environments. The applica
279
312
  **Solutions:**
280
313
 
281
314
  1. Contact [support@shakacode.com](mailto:support@shakacode.com) to renew your paid license
282
- 2. Update the `REACT_ON_RAILS_PRO_LICENSE` environment variable with the new token
315
+ 2. Update `config.license_token`, the Node renderer's `licenseToken`, or `REACT_ON_RAILS_PRO_LICENSE` with the new token
283
316
 
284
317
  ### Error: "License plan is not valid for production use"
285
318
 
@@ -333,8 +366,8 @@ Need help?
333
366
 
334
367
  ## Security Best Practices
335
368
 
336
- 1. ✅ **Never commit licenses to Git** — Keep license tokens in environment variables or secret managers
337
- 2. ✅ **Use environment variables in production**
369
+ 1. ✅ **Never commit licenses to Git** — Keep license tokens in Rails credentials, environment variables, or secret managers
370
+ 2. ✅ **Give each validating process access to the token through its own configuration**
338
371
  3. ✅ **Use CI secrets for production deployment pipelines**
339
372
  4. ✅ **Don't share licenses publicly**
340
373
 
data/README.md CHANGED
@@ -78,12 +78,23 @@ React on Rails Pro works **without a license token** for evaluation, development
78
78
 
79
79
  ### License Setup
80
80
 
81
- Once you have a paid license, set the environment variable:
81
+ Once you have a paid license, configure it through Rails credentials:
82
+
83
+ ```ruby
84
+ ReactOnRailsPro.configure do |config|
85
+ config.license_token = Rails.application.credentials.dig(:react_on_rails_pro, :license_token)
86
+ end
87
+ ```
88
+
89
+ Or set the environment variable:
82
90
 
83
91
  ```bash
84
92
  export REACT_ON_RAILS_PRO_LICENSE="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
85
93
  ```
86
94
 
95
+ A standalone Node renderer cannot read Rails credentials, so pass the same token through its `licenseToken` option or
96
+ environment when you use that deployment mode.
97
+
87
98
  **📖 Detailed setup instructions**: See [LICENSE_SETUP.md](./LICENSE_SETUP.md) for complete configuration guide, team setup, and troubleshooting.
88
99
 
89
100
  ---
@@ -93,10 +93,12 @@ module ReactOnRailsPro
93
93
  # mount) must pass distinct prefixes so the Rails route registry
94
94
  # doesn't raise on duplicate names.
95
95
  def draw_routes(mapper, path:, as_prefix: DEFAULT_ROUTE_PREFIX)
96
- mapper.get("#{path}/manifest",
96
+ route_prefix = path.to_s.dup
97
+ route_prefix.delete_suffix!("/") while route_prefix.end_with?("/")
98
+ mapper.get("#{route_prefix}/manifest",
97
99
  to: "react_on_rails_pro/rolling_deploy/bundles#manifest",
98
100
  as: :"#{as_prefix}_manifest")
99
- mapper.get("#{path}/bundles/:hash",
101
+ mapper.get("#{route_prefix}/bundles/:hash",
100
102
  to: "react_on_rails_pro/rolling_deploy/bundles#show",
101
103
  constraints: { hash: ROUTE_HASH_PATTERN },
102
104
  as: :"#{as_prefix}_bundle")
@@ -118,15 +120,17 @@ module ReactOnRailsPro
118
120
  # bundle file.
119
121
  BUNDLE_ENTRY_NAME = "bundle.js"
120
122
 
121
- PROTOCOL_VERSION = 1
123
+ PROTOCOL_VERSION = 2
124
+ ARTIFACT_IDENTITY = { scheme: "rorp-v2-sha256", version: 2 }.freeze
122
125
 
123
126
  def manifest
124
- sources = safe_current_bundle_sources
127
+ artifacts = safe_current_artifacts
125
128
  render json: {
126
- hashes: sources.map { |_, hash| hash },
129
+ hashes: artifacts.map(&:id),
127
130
  rsc_enabled: ReactOnRailsPro.configuration.enable_rsc_support,
128
131
  generated_at: Time.now.utc.iso8601,
129
- protocol_version: PROTOCOL_VERSION
132
+ protocol_version: PROTOCOL_VERSION,
133
+ artifact_identity: ARTIFACT_IDENTITY
130
134
  }
131
135
  end
132
136
 
@@ -137,12 +141,10 @@ module ReactOnRailsPro
137
141
  # filesystem operation looks at it.
138
142
  return head(:not_found) unless SAFE_HASH_PATTERN.match?(hash)
139
143
 
140
- sources = safe_current_bundle_sources
141
- match = sources.find { |_, h| h == hash }
142
- return head(:not_found) unless match
144
+ artifact = safe_current_artifacts.find { |candidate| candidate.id == hash }
145
+ return head(:not_found) unless artifact
143
146
 
144
- bundle_path, _matched_hash = match
145
- serve_bundle_tarball(bundle_path)
147
+ serve_bundle_tarball(artifact)
146
148
  end
147
149
 
148
150
  private
@@ -188,13 +190,7 @@ module ReactOnRailsPro
188
190
  response.headers["X-Content-Type-Options"] = "nosniff"
189
191
  end
190
192
 
191
- # Wraps bundle_sources to absorb the "bundle file not present yet" case
192
- # so the manifest endpoint can still 200 with an empty hashes list during
193
- # the brief window after Rails boots but before assets:precompile has
194
- # produced the bundle on this dyno. Returns `[]` rather than raising so
195
- # the build-CI side sees "this server has nothing to seed" instead of a
196
- # 500 that would otherwise show up as a noisy deploy alert.
197
- def safe_current_bundle_sources
193
+ def safe_current_artifacts
198
194
  unless ReactOnRailsPro.configuration.node_renderer?
199
195
  Rails.logger.warn(
200
196
  "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
@@ -205,103 +201,81 @@ module ReactOnRailsPro
205
201
  return []
206
202
  end
207
203
 
208
- pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
209
- ReactOnRailsPro::RendererCacheHelpers.bundle_sources(pool, "serving rolling-deploy tarball")
204
+ artifacts = ReactOnRailsPro::Utils.renderer_artifacts(action_description: "serving rolling-deploy tarball")
205
+ artifacts.select { |artifact| artifact_servable?(artifact) }
210
206
  rescue StandardError => e
211
207
  Rails.logger.warn(
212
208
  "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
213
- "bundle source discovery failed: #{e.class}: #{e.message}. " \
209
+ "artifact discovery failed: #{e.class}: #{e.message}. " \
214
210
  "Returning empty manifest — verify bundles have been precompiled."
215
211
  )
216
212
  []
217
213
  end
218
214
 
219
- def serve_bundle_tarball(bundle_path)
220
- entries = tarball_entries(bundle_path)
221
-
222
- ReactOnRailsPro::RollingDeploy::Tarball.compose_to_tempfile(entries) do |io|
223
- # We've already buffered the tarball to a Tempfile inside
224
- # compose_to_tempfile; send_data reads the contents once. For very
225
- # large bundles a streaming send via ActionController::Live would
226
- # save memory; that's deferred to a follow-up PR — the current
227
- # default ceiling (200 MB) fits comfortably in memory on every
228
- # Rails app instance we'd expect to deploy.
229
- send_data io.read,
230
- type: "application/gzip",
231
- disposition: "inline",
232
- filename: "#{params[:hash]}.tar.gz"
215
+ def serve_bundle_tarball(artifact)
216
+ # artifact_servable? validates the original source provenance before
217
+ # this point. Materialize only the bytes captured by that validated
218
+ # artifact, then compose and consume the tarball before both bounded
219
+ # temp scopes close. Live webpack paths are never read under a stale ID.
220
+ artifact.with_materialized_files(bundle_name: BUNDLE_ENTRY_NAME) do |bundle, companions|
221
+ entries = { BUNDLE_ENTRY_NAME => bundle }.merge(companions)
222
+ ReactOnRailsPro::RollingDeploy::Tarball.compose_to_tempfile(entries) do |io|
223
+ # Identity correctness requires the operation-scoped artifact byte
224
+ # snapshots above. `send_data` adds one compressed response String;
225
+ # none of these bodies are memoized after this request. This is an
226
+ # explicit peak-memory tradeoff for a simple authenticated endpoint.
227
+ # Tarball::DEFAULT_MAX_SIZE is an extraction-side safety cap, not a
228
+ # compose-side memory limit; a future streaming response can lower
229
+ # peak memory without weakening the immutable-byte contract.
230
+ send_data io.read,
231
+ type: "application/gzip",
232
+ disposition: "inline",
233
+ filename: "#{params[:hash]}.tar.gz"
234
+ end
233
235
  end
234
236
  end
235
237
 
236
- # Pairs the bundle file (renamed to `bundle.js` on the wire) with the
237
- # current build's companion assets. Each tarball carries the full
238
- # companion set so the receiver can stage a complete cache entry without
239
- # a second round-trip — matching the rolling_deploy_adapter contract
240
- # that `fetch(hash)` returns bundle + assets together.
241
- #
242
- # Companions are skipped (with a warning) when they would shadow the
243
- # bundle entry, collide with another companion's basename, or carry a
244
- # name that the tarball helper would reject during compose. This
245
- # matches the publish-side behavior in AssetsPrecompile where missing
246
- # or unsafe assets degrade rather than fail the build.
247
- def tarball_entries(bundle_path)
248
- entries = { BUNDLE_ENTRY_NAME => bundle_path }
249
- companion_assets.each do |asset_path|
250
- name = File.basename(asset_path)
251
- next if skip_companion?(name, asset_path, entries)
252
-
253
- entries[name] = asset_path
254
- end
255
- entries
256
- end
257
-
258
- def skip_companion?(name, asset_path, entries)
259
- if name == BUNDLE_ENTRY_NAME
260
- warn_companion_skipped(
261
- "companion #{asset_path.inspect} basename collides with bundle entry #{BUNDLE_ENTRY_NAME.inspect}"
262
- )
263
- return true
264
- end
265
- unless ReactOnRailsPro::RollingDeploy::Tarball::ENTRY_NAME_PATTERN.match?(name)
266
- warn_companion_skipped(
267
- "companion #{asset_path.inspect} basename #{name.inspect} is not a safe tarball entry name"
238
+ def artifact_servable?(artifact)
239
+ rails_root = File.realpath(File.expand_path(Rails.root.to_s))
240
+ if artifact.companions.each_key.any? { |name| name.casecmp?(BUNDLE_ENTRY_NAME) }
241
+ Rails.logger.warn(
242
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] artifact #{artifact.id} cannot be served as a " \
243
+ "complete artifact because a companion collides with #{BUNDLE_ENTRY_NAME.inspect}."
268
244
  )
269
- return true
245
+ return false
270
246
  end
271
- if entries.key?(name)
272
- warn_companion_skipped(
273
- "duplicate companion basename #{name.inspect}; " \
274
- "keeping #{entries[name].inspect}, dropping #{asset_path.inspect}"
275
- )
276
- return true
247
+ return false unless bundle_source_servable?(artifact)
248
+
249
+ invalid = artifact.companions.find do |name, source|
250
+ source.is_a?(RendererArtifact::InlineCompanion) ||
251
+ !ReactOnRailsPro::RollingDeploy::Tarball.safe_entry_name?(name) ||
252
+ !safe_companion_asset_path(source.to_s, rails_root, rails_root)
277
253
  end
254
+ return true unless invalid
255
+
256
+ name, source = invalid
257
+ Rails.logger.warn(
258
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] artifact #{artifact.id} cannot be served as a " \
259
+ "complete artifact because #{name.inspect} resolves to unsupported source #{source_label(source)}."
260
+ )
278
261
  false
279
262
  end
280
263
 
281
- def warn_companion_skipped(message)
282
- Rails.logger.warn("[ReactOnRailsPro::RollingDeploy::BundlesController] #{message}.")
283
- end
264
+ def bundle_source_servable?(artifact)
265
+ return true if File.file?(artifact.bundle)
284
266
 
285
- def companion_assets
286
- rails_root = File.expand_path(Rails.root.to_s)
287
- rails_root_realpath = File.realpath(rails_root)
288
-
289
- # `collect_assets` returns the live build's loadable-stats + RSC
290
- # manifests; missing assets are silently dropped to match the
291
- # publish-side behavior in AssetsPrecompile.
292
- ReactOnRailsPro::RendererCacheHelpers.collect_assets
293
- .map(&:to_s)
294
- .reject { |p| ReactOnRailsPro::RendererCacheHelpers.http_url?(p) }
295
- .filter_map do |path|
296
- safe_companion_asset_path(path, rails_root, rails_root_realpath)
297
- end
298
- rescue StandardError => e
299
267
  Rails.logger.warn(
300
- "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
301
- "companion asset discovery failed: #{e.class}: #{e.message}. " \
302
- "Serving bundle without companion assets — RSC clients may fall back to runtime 410-retry."
268
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] artifact #{artifact.id} cannot be served as a " \
269
+ "complete artifact because #{BUNDLE_ENTRY_NAME.inspect} resolves to unsupported source " \
270
+ "#{source_label(artifact.bundle)}."
303
271
  )
304
- []
272
+ false
273
+ end
274
+
275
+ def source_label(source)
276
+ return "redacted inline URL" if source.is_a?(RendererArtifact::InlineCompanion)
277
+
278
+ source.to_s.inspect
305
279
  end
306
280
 
307
281
  def safe_companion_asset_path(path, rails_root, rails_root_realpath)