react_on_rails_pro 17.0.0.rc.0 → 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: 318109f164359903fe25daa4f3b63cbd4c6814e0f2cc34d4c91c21bb19ec9590
4
- data.tar.gz: b45033f1471e37ee756825b4aa00238229416011efa4054151a58bf942ca9a03
3
+ metadata.gz: d82436e63c02deff1774b11e01658f6f06a17bca6c583986aea35ac6f8b865de
4
+ data.tar.gz: e88f6d4495a06792faf61891d8aefe335323dd53461deb612412d5796d8ebe10
5
5
  SHA512:
6
- metadata.gz: 900bc242f0c60afaa7201215e2d9306f1f41d5a5c2f22fa11c8bbcd0e49c3ed54bdfaa59568ddefea8736e3d2719e42a1bff591e9eb11084bb2b566e72013a15
7
- data.tar.gz: e8055b9d34b0255730974669b6b1926924e34e14029d478bb62cc49830bdd0e9bfd261670b43f488bb7781a5c7c137a44208d0a20a81f1c7c97bbeef82662749
6
+ metadata.gz: 4867d95e20f8e7bb15732ea1d749d34f49e8232a8a16b4d8ce5c2203ada419d2d5815569208b410e3f6f3e01ae7dd842896922e686aae5f49edca13de9dd61f6
7
+ data.tar.gz: 40720afca29e6f6ef1a527d9674adf81bde008edc6b4da86cafc009e2c2ef6e75b6c338e3f4ff4a4ef9dd7d5924aba3d81b5a313eff195bafc0de2750f361752
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (17.0.0.rc.0)
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 (17.0.0.rc.0)
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 (= 17.0.0.rc.0)
31
+ react_on_rails (= 17.0.0.rc.1)
32
32
 
33
33
  GEM
34
34
  remote: https://rubygems.org/
@@ -13,11 +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
- # 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:
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:
21
22
  #
22
23
  # # config/routes.rb
23
24
  # ReactOnRailsPro::RollingDeploy::BundlesController.draw_routes(
@@ -25,10 +26,11 @@ module ReactOnRailsPro
25
26
  # path: "/internal/rolling-deploy"
26
27
  # )
27
28
  #
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`.
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`.
32
34
  #
33
35
  # Security:
34
36
  # * Bearer-token auth via `Authorization: Bearer <token>`, constant-time
@@ -56,10 +58,19 @@ module ReactOnRailsPro
56
58
  before_action :set_no_store_headers
57
59
 
58
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"))
59
68
 
60
69
  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.
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.
63
74
  #
64
75
  # `as_prefix:` controls the generated named-route helpers
65
76
  # (`<prefix>_manifest`, `<prefix>_bundle`). Callers that mount the
@@ -72,7 +83,7 @@ module ReactOnRailsPro
72
83
  as: :"#{as_prefix}_manifest")
73
84
  mapper.get("#{path}/bundles/:hash",
74
85
  to: "react_on_rails_pro/rolling_deploy/bundles#show",
75
- constraints: { hash: SAFE_HASH_PATTERN },
86
+ constraints: { hash: ROUTE_HASH_PATTERN },
76
87
  as: :"#{as_prefix}_bundle")
77
88
  end
78
89
  end
@@ -81,8 +92,6 @@ module ReactOnRailsPro
81
92
  # path-traversal value through, the controller still rejects it
82
93
  # before any disk lookup because the hash must be in the
83
94
  # (regex-validated) current-hash set.
84
- SAFE_HASH_PATTERN = ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN
85
-
86
95
  # Tarball entry name reserved for the server bundle. Companion assets
87
96
  # whose basename collides with this are skipped to keep the receiver
88
97
  # from extracting the wrong bytes into the bundle slot.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  module ReactOnRailsPro
6
4
  def self.configure
7
5
  yield(configuration)
@@ -182,6 +180,8 @@ module ReactOnRailsPro
182
180
  self.rolling_deploy_adapter = rolling_deploy_adapter
183
181
  self.rolling_deploy_token = rolling_deploy_token
184
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.
185
185
  self.rolling_deploy_mount_path = rolling_deploy_mount_path.presence || DEFAULT_ROLLING_DEPLOY_MOUNT_PATH
186
186
  self.ssr_pre_hook_js = ssr_pre_hook_js
187
187
  self.assets_to_copy = assets_to_copy
@@ -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"
@@ -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)
@@ -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 = "17.0.0.rc.0"
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: 17.0.0.rc.0
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-30 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: 17.0.0.rc.0
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: 17.0.0.rc.0
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
  - - ">="