react_on_rails_pro 16.7.0.rc.3 → 17.0.0.rc.1

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: 7a4266ac7cb6a9cdbb62755a17d95282222b6909e64d41ff7b7a7bcf897c2f0a
4
- data.tar.gz: 8372bde1ffd861b8f8026e6fa28f4a9f65130ec6c70a42cdfa09a33f6a449c5a
3
+ metadata.gz: d82436e63c02deff1774b11e01658f6f06a17bca6c583986aea35ac6f8b865de
4
+ data.tar.gz: e88f6d4495a06792faf61891d8aefe335323dd53461deb612412d5796d8ebe10
5
5
  SHA512:
6
- metadata.gz: 40ebe444b6abb44184984519f44a61df910f69e9e0fa73841ae552de49ba799ed872e8fc3bd8d8b0af8ca27c5c88946fa8abc28278fe42ac0603ac3d5de8cfde
7
- data.tar.gz: fd3568d5daff73971afddf96575237d2e07a738c972c1f0067e6ac8dc32ed4923c3dbdcf41fbe8dc06cb9d446c48b8544fb30e7ede7e8a57f9606fa2ec25dcc7
6
+ metadata.gz: 4867d95e20f8e7bb15732ea1d749d34f49e8232a8a16b4d8ce5c2203ada419d2d5815569208b410e3f6f3e01ae7dd842896922e686aae5f49edca13de9dd61f6
7
+ data.tar.gz: 40720afca29e6f6ef1a527d9674adf81bde008edc6b4da86cafc009e2c2ef6e75b6c338e3f4ff4a4ef9dd7d5924aba3d81b5a313eff195bafc0de2750f361752
@@ -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"
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.3)
12
+ react_on_rails (17.0.0.rc.1)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,7 +20,7 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (16.7.0.rc.3)
23
+ react_on_rails_pro (17.0.0.rc.1)
24
24
  addressable
25
25
  async (>= 2.29)
26
26
  async-http (~> 0.95)
@@ -28,7 +28,7 @@ PATH
28
28
  io-endpoint (~> 0.17.0)
29
29
  jwt (>= 2.5, < 4)
30
30
  rainbow
31
- react_on_rails (= 16.7.0.rc.3)
31
+ react_on_rails (= 17.0.0.rc.1)
32
32
 
33
33
  GEM
34
34
  remote: https://rubygems.org/
@@ -296,7 +296,7 @@ GEM
296
296
  nio4r (~> 2.0)
297
297
  racc (1.8.1)
298
298
  rack (3.2.5)
299
- rack-proxy (0.7.7)
299
+ rack-proxy (0.8.2)
300
300
  rack
301
301
  rack-session (2.1.1)
302
302
  base64 (>= 0.1.0)
@@ -425,7 +425,7 @@ GEM
425
425
  rubyzip (>= 1.2.2, < 3.0)
426
426
  websocket (~> 1.0)
427
427
  semantic_range (3.1.1)
428
- shakapacker (9.6.1)
428
+ shakapacker (10.1.0)
429
429
  activesupport (>= 5.2)
430
430
  package_json
431
431
  rack-proxy (>= 0.6.1)
@@ -539,7 +539,7 @@ DEPENDENCIES
539
539
  sass-rails
540
540
  scss_lint
541
541
  selenium-webdriver (= 4.9.0)
542
- shakapacker (= 9.6.1)
542
+ shakapacker (= 10.1.0)
543
543
  simplecov (~> 0.16.1)
544
544
  spring
545
545
  spring-watcher-listen
@@ -13,9 +13,12 @@ module ReactOnRailsPro
13
13
  # ReactOnRailsPro::RollingDeployAdapters::Http adapter on the next
14
14
  # deploy's build CI consumes both endpoints.
15
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:
16
+ # When `config.rolling_deploy_adapter` is the built-in Http adapter, the
17
+ # Pro engine auto-mounts this controller at
18
+ # `config.rolling_deploy_mount_path` (default:
19
+ # `/react_on_rails_pro/rolling_deploy`). Set the mount path to nil or blank
20
+ # to opt out of the auto-mount. Use `draw_routes` only when you need a
21
+ # manual mount, such as a secondary path or app-specific routing wrapper:
19
22
  #
