react_on_rails_pro 16.7.0.rc.2 → 16.7.0.rc.3

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/.rubocop.yml +12 -0
  4. data/CLAUDE.md +8 -7
  5. data/Gemfile.development_dependencies +2 -1
  6. data/Gemfile.lock +31 -10
  7. data/README.md +2 -3
  8. data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +298 -0
  9. data/lib/react_on_rails_pro/configuration.rb +116 -14
  10. data/lib/react_on_rails_pro/renderer_http_client.rb +415 -0
  11. data/lib/react_on_rails_pro/request.rb +92 -177
  12. data/lib/react_on_rails_pro/rolling_deploy/safe_hash_pattern.rb +13 -0
  13. data/lib/react_on_rails_pro/rolling_deploy/tarball.rb +235 -0
  14. data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +264 -0
  15. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +14 -6
  16. data/lib/react_on_rails_pro/stream_request.rb +121 -48
  17. data/lib/react_on_rails_pro/version.rb +1 -1
  18. data/lib/react_on_rails_pro.rb +2 -3
  19. data/rakelib/rbs.rake +4 -5
  20. data/react_on_rails_pro.gemspec +6 -7
  21. data/scripts/load/README.md +110 -0
  22. data/scripts/load/lib/config.rb +164 -0
  23. data/scripts/load/lib/harness.rb +177 -0
  24. data/scripts/load/lib/memory_sampler.rb +115 -0
  25. data/scripts/load/lib/metrics.rb +61 -0
  26. data/scripts/load/lib/reporters/csv_reporter.rb +48 -0
  27. data/scripts/load/lib/reporters/json_reporter.rb +17 -0
  28. data/scripts/load/lib/reporters/terminal_reporter.rb +59 -0
  29. data/scripts/load/lib/request_result.rb +16 -0
  30. data/scripts/load/lib/runner.rb +345 -0
  31. data/scripts/load/lib/scenario_registry.rb +11 -0
  32. data/scripts/load/lib/scenarios/base.rb +168 -0
  33. data/scripts/load/lib/scenarios/standard_render.rb +45 -0
  34. data/scripts/load/lib/scenarios/streaming_render.rb +53 -0
  35. data/scripts/load/renderer_harness.rb +25 -0
  36. data/sig/react_on_rails_pro/renderer_http_client.rbs +76 -0
  37. metadata +42 -33
  38. data/.prettierignore +0 -14
  39. data/.prettierrc +0 -19
  40. data/eslint.config.mjs +0 -239
  41. data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +0 -42
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3846c8e0c8bc0e3f48b7f09f2068b7f4935a4e0a69b3b07160f635d57988c8e9
4
- data.tar.gz: eecf2b6b0be9d82ea7cde3a8b60e9b2ff3b1d21e247175601ff6c54de4492eeb
3
+ metadata.gz: 7a4266ac7cb6a9cdbb62755a17d95282222b6909e64d41ff7b7a7bcf897c2f0a
4
+ data.tar.gz: 8372bde1ffd861b8f8026e6fa28f4a9f65130ec6c70a42cdfa09a33f6a449c5a
5
5
  SHA512:
6
- metadata.gz: 206fba54350de7c2423b02c4a1a366674030ab178db0007b52e26a638c867d4cb164d8120b9281f90694b046b241e1ff5ed98769483636c05b41825086d92d91
7
- data.tar.gz: a93ea4c57242bbf7d272856e2bbba8379af3e19c2dd29ee0901bcd60f4b296d27ea3b2eb6136f38d5a726601ed2c6967d9c8512deff149f1f1d10f637825f43d
6
+ metadata.gz: 40ebe444b6abb44184984519f44a61df910f69e9e0fa73841ae552de49ba799ed872e8fc3bd8d8b0af8ca27c5c88946fa8abc28278fe42ac0603ac3d5de8cfde
7
+ data.tar.gz: fd3568d5daff73971afddf96575237d2e07a738c972c1f0067e6ac8dc32ed4923c3dbdcf41fbe8dc06cb9d446c48b8544fb30e7ede7e8a57f9606fa2ec25dcc7
data/.gitignore CHANGED
@@ -6,7 +6,6 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  *.gem
9
- /vendor/
10
9
  tmp
