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 +4 -4
- data/Gemfile.development_dependencies +1 -1
- data/Gemfile.lock +6 -6
- data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +23 -13
- data/lib/react_on_rails_pro/configuration.rb +89 -43
- data/lib/react_on_rails_pro/engine.rb +17 -0
- data/lib/react_on_rails_pro/renderer_cache_helpers.rb +0 -1
- data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +97 -23
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/react_on_rails_pro.gemspec +1 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d82436e63c02deff1774b11e01658f6f06a17bca6c583986aea35ac6f8b865de
|
|
4
|
+
data.tar.gz: e88f6d4495a06792faf61891d8aefe335323dd53461deb612412d5796d8ebe10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
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 (
|
|
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 (=
|
|
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.
|
|
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 (
|
|
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 (=
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# doesn't raise `ArgumentError: Invalid route name,
|
|
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
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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:
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
+
is_production_like = !(runtime_envs.any? && runtime_envs.all? { |e| allowed_envs.include?(e) })
|
|
479
504
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
when using the NodeRenderer.
|
|
505
|
+
if renderer_password.blank?
|
|
506
|
+
return unless is_production_like
|
|
483
507
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
+
To fix this, set the RENDERER_PASSWORD environment variable:
|
|
491
517
|
|
|
492
|
-
|
|
518
|
+
export RENDERER_PASSWORD="your-secure-password"
|
|
493
519
|
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
(both
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
190
|
-
|
|
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
|
|
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
|
data/react_on_rails_pro.gemspec
CHANGED
|
@@ -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:
|
|
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-
|
|
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:
|
|
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:
|
|
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:
|
|
314
|
+
version: 3.3.0
|
|
315
315
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
316
316
|
requirements:
|
|
317
317
|
- - ">="
|