20
23
  # # config/routes.rb
21
24
  # ReactOnRailsPro::RollingDeploy::BundlesController.draw_routes(
@@ -23,10 +26,11 @@ module ReactOnRailsPro
23
26
  # path: "/internal/rolling-deploy"
24
27
  # )
25
28
  #
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`.
29
+ # The engine auto-mount uses an internal route-helper prefix so existing
30
+ # manual mounts that use the default prefix keep booting during upgrades.
31
+ # Multiple manual mounts still need distinct `as_prefix:` values so Rails'
32
+ # named-route registry doesn't raise `ArgumentError: Invalid route name,
33
+ # already in use`.
30
34
  #
31
35
  # Security:
32
36
  # * Bearer-token auth via `Authorization: Bearer <token>`, constant-time
@@ -54,11 +58,19 @@ module ReactOnRailsPro
54
58
  before_action :set_no_store_headers
55
59
 
56
60
  DEFAULT_ROUTE_PREFIX = "react_on_rails_pro_rolling_deploy"
61
+ SAFE_HASH_PATTERN = ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN
62
+ # Rails route requirements reject anchor characters, while the route
63
+ # matcher applies segment constraints to the full segment. Derived from
64
+ # SAFE_HASH_PATTERN by stripping the \A/\z anchors; the controller still
65
+ # performs the anchored defense-in-depth validation before any filesystem
66
+ # lookup.
67
+ ROUTE_HASH_PATTERN = Regexp.new(SAFE_HASH_PATTERN.source.delete_prefix("\\A").delete_suffix("\\z"))
57
68
 
58
69
  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).
70
+ # Helper for manual route mounts. The Pro engine uses these same route
71
+ # definitions for the default auto-mount when the built-in Http adapter
72
+ # is configured, with an internal `as_prefix:` to avoid collisions with
73
+ # existing manual mounts.
62
74
  #
63
75
  # `as_prefix:` controls the generated named-route helpers
64
76
  # (`<prefix>_manifest`, `<prefix>_bundle`). Callers that mount the
@@ -71,7 +83,7 @@ module ReactOnRailsPro
71
83
  as: :"#{as_prefix}_manifest")
72
84
  mapper.get("#{path}/bundles/:hash",
73
85
  to: "react_on_rails_pro/rolling_deploy/bundles#show",
74
- constraints: { hash: SAFE_HASH_PATTERN },
86
+ constraints: { hash: ROUTE_HASH_PATTERN },
75
87
  as: :"#{as_prefix}_bundle")
76
88
  end
77
89
  end
@@ -80,8 +92,6 @@ module ReactOnRailsPro
80
92
  # path-traversal value through, the controller still rejects it
81
93
  # before any disk lookup because the hash must be in the
82
94
  # (regex-validated) current-hash set.
83
- SAFE_HASH_PATTERN = ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN
84
-
85
95
  # Tarball entry name reserved for the server bundle. Companion assets
86
96
  # whose basename collides with this are skipped to keep the receiver
87
97
  # from extracting the wrong bytes into the bundle slot.
@@ -180,6 +180,8 @@ module ReactOnRailsPro
180
180
  self.rolling_deploy_adapter = rolling_deploy_adapter
181
181
  self.rolling_deploy_token = rolling_deploy_token
182
182
  self.rolling_deploy_previous_url = rolling_deploy_previous_url
183
+ # Constructor nil/blank means "use the default"; configure-block assignment
184
+ # can still set nil/blank later to opt out of the engine auto-mount.
183
185
  self.rolling_deploy_mount_path = rolling_deploy_mount_path.presence || DEFAULT_ROLLING_DEPLOY_MOUNT_PATH
184
186
  self.ssr_pre_hook_js = ssr_pre_hook_js
185
187
  self.assets_to_copy = assets_to_copy
@@ -298,10 +300,13 @@ module ReactOnRailsPro
298
300
 
299
301
  def validate_url
300
302
  URI(renderer_url)