11
10
 
12
11
  **/spec/examples.txt
data/.rubocop.yml CHANGED
@@ -17,6 +17,18 @@ AllCops:
17
17
  - 'spec/dummy/bin/**/*'
18
18
  - 'bin/**/*'
19
19
 
20
+ Naming/VariableNumber:
21
+ AllowedIdentifiers:
22
+ - p99_9
23
+
24
+ RSpec/FilePath:
25
+ Exclude:
26
+ - 'spec/load/**/*_spec.rb'
27
+
28
+ RSpec/SpecFilePathFormat:
29
+ Exclude:
30
+ - 'spec/load/**/*_spec.rb'
31
+
20
32
  RSpec/BeforeAfterAll:
21
33
  Exclude:
22
34
  - 'spec/react_on_rails/generators/dev_tests_generator_spec.rb'
data/CLAUDE.md CHANGED
@@ -16,11 +16,14 @@ Pro-specific guidance. See root CLAUDE.md for general project rules.
16
16
 
17
17
  ### Linting
18
18
 
19
- Pro has its **own** ESLint and Prettier configs (separate from root): `eslint.config.mjs`, `.prettierrc`, `.prettierignore`.
19
+ Pro uses the root ESLint and Prettier configs. Run JS/TS lint and formatting from the repo root.
20
20
 
21
- - Pro ESLint: `cd react_on_rails_pro && pnpm run eslint .`
22
- - Pro Prettier check: `cd react_on_rails_pro && pnpm run prettier --check .`
23
- - CI runs both root and Pro linting separately
21
+ - JS/TS lint: `pnpm run eslint --report-unused-disable-directives`
22
+ - Prettier check: `pnpm start format.listDifferent`
23
+ - Pro Ruby lint: `cd react_on_rails_pro && bundle exec rubocop --ignore-parent-exclusion`
24
+ - Pro RBS validation: `cd react_on_rails_pro && bundle exec rake rbs:validate`
25
+ - Pro TypeScript check: `pnpm run nps check-typescript`
26
+ - CI runs unified ESLint/Prettier in `lint-js-and-ruby.yml`; Pro Ruby/RBS/TypeScript checks run in the `pro-lint` job in `pro-test-package-and-gem.yml`
24
27
 
25
28
  ### Building
26
29
 
@@ -91,8 +94,7 @@ Order matters. If the base package isn't published first, the chain breaks.
91
94
  GitHub Actions workflows for Pro (at repo root `.github/workflows/`):
92
95
 
93
96
  - `pro-integration-tests.yml` — Pro dummy app integration tests
94
- - `pro-lint.yml` — Pro-specific linting
95
- - `pro-test-package-and-gem.yml` — Pro gem + JS package tests
97
+ - `pro-test-package-and-gem.yml` — Pro gem + JS package tests, plus Pro Ruby/RBS/TypeScript linting
96
98
 
97
99
  ## Key Differences from Open-Source
98
100
 
@@ -102,5 +104,4 @@ GitHub Actions workflows for Pro (at repo root `.github/workflows/`):
102
104
  | SSR | ExecJS or basic Node | Dedicated node renderer process |
103
105
  | Server bundles | Public | Private (`ssr-generated/`, `enforce_private_server_bundles = true`) |
104
106
  | Transpiler | SWC | Babel (needed for advanced transforms) |
105
- | Lint/format config | Root configs | Own ESLint + Prettier configs |
106
107
  | Test commands | `rake run_rspec:dummy` | Separate Pro commands (see above) |
@@ -55,9 +55,10 @@ group :development, :test do
55
55
  gem "scss_lint", require: false
56
56
  gem "fakefs", require: "fakefs/safe"
57
57
 
58
- # Ruby 3.5+ removed these from the default gem set; they must now be declared explicitly
58
+ # Ruby 3.4+ removed these from the default gem set; they must now be declared explicitly
59
59
  # to avoid `cannot load such file` errors from gems that lazy-require them (e.g. jbuilder).
