react_on_rails_pro 16.7.0.rc.2 → 17.0.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 (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 +3 -2
  6. data/Gemfile.lock +34 -13
  7. data/README.md +2 -3
  8. data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +299 -0
  9. data/lib/react_on_rails_pro/configuration.rb +203 -55
  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: 318109f164359903fe25daa4f3b63cbd4c6814e0f2cc34d4c91c21bb19ec9590
4
+ data.tar.gz: b45033f1471e37ee756825b4aa00238229416011efa4054151a58bf942ca9a03
5
5
  SHA512:
6
- metadata.gz: 206fba54350de7c2423b02c4a1a366674030ab178db0007b52e26a638c867d4cb164d8120b9281f90694b046b241e1ff5ed98769483636c05b41825086d92d91
7
- data.tar.gz: a93ea4c57242bbf7d272856e2bbba8379af3e19c2dd29ee0901bcd60f4b296d27ea3b2eb6136f38d5a726601ed2c6967d9c8512deff149f1f1d10f637825f43d
6
+ metadata.gz: 900bc242f0c60afaa7201215e2d9306f1f41d5a5c2f22fa11c8bbcd0e49c3ed54bdfaa59568ddefea8736e3d2719e42a1bff591e9eb11084bb2b566e72013a15
7
+ data.tar.gz: e8055b9d34b0255730974669b6b1926924e34e14029d478bb62cc49830bdd0e9bfd261670b43f488bb7781a5c7c137a44208d0a20a81f1c7c97bbeef82662749
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) |
@@ -8,7 +8,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
8
8
 
9
9
  gem "react_on_rails", path: "../"
10
10
 
11
- gem "shakapacker", "9.6.1"
11
+ gem "shakapacker", "10.1.0"
12
12
  gem "bootsnap", require: false
13
13
  gem "rails", "~> 7.1"
14
14
  gem "puma", "~> 6"
@@ -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 (17.0.0.rc.0)
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 (17.0.0.rc.0)
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 (= 17.0.0.rc.0)
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)
@@ -276,7 +296,7 @@ GEM
276
296
  nio4r (~> 2.0)
277
297
  racc (1.8.1)
278
298
  rack (3.2.5)
279
- rack-proxy (0.7.7)
299
+ rack-proxy (0.8.2)
280
300
  rack
281
301
  rack-session (2.1.1)
282
302
  base64 (>= 0.1.0)
@@ -405,7 +425,7 @@ GEM
405
425
  rubyzip (>= 1.2.2, < 3.0)
406
426
  websocket (~> 1.0)
407
427
  semantic_range (3.1.1)
408
- shakapacker (9.6.1)
428
+ shakapacker (10.1.0)
409
429
  activesupport (>= 5.2)
410
430
  package_json
411
431
  rack-proxy (>= 0.6.1)
@@ -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
@@ -518,7 +539,7 @@ DEPENDENCIES
518
539
  sass-rails
519
540
  scss_lint
520
541
  selenium-webdriver (= 4.9.0)
521
- shakapacker (= 9.6.1)
542
+ shakapacker (= 10.1.0)
522
543
  simplecov (~> 0.16.1)
523
544
  spring