301
- rescue URI::InvalidURIError => e
302
- message = "Unparseable ReactOnRailsPro.config.renderer_url #{renderer_url} provided.\n" \
303
- "#{e.message}"
304
- raise ReactOnRailsPro::Error, message
303
+ rescue URI::InvalidURIError
304
+ # Deliberately do NOT echo renderer_url or the URI error message: a
305
+ # malformed renderer_url may embed credentials (https://:password@host),
306
+ # and reproducing it here would leak the password into logs/error reporters.
307
+ raise ReactOnRailsPro::Error,
308
+ "ReactOnRailsPro.config.renderer_url is not a parseable URI. Verify the value " \
309
+ "(it is not echoed here because it may contain credentials)."
305
310
  end
306
311
 
307
312
  def validate_remote_bundle_cache_adapter
@@ -362,7 +367,7 @@ module ReactOnRailsPro
362
367
  "config.rolling_deploy_token is required when using the built-in " \
363
368
  "ReactOnRailsPro::RollingDeployAdapters::Http adapter. Generate one " \
364
369
  "with SecureRandom.hex(32) and set it on both Rails and your build CI. " \
365
- "See docs/pro/rolling-deploy-http-adapter.md."
370
+ "See docs/pro/rolling-deploy-adapters.md."
366
371
  end
367
372
  # Compare on `bytesize` (not `length`) so the validator matches the
368
373
  # byte-level constant-time check in `BundlesController#authenticate_rolling_deploy_request`.
@@ -376,7 +381,7 @@ module ReactOnRailsPro
376
381
  "config.rolling_deploy_token must be at least " \
377
382
  "#{ROLLING_DEPLOY_TOKEN_MIN_LENGTH} bytes (got #{token.bytesize}). " \
378
383
  "Generate a stronger token with SecureRandom.hex(32). " \
379
- "See docs/pro/rolling-deploy-http-adapter.md."
384
+ "See docs/pro/rolling-deploy-adapters.md."
380
385
  end
381
386
 
382
387
  def validate_rolling_deploy_upload_signature
@@ -450,67 +455,108 @@ module ReactOnRailsPro
450
455
  end
451
456
 
452
457
  def setup_renderer_password
458
+ resolve_renderer_password
459
+ # The password is sent to the Node Renderer in the request body, never via
460
+ # the URL (the HTTP client connects to scheme://host:port and the renderer
461
+ # authenticates on the body field). A password embedded in renderer_url is
462
+ # purely a Rails-side config convenience — once resolved above, strip it
463
+ # from the stored URL so the credential can never leak through any log line
464
+ # or error message that interpolates renderer_url downstream.
465
+ strip_renderer_url_userinfo
466
+ end
467
+
468
+ def resolve_renderer_password
453
469
  # Explicit passwords, including values loaded from ENV in the initializer, skip URL extraction.
454
470
  # Blank values (nil or "") fall through so URL extraction and ENV fallback still apply.
455
471
  return if renderer_password.present?
456
472
 
457
- uri = URI(renderer_url)
458
- self.renderer_password = uri.password
473
+ self.renderer_password = URI(renderer_url).password
459
474
 
460
475
  # Mirror Node-side defaults: if Rails config and URL are both missing a password,
461
476
  # use RENDERER_PASSWORD from env.
462
477
  self.renderer_password = ENV.fetch("RENDERER_PASSWORD", nil) if renderer_password.blank?
463
478
  end
464
479
 
480
+ def strip_renderer_url_userinfo
481
+ return if renderer_url.blank?
482
+
483
+ uri = URI(renderer_url)
484
+ return if uri.userinfo.nil?
485
+
486
+ # Order matters: URI rejects a password without a user, so clear password first.
487
+ uri.password = nil
488
+ uri.user = nil
489
+ self.renderer_url = uri.to_s
490
+ end
491
+
492
+ KNOWN_WEAK_RENDERER_PASSWORDS = %w[
493
+ devPassword myPassword1 password changeme admin secret test renderer
494
+ ].to_set(&:downcase).freeze
495
+
496
+ MIN_RENDERER_PASSWORD_LENGTH = 16
497
+
465
498
  def validate_renderer_password_for_production