60
60
  gem "benchmark", require: false
61
+ gem "csv", require: false
61
62
  gem "logger", require: false
62
63
  gem "ostruct", require: false
63
64
  # base64 was promoted from a default gem to a bundled gem in Ruby 3.4, so Bundler-managed
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (16.7.0.rc.2)
12
+ react_on_rails (16.7.0.rc.3)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,16 +20,15 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (16.7.0.rc.2)
23
+ react_on_rails_pro (16.7.0.rc.3)
24
24
  addressable
25
25
  async (>= 2.29)
26
- connection_pool
26
+ async-http (~> 0.95)
27
27
  execjs (~> 2.9)
28
- http-2 (>= 1.1.1)
29
- httpx (~> 1.5)
30
- jwt (>= 2.7)
28
+ io-endpoint (~> 0.17.0)
29
+ jwt (>= 2.5, < 4)
31
30
  rainbow
32
- react_on_rails (= 16.7.0.rc.2)
31
+ react_on_rails (= 16.7.0.rc.3)
33
32
 
34
33
  GEM
35
34
  remote: https://rubygems.org/
@@ -117,6 +116,19 @@ GEM
117
116
  io-event (~> 1.11)
118
117
  metrics (~> 0.12)
119
118
  traces (~> 0.18)
119
+ async-http (0.95.1)
120
+ async (>= 2.10.2)
121
+ async-pool (~> 0.11)
122
+ io-endpoint (~> 0.14)
123
+ io-stream (~> 0.6)
124
+ metrics (~> 0.12)
125
+ protocol-http (~> 0.62)
126
+ protocol-http1 (~> 0.39)
127
+ protocol-http2 (~> 0.26)
128
+ protocol-url (~> 0.2)
129
+ traces (~> 0.10)
130
+ async-pool (0.11.2)
131
+ async (>= 2.0)
120
132
  base64 (0.3.0)
121
133
  benchmark (0.5.0)
122
134
  bigdecimal (4.0.1)
@@ -153,6 +165,7 @@ GEM
153
165
  bigdecimal
154
166
  rexml
155
167
  crass (1.0.6)
168
+ csv (3.3.5)
156
169
  date (3.5.1)
157
170
  diff-lcs (1.5.1)
158
171
  docile (1.4.0)
@@ -181,13 +194,12 @@ GEM
181
194
  graphiql-rails (1.10.0)
182
195
  railties
183
196
  hashdiff (1.1.0)
184
- http-2 (1.1.1)
185
- httpx (1.7.0)
186
- http-2 (>= 1.0.0)
187
197
  i18n (1.14.8)
188
198
  concurrent-ruby (~> 1.0)
189
199
  io-console (0.8.2)
200
+ io-endpoint (0.17.2)
190
201
  io-event (1.14.2)
202
+ io-stream (0.13.0)
191
203
  irb (1.17.0)
192
204
  pp (>= 0.6.0)
193
205
  prism (>= 1.3.0)
@@ -258,6 +270,14 @@ GEM
258
270
  prettyprint
259
271
  prettyprint (0.2.0)
260
272
  prism (1.9.0)
273
+ protocol-hpack (1.5.1)
274
+ protocol-http (0.62.2)
275
+ protocol-http1 (0.39.0)
276
+ protocol-http (~> 0.62)
277
+ protocol-http2 (0.26.0)
278
+ protocol-hpack (~> 1.4)
279
+ protocol-http (~> 0.62)
280
+ protocol-url (0.4.0)
261
281
  pry (0.14.2)
262
282
  coderay (~> 1.1)
263
283
  method_source (~> 1.0)
@@ -483,6 +503,7 @@ DEPENDENCIES
483
503
  capybara (>= 3.38.0)
484
504
  capybara-screenshot
485
505
  commonmarker
506
+ csv
486
507
  equivalent-xml
487
508
  fakefs
488
509
  faker
