react_on_rails_pro 16.2.0.beta.8
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 +7 -0
- data/.controlplane/Dockerfile +49 -0
- data/.controlplane/controlplane.yml +22 -0
- data/.controlplane/gvc.yml +25 -0
- data/.controlplane/postgres.yml +33 -0
- data/.controlplane/rails.yml +49 -0
- data/.controlplane/redis.yml +18 -0
- data/.gitignore +77 -0
- data/.prettierignore +12 -0
- data/.prettierrc +19 -0
- data/.rspec +2 -0
- data/.rubocop.yml +120 -0
- data/.scss-lint.yml +205 -0
- data/CHANGELOG.md +570 -0
- data/CI_SETUP.md +502 -0
- data/CONTRIBUTING.md +376 -0
- data/Dockerfile +63 -0
- data/Gemfile +8 -0
- data/Gemfile.development_dependencies +74 -0
- data/Gemfile.loader +32 -0
- data/Gemfile.lock +527 -0
- data/LICENSE +98 -0
- data/LICENSE_SETUP.md +272 -0
- data/README.md +577 -0
- data/Rakefile +13 -0
- data/app/controllers/react_on_rails_pro/rsc_payload_controller.rb +7 -0
- data/app/helpers/react_on_rails_pro_helper.rb +360 -0
- data/app/views/react_on_rails_pro/rsc_payload.html.erb +1 -0
- data/babel.config.js +4 -0
- data/docs/bundle-caching.md +205 -0
- data/docs/caching.md +234 -0
- data/docs/code-splitting-loadable-components.md +313 -0
- data/docs/code-splitting.md +349 -0
- data/docs/configuration.md +165 -0
- data/docs/contributors-info/onboarding-customers.md +6 -0
- data/docs/contributors-info/releasing.md +40 -0
- data/docs/contributors-info/style.md +33 -0
- data/docs/home-pro.md +146 -0
- data/docs/installation.md +203 -0
- data/docs/js-memory-leaks.md +22 -0
- data/docs/node-renderer/basics.md +92 -0
- data/docs/node-renderer/debugging.md +38 -0
- data/docs/node-renderer/error-reporting-and-tracing.md +160 -0
- data/docs/node-renderer/heroku.md +102 -0
- data/docs/node-renderer/js-configuration.md +91 -0
- data/docs/node-renderer/troubleshooting.md +5 -0
- data/docs/profiling-server-side-rendering-code.md +179 -0
- data/docs/react-server-components/add-streaming-and-interactivity.md +190 -0
- data/docs/react-server-components/create-without-ssr.md +448 -0
- data/docs/react-server-components/glossary.md +102 -0
- data/docs/react-server-components/how-react-server-components-work.md +243 -0
- data/docs/react-server-components/inside-client-components.md +332 -0
- data/docs/react-server-components/purpose-and-benefits.md +243 -0
- data/docs/react-server-components/rendering-flow.md +86 -0
- data/docs/react-server-components/selective-hydration-in-streamed-components.md +75 -0
- data/docs/react-server-components/server-side-rendering.md +72 -0
- data/docs/react-server-components/tutorial.md +19 -0
- data/docs/release-notes/4.0.md +94 -0
- data/docs/release-notes/v4-react-server-components.md +66 -0
- data/docs/ruby-api.md +11 -0
- data/docs/streaming-server-rendering.md +210 -0
- data/docs/troubleshooting.md +24 -0
- data/docs/updating.md +219 -0
- data/eslint.config.mjs +220 -0
- data/lib/react_on_rails_pro/assets_precompile.rb +230 -0
- data/lib/react_on_rails_pro/cache.rb +88 -0
- data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +38 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +103 -0
- data/lib/react_on_rails_pro/configuration.rb +228 -0
- data/lib/react_on_rails_pro/constants.rb +8 -0
- data/lib/react_on_rails_pro/engine.rb +24 -0
- data/lib/react_on_rails_pro/error.rb +14 -0
- data/lib/react_on_rails_pro/license_public_key.rb +30 -0
- data/lib/react_on_rails_pro/license_validator.rb +188 -0
- data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +40 -0
- data/lib/react_on_rails_pro/rendering_error.rb +5 -0
- data/lib/react_on_rails_pro/request.rb +318 -0
- data/lib/react_on_rails_pro/routes.rb +13 -0
- data/lib/react_on_rails_pro/server_rendering_js_code.rb +102 -0
- data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +133 -0
- data/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb +117 -0
- data/lib/react_on_rails_pro/stream_cache.rb +61 -0
- data/lib/react_on_rails_pro/stream_request.rb +170 -0
- data/lib/react_on_rails_pro/utils.rb +222 -0
- data/lib/react_on_rails_pro/v8_log_processor.rb +50 -0
- data/lib/react_on_rails_pro/version.rb +6 -0
- data/lib/react_on_rails_pro.rb +23 -0
- data/package-scripts.yml +109 -0
- data/package.json +159 -0
- data/rakelib/dummy_apps.rake +22 -0
- data/rakelib/lint.rake +32 -0
- data/rakelib/public_key_management.rake +155 -0
- data/rakelib/rbs.rake +47 -0
- data/rakelib/run_rspec.rake +81 -0
- data/rakelib/task_helpers.rb +45 -0
- data/rakelib/yard.rake +20 -0
- data/react_on_rails_pro.gemspec +47 -0
- data/readme-gen-docs.md +1 -0
- data/script/bootstrap +33 -0
- data/script/preinstall.js +31 -0
- data/script/setup +23 -0
- data/script/test +38 -0
- data/sig/react_on_rails_pro/cache.rbs +13 -0
- data/sig/react_on_rails_pro/configuration.rbs +100 -0
- data/sig/react_on_rails_pro/error.rbs +4 -0
- data/sig/react_on_rails_pro/utils.rbs +7 -0
- data/sig/react_on_rails_pro.rbs +5 -0
- data/yarn.lock +7599 -0
- metadata +319 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
# Status code 410 means to resend the request with the updated bundle.
|
|
5
|
+
STATUS_SEND_BUNDLE = 410
|
|
6
|
+
# Status code 412 means protocol versions are incompatible between the server and the renderer.
|
|
7
|
+
STATUS_INCOMPATIBLE = 412
|
|
8
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module ReactOnRailsPro
|
|
6
|
+
class Engine < Rails::Engine
|
|
7
|
+
initializer "react_on_rails_pro.routes" do
|
|
8
|
+
ActionDispatch::Routing::Mapper.include ReactOnRailsPro::Routes
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Validate license on Rails startup
|
|
12
|
+
# This ensures the application fails fast if the license is invalid or missing
|
|
13
|
+
initializer "react_on_rails_pro.validate_license" do
|
|
14
|
+
# Use after_initialize to ensure Rails.logger is available
|
|
15
|
+
config.after_initialize do
|
|
16
|
+
Rails.logger.info "[React on Rails Pro] Validating license..."
|
|
17
|
+
|
|
18
|
+
ReactOnRailsPro::LicenseValidator.validated_license_data!
|
|
19
|
+
|
|
20
|
+
Rails.logger.info "[React on Rails Pro] License validation successful"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "react_on_rails/error"
|
|
4
|
+
|
|
5
|
+
module ReactOnRailsPro
|
|
6
|
+
class Error < ::ReactOnRails::Error
|
|
7
|
+
def self.raise_duplicate_bundle_upload_error
|
|
8
|
+
raise ReactOnRailsPro::Error,
|
|
9
|
+
"The bundle has already been uploaded, " \
|
|
10
|
+
"but the server is still sending the send_bundle status code. " \
|
|
11
|
+
"This is unexpected behavior."
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
module LicensePublicKey
|
|
5
|
+
# ShakaCode's public key for React on Rails Pro license verification
|
|
6
|
+
# The private key corresponding to this public key is held by ShakaCode
|
|
7
|
+
# and is never committed to the repository
|
|
8
|
+
# Last updated: 2025-10-09 15:57:09 UTC
|
|
9
|
+
# Source: http://shakacode.com/api/public-key
|
|
10
|
+
#
|
|
11
|
+
# You can update this public key by running the rake task:
|
|
12
|
+
# react_on_rails_pro:update_public_key
|
|
13
|
+
# This task fetches the latest key from the API endpoint:
|
|
14
|
+
# http://shakacode.com/api/public-key
|
|
15
|
+
#
|
|
16
|
+
# TODO: Add a prepublish check to ensure this key matches the latest public key from the API.
|
|
17
|
+
# This should be implemented after publishing the API endpoint on the ShakaCode website.
|
|
18
|
+
KEY = OpenSSL::PKey::RSA.new(<<~PEM.strip.strip_heredoc)
|
|
19
|
+
-----BEGIN PUBLIC KEY-----
|
|
20
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzcS/fpHz5CbnTQxb4Zot
|
|
21
|
+
khjzXu7xNS+Y9VKfapMaHOMzNoCMfy1++hxHJatRedr+YQfZRCjfiN168Cpe+dhe
|
|
22
|
+
yfNtOoLU9/+/5jTsxH+WQJWNRswyKms5HNajlIMN1GEYdZmZbvOPaZvh6ENsT+EV
|
|
23
|
+
HnhjJtsHl7qltBoL0ul7rONxaNHCzJcKk4lf3B2/1j1wpA91MKz4bbQVh4/6Th0E
|
|
24
|
+
/39f0PWvvBXzQS+yt1qaa1DIX5YL6Aug5uEpb1+6QWcN3hCzqSPBv1HahrG50rsD
|
|
25
|
+
gf8KORV3X2N9t6j6iqPmRqfRcTBKtmPhM9bORtKiSwBK8LsIUzp2/UUmkdHnkyzu
|
|
26
|
+
NQIDAQAB
|
|
27
|
+
-----END PUBLIC KEY-----
|
|
28
|
+
PEM
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module ReactOnRailsPro
|
|
6
|
+
class LicenseValidator
|
|
7
|
+
# Grace period: 1 month (in seconds)
|
|
8
|
+
GRACE_PERIOD_SECONDS = 30 * 24 * 60 * 60
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Validates the license and returns the license data
|
|
12
|
+
# Caches the result after first validation
|
|
13
|
+
# @return [Hash] The license data
|
|
14
|
+
# @raise [ReactOnRailsPro::Error] if license is invalid
|
|
15
|
+
def validated_license_data!
|
|
16
|
+
return @license_data if defined?(@license_data)
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
# Load and decode license (but don't cache yet)
|
|
20
|
+
license_data = load_and_decode_license
|
|
21
|
+
|
|
22
|
+
# Validate the license (raises if invalid, returns grace_days)
|
|
23
|
+
grace_days = validate_license_data(license_data)
|
|
24
|
+
|
|
25
|
+
# Validation passed - now cache both data and grace days
|
|
26
|
+
@license_data = license_data
|
|
27
|
+
@grace_days_remaining = grace_days
|
|
28
|
+
|
|
29
|
+
@license_data
|
|
30
|
+
rescue JWT::DecodeError => e
|
|
31
|
+
error = "Invalid license signature: #{e.message}. " \
|
|
32
|
+
"Your license file may be corrupted. " \
|
|
33
|
+
"Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro"
|
|
34
|
+
handle_invalid_license(error)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
error = "License validation error: #{e.message}. " \
|
|
37
|
+
"Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro"
|
|
38
|
+
handle_invalid_license(error)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reset!
|
|
43
|
+
remove_instance_variable(:@license_data) if defined?(@license_data)
|
|
44
|
+
remove_instance_variable(:@grace_days_remaining) if defined?(@grace_days_remaining)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Checks if the current license is an evaluation/free license
|
|
48
|
+
# @return [Boolean] true if plan is not "paid"
|
|
49
|
+
def evaluation?
|
|
50
|
+
data = validated_license_data!
|
|
51
|
+
plan = data["plan"].to_s
|
|
52
|
+
plan != "paid" && !plan.start_with?("paid_")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns remaining grace period days if license is expired but in grace period
|
|
56
|
+
# @return [Integer, nil] Number of days remaining, or nil if not in grace period
|
|
57
|
+
def grace_days_remaining
|
|
58
|
+
# Ensure license is validated and cached
|
|
59
|
+
validated_license_data!
|
|
60
|
+
|
|
61
|
+
# Return cached grace days (nil if not in grace period)
|
|
62
|
+
@grace_days_remaining
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Validates the license data and raises if invalid
|
|
68
|
+
# Logs info/errors and handles grace period logic
|
|
69
|
+
# @param license [Hash] The decoded license data
|
|
70
|
+
# @return [Integer, nil] Grace days remaining if in grace period, nil otherwise
|
|
71
|
+
# @raise [ReactOnRailsPro::Error] if license is invalid
|
|
72
|
+
def validate_license_data(license)
|
|
73
|
+
# Check that exp field exists
|
|
74
|
+
unless license["exp"]
|
|
75
|
+
error = "License is missing required expiration field. " \
|
|
76
|
+
"Your license may be from an older version. " \
|
|
77
|
+
"Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro"
|
|
78
|
+
handle_invalid_license(error)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check expiry with grace period for production
|
|
82
|
+
current_time = Time.now.to_i
|
|
83
|
+
exp_time = license["exp"]
|
|
84
|
+
grace_days = nil
|
|
85
|
+
|
|
86
|
+
if current_time > exp_time
|
|
87
|
+
days_expired = ((current_time - exp_time) / (24 * 60 * 60)).to_i
|
|
88
|
+
|
|
89
|
+
error = "License has expired #{days_expired} day(s) ago. " \
|
|
90
|
+
"Get a FREE evaluation license (3 months) at https://shakacode.com/react-on-rails-pro " \
|
|
91
|
+
"or upgrade to a paid license for production use."
|
|
92
|
+
|
|
93
|
+
# In production, allow a grace period of 1 month with error logging
|
|
94
|
+
if production? && within_grace_period?(exp_time)
|
|
95
|
+
# Calculate grace days once here
|
|
96
|
+
grace_days = calculate_grace_days_remaining(exp_time)
|
|
97
|
+
Rails.logger.error(
|
|
98
|
+
"[React on Rails Pro] WARNING: #{error} " \
|
|
99
|
+
"Grace period: #{grace_days} day(s) remaining. " \
|
|
100
|
+
"Application will fail to start after grace period expires."
|
|
101
|
+
)
|
|
102
|
+
else
|
|
103
|
+
handle_invalid_license(error)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Log license type if present (for analytics)
|
|
108
|
+
log_license_info(license)
|
|
109
|
+
|
|
110
|
+
# Return grace days (nil if not in grace period)
|
|
111
|
+
grace_days
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def production?
|
|
115
|
+
Rails.env.production?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def within_grace_period?(exp_time)
|
|
119
|
+
Time.now.to_i <= exp_time + GRACE_PERIOD_SECONDS
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Calculates remaining grace period days
|
|
123
|
+
# @param exp_time [Integer] Expiration timestamp
|
|
124
|
+
# @return [Integer] Days remaining (0 or more)
|
|
125
|
+
def calculate_grace_days_remaining(exp_time)
|
|
126
|
+
grace_end = exp_time + GRACE_PERIOD_SECONDS
|
|
127
|
+
seconds_remaining = grace_end - Time.now.to_i
|
|
128
|
+
return 0 if seconds_remaining <= 0
|
|
129
|
+
|
|
130
|
+
(seconds_remaining / (24 * 60 * 60)).to_i
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def load_and_decode_license
|
|
134
|
+
license_string = load_license_string
|
|
135
|
+
|
|
136
|
+
JWT.decode(
|
|
137
|
+
# The JWT token containing the license data
|
|
138
|
+
license_string,
|
|
139
|
+
# RSA public key used to verify the JWT signature
|
|
140
|
+
public_key,
|
|
141
|
+
# verify_signature: NEVER set to false! When false, signature verification is skipped,
|
|
142
|
+
# allowing anyone to forge licenses. Must always be true for security.
|
|
143
|
+
true,
|
|
144
|
+
# NOTE: Never remove the 'algorithm' parameter from JWT.decode to prevent algorithm bypassing vulnerabilities.
|
|
145
|
+
# Ensure to hardcode the expected algorithm.
|
|
146
|
+
# See: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
|
|
147
|
+
algorithm: "RS256",
|
|
148
|
+
# Disable automatic expiration verification so we can handle it manually with custom logic
|
|
149
|
+
verify_expiration: false
|
|
150
|
+
# JWT.decode returns an array [data, header]; we use `.first` to get the data (payload).
|
|
151
|
+
).first
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def load_license_string
|
|
155
|
+
# First try environment variable
|
|
156
|
+
license = ENV.fetch("REACT_ON_RAILS_PRO_LICENSE", nil)
|
|
157
|
+
return license if license.present?
|
|
158
|
+
|
|
159
|
+
# Then try config file
|
|
160
|
+
config_path = Rails.root.join("config", "react_on_rails_pro_license.key")
|
|
161
|
+
return File.read(config_path).strip if config_path.exist?
|
|
162
|
+
|
|
163
|
+
error_msg = "No license found. Please set REACT_ON_RAILS_PRO_LICENSE environment variable " \
|
|
164
|
+
"or create #{config_path} file. " \
|
|
165
|
+
"Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro"
|
|
166
|
+
handle_invalid_license(error_msg)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def public_key
|
|
170
|
+
ReactOnRailsPro::LicensePublicKey::KEY
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def handle_invalid_license(message)
|
|
174
|
+
full_message = "[React on Rails Pro] #{message}"
|
|
175
|
+
Rails.logger.error(full_message)
|
|
176
|
+
raise ReactOnRailsPro::Error, full_message
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def log_license_info(license)
|
|
180
|
+
plan = license["plan"]
|
|
181
|
+
iss = license["iss"]
|
|
182
|
+
|
|
183
|
+
Rails.logger.info("[React on Rails Pro] License plan: #{plan}") if plan
|
|
184
|
+
Rails.logger.info("[React on Rails Pro] Issued by: #{iss}") if iss
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module ReactOnRailsPro
|
|
6
|
+
class PrepareNodeRenderBundles
|
|
7
|
+
extend FileUtils
|
|
8
|
+
|
|
9
|
+
def self.make_relative_symlink(source, destination)
|
|
10
|
+
FileUtils.rm_f(destination)
|
|
11
|
+
relative_source_path = Pathname.new(source).relative_path_from(Pathname.new(destination).dirname)
|
|
12
|
+
File.symlink(relative_source_path, destination)
|
|
13
|
+
puts "[ReactOnRailsPro] Symlinked #{relative_source_path} to #{destination}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.call
|
|
17
|
+
# TODO: temporarily hardcoding tmp/bundles directory. renderer and rails should read from a Yaml file
|
|
18
|
+
src_bundle_path = ReactOnRails::Utils.server_bundle_js_file_path
|
|
19
|
+
renderer_bundle_file_name = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool.renderer_bundle_file_name
|
|
20
|
+
dest_path = ENV["RENDERER_BUNDLE_PATH"].presence || Rails.root.join(".node-renderer-bundles").to_s
|
|
21
|
+
bundle_dest_path = File.join(dest_path, renderer_bundle_file_name.to_s).to_s
|
|
22
|
+
puts "[ReactOnRailsPro] Symlinking assets to local node-renderer, path #{dest_path}"
|
|
23
|
+
mkdir_p(dest_path)
|
|
24
|
+
|
|
25
|
+
make_relative_symlink(src_bundle_path, bundle_dest_path)
|
|
26
|
+
|
|
27
|
+
return unless ReactOnRailsPro.configuration.assets_to_copy.present?
|
|
28
|
+
|
|
29
|
+
ReactOnRailsPro.configuration.assets_to_copy.each do |asset_path|
|
|
30
|
+
unless File.exist?(asset_path)
|
|
31
|
+
warn "Asset not found #{asset_path}"
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
destination_full_path = File.join(dest_path, asset_path.basename.to_s)
|
|
36
|
+
make_relative_symlink(asset_path, destination_full_path)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "httpx"
|
|
5
|
+
require_relative "stream_request"
|
|
6
|
+
|
|
7
|
+
module ReactOnRailsPro
|
|
8
|
+
class Request # rubocop:disable Metrics/ClassLength
|
|
9
|
+
class << self
|
|
10
|
+
def reset_connection
|
|
11
|
+
@connection&.close
|
|
12
|
+
@connection = create_connection
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render_code(path, js_code, send_bundle)
|
|
16
|
+
Rails.logger.info { "[ReactOnRailsPro] Perform rendering request #{path}" }
|
|
17
|
+
form = form_with_code(js_code, send_bundle)
|
|
18
|
+
perform_request(path, form: form)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render_code_as_stream(path, js_code, is_rsc_payload:)
|
|
22
|
+
Rails.logger.info { "[ReactOnRailsPro] Perform rendering request as a stream #{path}" }
|
|
23
|
+
if is_rsc_payload && !ReactOnRailsPro.configuration.enable_rsc_support
|
|
24
|
+
raise ReactOnRailsPro::Error,
|
|
25
|
+
"RSC support is not enabled. Please set enable_rsc_support to true in your " \
|
|
26
|
+
"config/initializers/react_on_rails_pro.rb file before " \
|
|
27
|
+
"rendering any RSC payload."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ReactOnRailsPro::StreamRequest.create do |send_bundle|
|
|
31
|
+
form = form_with_code(js_code, send_bundle)
|
|
32
|
+
perform_request(path, form: form, stream: true)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def upload_assets
|
|
37
|
+
Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }
|
|
38
|
+
|
|
39
|
+
# Check if server bundle exists before trying to upload assets
|
|
40
|
+
server_bundle_path = ReactOnRails::Utils.server_bundle_js_file_path
|
|
41
|
+
unless File.exist?(server_bundle_path)
|
|
42
|
+
raise ReactOnRailsPro::Error, "Server bundle not found at #{server_bundle_path}. " \
|
|
43
|
+
"Please build your bundles before uploading assets."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Create a list of bundle timestamps to send to the node renderer
|
|
47
|
+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
48
|
+
target_bundles = [pool.server_bundle_hash]
|
|
49
|
+
|
|
50
|
+
# Add RSC bundle if enabled
|
|
51
|
+
if ReactOnRailsPro.configuration.enable_rsc_support
|
|
52
|
+
rsc_bundle_path = ReactOnRailsPro::Utils.rsc_bundle_js_file_path
|
|
53
|
+
unless File.exist?(rsc_bundle_path)
|
|
54
|
+
raise ReactOnRailsPro::Error, "RSC bundle not found at #{rsc_bundle_path}. " \
|
|
55
|
+
"Please build your bundles before uploading assets."
|
|
56
|
+
end
|
|
57
|
+
target_bundles << pool.rsc_bundle_hash
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
form = form_with_assets_and_bundle
|
|
61
|
+
form["targetBundles"] = target_bundles
|
|
62
|
+
|
|
63
|
+
perform_request("/upload-assets", form: form)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def asset_exists_on_vm_renderer?(filename)
|
|
67
|
+
Rails.logger.info { "[ReactOnRailsPro] Sending request to check if file exist on node-renderer: #{filename}" }
|
|
68
|
+
|
|
69
|
+
form_data = common_form_data
|
|
70
|
+
|
|
71
|
+
# Add targetBundles from the current bundle hash and RSC bundle hash
|
|
72
|
+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
73
|
+
target_bundles = [pool.server_bundle_hash]
|
|
74
|
+
|
|
75
|
+
target_bundles << pool.rsc_bundle_hash if ReactOnRailsPro.configuration.enable_rsc_support
|
|
76
|
+
|
|
77
|
+
form_data["targetBundles"] = target_bundles
|
|
78
|
+
|
|
79
|
+
response = perform_request("/asset-exists?filename=#{filename}", json: form_data)
|
|
80
|
+
JSON.parse(response.body)["exists"] == true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def connection
|
|
86
|
+
@connection ||= create_connection
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
90
|
+
available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit
|
|
91
|
+
retry_request = true
|
|
92
|
+
while retry_request
|
|
93
|
+
begin
|
|
94
|
+
start_time = Time.now
|
|
95
|
+
response = connection.post(path, **post_options)
|
|
96
|
+
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
97
|
+
|
|
98
|
+
request_time = Time.now - start_time
|
|
99
|
+
warn_timeout = ReactOnRailsPro.configuration.renderer_http_pool_warn_timeout
|
|
100
|
+
if request_time > warn_timeout
|
|
101
|
+
Rails.logger.warn "Request to #{path} took #{request_time} seconds, expected at most #{warn_timeout}."
|
|
102
|
+
end
|
|
103
|
+
retry_request = false
|
|
104
|
+
rescue HTTPX::TimeoutError => e
|
|
105
|
+
# Testing timeout catching:
|
|
106
|
+
# https://github.com/shakacode/react_on_rails_pro/pull/136#issue-463421204
|
|
107
|
+
if available_retries.zero?
|
|
108
|
+
raise ReactOnRailsPro::Error, "Time out error when getting the response on: #{path}.\n" \
|
|
109
|
+
"Original error:\n#{e}\n#{e.backtrace}"
|
|
110
|
+
end
|
|
111
|
+
Rails.logger.info do
|
|
112
|
+
"[ReactOnRailsPro] Timed out trying to make a request to the Node Renderer. " \
|
|
113
|
+
"Retrying #{available_retries} more times..."
|
|
114
|
+
end
|
|
115
|
+
available_retries -= 1
|
|
116
|
+
next
|
|
117
|
+
rescue HTTPX::Error => e # Connection errors or other unexpected errors
|
|
118
|
+
# Such errors are handled by ReactOnRailsPro::StreamRequest instead
|
|
119
|
+
raise if e.is_a?(HTTPX::HTTPError) && post_options[:stream]
|
|
120
|
+
|
|
121
|
+
raise ReactOnRailsPro::Error,
|
|
122
|
+
"Node renderer request failed: #{path}.\nOriginal error:\n#{e}\n#{e.backtrace}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Rails.logger.info { "[ReactOnRailsPro] Node Renderer responded" }
|
|
127
|
+
|
|
128
|
+
# +response+ can also be an +HTTPX::ErrorResponse+ or an +HTTPX::StreamResponse+, which don't have +#status+.
|
|
129
|
+
if response.is_a?(HTTPX::Response) && response.status == ReactOnRailsPro::STATUS_INCOMPATIBLE
|
|
130
|
+
raise ReactOnRailsPro::Error, response.body
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
response
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def form_with_code(js_code, send_bundle)
|
|
137
|
+
form = common_form_data
|
|
138
|
+
form["renderingRequest"] = js_code
|
|
139
|
+
populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle
|
|
140
|
+
form
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def populate_form_with_bundle_and_assets(form, check_bundle:)
|
|
144
|
+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
145
|
+
|
|
146
|
+
add_bundle_to_form(
|
|
147
|
+
form,
|
|
148
|
+
bundle_path: ReactOnRails::Utils.server_bundle_js_file_path,
|
|
149
|
+
bundle_file_name: pool.renderer_bundle_file_name,
|
|
150
|
+
bundle_hash: pool.server_bundle_hash,
|
|
151
|
+
check_bundle: check_bundle
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if ReactOnRailsPro.configuration.enable_rsc_support
|
|
155
|
+
add_bundle_to_form(
|
|
156
|
+
form,
|
|
157
|
+
bundle_path: ReactOnRailsPro::Utils.rsc_bundle_js_file_path,
|
|
158
|
+
bundle_file_name: pool.rsc_renderer_bundle_file_name,
|
|
159
|
+
bundle_hash: pool.rsc_bundle_hash,
|
|
160
|
+
check_bundle: check_bundle
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
add_assets_to_form(form)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def add_bundle_to_form(form, bundle_path:, bundle_file_name:, bundle_hash:, check_bundle:)
|
|
168
|
+
raise ReactOnRailsPro::Error, "Bundle not found #{bundle_path}" if check_bundle && !File.exist?(bundle_path)
|
|
169
|
+
|
|
170
|
+
form["bundle_#{bundle_hash}"] = {
|
|
171
|
+
body: get_form_body_for_file(bundle_path),
|
|
172
|
+
content_type: "text/javascript",
|
|
173
|
+
filename: bundle_file_name
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def add_assets_to_form(form)
|
|
178
|
+
assets_to_copy = (ReactOnRailsPro.configuration.assets_to_copy || []).dup
|
|
179
|
+
# react_client_manifest and react_server_manifest files are needed to generate react server components payload
|
|
180
|
+
if ReactOnRailsPro.configuration.enable_rsc_support
|
|
181
|
+
assets_to_copy << ReactOnRailsPro::Utils.react_client_manifest_file_path
|
|
182
|
+
assets_to_copy << ReactOnRailsPro::Utils.react_server_client_manifest_file_path
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return form unless assets_to_copy.present?
|
|
186
|
+
|
|
187
|
+
assets_to_copy.each_with_index do |asset_path, idx|
|
|
188
|
+
Rails.logger.info { "[ReactOnRailsPro] Uploading asset #{asset_path}" }
|
|
189
|
+
unless http_url?(asset_path) || File.exist?(asset_path)
|
|
190
|
+
warn "Asset not found #{asset_path}"
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
content_type = ReactOnRailsPro::Utils.mine_type_from_file_name(asset_path)
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
form["assetsToCopy#{idx}"] = {
|
|
198
|
+
body: get_form_body_for_file(asset_path),
|
|
199
|
+
content_type: content_type,
|
|
200
|
+
filename: File.basename(asset_path)
|
|
201
|
+
}
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn "[ReactOnRailsPro] Error uploading asset #{asset_path}: #{e}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
form
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def form_with_assets_and_bundle
|
|
211
|
+
form = common_form_data
|
|
212
|
+
populate_form_with_bundle_and_assets(form, check_bundle: true)
|
|
213
|
+
form
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def common_form_data
|
|
217
|
+
ReactOnRailsPro::Utils.common_form_data
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
221
|
+
url = ReactOnRailsPro.configuration.renderer_url
|
|
222
|
+
Rails.logger.info do
|
|
223
|
+
"[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
HTTPX
|
|
227
|
+
# For persistent connections we want retries,
|
|
228
|
+
# so the requests don't just fail if the other side closes the connection
|
|
229
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Persistent
|
|
230
|
+
.plugin(
|
|
231
|
+
:retries, max_retries: 1,
|
|
232
|
+
retry_change_requests: true,
|
|
233
|
+
# Official HTTPx docs says that we should use the retry_on option to decide if the
|
|
234
|
+
# request should be retried or not
|
|
235
|
+
# However, HTTPx assumes that connection errors such as timeout error should be retried
|
|
236
|
+
# by default and it doesn't consider retry_on block at all at that case
|
|
237
|
+
# So, we have to do the following trick to avoid retries when a Timeout error happens
|
|
238
|
+
# while streaming a component
|
|
239
|
+
# If the streamed component returned any chunks, it shouldn't retry on errors, as it
|
|
240
|
+
# would cause page duplication
|
|
241
|
+
# The SSR-generated html will be written to the page two times in this case
|
|
242
|
+
retry_after: lambda do |request, response|
|
|
243
|
+
if request.stream.instance_variable_get(:@react_on_rails_received_first_chunk)
|
|
244
|
+
e = response.error
|
|
245
|
+
raise(
|
|
246
|
+
ReactOnRailsPro::Error,
|
|
247
|
+
"An error happened during server side render streaming " \
|
|
248
|
+
"of a component.\nOriginal error:\n#{e}\n#{e.backtrace}"
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
Rails.logger.info do
|
|
253
|
+
"[ReactOnRailsPro] An error occurred while making " \
|
|
254
|
+
"a request to the Node Renderer.\n" \
|
|
255
|
+
"Error: #{response.error}.\n" \
|
|
256
|
+
"Retrying by HTTPX \"retries\" plugin..."
|
|
257
|
+
end
|
|
258
|
+
# The retry_after block expects to return a delay to wait before
|
|
259
|
+
# retrying the request
|
|
260
|
+
# nil means no waiting delay
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
)
|
|
264
|
+
.plugin(:stream)
|
|
265
|
+
# See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options
|
|
266
|
+
.with(
|
|
267
|
+
origin: url,
|
|
268
|
+
# Version of HTTP protocol to use by default in the absence of protocol negotiation
|
|
269
|
+
fallback_protocol: "h2",
|
|
270
|
+
persistent: true,
|
|
271
|
+
pool_options: {
|
|
272
|
+
max_connections_per_origin: ReactOnRailsPro.configuration.renderer_http_pool_size
|
|
273
|
+
},
|
|
274
|
+
# Other timeouts supported https://honeyryderchuck.gitlab.io/httpx/wiki/Timeouts:
|
|
275
|
+
# :write_timeout
|
|
276
|
+
# :request_timeout
|
|
277
|
+
# :operation_timeout
|
|
278
|
+
# :keep_alive_timeout
|
|
279
|
+
timeout: {
|
|
280
|
+
connect_timeout: ReactOnRailsPro.configuration.renderer_http_pool_timeout,
|
|
281
|
+
read_timeout: ReactOnRailsPro.configuration.ssr_timeout
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
message = <<~MSG
|
|
286
|
+
[ReactOnRailsPro] Error creating HTTPX connection.
|
|
287
|
+
renderer_http_pool_size = #{ReactOnRailsPro.configuration.renderer_http_pool_size}
|
|
288
|
+
renderer_http_pool_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_timeout}
|
|
289
|
+
renderer_http_pool_warn_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_warn_timeout}
|
|
290
|
+
renderer_url = #{url}
|
|
291
|
+
Be sure to use a url that contains the protocol of http or https.
|
|
292
|
+
Original error is
|
|
293
|
+
#{e}
|
|
294
|
+
MSG
|
|
295
|
+
raise ReactOnRailsPro::Error, message
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def get_form_body_for_file(path)
|
|
299
|
+
# Handles the case when the file is served from the dev server
|
|
300
|
+
if http_url?(path)
|
|
301
|
+
unless Rails.env.development?
|
|
302
|
+
raise ReactOnRailsPro::Error,
|
|
303
|
+
"Not expected to get HTTP url for bundle or assets in production mode"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
response = HTTPX.get(path)
|
|
307
|
+
response.body
|
|
308
|
+
else
|
|
309
|
+
Pathname.new(path)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def http_url?(path)
|
|
314
|
+
path.to_s.match?(%r{https?://})
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
module Routes
|
|
5
|
+
def rsc_payload_route(
|
|
6
|
+
path: ReactOnRailsPro.configuration.rsc_payload_generation_url_path,
|
|
7
|
+
controller: "react_on_rails_pro/rsc_payload",
|
|
8
|
+
**options
|
|
9
|
+
)
|
|
10
|
+
get "#{path}/:component_name", to: "#{controller}#rsc_payload", **options
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|