466
- # Defense-in-depth: skip validation when a password is already configured (e.g. extracted
467
- # from the renderer URL by setup_renderer_password, or set directly in the initializer).
468
- return if renderer_password.present?
469
499
  return unless node_renderer?
470
500
 
471
- # Fail closed: only skip validation when every present runtime env is explicitly
472
- # development or test. This mirrors the Node-side runtimeEnvsAllowDevelopmentDefaults()
473
- # which checks both NODE_ENV and RAILS_ENV. Checking NODE_ENV here surfaces
474
- # misconfigurations (e.g. NODE_ENV=production + RAILS_ENV=development) at Rails boot
475
- # time rather than waiting for the Node renderer to reject the request.
476
501
  runtime_envs = [ENV.fetch("RAILS_ENV", nil), ENV.fetch("NODE_ENV", nil)].compact_blank.map(&:downcase)
477
502
  allowed_envs = %w[development test].freeze
478
- return if runtime_envs.any? && runtime_envs.all? { |e| allowed_envs.include?(e) }
503
+ is_production_like = !(runtime_envs.any? && runtime_envs.all? { |e| allowed_envs.include?(e) })
479
504
 
480
- raise ReactOnRailsPro::Error, <<~MSG
481
- RENDERER_PASSWORD must be set in production-like environments (staging, production, etc.)
482
- when using the NodeRenderer.
505
+ if renderer_password.blank?
506
+ return unless is_production_like
483
507
 
484
- In development and test environments, the renderer password is optional and no authentication
485
- is required. In all other environments, you must explicitly configure a password to secure
486
- communication between Rails and the Node Renderer.
508
+ raise ReactOnRailsPro::Error, <<~MSG
509
+ RENDERER_PASSWORD must be set in production-like environments (staging, production, etc.)
510
+ when using the NodeRenderer.
487
511
 
488
- To fix this, set the RENDERER_PASSWORD environment variable:
512
+ In development and test environments, the renderer password is optional and no authentication
513
+ is required. In all other environments, you must explicitly configure a password to secure
514
+ communication between Rails and the Node Renderer.
489
515
 
490
- export RENDERER_PASSWORD="your-secure-password"
516
+ To fix this, set the RENDERER_PASSWORD environment variable:
491
517
 
492
- Rails reads it automatically. If you prefer to make it explicit in your initializer:
518
+ export RENDERER_PASSWORD="your-secure-password"
493
519
 
494
- # config/initializers/react_on_rails_pro.rb
495
- ReactOnRailsPro.configure do |config|
496
- config.renderer_password = ENV.fetch("RENDERER_PASSWORD")
497
- end
520
+ Rails reads it automatically. If you prefer to make it explicit in your initializer:
498
521
 
499
- Set the same password for the Node Renderer via the RENDERER_PASSWORD environment variable.
500
- Rails resolves the password in this order:
501
- 1) config.renderer_password (blank values fall through to the next step)
502
- 2) Password embedded in config.renderer_url (for example, https://:password@host:3800)
503
- 3) ENV["RENDERER_PASSWORD"]
522
+ # config/initializers/react_on_rails_pro.rb
523
+ ReactOnRailsPro.configure do |config|
524
+ config.renderer_password = ENV.fetch("RENDERER_PASSWORD")
525
+ end
504
526
 
505
- If Rails and the Node Renderer disagree about startup behavior, verify both RAILS_ENV and NODE_ENV.
527
+ Set the same password for the Node Renderer via the RENDERER_PASSWORD environment variable.
528
+ Rails resolves the password in this order:
529
+ 1) config.renderer_password (blank values fall through to the next step)
530
+ 2) Password embedded in config.renderer_url (for example, https://:password@host:3800)
531
+ 3) ENV["RENDERER_PASSWORD"]
506
532
 