524
545
  spring-watcher-listen
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,299 @@
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
+ # Mount this in your application's routes with `draw_routes` so the Http
17
+ # adapter on the next deploy can reach it. (Engine auto-mount keyed on
18
+ # `config.rolling_deploy_adapter` is planned for a follow-up but is not
19
+ # wired yet, so an explicit mount is currently required.) You can also use
20
+ # a custom mount path or layer your own auth middleware:
21
+ #
22
+ # # config/routes.rb
23
+ # ReactOnRailsPro::RollingDeploy::BundlesController.draw_routes(
24
+ # self,
25
+ # path: "/internal/rolling-deploy"
26
+ # )
27
+ #
28
+ # Callers that mount the controller more than once (for example, a future
29
+ # engine auto-mount plus a user-controlled secondary path) must pass a
30
+ # distinct `as_prefix:` per call so Rails' named-route registry doesn't
31
+ # raise `ArgumentError: Invalid route name, already in use`.
32
+ #
33
+ # Security:
34
+ # * Bearer-token auth via `Authorization: Bearer <token>`, constant-time
35
+ # compare (ActiveSupport::SecurityUtils.secure_compare). 401 returned
36
+ # uniformly for missing / malformed / wrong token so callers can't
37
+ # distinguish failure modes.
38
+ # * `:hash` URL param is matched against an allowlist of the current
39
+ # deployment's actual bundle hashes — anything else returns 404. The
40
+ # hash never touches the filesystem layer.
41
+ # * Responses include `Cache-Control: no-store` so a misconfigured
42
+ # intermediary doesn't cache the bundle behind the auth check.
43
+ # * Uses `protect_from_forgery with: :exception` (the Rails default)
44
+ # rather than `:null_session`. CodeQL flags `:null_session` as a
45
+ # weakened CSRF strategy, and an `ActionController::API` controller
46
+ # with no `protect_from_forgery` at all as missing protection — both
47
+ # are false positives here (this is a GET-only bearer-token API, so
48
+ # CSRF never actually fires regardless of strategy), but `:exception`
49
+ # on `ActionController::Base` is the form CodeQL accepts. The check
50
+ # is a no-op at runtime because Rails only invokes
51
+ # `verify_authenticity_token` on non-GET requests.
52
+ class BundlesController < ActionController::Base
53
+ protect_from_forgery with: :exception
54
+
55
+ before_action :authenticate_rolling_deploy_request
56
+ before_action :set_no_store_headers
57
+
58
+ DEFAULT_ROUTE_PREFIX = "react_on_rails_pro_rolling_deploy"
59
+
60
+ class << self
61
+ # Helper for mounting the controller in your application's routes. A
62
+ # planned engine auto-mount will reuse these same route definitions.
63
+ #
64
+ # `as_prefix:` controls the generated named-route helpers
65
+ # (`<prefix>_manifest`, `<prefix>_bundle`). Callers that mount the
66
+ # controller more than once (e.g. auto-mount plus a secondary user
67
+ # mount) must pass distinct prefixes so the Rails route registry
68
+ # doesn't raise on duplicate names.
69
+ def draw_routes(mapper, path:, as_prefix: DEFAULT_ROUTE_PREFIX)
70
+ mapper.get("#{path}/manifest",
71
+ to: "react_on_rails_pro/rolling_deploy/bundles#manifest",
72
+ as: :"#{as_prefix}_manifest")
73
+ mapper.get("#{path}/bundles/:hash",
74
+ to: "react_on_rails_pro/rolling_deploy/bundles#show",
75
+ constraints: { hash: SAFE_HASH_PATTERN },
76
+ as: :"#{as_prefix}_bundle")
77
+ end
78
+ end
79
+
80
+ # Defense-in-depth: even if the route constraint somehow let a
81
+ # path-traversal value through, the controller still rejects it
82
+ # before any disk lookup because the hash must be in the
83
+ # (regex-validated) current-hash set.
84
+ SAFE_HASH_PATTERN = ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN
85
+
86
+ # Tarball entry name reserved for the server bundle. Companion assets
87
+ # whose basename collides with this are skipped to keep the receiver
88
+ # from extracting the wrong bytes into the bundle slot.
89
+ #
90
+ # Wire-format constant: must stay in sync with
91
+ # `ReactOnRailsPro::RollingDeployAdapters::Http::BUNDLE_ENTRY_NAME`. If
92
+ # one side bumps the entry name (e.g. a protocol version change) the
93
+ # other must follow or the client extraction will fail to find the
94
+ # bundle file.
95
+ BUNDLE_ENTRY_NAME = "bundle.js"
96
+
97
+ PROTOCOL_VERSION = 1
98
+
99
+ def manifest
100
+ sources = safe_current_bundle_sources
101
+ render json: {
102
+ hashes: sources.map { |_, hash| hash },
103
+ rsc_enabled: ReactOnRailsPro.configuration.enable_rsc_support,
104
+ generated_at: Time.now.utc.iso8601,
105
+ protocol_version: PROTOCOL_VERSION
106
+ }
107
+ end
108
+
109
+ def show
110
+ hash = params[:hash].to_s
111
+ # Defense in depth — route constraint should already enforce this,
112
+ # but we also reject any value that slipped past it before any
113
+ # filesystem operation looks at it.
114
+ return head(:not_found) unless SAFE_HASH_PATTERN.match?(hash)
115
+
116
+ sources = safe_current_bundle_sources
117
+ match = sources.find { |_, h| h == hash }
118
+ return head(:not_found) unless match
119
+
120
+ bundle_path, _matched_hash = match
121
+ serve_bundle_tarball(bundle_path)
122
+ end
123
+
124
+ private
125
+
126
+ def authenticate_rolling_deploy_request
127
+ configured = ReactOnRailsPro.configuration.rolling_deploy_token.to_s
128
+ # If the controller is reached without a configured token, refuse
129
+ # unconditionally. This is defense-in-depth — the engine should not
130
+ # mount the controller in that state — but it makes the no-token
131
+ # mode a hard fail rather than an open endpoint.
132
+ return head(:unauthorized) if configured.empty?
133
+
134
+ provided = extract_bearer_token(request.headers["Authorization"])
135
+ return head(:unauthorized) if provided.empty?
136
+
137
+ # `secure_compare` raises ArgumentError when the two strings differ
138
+ # in length, so we gate on bytesize first. This does leak whether
139
+ # the provided token has the correct byte length, but the
140
+ # configuration validator enforces a minimum of 32 bytes and
141
+ # operators are advised to use `SecureRandom.hex(32)` (64 bytes),
142
+ # so the only information exposed is the token's exact byte length
143
+ # — not any of its bits. The body of the comparison is constant-time
144
+ # via `secure_compare` regardless of which byte differs.
145
+ match = provided.bytesize == configured.bytesize &&
146
+ ActiveSupport::SecurityUtils.secure_compare(provided, configured)
147
+ head(:unauthorized) unless match
148
+ end
149
+
150
+ def extract_bearer_token(header)
151
+ return "" if header.blank?
152
+ return "" unless header.start_with?("Bearer ", "bearer ")
153
+
154
+ # Take the token bytes verbatim. We deliberately do not `.strip` here
155
+ # because the configured side is compared without stripping — if an
156
+ # operator misconfigures a token with trailing whitespace, an
157
+ # asymmetric strip would silently authenticate a shorter token.
158
+ header[7..].to_s
159
+ end
160
+
161
+ def set_no_store_headers
162
+ response.headers["Cache-Control"] = "no-store"
163
+ response.headers["Pragma"] = "no-cache"
164
+ response.headers["X-Content-Type-Options"] = "nosniff"
165
+ end
166
+
167
+ # Wraps bundle_sources to absorb the "bundle file not present yet" case
168
+ # so the manifest endpoint can still 200 with an empty hashes list during
169
+ # the brief window after Rails boots but before assets:precompile has
170
+ # produced the bundle on this dyno. Returns `[]` rather than raising so
171
+ # the build-CI side sees "this server has nothing to seed" instead of a
172
+ # 500 that would otherwise show up as a noisy deploy alert.
173
+ def safe_current_bundle_sources
174
+ unless ReactOnRailsPro.configuration.node_renderer?
175
+ Rails.logger.warn(
176
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
177
+ "node_renderer? is false — returning an empty manifest. " \
178
+ "Verify that the Pro configuration enables the node renderer; " \
179
+ "the HTTP rolling-deploy adapter only serves bundles when it does."
180
+ )
181
+ return []
182
+ end
183
+
184
+ pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
185
+ ReactOnRailsPro::RendererCacheHelpers.bundle_sources(pool, "serving rolling-deploy tarball")
186
+ rescue StandardError => e
187
+ Rails.logger.warn(
188
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
189
+ "bundle source discovery failed: #{e.class}: #{e.message}. " \
190
+ "Returning empty manifest — verify bundles have been precompiled."
191
+ )
192
+ []
193
+ end
194
+
195
+ def serve_bundle_tarball(bundle_path)
196
+ entries = tarball_entries(bundle_path)
197
+
198
+ ReactOnRailsPro::RollingDeploy::Tarball.compose_to_tempfile(entries) do |io|
199
+ # We've already buffered the tarball to a Tempfile inside
200
+ # compose_to_tempfile; send_data reads the contents once. For very
201
+ # large bundles a streaming send via ActionController::Live would
202
+ # save memory; that's deferred to a follow-up PR — the current
203
+ # default ceiling (200 MB) fits comfortably in memory on every
204
+ # Rails app instance we'd expect to deploy.
205
+ send_data io.read,
206
+ type: "application/gzip",
207
+ disposition: "inline",
208
+ filename: "#{params[:hash]}.tar.gz"
209
+ end
210
+ end
211
+
212
+ # Pairs the bundle file (renamed to `bundle.js` on the wire) with the
213
+ # current build's companion assets. Each tarball carries the full
214
+ # companion set so the receiver can stage a complete cache entry without
215
+ # a second round-trip — matching the rolling_deploy_adapter contract
216
+ # that `fetch(hash)` returns bundle + assets together.
217
+ #
218
+ # Companions are skipped (with a warning) when they would shadow the
219
+ # bundle entry, collide with another companion's basename, or carry a
220
+ # name that the tarball helper would reject during compose. This
221
+ # matches the publish-side behavior in AssetsPrecompile where missing
222
+ # or unsafe assets degrade rather than fail the build.
223
+ def tarball_entries(bundle_path)
224
+ entries = { BUNDLE_ENTRY_NAME => bundle_path }
225
+ companion_assets.each do |asset_path|
226
+ name = File.basename(asset_path)
227
+ next if skip_companion?(name, asset_path, entries)
228
+
229
+ entries[name] = asset_path
230
+ end
231
+ entries
232
+ end
233
+
234
+ def skip_companion?(name, asset_path, entries)
235
+ if name == BUNDLE_ENTRY_NAME
236
+ warn_companion_skipped(
237
+ "companion #{asset_path.inspect} basename collides with bundle entry #{BUNDLE_ENTRY_NAME.inspect}"
238
+ )
239
+ return true
240
+ end
241
+ unless ReactOnRailsPro::RollingDeploy::Tarball::ENTRY_NAME_PATTERN.match?(name)
242
+ warn_companion_skipped(
243
+ "companion #{asset_path.inspect} basename #{name.inspect} is not a safe tarball entry name"
244
+ )
245
+ return true
246
+ end
247
+ if entries.key?(name)
248
+ warn_companion_skipped(
249
+ "duplicate companion basename #{name.inspect}; " \
250
+ "keeping #{entries[name].inspect}, dropping #{asset_path.inspect}"
251
+ )
252
+ return true
253
+ end
254
+ false
255
+ end
256
+
257
+ def warn_companion_skipped(message)
258
+ Rails.logger.warn("[ReactOnRailsPro::RollingDeploy::BundlesController] #{message}.")
259
+ end
260
+
261
+ def companion_assets
262
+ rails_root = File.expand_path(Rails.root.to_s)
263
+ rails_root_realpath = File.realpath(rails_root)
264
+
265
+ # `collect_assets` returns the live build's loadable-stats + RSC
266
+ # manifests; missing assets are silently dropped to match the
267
+ # publish-side behavior in AssetsPrecompile.
268
+ ReactOnRailsPro::RendererCacheHelpers.collect_assets
269
+ .map(&:to_s)
270
+ .reject { |p| ReactOnRailsPro::RendererCacheHelpers.http_url?(p) }
271
+ .filter_map do |path|
272
+ safe_companion_asset_path(path, rails_root, rails_root_realpath)
273
+ end
274
+ rescue StandardError => e
275
+ Rails.logger.warn(
276
+ "[ReactOnRailsPro::RollingDeploy::BundlesController] " \
277
+ "companion asset discovery failed: #{e.class}: #{e.message}. " \
278
+ "Serving bundle without companion assets — RSC clients may fall back to runtime 410-retry."
279
+ )
280
+ []
281
+ end
282
+
283
+ def safe_companion_asset_path(path, rails_root, rails_root_realpath)
284
+ expanded = File.expand_path(path, rails_root)
285
+ return nil unless path_within_root?(expanded, rails_root)
286
+ return nil unless File.file?(expanded)
287
+
288
+ realpath = File.realpath(expanded)
289
+ return nil unless path_within_root?(realpath, rails_root_realpath)
290
+
291
+ expanded
292
+ end
293
+
294
+ def path_within_root?(path, root)
295
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
296
+ end
297
+ end
298
+ end
299
+ end