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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/.controlplane/Dockerfile +49 -0
  3. data/.controlplane/controlplane.yml +22 -0
  4. data/.controlplane/gvc.yml +25 -0
  5. data/.controlplane/postgres.yml +33 -0
  6. data/.controlplane/rails.yml +49 -0
  7. data/.controlplane/redis.yml +18 -0
  8. data/.gitignore +77 -0
  9. data/.prettierignore +12 -0
  10. data/.prettierrc +19 -0
  11. data/.rspec +2 -0
  12. data/.rubocop.yml +120 -0
  13. data/.scss-lint.yml +205 -0
  14. data/CHANGELOG.md +570 -0
  15. data/CI_SETUP.md +502 -0
  16. data/CONTRIBUTING.md +376 -0
  17. data/Dockerfile +63 -0
  18. data/Gemfile +8 -0
  19. data/Gemfile.development_dependencies +74 -0
  20. data/Gemfile.loader +32 -0
  21. data/Gemfile.lock +527 -0
  22. data/LICENSE +98 -0
  23. data/LICENSE_SETUP.md +272 -0
  24. data/README.md +577 -0
  25. data/Rakefile +13 -0
  26. data/app/controllers/react_on_rails_pro/rsc_payload_controller.rb +7 -0
  27. data/app/helpers/react_on_rails_pro_helper.rb +360 -0
  28. data/app/views/react_on_rails_pro/rsc_payload.html.erb +1 -0
  29. data/babel.config.js +4 -0
  30. data/docs/bundle-caching.md +205 -0
  31. data/docs/caching.md +234 -0
  32. data/docs/code-splitting-loadable-components.md +313 -0
  33. data/docs/code-splitting.md +349 -0
  34. data/docs/configuration.md +165 -0
  35. data/docs/contributors-info/onboarding-customers.md +6 -0
  36. data/docs/contributors-info/releasing.md +40 -0
  37. data/docs/contributors-info/style.md +33 -0
  38. data/docs/home-pro.md +146 -0
  39. data/docs/installation.md +203 -0
  40. data/docs/js-memory-leaks.md +22 -0
  41. data/docs/node-renderer/basics.md +92 -0
  42. data/docs/node-renderer/debugging.md +38 -0
  43. data/docs/node-renderer/error-reporting-and-tracing.md +160 -0
  44. data/docs/node-renderer/heroku.md +102 -0
  45. data/docs/node-renderer/js-configuration.md +91 -0
  46. data/docs/node-renderer/troubleshooting.md +5 -0
  47. data/docs/profiling-server-side-rendering-code.md +179 -0
  48. data/docs/react-server-components/add-streaming-and-interactivity.md +190 -0
  49. data/docs/react-server-components/create-without-ssr.md +448 -0
  50. data/docs/react-server-components/glossary.md +102 -0
  51. data/docs/react-server-components/how-react-server-components-work.md +243 -0
  52. data/docs/react-server-components/inside-client-components.md +332 -0
  53. data/docs/react-server-components/purpose-and-benefits.md +243 -0
  54. data/docs/react-server-components/rendering-flow.md +86 -0
  55. data/docs/react-server-components/selective-hydration-in-streamed-components.md +75 -0
  56. data/docs/react-server-components/server-side-rendering.md +72 -0
  57. data/docs/react-server-components/tutorial.md +19 -0
  58. data/docs/release-notes/4.0.md +94 -0
  59. data/docs/release-notes/v4-react-server-components.md +66 -0
  60. data/docs/ruby-api.md +11 -0
  61. data/docs/streaming-server-rendering.md +210 -0
  62. data/docs/troubleshooting.md +24 -0
  63. data/docs/updating.md +219 -0
  64. data/eslint.config.mjs +220 -0
  65. data/lib/react_on_rails_pro/assets_precompile.rb +230 -0
  66. data/lib/react_on_rails_pro/cache.rb +88 -0
  67. data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +38 -0
  68. data/lib/react_on_rails_pro/concerns/stream.rb +103 -0
  69. data/lib/react_on_rails_pro/configuration.rb +228 -0
  70. data/lib/react_on_rails_pro/constants.rb +8 -0
  71. data/lib/react_on_rails_pro/engine.rb +24 -0
  72. data/lib/react_on_rails_pro/error.rb +14 -0
  73. data/lib/react_on_rails_pro/license_public_key.rb +30 -0
  74. data/lib/react_on_rails_pro/license_validator.rb +188 -0
  75. data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +40 -0
  76. data/lib/react_on_rails_pro/rendering_error.rb +5 -0
  77. data/lib/react_on_rails_pro/request.rb +318 -0
  78. data/lib/react_on_rails_pro/routes.rb +13 -0
  79. data/lib/react_on_rails_pro/server_rendering_js_code.rb +102 -0
  80. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +133 -0
  81. data/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb +117 -0
  82. data/lib/react_on_rails_pro/stream_cache.rb +61 -0
  83. data/lib/react_on_rails_pro/stream_request.rb +170 -0
  84. data/lib/react_on_rails_pro/utils.rb +222 -0
  85. data/lib/react_on_rails_pro/v8_log_processor.rb +50 -0
  86. data/lib/react_on_rails_pro/version.rb +6 -0
  87. data/lib/react_on_rails_pro.rb +23 -0
  88. data/package-scripts.yml +109 -0
  89. data/package.json +159 -0
  90. data/rakelib/dummy_apps.rake +22 -0
  91. data/rakelib/lint.rake +32 -0
  92. data/rakelib/public_key_management.rake +155 -0
  93. data/rakelib/rbs.rake +47 -0
  94. data/rakelib/run_rspec.rake +81 -0
  95. data/rakelib/task_helpers.rb +45 -0
  96. data/rakelib/yard.rake +20 -0
  97. data/react_on_rails_pro.gemspec +47 -0
  98. data/readme-gen-docs.md +1 -0
  99. data/script/bootstrap +33 -0
  100. data/script/preinstall.js +31 -0
  101. data/script/setup +23 -0
  102. data/script/test +38 -0
  103. data/sig/react_on_rails_pro/cache.rbs +13 -0
  104. data/sig/react_on_rails_pro/configuration.rbs +100 -0
  105. data/sig/react_on_rails_pro/error.rbs +4 -0
  106. data/sig/react_on_rails_pro/utils.rbs +7 -0
  107. data/sig/react_on_rails_pro.rbs +5 -0
  108. data/yarn.lock +7599 -0
  109. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ class RenderingError < RuntimeError; end
5
+ 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