507
- Environment matrix (both RAILS_ENV and NODE_ENV are checked):
508
- development/test — password optional when every set env is development or test
509
- (both unset) — treated as production-like; RENDERER_PASSWORD required
510
- staging RENDERER_PASSWORD required
511
- production — RENDERER_PASSWORD required
512
- (mixed envs) — RENDERER_PASSWORD required (e.g. NODE_ENV=production + RAILS_ENV=development)
513
- MSG
533
+ If Rails and the Node Renderer disagree about startup behavior, verify both RAILS_ENV and NODE_ENV.
534
+
535
+ Environment matrix (both RAILS_ENV and NODE_ENV are checked):
536
+ development/test password optional when every set env is development or test
537
+ (both unset) treated as production-like; RENDERER_PASSWORD required
538
+ staging — RENDERER_PASSWORD required
539
+ production — RENDERER_PASSWORD required
540
+ (mixed envs) — RENDERER_PASSWORD required (e.g. NODE_ENV=production + RAILS_ENV=development)
541
+ MSG
542
+ end
543
+
544
+ warn_if_renderer_password_weak
545
+ end
546
+
547
+ def warn_if_renderer_password_weak
548
+ if KNOWN_WEAK_RENDERER_PASSWORDS.include?(renderer_password.downcase)
549
+ # Don't log the literal value — even a known-default value is the
550
+ # user's *current* live credential until they rotate it.
551
+ Rails.logger.warn "[react_on_rails_pro] renderer_password matches a known-default value. " \
552
+ "Set RENDERER_PASSWORD to a random value of at least " \
553
+ "#{MIN_RENDERER_PASSWORD_LENGTH} characters."
554
+ elsif renderer_password.length < MIN_RENDERER_PASSWORD_LENGTH
555
+ Rails.logger.warn "[react_on_rails_pro] renderer_password is shorter than " \
556
+ "#{MIN_RENDERER_PASSWORD_LENGTH} characters " \
557
+ "(current length: #{renderer_password.length}). " \
558
+ "Consider using a stronger password."
559
+ end
514
560
  end
515
561
  end
516
562
  end
@@ -7,13 +7,30 @@ module ReactOnRailsPro
7
7
  LICENSE_URL = "https://pro.reactonrails.com/"
8
8
  # TODO: Remove this legacy migration warning path after 16.5.0 stable release (target: 2026-05-31).
9
9
  LEGACY_LICENSE_FILE = "config/react_on_rails_pro_license.key"
10
+ ROLLING_DEPLOY_AUTO_ROUTE_PREFIX = "react_on_rails_pro_auto_rolling_deploy"
10
11
  private_constant :LICENSE_URL
11
12
  private_constant :LEGACY_LICENSE_FILE
13
+ private_constant :ROLLING_DEPLOY_AUTO_ROUTE_PREFIX
12
14
 
13
15
  initializer "react_on_rails_pro.routes" do
14
16
  ActionDispatch::Routing::Mapper.include ReactOnRailsPro::Routes
15
17
  end
16
18
 
19
+ initializer "react_on_rails_pro.rolling_deploy_routes" do |app|
20
+ app.routes.prepend do
21
+ pro_config = ReactOnRailsPro.configuration
22
+ mount_path = pro_config.rolling_deploy_mount_path
23
+
24
+ if pro_config.rolling_deploy_http_adapter? && mount_path.present?
25
+ ReactOnRailsPro::RollingDeploy::BundlesController.draw_routes(
26
+ self,
27
+ path: mount_path,
28
+ as_prefix: ROLLING_DEPLOY_AUTO_ROUTE_PREFIX
29
+ )
30
+ end
31
+ end
32
+ end
33
+
17
34
  # Check license status on Rails startup and log appropriately
18
35
  # App continues running regardless of license status
19
36
  initializer "react_on_rails_pro.check_license" do
@@ -3,7 +3,6 @@
3
3
  require "fileutils"
4
4
  require "securerandom"
5
5
  require "pathname"
6
- require "set"
7
6
 
8
7
  require "react_on_rails_pro/error"
9
8
 
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "json"
5
5
  require "net/http"
6
6
  require "openssl"
7
+ require "tempfile"
7
8
  require "uri"
8
9
 
9
10
  require "react_on_rails_pro/error"