data/README.md CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  [![License](https://img.shields.io/badge/license-Commercial-blue.svg)](./LICENSE)
4
4
  [![Build Integration Tests](https://github.com/shakacode/react_on_rails/actions/workflows/pro-integration-tests.yml/badge.svg)](https://github.com/shakacode/react_on_rails/actions/workflows/pro-integration-tests.yml)
5
- [![Build Lint](https://github.com/shakacode/react_on_rails/actions/workflows/pro-lint.yml/badge.svg)](https://github.com/shakacode/react_on_rails/actions/workflows/pro-lint.yml)
6
5
  [![Build Package Tests](https://github.com/shakacode/react_on_rails/actions/workflows/pro-test-package-and-gem.yml/badge.svg)](https://github.com/shakacode/react_on_rails/actions/workflows/pro-test-package-and-gem.yml)
7
6
 
8
7
  **Performance enhancements and advanced features for [React on Rails](https://github.com/shakacode/react_on_rails).**
@@ -257,7 +256,7 @@ end
257
256
 
258
257
  ### Prerequisites
259
258
 
260
- - **Ruby**: >= 3.0
259
+ - **Ruby**: >= 3.3
261
260
  - **Rails**: >= 6.0 (recommended: 7.0+)
262
261
  - **React on Rails**: >= 11.0.7 (recommended: latest)
263
262
  - **Node.js**: >= 18 (for Node Renderer)
@@ -267,7 +266,7 @@ end
267
266
 
268
267
  | React on Rails Pro | React on Rails | Rails | Ruby | React |
269
268
  | ------------------ | -------------- | ------ | ------ | ------- |
270
- | 16.x | >= 16.0 | >= 7.0 | >= 3.2 | >= 18 |
269
+ | 16.x | >= 16.0 | >= 7.0 | >= 3.3 | >= 18 |
271
270
  | 3.x | >= 13.0 | >= 6.0 | >= 3.0 | >= 16.8 |
272
271
 
273
272
  > **Note:** Pro version numbers were aligned with the core gem starting at 16.2.0. Pro 16.x is the direct successor to Pro 3.x/4.x.
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/security_utils"
4
+
5
+ require "react_on_rails_pro/rolling_deploy/safe_hash_pattern"
6
+ require "react_on_rails_pro/rolling_deploy/tarball"
7
+
8
+ module ReactOnRailsPro
9
+ module RollingDeploy
10
+ # Server side of the built-in HTTP rolling-deploy adapter. Exposes the
11
+ # current deployment's bundle hashes (`GET /manifest`) and serves a
12
+ # gzipped tarball per hash (`GET /bundles/:hash`). The
13
+ # ReactOnRailsPro::RollingDeployAdapters::Http adapter on the next
14
+ # deploy's build CI consumes both endpoints.
15
+ #
16
+ # Auto-mounted by the engine when `config.rolling_deploy_adapter` is the
17
+ # Http adapter (or a subclass). Users who need a custom mount path or want
18
+ # to layer their own auth middleware can mount manually:
19
+ #
20
+ # # config/routes.rb
21
+ # ReactOnRailsPro::RollingDeploy::BundlesController.draw_routes(
22
+ # self,
23
+ # path: "/internal/rolling-deploy"
24
+ # )
25
+ #
26
+ # Callers that need to mount the controller more than once (for example,
27
+ # the engine auto-mount plus a user-controlled secondary path) must pass
28
+ # a distinct `as_prefix:` per call so Rails' named-route registry
29
+ # doesn't raise `ArgumentError: Invalid route name, already in use`.
30
+ #
31
+ # Security:
32
+ # * Bearer-token auth via `Authorization: Bearer <token>`, constant-time
33
+ # compare (ActiveSupport::SecurityUtils.secure_compare). 401 returned
34
+ # uniformly for missing / malformed / wrong token so callers can't
35
+ # distinguish failure modes.
36
+ # * `:hash` URL param is matched against an allowlist of the current
37
+ # deployment's actual bundle hashes — anything else returns 404. The
38
+ # hash never touches the filesystem layer.
39
+ # * Responses include `Cache-Control: no-store` so a misconfigured
40
+ # intermediary doesn't cache the bundle behind the auth check.
41
+ # * Uses `protect_from_forgery with: :exception` (the Rails default)
42
+ # rather than `:null_session`. CodeQL flags `:null_session` as a
43
+ # weakened CSRF strategy, and an `ActionController::API` controller
44
+ # with no `protect_from_forgery` at all as missing protection — both
45
+ # are false positives here (this is a GET-only bearer-token API, so
46
+ # CSRF never actually fires regardless of strategy), but `:exception`
47
+ # on `ActionController::Base` is the form CodeQL accepts. The check
48
+ # is a no-op at runtime because Rails only invokes
49
+ # `verify_authenticity_token` on non-GET requests.
50
+ class BundlesController < ActionController::Base
51
+ protect_from_forgery with: :exception
52
+
53
+ before_action :authenticate_rolling_deploy_request
54
+ before_action :set_no_store_headers
55
+
56
+ DEFAULT_ROUTE_PREFIX = "react_on_rails_pro_rolling_deploy"
57
+
58
+ class << self
59
+ # Helper for users who want to mount manually under a custom path. The
60
+ # auto-mount path uses these same route definitions via the engine
61
+ # initializer (see ReactOnRailsPro::Engine).
62
+ #
63
+ # `as_prefix:` controls the generated named-route helpers
64
+ # (`<prefix>_manifest`, `<prefix>_bundle`). Callers that mount the
65
+ # controller more than once (e.g. auto-mount plus a secondary user
66
+ # mount) must pass distinct prefixes so the Rails route registry
67
+ # doesn't raise on duplicate names.
68
+ def draw_routes(mapper, path:, as_prefix: DEFAULT_ROUTE_PREFIX)
69
+ mapper.get("#{path}/manifest",
70
+ to: "react_on_rails_pro/rolling_deploy/bundles#manifest",
71
+ as: :"#{as_prefix}_manifest")
72
+ mapper.get("#{path}/bundles/:hash",
73
+ to: "react_on_rails_pro/rolling_deploy/bundles#show",
74
+ constraints: { hash: SAFE_HASH_PATTERN },
75
+ as: :"#{as_prefix}_bundle")
76
+ end
77
+ end
78
+
79
+ # Defense-in-depth: even if the route constraint somehow let a
80
+ # path-traversal value through, the controller still rejects it
81
+ # before any disk lookup because the hash must be in the
82
+ # (regex-validated) current-hash set.
83
+ SAFE_HASH_PATTERN = ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN
84
+
85
+ # Tarball entry name reserved for the server bundle. Companion assets
86
+ # whose basename collides with this are skipped to keep the receiver
87
+ # from extracting the wrong bytes into the bundle slot.
88
+ #
89
+ # Wire-format constant: must stay in sync with
90
+ # `ReactOnRailsPro::RollingDeployAdapters::Http::BUNDLE_ENTRY_NAME`. If
91
+ # one side bumps the entry name (e.g. a protocol version change) the
92
+ # other must follow or the client extraction will fail to find the
93
+ # bundle file.
94
+ BUNDLE_ENTRY_NAME = "bundle.js"
95
+
96
+ PROTOCOL_VERSION = 1
97
+
98
+ def manifest
99
+ sources = safe_current_bundle_sources
100
+ render json: {
101
+ hashes: sources.map { |_, hash| hash },
102
+ rsc_enabled: ReactOnRailsPro.configuration.enable_rsc_support,
103
+ generated_at: Time.now.utc.iso8601,
104
+ protocol_version: PROTOCOL_VERSION
105
+ }
106
+ end
107
+
108
+ def show
109
+ hash = params[:hash].to_s
110
+ # Defense in depth — route constraint should already enforce this,
111
+ # but we also reject any value that slipped past it before any
112
+ # filesystem operation looks at it.
113
+ return head(:not_found) unless SAFE_HASH_PATTERN.match?(hash)
114
+
115
+ sources = safe_current_bundle_sources
116
+ match = sources.find { |_, h| h == hash }
117
+ return head(:not_found) unless match
118
+
119
+ bundle_path, _matched_hash = match
120
+ serve_bundle_tarball(bundle_path)
121
+ end
122
+
123
+ private
124
+
125
+ def authenticate_rolling_deploy_request
126
+ configured = ReactOnRailsPro.configuration.rolling_deploy_token.to_s
127
+ # If the controller is reached without a configured token, refuse
128
+ # unconditionally. This is defense-in-depth — the engine should not
129
+ # mount the controller in that state — but it makes the no-token
130
+ # mode a hard fail rather than an open endpoint.
131
+ return head(:unauthorized) if configured.empty?
132
+
133
+ provided = extract_bearer_token(request.headers["Authorization"])
134
+ return head(:unauthorized) if provided.empty?
135
+
136
+ # `secure_compare` raises ArgumentError when the two strings differ
137
+ # in length, so we gate on bytesize first. This does leak whether
138
+ # the provided token has the correct byte length, but the
139
+ # configuration validator enforces a minimum of 32 bytes and
140
+ # operators are advised to use `SecureRandom.hex(32)` (64 bytes),
141
+ # so the only information exposed is the token's exact byte length
142
+ # — not any of its bits. The body of the comparison is constant-time
143
+ # via `secure_compare` regardless of which byte differs.
144
+ match = provided.bytesize == configured.bytesize &&
145
+ ActiveSupport::SecurityUtils.secure_compare(provided, configured)
146
+ head(:unauthorized) unless match
147
+ end
148
+
149
+ def extract_bearer_token(header)
150
+ return "" if header.blank?
151
+ return "" unless header.start_with?("Bearer ", "bearer ")
152
+
153
+ # Take the token bytes verbatim. We deliberately do not `.strip` here
154
+ # because the configured side is compared without stripping — if an
155
+ # operator misconfigures a token with trailing whitespace, an
156
+ # asymmetric strip would silently authenticate a shorter token.
157
+ header[7..].to_s
158
+ end
159
+
160
+ def set_no_store_headers
161
+ response.headers["Cache-Control"] = "no-store"
162
+ response.headers["Pragma"] = "no-cache"
163
+ response.headers["X-Content-Type-Options"] = "nosniff"
164
+ end
165
+
166
+ # Wraps bundle_sources to absorb the "bundle file not present yet" case
167
+ # so the manifest endpoint can still 200 with an empty hashes list during
168
+ # the brief window after Rails boots but before assets:precompile has
169
+ # produced the bundle on this dyno. Returns `[]` rather than raising so
170
+ # the build-CI side sees "this server has nothing to seed" instead of a
171
+ # 500 that would otherwise show up as a noisy deploy alert.
172
+ def safe_current_bundle_sources
173
+ unless ReactOnRailsPro.configuration.node_renderer?
174
+ Rails.logger.warn(
175
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
176
+ "node_renderer? is false — returning an empty manifest. " \
177
+ "Verify that the Pro configuration enables the node renderer; " \
178
+ "the HTTP rolling-deploy adapter only serves bundles when it does."
179
+ )
180
+ return []
181
+ end
182
+
183
+ pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
184
+ ReactOnRailsPro::RendererCacheHelpers.bundle_sources(pool, "serving rolling-deploy tarball")
185
+ rescue StandardError => e
186
+ Rails.logger.warn(
187
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
188
+ "bundle source discovery failed: #{e.class}: #{e.message}. " \
189
+ "Returning empty manifest — verify bundles have been precompiled."
190
+ )
191
+ []
192
+ end
193
+
194
+ def serve_bundle_tarball(bundle_path)
195
+ entries = tarball_entries(bundle_path)
196
+
197
+ ReactOnRailsPro::RollingDeploy::Tarball.compose_to_tempfile(entries) do |io|
198
+ # We've already buffered the tarball to a Tempfile inside
199
+ # compose_to_tempfile; send_data reads the contents once. For very
200
+ # large bundles a streaming send via ActionController::Live would
201
+ # save memory; that's deferred to a follow-up PR — the current
202
+ # default ceiling (200 MB) fits comfortably in memory on every
203
+ # Rails app instance we'd expect to deploy.
204
+ send_data io.read,
205
+ type: "application/gzip",
206
+ disposition: "inline",
207
+ filename: "#{params[:hash]}.tar.gz"
208
+ end
209
+ end
210
+
211
+ # Pairs the bundle file (renamed to `bundle.js` on the wire) with the
212
+ # current build's companion assets. Each tarball carries the full
213
+ # companion set so the receiver can stage a complete cache entry without
214
+ # a second round-trip — matching the rolling_deploy_adapter contract
215
+ # that `fetch(hash)` returns bundle + assets together.
216
+ #
217
+ # Companions are skipped (with a warning) when they would shadow the
218
+ # bundle entry, collide with another companion's basename, or carry a
219
+ # name that the tarball helper would reject during compose. This
220
+ # matches the publish-side behavior in AssetsPrecompile where missing
221
+ # or unsafe assets degrade rather than fail the build.
222
+ def tarball_entries(bundle_path)
223
+ entries = { BUNDLE_ENTRY_NAME => bundle_path }
224
+ companion_assets.each do |asset_path|
225
+ name = File.basename(asset_path)
226
+ next if skip_companion?(name, asset_path, entries)
227
+
228
+ entries[name] = asset_path
229
+ end
230
+ entries
231
+ end
232
+
233
+ def skip_companion?(name, asset_path, entries)
234
+ if name == BUNDLE_ENTRY_NAME
235
+ warn_companion_skipped(
236
+ "companion #{asset_path.inspect} basename collides with bundle entry #{BUNDLE_ENTRY_NAME.inspect}"
237
+ )
238
+ return true
239
+ end
240
+ unless ReactOnRailsPro::RollingDeploy::Tarball::ENTRY_NAME_PATTERN.match?(name)
241
+ warn_companion_skipped(
242
+ "companion #{asset_path.inspect} basename #{name.inspect} is not a safe tarball entry name"
243
+ )
244
+ return true
245
+ end
246
+ if entries.key?(name)
247
+ warn_companion_skipped(
248
+ "duplicate companion basename #{name.inspect}; " \
249
+ "keeping #{entries[name].inspect}, dropping #{asset_path.inspect}"
250
+ )
251
+ return true
252
+ end
253
+ false
254
+ end
255
+
256
+ def warn_companion_skipped(message)
257
+ Rails.logger.warn("[ReactOnRailsPro::RollingDeploy::BundlesController] #{message}.")
258
+ end
259
+
260
+ def companion_assets
261
+ rails_root = File.expand_path(Rails.root.to_s)
262
+ rails_root_realpath = File.realpath(rails_root)
263
+
264
+ # `collect_assets` returns the live build's loadable-stats + RSC
265
+ # manifests; missing assets are silently dropped to match the
266
+ # publish-side behavior in AssetsPrecompile.
267
+ ReactOnRailsPro::RendererCacheHelpers.collect_assets
268
+ .map(&:to_s)
269
+ .reject { |p| ReactOnRailsPro::RendererCacheHelpers.http_url?(p) }
270
+ .filter_map do |path|
271
+ safe_companion_asset_path(path, rails_root, rails_root_realpath)
272
+ end
273
+ rescue StandardError => e
274
+ Rails.logger.warn(
275
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
276
+ "companion asset discovery failed: #{e.class}: #{e.message}. " \
277
+ "Serving bundle without companion assets — RSC clients may fall back to runtime 410-retry."
278
+ )
279
+ []
280
+ end
281
+
282
+ def safe_companion_asset_path(path, rails_root, rails_root_realpath)
283
+ expanded = File.expand_path(path, rails_root)
284
+ return nil unless path_within_root?(expanded, rails_root)
285
+ return nil unless File.file?(expanded)
286
+
287
+ realpath = File.realpath(expanded)
288
+ return nil unless path_within_root?(realpath, rails_root_realpath)
289
+
290
+ expanded
291
+ end
292
+
293
+ def path_within_root?(path, root)
294
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
295
+ end
296
+ end
297
+ end
298
+ end