react_on_rails 17.1.0 → 17.2.0.rc.0
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 +1 -1
- data/Gemfile.development_dependencies +2 -2
- data/Gemfile.lock +65 -73
- data/Steepfile +1 -0
- data/docs/agent/install-and-upgrade.md +8 -3
- data/docs/agent/rsc-adoption.md +3 -0
- data/lib/generators/react_on_rails/base_generator.rb +2 -1
- data/lib/generators/react_on_rails/generated_webpack_config_pair.rb +84 -0
- data/lib/generators/react_on_rails/generator_helper.rb +2 -4
- data/lib/generators/react_on_rails/generator_messages.rb +1 -1
- data/lib/generators/react_on_rails/install_generator.rb +2 -1
- data/lib/generators/react_on_rails/js_dependency_manager.rb +14 -14
- data/lib/generators/react_on_rails/pro/USAGE +14 -3
- data/lib/generators/react_on_rails/pro_generator.rb +1 -1
- data/lib/generators/react_on_rails/pro_setup.rb +41 -213
- data/lib/generators/react_on_rails/rsc_setup.rb +7 -16
- data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
- data/lib/react_on_rails/agent_guardrails.rb +217 -39
- data/lib/react_on_rails/dev/process_manager.rb +14 -0
- data/lib/react_on_rails/dev/server_manager.rb +1727 -103
- data/lib/react_on_rails/doctor.rb +875 -44
- data/lib/react_on_rails/helper.rb +3 -3
- data/lib/react_on_rails/react_component/render_options.rb +5 -7
- data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +27 -116
- data/lib/react_on_rails/utils.rb +65 -0
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/react_on_rails/version_checker/lockfile_resolution.rb +170 -0
- data/lib/react_on_rails/version_checker.rb +43 -22
- data/rakelib/run_rspec.rake +6 -1
- data/sig/react_on_rails/configuration.rbs +28 -26
- data/sig/react_on_rails/controller.rbs +2 -1
- data/sig/react_on_rails/dev/process_manager.rbs +1 -0
- data/sig/react_on_rails/dev/server_manager.rbs +150 -10
- data/sig/react_on_rails/git_utils.rbs +5 -1
- data/sig/react_on_rails/locales.rbs +4 -4
- data/sig/react_on_rails/prerender_error.rbs +2 -2
- data/sig/react_on_rails/test_helper.rbs +15 -1
- data/sig/react_on_rails/utils.rbs +2 -2
- data/sig/react_on_rails/version_checker/lockfile_resolution.rbs +31 -0
- data/sig/react_on_rails/version_checker.rbs +30 -7
- metadata +4 -1
|
@@ -737,8 +737,8 @@ module ReactOnRails
|
|
|
737
737
|
end
|
|
738
738
|
|
|
739
739
|
# Wraps console replay JavaScript code in a script tag with CSP nonce if available.
|
|
740
|
-
# The console_script_code is already
|
|
741
|
-
# so using html_safe here is secure.
|
|
740
|
+
# The console_script_code is already escaped by escapeScript() (applied inside
|
|
741
|
+
# consoleReplay() in the JavaScript layer), so using html_safe here is secure.
|
|
742
742
|
def wrap_console_script_with_nonce(console_script_code)
|
|
743
743
|
return "" if console_script_code.blank?
|
|
744
744
|
|
|
@@ -748,7 +748,7 @@ module ReactOnRails
|
|
|
748
748
|
script_options = { id: "consoleReplayLog" }
|
|
749
749
|
script_options[:nonce] = nonce if nonce.present?
|
|
750
750
|
|
|
751
|
-
# Safe to use html_safe because content is pre-
|
|
751
|
+
# Safe to use html_safe because content is pre-escaped via escapeScript()
|
|
752
752
|
content_tag(:script, console_script_code.html_safe, script_options)
|
|
753
753
|
end
|
|
754
754
|
|
|
@@ -105,13 +105,11 @@ module ReactOnRails
|
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def dom_id
|
|
108
|
-
@dom_id ||= options
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
end
|
|
114
|
-
end
|
|
108
|
+
@dom_id ||= if options[:id].nil?
|
|
109
|
+
random_dom_id ? generate_unique_dom_id : base_dom_id
|
|
110
|
+
else
|
|
111
|
+
options[:id]
|
|
112
|
+
end
|
|
115
113
|
end
|
|
116
114
|
|
|
117
115
|
def random_dom_id?
|
|
@@ -149,8 +149,7 @@ module ReactOnRails
|
|
|
149
149
|
|
|
150
150
|
def read_bundle_js_code
|
|
151
151
|
server_js_file = ReactOnRails::Utils.server_bundle_js_file_path
|
|
152
|
-
|
|
153
|
-
if server_bundle_path_is_http
|
|
152
|
+
if ReactOnRails::Utils.server_bundle_path_is_http?
|
|
154
153
|
file_url_to_string(server_js_file)
|
|
155
154
|
else
|
|
156
155
|
File.read(server_js_file)
|
|
@@ -160,19 +159,12 @@ module ReactOnRails
|
|
|
160
159
|
# (a supported config convenience, e.g. https://:password@host:3800). This is the
|
|
161
160
|
# public entry point that file_url_to_string is called from, so without this the
|
|
162
161
|
# sanitization inside file_url_to_string's own raise/rescue is moot for any real
|
|
163
|
-
# caller — the credential would still leak here regardless.
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
sanitized_url = sanitized_renderer_url(server_js_file)
|
|
168
|
-
sanitized_error = if server_bundle_path_is_http && e.is_a?(ReactOnRails::ServerBundleLoadError)
|
|
169
|
-
e.message
|
|
170
|
-
else
|
|
171
|
-
sanitized_renderer_error_message(e.message, server_js_file, sanitized_url)
|
|
172
|
-
end
|
|
173
|
-
msg = "You specified server rendering JS file: #{sanitized_url}, but it cannot " \
|
|
162
|
+
# caller — the credential would still leak here regardless. e.message is scrubbed too:
|
|
163
|
+
# a URL malformed enough that URI.parse raises embeds the raw, unsanitized URL verbatim
|
|
164
|
+
# in URI::InvalidURIError's own message.
|
|
165
|
+
msg = "You specified server rendering JS file: #{sanitized_renderer_url(server_js_file)}, but it cannot " \
|
|
174
166
|
"be read. You may set the server_bundle_js_file in your configuration to be \"\" to " \
|
|
175
|
-
"avoid this warning.\nError is: #{
|
|
167
|
+
"avoid this warning.\nError is: #{strip_userinfo(e.message, configured_url: server_js_file)}\n\n" \
|
|
176
168
|
"#{Utils.default_troubleshooting_section}"
|
|
177
169
|
raise ReactOnRails::ServerBundleLoadError, msg
|
|
178
170
|
end
|
|
@@ -400,106 +392,27 @@ module ReactOnRails
|
|
|
400
392
|
nil
|
|
401
393
|
end
|
|
402
394
|
|
|
403
|
-
#
|
|
404
|
-
# interpolated into an error message, so a password in the URL (a supported config
|
|
405
|
-
# convenience, e.g. https://:password@host:3800) cannot leak into logs or error
|
|
406
|
-
# trackers. One configured value is not free-form prose: multiple schemes or line breaks
|
|
407
|
-
# after its first scheme make it structurally ambiguous, so that URL suffix fails closed
|
|
408
|
-
# through its final @. Any non-URL prefix is preserved for useful error context.
|
|
395
|
+
# Removes credentials from a renderer URL for safe display. See #5046.
|
|
409
396
|
def sanitized_renderer_url(url)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
scheme = url.match(%r{https?://}i)
|
|
413
|
-
return url unless scheme
|
|
414
|
-
|
|
415
|
-
prefix = url[...scheme.begin(0)]
|
|
416
|
-
configured_url = url[scheme.begin(0)..]
|
|
417
|
-
sanitized_url = if configured_url.match?(/[\r\n]/) || configured_url.scan(%r{https?://}i).length > 1
|
|
418
|
-
strip_malformed_url_userinfo(configured_url)
|
|
419
|
-
else
|
|
420
|
-
sanitized_valid_http_url(configured_url) || strip_malformed_url_userinfo(configured_url)
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
"#{prefix}#{sanitized_url}"
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def sanitized_renderer_error_message(message, raw_url, sanitized_url = sanitized_renderer_url(raw_url))
|
|
427
|
-
message = message.to_s
|
|
428
|
-
return strip_userinfo(message) if raw_url.nil? || raw_url.empty?
|
|
429
|
-
|
|
430
|
-
# URI::InvalidURIError embeds String#inspect, while other errors may embed the raw value.
|
|
431
|
-
message = message.gsub(raw_url.inspect) { sanitized_url.inspect }
|
|
432
|
-
.gsub(raw_url) { sanitized_url }
|
|
433
|
-
strip_userinfo(message)
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# Protects successfully parsed bare HTTP(S) tokens by assembling the result from spans,
|
|
437
|
-
# without collision-prone placeholder text. A candidate remains unprotected when any @
|
|
438
|
-
# follows it before the next scheme: punctuation, prose, and line breaks are not structural
|
|
439
|
-
# proof that the @ is unrelated to malformed userinfo. When an unresolved region precedes
|
|
440
|
-
# a credential-bearing candidate, that region is discarded and the candidate is sanitized
|
|
441
|
-
# independently rather than allowing one fallback match to consume through another scheme.
|
|
442
|
-
# Ambiguous text may be over-redacted for safety.
|
|
443
|
-
def strip_userinfo(text)
|
|
444
|
-
text = text.to_s
|
|
445
|
-
sanitized = text.dup.clear
|
|
446
|
-
unprotected_start = 0
|
|
447
|
-
|
|
448
|
-
text.to_enum(:scan, %r{https?://(?:(?!https?://)[^\s"'])+}i).each do
|
|
449
|
-
match = Regexp.last_match
|
|
450
|
-
protected_url = sanitized_valid_http_url(match[0])
|
|
451
|
-
next unless protected_url
|
|
452
|
-
next if userinfo_delimiter_before_next_scheme?(text, match)
|
|
453
|
-
|
|
454
|
-
unprotected = text[unprotected_start...match.begin(0)]
|
|
455
|
-
sanitized << sanitized_unprotected_prefix(unprotected, match[0], protected_url)
|
|
456
|
-
sanitized << protected_url
|
|
457
|
-
unprotected_start = match.end(0)
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
sanitized << strip_unprotected_userinfo(text[unprotected_start..])
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
def userinfo_delimiter_before_next_scheme?(text, match)
|
|
464
|
-
continuation = text[match.end(0)..]
|
|
465
|
-
boundary = continuation.match(%r{https?://}i)
|
|
466
|
-
continuation = continuation[...boundary.begin(0)] if boundary
|
|
467
|
-
continuation.include?("@")
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
def sanitized_unprotected_prefix(text, raw_url, sanitized_url)
|
|
471
|
-
unresolved_scheme = text.match(%r{https?://}i)
|
|
472
|
-
return strip_unprotected_userinfo(text) if raw_url == sanitized_url || unresolved_scheme.nil?
|
|
473
|
-
|
|
474
|
-
text[...unresolved_scheme.begin(0)]
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
# URI handles valid URLs structurally, so an @ in the path is never mistaken for userinfo.
|
|
478
|
-
# Returning nil for invalid input keeps that token unprotected for the fail-closed pass.
|
|
479
|
-
def sanitized_valid_http_url(url)
|
|
480
|
-
uri = URI.parse(url)
|
|
481
|
-
return url if uri.userinfo.nil?
|
|
482
|
-
|
|
483
|
-
# URI rejects a password without a user, so clear password first.
|
|
484
|
-
uri.password = nil
|
|
485
|
-
uri.user = nil
|
|
486
|
-
uri.to_s
|
|
487
|
-
rescue URI::Error
|
|
488
|
-
nil
|
|
397
|
+
Utils.sanitize_url_for_display(url)
|
|
489
398
|
end
|
|
490
399
|
|
|
491
|
-
|
|
492
|
-
|
|
400
|
+
# Scrubs credentials from error-message text. When the configured URL is
|
|
401
|
+
# known, replaces it exactly first (handles tricky passwords), then cleans
|
|
402
|
+
# any remaining URL-like patterns in the prose.
|
|
403
|
+
def strip_userinfo(text, configured_url: nil)
|
|
404
|
+
sanitized_text = if configured_url
|
|
405
|
+
text.to_s.gsub(configured_url) { sanitized_renderer_url(configured_url) }
|
|
406
|
+
else
|
|
407
|
+
text.to_s
|
|
408
|
+
end
|
|
409
|
+
Utils.sanitize_error_text(sanitized_text)
|
|
493
410
|
end
|
|
494
411
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def strip_malformed_url_userinfo(url)
|
|
498
|
-
scheme = url.match(%r{\Ahttps?://}i)
|
|
499
|
-
userinfo_delimiter = url.rindex("@")
|
|
500
|
-
return url unless scheme && userinfo_delimiter
|
|
412
|
+
def sanitized_url_error_message(error, configured_url:)
|
|
413
|
+
return strip_userinfo(error.message, configured_url:) unless error.is_a?(URI::InvalidURIError)
|
|
501
414
|
|
|
502
|
-
"
|
|
415
|
+
"bad URI (sanitized): #{sanitized_renderer_url(configured_url).inspect}"
|
|
503
416
|
end
|
|
504
417
|
|
|
505
418
|
def file_url_to_string(url)
|
|
@@ -518,10 +431,10 @@ module ReactOnRails
|
|
|
518
431
|
# equals the destination (UTF-8). charset_from_content_type returns UTF-8 for three of
|
|
519
432
|
# the four cases this method exists to handle (no Content-Type header, no charset
|
|
520
433
|
# parameter, and an explicit charset=utf-8), so `.encode(Encoding::UTF_8)` alone would
|
|
521
|
-
# silently let invalid bytes through unchanged on those three paths
|
|
522
|
-
# cross-encoding transcode
|
|
523
|
-
#
|
|
524
|
-
# fails fast, not just the cross-encoding one.
|
|
434
|
+
# silently let invalid bytes through unchanged on those three paths. A genuine
|
|
435
|
+
# cross-encoding transcode can reject malformed input only when the source encoding has
|
|
436
|
+
# invalid byte sequences of its own (such as Shift_JIS). valid_encoding? is therefore
|
|
437
|
+
# checked explicitly afterward so every path fails fast, not just the cross-encoding one.
|
|
525
438
|
#
|
|
526
439
|
# An undecodable byte sequence (a charset that doesn't actually match the bytes) is
|
|
527
440
|
# deliberately raised here rather than silently replaced with U+FFFD: this is source
|
|
@@ -542,10 +455,8 @@ module ReactOnRails
|
|
|
542
455
|
# message, which Rails.logger and error trackers can persist. e.message is scrubbed as
|
|
543
456
|
# well as url itself: a URL malformed enough that URI.parse raises embeds the raw,
|
|
544
457
|
# unsanitized URL verbatim in URI::InvalidURIError's own message.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
msg = "file_url_to_string #{sanitized_url} failed\nError is: " \
|
|
548
|
-
"#{sanitized_error}\n\n#{Utils.default_troubleshooting_section}"
|
|
458
|
+
msg = "file_url_to_string #{sanitized_renderer_url(url)} failed\nError is: " \
|
|
459
|
+
"#{sanitized_url_error_message(e, configured_url: url)}\n\n#{Utils.default_troubleshooting_section}"
|
|
549
460
|
raise ReactOnRails::ServerBundleLoadError, msg
|
|
550
461
|
end
|
|
551
462
|
|
data/lib/react_on_rails/utils.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "English"
|
|
4
4
|
require "open3"
|
|
5
5
|
require "rainbow"
|
|
6
|
+
require "uri"
|
|
6
7
|
require "active_support"
|
|
7
8
|
require "active_support/core_ext/string"
|
|
8
9
|
require "shellwords"
|
|
@@ -444,6 +445,70 @@ module ReactOnRails
|
|
|
444
445
|
end
|
|
445
446
|
end
|
|
446
447
|
|
|
448
|
+
# Removes credentials from a URL for safe display in logs and error messages.
|
|
449
|
+
# Uses URI.parse for all heavy lifting — no regex on the URL itself.
|
|
450
|
+
#
|
|
451
|
+
# URI::File silently drops userinfo during parsing, so uri.to_s is already
|
|
452
|
+
# clean for file:// URLs. Malformed URLs that fail URI.parse get a safe
|
|
453
|
+
# placeholder. See #5046.
|
|
454
|
+
def self.sanitize_url_for_display(url)
|
|
455
|
+
return url if url.nil? || url.empty?
|
|
456
|
+
|
|
457
|
+
# Prepend http:// if there's no scheme — bare "host:port" strings are
|
|
458
|
+
# valid targets but URI.parse misparses them (treats "host" as the scheme).
|
|
459
|
+
# The HTTP stack makes the same assumption.
|
|
460
|
+
added_scheme = false
|
|
461
|
+
unless url.include?("://") || url.start_with?("/")
|
|
462
|
+
url = "http://#{url}"
|
|
463
|
+
added_scheme = true
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
begin
|
|
467
|
+
uri = URI.parse(url)
|
|
468
|
+
rescue URI::InvalidURIError
|
|
469
|
+
return "[unparseable URL redacted]"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
if uri.userinfo
|
|
473
|
+
uri.password = nil
|
|
474
|
+
uri.user = nil
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
result = redact_query_values(uri)
|
|
478
|
+
# Strip the scheme we added — output should match the input format
|
|
479
|
+
result = result.delete_prefix("http://") if added_scheme
|
|
480
|
+
result
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Scrubs credentials from error-message text that may contain URLs.
|
|
484
|
+
def self.sanitize_error_text(text)
|
|
485
|
+
return text if text.nil? || text.empty?
|
|
486
|
+
|
|
487
|
+
text.to_s
|
|
488
|
+
.gsub(%r{//[^/?#]*@}, "//")
|
|
489
|
+
.gsub(%r{https?://[^\s]+}) do |match|
|
|
490
|
+
trimmed = match.sub(/[).,;:'">\]]+\z/, "")
|
|
491
|
+
sanitize_url_for_display(trimmed) + match[trimmed.length..]
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Replaces query values with [REDACTED], keeping keys for diagnostics.
|
|
496
|
+
# Bare components without = (like ?eyJhbGciOi...) are treated as opaque
|
|
497
|
+
# tokens and fully redacted. Operates on a parsed URI object.
|
|
498
|
+
def self.redact_query_values(uri)
|
|
499
|
+
if uri.query && !uri.query.empty?
|
|
500
|
+
begin
|
|
501
|
+
pairs = URI.decode_www_form(uri.query)
|
|
502
|
+
uri.query = pairs.map { |k, v| v.empty? ? "[REDACTED]" : "#{k}=[REDACTED]" }.join("&")
|
|
503
|
+
rescue ArgumentError
|
|
504
|
+
uri.query = "[REDACTED]"
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
uri.to_s
|
|
509
|
+
end
|
|
510
|
+
private_class_method :redact_query_values
|
|
511
|
+
|
|
447
512
|
def self.default_troubleshooting_section
|
|
448
513
|
<<~DEFAULT
|
|
449
514
|
📞 Get Help & Support:
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
require "strscan"
|
|
7
|
+
|
|
8
|
+
module ReactOnRails
|
|
9
|
+
class VersionChecker
|
|
10
|
+
# Parsers for the lockfile formats that NodePackageVersion#resolve_version consults beyond
|
|
11
|
+
# Yarn classic and package-lock.json: pnpm-lock.yaml, bun.lock, and Yarn Berry's YAML
|
|
12
|
+
# yarn.lock (issue #5049). Semantics deliberately mirror the pre-existing parsers: entries
|
|
13
|
+
# are found by package name, and any unreadable or unrecognized lockfile simply yields nil
|
|
14
|
+
# so resolution falls back to the package.json version.
|
|
15
|
+
#
|
|
16
|
+
# The binary bun.lockb is intentionally NOT parsed (undocumented format); projects still on
|
|
17
|
+
# it resolve from package.json until they migrate to bun's text lockfile
|
|
18
|
+
# (bun install --save-text-lockfile --frozen-lockfile --lockfile-only, bun >= 1.1.39).
|
|
19
|
+
module LockfileResolution
|
|
20
|
+
# Resolve the installed version from pnpm-lock.yaml or bun.lock in +dir+ (the directory
|
|
21
|
+
# containing the package.json being checked). Returns a version String or nil.
|
|
22
|
+
def self.version(dir, package_name)
|
|
23
|
+
pnpm_lock = File.join(dir, "pnpm-lock.yaml")
|
|
24
|
+
if File.exist?(pnpm_lock)
|
|
25
|
+
version = guard { PnpmLockfile.version(pnpm_lock, package_name) }
|
|
26
|
+
return version if version
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
bun_lock = File.join(dir, "bun.lock")
|
|
30
|
+
if File.exist?(bun_lock)
|
|
31
|
+
version = guard { BunLockfile.version(bun_lock, package_name) }
|
|
32
|
+
return version if version
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Yarn Berry (yarn 2+) yarn.lock content, dispatched from version_from_yarn_lock when the
|
|
39
|
+
# __metadata marker is present. Returns a version String or nil.
|
|
40
|
+
def self.berry_yarn_version(content, package_name)
|
|
41
|
+
guard { BerryYarnLockfile.version(content, package_name) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A malformed, unreadable, or unexpectedly shaped lockfile must behave exactly like a
|
|
45
|
+
# missing one (package.json fallback), mirroring the JSON::ParserError rescue the
|
|
46
|
+
# package-lock.json parser has always had — never crash the Rails initializer.
|
|
47
|
+
def self.guard
|
|
48
|
+
yield
|
|
49
|
+
rescue Psych::Exception, JSON::ParserError, TypeError, NoMethodError,
|
|
50
|
+
SystemCallError, EncodingError, ArgumentError => e
|
|
51
|
+
# These classes are how bad lockfile CONTENT surfaces (shape surprises from dig chains,
|
|
52
|
+
# invalid encodings, parse errors) — but a bug inside a parser would raise the same
|
|
53
|
+
# classes. Leave a debug trace so a swallowed programming error stays discoverable.
|
|
54
|
+
log_ignored(e)
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.log_ignored(error)
|
|
59
|
+
return unless defined?(Rails) && Rails.logger
|
|
60
|
+
|
|
61
|
+
Rails.logger.debug { "React on Rails lockfile resolution ignored #{error.class}: #{error.message}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# pnpm-lock.yaml. Handles lockfileVersion 5.x (pnpm <= 7: bare version strings), 6.0
|
|
65
|
+
# (pnpm 8: {specifier, version} objects), and 9.0 (pnpm 9/10: importers section),
|
|
66
|
+
# including the pnpm 11 multi-document form where an env document precedes the project
|
|
67
|
+
# document.
|
|
68
|
+
module PnpmLockfile
|
|
69
|
+
def self.version(path, package_name)
|
|
70
|
+
doc = project_document(path)
|
|
71
|
+
return nil unless doc
|
|
72
|
+
|
|
73
|
+
entry = dependencies(doc)&.[](package_name)
|
|
74
|
+
case entry
|
|
75
|
+
when Hash then strip_peer_suffix(entry["version"])
|
|
76
|
+
when String then strip_peer_suffix(entry)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.dependencies(doc)
|
|
81
|
+
return doc.dig("importers", ".", "dependencies") if doc.key?("importers")
|
|
82
|
+
|
|
83
|
+
doc["dependencies"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# YAML.safe_load would return only the FIRST document of a pnpm 11 multi-document
|
|
87
|
+
# lockfile — the env document, which itself carries an importers section (with
|
|
88
|
+
# configDependencies/packageManagerDependencies under ".", never dependencies), so a
|
|
89
|
+
# first-match scan would pick it and resolution would come up empty. pnpm always writes
|
|
90
|
+
# the project document last (writeEnvLockfile prepends the env doc on every write), so
|
|
91
|
+
# scan the stream from the end. permitted_classes: some pnpm versions write unquoted
|
|
92
|
+
# ISO timestamps (a time: map), which Psych types as Date/Time and safe parsing would
|
|
93
|
+
# otherwise reject.
|
|
94
|
+
def self.project_document(path)
|
|
95
|
+
docs = YAML.safe_load_stream(File.read(path), permitted_classes: [Date, Time])
|
|
96
|
+
docs.reverse_each.find { |d| d.is_a?(Hash) && (d.key?("importers") || d.key?("dependencies")) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# lockfileVersion >= 6: "16.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)" -> "16.6.0"
|
|
100
|
+
# lockfileVersion 5.x: "16.6.0_react@19.3.0" -> "16.6.0"
|
|
101
|
+
def self.strip_peer_suffix(version)
|
|
102
|
+
return nil if version.nil?
|
|
103
|
+
|
|
104
|
+
cut = [version.index("("), version.index("_")].compact.min
|
|
105
|
+
cut ? version[0...cut] : version
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# bun.lock — bun's text lockfile (lockfileVersion 0 in bun 1.1.39, 1 in bun 1.2-1.3,
|
|
110
|
+
# 2 in bun 1.4+; same on-disk shape throughout). The file is JSONC: strict JSON.parse
|
|
111
|
+
# fails on its trailing commas, so comments and trailing commas are stripped first.
|
|
112
|
+
module BunLockfile
|
|
113
|
+
def self.version(path, package_name)
|
|
114
|
+
doc = JSON.parse(jsonc_to_json(File.read(path)))
|
|
115
|
+
resolution = doc.dig("packages", package_name, 0)
|
|
116
|
+
return nil unless resolution.is_a?(String)
|
|
117
|
+
|
|
118
|
+
# "react-on-rails@16.6.0" / "@scope/pkg@1.2.3" -> version after the last "@"
|
|
119
|
+
resolution.rpartition("@").last
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One complete JSON string literal (escape-aware), a // line comment, a /* */ block
|
|
123
|
+
# comment, and a trailing comma directly before } or ].
|
|
124
|
+
STRING_LITERAL = /"(?:[^"\\]|\\.)*"/
|
|
125
|
+
LINE_COMMENT = %r{//[^\n]*}
|
|
126
|
+
BLOCK_COMMENT = %r{/\*.*?\*/}m
|
|
127
|
+
TRAILING_COMMA = /,(?=\s*[}\]])/
|
|
128
|
+
|
|
129
|
+
# String-aware JSONC-to-JSON: comments and trailing commas are only stripped OUTSIDE
|
|
130
|
+
# string literals, so values like "x,]" or "https://..." can never be corrupted.
|
|
131
|
+
def self.jsonc_to_json(content)
|
|
132
|
+
scanner = StringScanner.new(content)
|
|
133
|
+
result = +""
|
|
134
|
+
until scanner.eos?
|
|
135
|
+
if (string = scanner.scan(STRING_LITERAL))
|
|
136
|
+
result << string
|
|
137
|
+
elsif scanner.scan(LINE_COMMENT) || scanner.scan(BLOCK_COMMENT) || scanner.scan(TRAILING_COMMA)
|
|
138
|
+
next
|
|
139
|
+
else
|
|
140
|
+
result << scanner.getch
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Yarn Berry yarn.lock (real YAML; entry shape is identical across __metadata versions
|
|
148
|
+
# 4-8, i.e. yarn 2 through yarn 4). Mirrors the Yarn classic parser's semantics: the
|
|
149
|
+
# first entry keyed by this package name wins.
|
|
150
|
+
module BerryYarnLockfile
|
|
151
|
+
def self.version(content, package_name)
|
|
152
|
+
# permitted_classes mirrors the pnpm parser: tolerate unquoted timestamp scalars.
|
|
153
|
+
doc = YAML.safe_load(content, permitted_classes: [Date, Time])
|
|
154
|
+
return nil unless doc.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
# Berry keys registry deps with the npm: protocol ("react-on-rails@npm:^16.1.1") and
|
|
157
|
+
# can merge several selectors into one key. Matching "#{name}@" cannot collide with
|
|
158
|
+
# longer names ("react-on-rails-pro@..." does not start with "react-on-rails@").
|
|
159
|
+
doc.each do |key, entry|
|
|
160
|
+
next if key == "__metadata"
|
|
161
|
+
next unless key.to_s.split(",").any? { |selector| selector.strip.start_with?("#{package_name}@") }
|
|
162
|
+
|
|
163
|
+
return entry["version"]&.to_s if entry.is_a?(Hash)
|
|
164
|
+
end
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require_relative "version_syntax_converter"
|
|
5
5
|
require_relative "shakapacker_config_helpers"
|
|
6
6
|
require_relative "rsc_rspack_support"
|
|
7
|
+
require_relative "version_checker/lockfile_resolution"
|
|
7
8
|
|
|
8
9
|
module ReactOnRails
|
|
9
10
|
# Responsible for checking versions of rubygem versus yarn node package
|
|
@@ -15,7 +16,10 @@ module ReactOnRails
|
|
|
15
16
|
attr_reader :node_package_version
|
|
16
17
|
|
|
17
18
|
# Semver uses - to separate pre-release, but RubyGems use .
|
|
18
|
-
|
|
19
|
+
# Possessive quantifiers (++) keep matching linear on adversarial inputs (e.g. a version
|
|
20
|
+
# string of thousands of digits): a digit run never needs to backtrack for the following
|
|
21
|
+
# "." to match, so the semantics are unchanged.
|
|
22
|
+
VERSION_PARTS_REGEX = /(\d++)\.(\d++)\.(\d++)(?:[-.]([0-9A-Za-z.-]++))?/
|
|
19
23
|
def self.build
|
|
20
24
|
new(NodePackageVersion.build)
|
|
21
25
|
end
|
|
@@ -205,8 +209,10 @@ module ReactOnRails
|
|
|
205
209
|
Detected: #{node_package_version.raw}
|
|
206
210
|
Gem: #{gem_version}
|
|
207
211
|
|
|
208
|
-
React on Rails
|
|
209
|
-
|
|
212
|
+
React on Rails checks the INSTALLED package version against the gem version, so semver
|
|
213
|
+
ranges like ^ or ~ in package.json are fine when a lockfile can resolve them. No lockfile
|
|
214
|
+
could resolve the installed version here, so the package.json version itself must be an
|
|
215
|
+
exact version matching the gem.
|
|
210
216
|
|
|
211
217
|
Fix:
|
|
212
218
|
Run: #{install_cmd}
|
|
@@ -579,24 +585,33 @@ module ReactOnRails
|
|
|
579
585
|
|
|
580
586
|
# Resolve version from lockfiles if available, otherwise use package.json version
|
|
581
587
|
def resolve_version(package_json_version, package_name)
|
|
582
|
-
# If package.json specifies a local path or
|
|
583
|
-
# Lockfiles
|
|
584
|
-
|
|
588
|
+
# If package.json specifies a local path, URL, or workspace link, don't try to resolve
|
|
589
|
+
# from lockfiles. Lockfiles record placeholders for links (e.g. "0.0.0", the Yarn Berry
|
|
590
|
+
# "0.0.0-use.local"), and keeping the raw spec lets the validators apply their
|
|
591
|
+
# local/workspace exemptions. A JSON null dependency value stays nil (validators treat a
|
|
592
|
+
# nil raw as "not installed").
|
|
593
|
+
return package_json_version if package_json_version.nil? ||
|
|
594
|
+
local_path_or_url_version?(package_json_version) ||
|
|
595
|
+
package_json_version.start_with?("workspace:")
|
|
596
|
+
|
|
597
|
+
# Fall back to the package.json version when no lockfile resolves one.
|
|
598
|
+
lockfile_version(package_name) || package_json_version
|
|
599
|
+
end
|
|
585
600
|
|
|
586
|
-
|
|
601
|
+
# Try yarn.lock first, then package-lock.json, then the other package managers'
|
|
602
|
+
# lockfiles (pnpm-lock.yaml, bun.lock), which live in the same directory as package.json.
|
|
603
|
+
def lockfile_version(package_name)
|
|
587
604
|
if yarn_lock && File.exist?(yarn_lock)
|
|
588
|
-
|
|
589
|
-
return
|
|
605
|
+
version = version_from_yarn_lock(package_name)
|
|
606
|
+
return version if version
|
|
590
607
|
end
|
|
591
608
|
|
|
592
|
-
# Try package-lock.json
|
|
593
609
|
if package_lock && File.exist?(package_lock)
|
|
594
|
-
|
|
595
|
-
return
|
|
610
|
+
version = version_from_package_lock(package_name)
|
|
611
|
+
return version if version
|
|
596
612
|
end
|
|
597
613
|
|
|
598
|
-
|
|
599
|
-
package_json_version
|
|
614
|
+
LockfileResolution.version(File.dirname(package_json.to_s), package_name)
|
|
600
615
|
end
|
|
601
616
|
|
|
602
617
|
# Check if a version string represents a local path or URL
|
|
@@ -616,6 +631,13 @@ module ReactOnRails
|
|
|
616
631
|
def version_from_yarn_lock(package_name)
|
|
617
632
|
return nil unless yarn_lock && File.exist?(yarn_lock)
|
|
618
633
|
|
|
634
|
+
# Yarn Berry (yarn 2+) writes the same yarn.lock filename in a YAML format marked by an
|
|
635
|
+
# __metadata section near the top; the format is decided by content, not by the yarn
|
|
636
|
+
# version in use. Sniff only the file head so the classic path below keeps streaming.
|
|
637
|
+
if File.open(yarn_lock) { |f| f.read(1024) }.to_s.include?("__metadata:")
|
|
638
|
+
return LockfileResolution.berry_yarn_version(File.read(yarn_lock), package_name)
|
|
639
|
+
end
|
|
640
|
+
|
|
619
641
|
in_package_block = false
|
|
620
642
|
File.foreach(yarn_lock) do |line|
|
|
621
643
|
# Check if we're starting the block for our package
|
|
@@ -627,15 +649,14 @@ module ReactOnRails
|
|
|
627
649
|
end
|
|
628
650
|
|
|
629
651
|
# If we're in the package block, look for the version line
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
# If we hit a blank line or new package, we've left the block
|
|
637
|
-
break if line.strip.empty? || (line[0] != " " && line[0] != "\t")
|
|
652
|
+
next unless in_package_block
|
|
653
|
+
# Version line looks like: version "16.1.1"
|
|
654
|
+
if (match = line.match(/^\s+version\s+"([^"]+)"/))
|
|
655
|
+
return match[1]
|
|
638
656
|
end
|
|
657
|
+
|
|
658
|
+
# If we hit a blank line or new package, we've left the block
|
|
659
|
+
break if line.strip.empty? || (line[0] != " " && line[0] != "\t")
|
|
639
660
|
end
|
|
640
661
|
|
|
641
662
|
nil
|
data/rakelib/run_rspec.rake
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
require "yaml"
|
|
5
6
|
|
|
6
7
|
require_relative "task_helpers"
|
|
@@ -42,7 +43,11 @@ namespace :run_rspec do
|
|
|
42
43
|
# by appending RBS runtime hook instead of replacing
|
|
43
44
|
existing_rubyopt = ENV.fetch("RUBYOPT", nil)
|
|
44
45
|
rubyopt_parts = ["-rrbs/test/setup", existing_rubyopt].compact.reject(&:empty?)
|
|
45
|
-
|
|
46
|
+
# Configuration accepts Rails.root and documented locale directories as Pathname values.
|
|
47
|
+
# Load their standard-library signatures without dropping caller-supplied RBS options.
|
|
48
|
+
rbs_options = "#{ENV.fetch('RBS_TEST_OPT', '-I sig')} -r pathname"
|
|
49
|
+
"RBS_TEST_OPT=#{Shellwords.escape(rbs_options)} " \
|
|
50
|
+
"RBS_TEST_TARGET='ReactOnRails::*' RUBYOPT='#{rubyopt_parts.join(' ')}'"
|
|
46
51
|
rescue LoadError
|
|
47
52
|
# RBS not available - silently skip runtime checking
|
|
48
53
|
# This is expected in environments without the rbs gem
|