@@ -21,7 +22,7 @@ module ReactOnRailsPro
21
22
  # The currently-deployed Rails server already has the bundles + companion
22
23
  # assets sitting on disk; this adapter pulls them via authenticated HTTP.
23
24
  #
24
- # Configuration (see docs/pro/rolling-deploy-http-adapter.md):
25
+ # Configuration (see docs/pro/rolling-deploy-adapters.md):
25
26
  #
26
27
  # ReactOnRailsPro.configure do |config|
27
28
  # config.rolling_deploy_adapter = ReactOnRailsPro::RollingDeployAdapters::Http
@@ -32,6 +33,7 @@ module ReactOnRailsPro
32
33
  # Error contract matches the rolling_deploy_adapter protocol: every
33
34
  # exception is caught and reported as a warning so a failed seed degrades
34
35
  # to the runtime 410-retry fallback rather than failing the build.
36
+ # rubocop:disable Metrics/ClassLength
35
37
  class Http
36
38
  # Per-request HTTP timeouts. The outer Timeout.timeout in
37
39
  # RollingDeployCacheStager bounds the total wall-clock budget (10s for
@@ -49,6 +51,12 @@ module ReactOnRailsPro
49
51
  # exhaust disk via a zip-bomb-style response.
50
52
  DEFAULT_MAX_SIZE = ReactOnRailsPro::RollingDeploy::Tarball::DEFAULT_MAX_SIZE
51
53
 
54
+ # Maximum compressed bytes accepted from /bundles/:hash before extract
55
+ # enforces DEFAULT_MAX_SIZE on the uncompressed tarball contents.
56
+ # Set near 1/4 of DEFAULT_MAX_SIZE: JS bundles typically decompress 3-5x,
57
+ # so a 50 MB wire payload that decompresses beyond 200 MB is anomalous.
58
+ COMPRESSED_BODY_CAP = 50 * 1024 * 1024
59
+
52
60
  LOG_PREFIX = "[ReactOnRailsPro::RollingDeployAdapters::Http]"
53
61
 
54
62
  # Wire-format constant: must stay in sync with
@@ -101,10 +109,13 @@ module ReactOnRailsPro
101
109
 
102
110
  dir = bundle_dir(bundle_hash)
103
111
  FileUtils.mkdir_p(dir)
104
- tarball_body = download_bundle_tarball(base, bundle_hash)
105
- return cleanup_and_return(dir, nil) if tarball_body.nil?
106
112
 
107
- extract_payload(tarball_body, dir, bundle_hash)
113
+ result = download_bundle_tarball(base, bundle_hash) do |tarball|
114
+ extract_payload(tarball, dir, bundle_hash)
115
+ end
116
+ return cleanup_and_return(dir, nil) if result.nil?
117
+
118
+ result
108
119
  rescue StandardError => e
109
120
  cleanup_and_return(dir, nil) if dir
110
121
  warn_and_return("fetch(#{bundle_hash.inspect}) failed: #{e.class}: #{e.message}", nil)
@@ -113,7 +124,7 @@ module ReactOnRailsPro
113
124
  # Intentional no-op. The running Rails server IS the artifact store —
114
125
  # bundle + companion assets are already on local disk where the
115
126
  # mountable BundlesController will serve them on the next deploy's
116
- # build CI. Documented in docs/pro/rolling-deploy-http-adapter.md.
127
+ # build CI. Documented in docs/pro/rolling-deploy-adapters.md.
117
128
  def upload(_bundle_hash, bundle:, assets:)
118
129
  # See class doc above.
119
130
  end
@@ -168,26 +179,77 @@ module ReactOnRailsPro
168
179
  end
169
180
 
170
181
  def download_bundle_tarball(base, bundle_hash)
171
- response = http_get(URI("#{base}/bundles/#{bundle_hash}"))
172
- unless response.is_a?(Net::HTTPSuccess)
173
- Rails.logger.warn(
174
- "#{LOG_PREFIX} bundles/#{bundle_hash} returned HTTP #{response.code}; skipping this hash."
175
- )
176
- return nil
182
+ Tempfile.create(["rolling-deploy-download-", ".tar.gz"]) do |tmp|
183
+ tmp.binmode
184
+ response = http_stream(URI("#{base}/bundles/#{bundle_hash}")) do |streaming_response|
185
+ unless streaming_response.is_a?(Net::HTTPSuccess)
186
+ Rails.logger.warn(
187
+ "#{LOG_PREFIX} bundles/#{bundle_hash} returned HTTP #{streaming_response.code}; skipping this hash."
188
+ )
189
+ # Drain the error body (capped) so Net::HTTP can finish the
190
+ # response cleanly. If the body itself exceeds the cap,
191
+ # `drain_response_body` raises and `fetch`'s rescue logs a second
192
+ # "fetch(...) failed" warning alongside the "skipping this hash"
193
+ # line above. Both lines are expected for an oversized error
194
+ # body — the pair signals "non-2xx, and the body was too large
195
+ # to drain," not a separate failure.
196
+ drain_response_body(streaming_response)
197
+ next
198
+ end
199
+
200
+ stream_response_body(streaming_response, tmp)
201
+ end
202
+ # Non-local return: exits `download_bundle_tarball`; Tempfile.create's
203
+ # ensure block still unlinks `tmp` before the method returns.
204
+ return nil unless response.is_a?(Net::HTTPSuccess)
205
+
206
+ tmp.flush
207
+ tmp.rewind
208
+ yield tmp
177
209
  end
178
- # `response.body` buffers the full compressed tarball into a single
179
- # Ruby String before `Tarball.extract` can enforce DEFAULT_MAX_SIZE on
180
- # uncompressed bytes. For v1 this is acceptable: build CI fetches are
181
- # infrequent, the 200 MB uncompressed ceiling fits in heap, and the
182
- # read timeout bounds slow responses. A future follow-up should stream
183
- # the body into a Tempfile with its own compressed-byte cap (mirroring
184
- # the controller's `compose_to_tempfile`) so an oversized wire payload
185
- # cannot fill build-CI heap before extraction aborts.
186
- response.body
187
210
  end
188
211
 
189
- def extract_payload(tarball_body, dir, bundle_hash)
190
- ReactOnRailsPro::RollingDeploy::Tarball.extract(tarball_body, dir, max_size: DEFAULT_MAX_SIZE)
212
+ def stream_response_body(response, io)
213
+ each_capped_body_chunk(response, context: "bundle body") { |chunk| io.write(chunk) }
214
+ end
215
+
216
+ # Reads and discards a non-success body solely to enforce the
217
+ # compressed-byte cap. No block is passed, so `each_capped_body_chunk`
218
+ # counts bytes without writing them anywhere.
219
+ def drain_response_body(response)
220
+ each_capped_body_chunk(response, context: "non-success response body")
221
+ end
222
+
223
+ def each_capped_body_chunk(response, context:)
224
+ bytes = 0
225
+ response.read_body do |chunk|
226
+ bytes += chunk.bytesize
227
+ # Strictly greater-than (`>`, not `>=`): exactly COMPRESSED_BODY_CAP
228
+ # bytes are allowed through, so the cap is an exclusive ceiling. The
229
+ # raise fires before `yield chunk`, so the offending chunk is counted
230
+ # but never written/drained — the Tempfile never sees more than
231
+ # COMPRESSED_BODY_CAP bytes.
232
+ if bytes > COMPRESSED_BODY_CAP
233
+ raise ReactOnRailsPro::Error,
234
+ "#{context} exceeded compressed body cap " \
235
+ "(#{compressed_body_cap_label}); aborting download"
236
+ end
237
+ yield chunk if block_given?
238
+ end
239
+ end
240
+
241
+ # Dynamic (not a constant) so specs that `stub_const` the cap to a small
242
+ # value still see the stubbed value reflected in the warning message.
243
+ def compressed_body_cap_label
244
+ megabyte_bytes = 1024 * 1024
245
+ megabytes = COMPRESSED_BODY_CAP / megabyte_bytes
246
+ return "#{megabytes} MB" if megabytes.positive? && megabytes * megabyte_bytes == COMPRESSED_BODY_CAP
247
+
248
+ "#{COMPRESSED_BODY_CAP} bytes"
249
+ end
250
+
251
+ def extract_payload(tarball_source, dir, bundle_hash)
252
+ ReactOnRailsPro::RollingDeploy::Tarball.extract(tarball_source, dir, max_size: DEFAULT_MAX_SIZE)
191
253
  bundle_path = File.join(dir, BUNDLE_ENTRY_NAME)
192
254
  unless File.file?(bundle_path)
193
255
  return cleanup_and_return(
@@ -225,18 +287,29 @@ module ReactOnRailsPro
225
287
  # connection pooling would force us to manage lifecycle / cleanup
226
288
  # across threads.
227
289
  def http_get(uri, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS)
290
+ http_for(uri, read_timeout: read_timeout).request(build_request(uri))
291
+ end
292
+
293
+ def http_stream(uri, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS, &block)
294
+ http_for(uri, read_timeout: read_timeout).request(build_request(uri), &block)
295
+ end
296
+
297
+ def build_request(uri)
228
298
  request = Net::HTTP::Get.new(uri.request_uri)
229
299
  token = configured_token
230
300
  request["Authorization"] = "Bearer #{token}" unless token.empty?
231
301
  request["Accept-Encoding"] = "identity" # tarball is already gzipped; don't double-compress
302
+ request
303
+ end
232
304
 
305
+ def http_for(uri, read_timeout:)
233
306
  http = Net::HTTP.new(uri.host, uri.port)
234
307
  http.use_ssl = (uri.scheme == "https")
235
308
  warn_plain_http_token(uri) unless http.use_ssl?
236
309
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
237
310
  http.open_timeout = DEFAULT_OPEN_TIMEOUT_SECONDS
238
311
  http.read_timeout = read_timeout
239
- http.request(request)
312
+ http
240
313
  end
241
314
 
242
315
  # Plain-HTTP guardrail. The full HTTPS-only guard lands in PR 2; until
@@ -260,5 +333,6 @@ module ReactOnRailsPro
260
333
  end
261
334
  end
262
335
  end
336
+ # rubocop:enable Metrics/ClassLength
263
337
  end
264
338
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactOnRailsPro
4
- VERSION = "16.7.0.rc.3"
4
+ VERSION = "17.0.0.rc.1"
5
5
  PROTOCOL_VERSION = "2.0.0"
6
6
  end
@@ -34,7 +34,7 @@ Gem::Specification.new do |s|
34
34
  s.executables = s.files.grep(%r{^exe/}) { |f| File.basename(f) }
35
35
  s.require_paths = ["lib"]
36
36
 
37
- s.required_ruby_version = ">= 3.3"
37
+ s.required_ruby_version = ">= 3.3.0"
38
38
 
39
39
  s.add_runtime_dependency "addressable"
40
40
  s.add_runtime_dependency "async", ">= 2.29"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react_on_rails_pro
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.7.0.rc.3
4
+ version: 17.0.0.rc.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -120,14 +120,14 @@ dependencies:
120
120
  requirements:
121
121
  - - '='
122
122
  - !ruby/object:Gem::Version
123
- version: 16.7.0.rc.3
123
+ version: 17.0.0.rc.1
124
124
  type: :runtime
125
125
  prerelease: false
126
126
  version_requirements: !ruby/object:Gem::Requirement
127
127
  requirements:
128
128
  - - '='
129
129
  - !ruby/object:Gem::Version
130
- version: 16.7.0.rc.3
130
+ version: 17.0.0.rc.1
131
131
  - !ruby/object:Gem::Dependency
132
132
  name: bundler
133
133
  requirement: !ruby/object:Gem::Requirement
@@ -311,7 +311,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
311
311
  requirements:
312
312
  - - ">="
313
313
  - !ruby/object:Gem::Version
314
- version: '3.3'
314
+ version: 3.3.0
315
315
  required_rubygems_version: !ruby/object:Gem::Requirement
316
316
  requirements:
317
317
  - - ">="