react_on_rails 17.0.1 → 17.1.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.lock +1 -1
- data/docs/agent/README.md +26 -0
- data/docs/agent/doctor-fix-loop.md +25 -0
- data/docs/agent/install-and-upgrade.md +162 -0
- data/docs/agent/rsc-adoption.md +31 -0
- data/docs/agent/streaming-debug.md +57 -0
- data/lib/generators/react_on_rails/base_generator.rb +4 -4
- data/lib/generators/react_on_rails/generator_helper.rb +12 -1
- data/lib/generators/react_on_rails/install_generator.rb +50 -5
- data/lib/generators/react_on_rails/js_dependency_manager.rb +1 -1
- data/lib/generators/react_on_rails/pro_generator.rb +5 -3
- data/lib/generators/react_on_rails/pro_setup.rb +51 -19
- data/lib/generators/react_on_rails/rsc_generator.rb +18 -1
- data/lib/generators/react_on_rails/rsc_setup/client_references.rb +63 -25
- data/lib/generators/react_on_rails/rsc_setup.rb +141 -39
- data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +31 -3
- data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev-static-assets → Procfile.dev-static-assets.tt} +1 -1
- data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev → Procfile.dev.tt} +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +18 -42
- data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb +215 -0
- data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_skill.md +71 -0
- data/lib/react_on_rails/agent_guardrails.rb +223 -0
- data/lib/react_on_rails/dev/service_checker.rb +1 -1
- data/lib/react_on_rails/doctor.rb +880 -48
- data/lib/react_on_rails/doctor_schema.rb +192 -0
- data/lib/react_on_rails/engine.rb +2 -2
- data/lib/react_on_rails/helper.rb +17 -10
- data/lib/react_on_rails/length_prefixed_parser.rb +22 -14
- data/lib/react_on_rails/lenient_json.rb +0 -0
- data/lib/react_on_rails/prerender_error.rb +38 -8
- data/lib/react_on_rails/react_component/render_options.rb +1 -1
- data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +168 -15
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/tasks/agent_guardrails.rake +21 -0
- data/rakelib/run_rspec.rake +14 -0
- data/sig/react_on_rails/prerender_error.rbs +2 -1
- data/skills/doctor-fix-loop/SKILL.md +26 -0
- data/skills/install-and-upgrade/SKILL.md +24 -0
- data/skills/rsc-adoption/SKILL.md +28 -0
- data/skills/streaming-debug/SKILL.md +53 -0
- metadata +18 -4
- data/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-watch +0 -26
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "erb"
|
|
5
|
+
require "open3"
|
|
5
6
|
require "stringio"
|
|
6
7
|
require "tempfile"
|
|
7
8
|
require "timeout"
|
|
9
|
+
require "uri"
|
|
8
10
|
require "yaml"
|
|
9
11
|
require_relative "utils"
|
|
10
12
|
require_relative "version"
|
|
@@ -15,6 +17,7 @@ require_relative "dev/server_mode"
|
|
|
15
17
|
require_relative "version_syntax_converter"
|
|
16
18
|
require_relative "version_synchronizer"
|
|
17
19
|
require_relative "system_checker"
|
|
20
|
+
require_relative "doctor_schema"
|
|
18
21
|
require_relative "pro_migration"
|
|
19
22
|
require_relative "node_renderer_procfile"
|
|
20
23
|
require_relative "../generators/react_on_rails/js_dependency_manager"
|
|
@@ -116,6 +119,104 @@ module ReactOnRails
|
|
|
116
119
|
].freeze
|
|
117
120
|
# Per-file safety gate to bound IO during the scan, not a meaningful size limit.
|
|
118
121
|
RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES = 1_048_576
|
|
122
|
+
NODE_RENDERER_CONFIG_MAX_BYTES = 1_048_576
|
|
123
|
+
NODE_RENDERER_SYNTAX_CHECK_TIMEOUT_SECONDS = 5
|
|
124
|
+
NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS = 0.5
|
|
125
|
+
NODE_RENDERER_ROLLOUT_GENERATIONS = 2
|
|
126
|
+
NODE_RENDERER_CURRENT_GENERATION_MANIFEST_FILENAME = /\Arorp-generation-v1-[0-9a-f]{64}\.json\z/
|
|
127
|
+
NODE_RENDERER_LAUNCHER_PATHS = %w[
|
|
128
|
+
Procfile
|
|
129
|
+
Procfile.dev
|
|
130
|
+
Procfile.dev-static-assets
|
|
131
|
+
Procfile.dev-prod-assets
|
|
132
|
+
Procfile.production
|
|
133
|
+
].freeze
|
|
134
|
+
NODE_RENDERER_SCRIPT_REFERENCE_PATTERN =
|
|
135
|
+
%r{\bnode\s+(?:\./)?((?:renderer|client)/node-renderer\.js)(?=\s|\z)}
|
|
136
|
+
NODE_RENDERER_DIRECT_LAUNCHER_PATTERN = %r{
|
|
137
|
+
\A[ \t]*[A-Za-z0-9_-]+:[ \t]*
|
|
138
|
+
(?:MAX_VM_POOL_SIZE=(?<assignment>[^\s]+)[ \t]+)?
|
|
139
|
+
node[ \t]+(?:\./)?(?<path>(?:renderer|client)/node-renderer\.js)
|
|
140
|
+
[ \t]*(?:\r?\n)?\z
|
|
141
|
+
}x
|
|
142
|
+
NODE_RENDERER_JS_STRING_PATTERN = /
|
|
143
|
+
"(?:\\.|[^"\\])*" |
|
|
144
|
+
'(?:\\.|[^'\\])*' |
|
|
145
|
+
`(?:\\.|[^`\\])*`
|
|
146
|
+
/mx
|
|
147
|
+
NODE_RENDERER_NESTED_OBJECT_PATTERN = /
|
|
148
|
+
\{
|
|
149
|
+
(?:#{NODE_RENDERER_JS_STRING_PATTERN}|[^{}"'`])*
|
|
150
|
+
\}
|
|
151
|
+
/mx
|
|
152
|
+
NODE_RENDERER_CONFIG_OBJECT_PATTERN = /
|
|
153
|
+
\{
|
|
154
|
+
(?:#{NODE_RENDERER_JS_STRING_PATTERN}|#{NODE_RENDERER_NESTED_OBJECT_PATTERN}|[^{}"'`])*
|
|
155
|
+
\}
|
|
156
|
+
/mx
|
|
157
|
+
NODE_RENDERER_BARE_CALL_PATTERN =
|
|
158
|
+
/(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\s*\(\s*/
|
|
159
|
+
# A canonical launcher needs one bare renderer call. This generous allowance
|
|
160
|
+
# bounds prefix-based reachability work while tolerating benign decoys.
|
|
161
|
+
MAX_NODE_RENDERER_BARE_CALL_CANDIDATES = 32
|
|
162
|
+
# The canonical export must be a shorthand specifier and the binding must
|
|
163
|
+
# end explicitly. Aliases and trusted-looking expression prefixes are not
|
|
164
|
+
# evidence that the real renderer runs.
|
|
165
|
+
NODE_RENDERER_PACKAGE_BINDING_PATTERN = /
|
|
166
|
+
(?:\A|[;\n])\s*
|
|
167
|
+
(?:
|
|
168
|
+
const\s*\{\s*
|
|
169
|
+
(?:
|
|
170
|
+
[A-Za-z_$][A-Za-z0-9_$]*(?:\s*:\s*[A-Za-z_$][A-Za-z0-9_$]*)?\s*,\s*
|
|
171
|
+
)*
|
|
172
|
+
reactOnRailsProNodeRenderer
|
|
173
|
+
(?:
|
|
174
|
+
\s*,\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\s*:\s*[A-Za-z_$][A-Za-z0-9_$]*)?
|
|
175
|
+
)*
|
|
176
|
+
\s*,?\s*\}\s*=\s*
|
|
177
|
+
require\s*\(\s*["']react-on-rails-pro-node-renderer["']\s*\)
|
|
178
|
+
|
|
|
179
|
+
import\s*\{\s*
|
|
180
|
+
(?:
|
|
181
|
+
[A-Za-z_$][A-Za-z0-9_$]*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*,\s*
|
|
182
|
+
)*
|
|
183
|
+
reactOnRailsProNodeRenderer
|
|
184
|
+
(?:
|
|
185
|
+
\s*,\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?
|
|
186
|
+
)*
|
|
187
|
+
\s*,?\s*\}\s+from\s+
|
|
188
|
+
["']react-on-rails-pro-node-renderer["']
|
|
189
|
+
)\s*(?:;|\z)
|
|
190
|
+
/mx
|
|
191
|
+
NODE_RENDERER_REQUIRE_IDENTIFIER_PATTERN = /(?<![.\p{ID_Continue}$#])require\b/
|
|
192
|
+
NODE_RENDERER_REQUIRE_FUNCTION_DECLARATION_PATTERN = /\bfunction\s*\*?\s+require\b/
|
|
193
|
+
NODE_RENDERER_ARGUMENTS_IDENTIFIER_PATTERN = /(?<![.\p{ID_Continue}$#])arguments\b/
|
|
194
|
+
NODE_RENDERER_DIRECT_EVAL_PATTERN = /(?<![.\p{ID_Continue}$#])eval\s*\(/
|
|
195
|
+
NODE_RENDERER_LOCAL_BINDING_PATTERN = %r{
|
|
196
|
+
\b(?:const|let|var)\s+
|
|
197
|
+
(?:reactOnRailsProNodeRenderer\b|\{[^{}]*\breactOnRailsProNodeRenderer\b[^{}]*\})
|
|
198
|
+
|
|
|
199
|
+
\b(?:function|class)\s+reactOnRailsProNodeRenderer\b
|
|
200
|
+
|
|
|
201
|
+
\bimport\s*\{[^{}]*\breactOnRailsProNodeRenderer\b[^{}]*\}\s+from\b
|
|
202
|
+
|
|
|
203
|
+
(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\b\s*
|
|
204
|
+
(?:\+\+|--|\*\*=|&&=|\|\|=|\?\?=|[+\-*\/%&|^]=|=(?!=|>))
|
|
205
|
+
|
|
|
206
|
+
(?:\+\+|--)\s*(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\b
|
|
207
|
+
}mx
|
|
208
|
+
NODE_RENDERER_IDENTIFIER_PATTERN =
|
|
209
|
+
/(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\b/
|
|
210
|
+
NODE_RENDERER_GLOBAL_OBJECT_PATTERN =
|
|
211
|
+
/(?<![.\p{ID_Continue}$#])(?:globalThis|global)\b/
|
|
212
|
+
NODE_RENDERER_UNPROVEN_CALL_CONTROL_PATTERN = /
|
|
213
|
+
(?:&&|\|\||=>|\?) |
|
|
214
|
+
\b(?:if|else|for|while|do|switch|case|catch|finally|function|return|throw)\b
|
|
215
|
+
/x
|
|
216
|
+
NODE_RENDERER_OPENING_DELIMITERS = ["(", "[", "{"].freeze
|
|
217
|
+
NODE_RENDERER_QUOTE_CHARACTERS = ['"', "'"].freeze
|
|
218
|
+
NODE_RENDERER_MATCHING_OPENING_DELIMITERS = { ")" => "(", "]" => "[", "}" => "{" }.freeze
|
|
219
|
+
NODE_RENDERER_UNPROVEN_INITIALIZER_OPENINGS = ["(", "["].freeze
|
|
119
220
|
# Defense-in-depth cap on how many files a single glob may contribute.
|
|
120
221
|
# Realistic repos have a handful of workflow / deploy-stage files; far more
|
|
121
222
|
# than this is a sign of an unexpectedly broad pattern, not legitimate config.
|
|
@@ -125,28 +226,11 @@ module ReactOnRails
|
|
|
125
226
|
# a machine-readable report (see JSON_SCHEMA_VERSION below).
|
|
126
227
|
OUTPUT_FORMATS = %i[text json].freeze
|
|
127
228
|
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
# {
|
|
132
|
-
# "schema_version": 1,
|
|
133
|
-
# "ror_version": "<ReactOnRails::VERSION>",
|
|
134
|
-
# "status": "pass" | "warn" | "fail", // worst check status
|
|
135
|
-
# "checks": [
|
|
136
|
-
# {
|
|
137
|
-
# "id": "<stable snake_case id from CHECK_SECTIONS>",
|
|
138
|
-
# "title": "<human section title>",
|
|
139
|
-
# "status": "pass" | "warn" | "fail",
|
|
140
|
-
# "message": "<most severe message content, or null>",
|
|
141
|
-
# "details": [ { "level": "success|warning|error|info", "content": "..." } ]
|
|
142
|
-
# }
|
|
143
|
-
# ],
|
|
144
|
-
# "summary": { "pass": <count>, "warn": <count>, "fail": <count> }
|
|
145
|
-
# }
|
|
146
|
-
#
|
|
229
|
+
# DoctorSchema is the canonical machine-readable report contract; bump its
|
|
230
|
+
# version only for breaking changes.
|
|
147
231
|
# No timestamp is included so output is deterministic for a given app state.
|
|
148
232
|
# Exit code semantics match text mode: 1 if any check fails, else 0.
|
|
149
|
-
JSON_SCHEMA_VERSION =
|
|
233
|
+
JSON_SCHEMA_VERSION = DoctorSchema::VERSION
|
|
150
234
|
|
|
151
235
|
# Doctor check sections. The :id values are part of the stable JSON schema
|
|
152
236
|
# contract (consumed by agents/tooling) — never rename or reuse them; add new
|
|
@@ -165,6 +249,8 @@ module ReactOnRails
|
|
|
165
249
|
{ id: "testing_setup", title: "Testing Setup", method: :check_testing_setup },
|
|
166
250
|
{ id: "development_environment", title: "Development Environment", method: :check_development },
|
|
167
251
|
{ id: "react_on_rails_pro_setup", title: "React on Rails Pro Setup", method: :check_pro_setup },
|
|
252
|
+
{ id: "node_renderer_rollout_capacity", title: "Node Renderer Rollout Capacity",
|
|
253
|
+
method: :check_node_renderer_rollout_capacity },
|
|
168
254
|
{ id: "react_server_components", title: "React Server Components", method: :check_rsc_setup }
|
|
169
255
|
].freeze
|
|
170
256
|
CHECK_SECTIONS_BY_ID = CHECK_SECTIONS.to_h { |section| [section[:id], section] }.freeze
|
|
@@ -259,7 +345,9 @@ module ReactOnRails
|
|
|
259
345
|
stray_output = capture_stdout { results = collect_check_results }
|
|
260
346
|
$stderr.print(stray_output) unless stray_output.empty?
|
|
261
347
|
|
|
262
|
-
|
|
348
|
+
report = build_json_report(results)
|
|
349
|
+
DoctorSchema.validate!(report)
|
|
350
|
+
puts JSON.pretty_generate(report)
|
|
263
351
|
exit(diagnosis_exit_code)
|
|
264
352
|
end
|
|
265
353
|
|
|
@@ -320,29 +408,47 @@ module ReactOnRails
|
|
|
320
408
|
{
|
|
321
409
|
schema_version: JSON_SCHEMA_VERSION,
|
|
322
410
|
ror_version: ReactOnRails::VERSION,
|
|
323
|
-
status: overall_status(statuses),
|
|
411
|
+
status: DoctorSchema.overall_status(statuses),
|
|
324
412
|
checks:,
|
|
325
|
-
summary:
|
|
326
|
-
pass: statuses.count("pass"),
|
|
327
|
-
warn: statuses.count("warn"),
|
|
328
|
-
fail: statuses.count("fail")
|
|
329
|
-
}
|
|
413
|
+
summary: DoctorSchema.summarize_statuses(statuses)
|
|
330
414
|
}
|
|
331
415
|
end
|
|
332
416
|
|
|
333
417
|
def build_check_entry(result)
|
|
334
418
|
messages = result[:messages]
|
|
335
419
|
status = check_status(messages)
|
|
420
|
+
severity = DoctorSchema::STATUS_SEVERITIES.fetch(status)
|
|
421
|
+
message = primary_message(messages, status)
|
|
422
|
+
metadata = DoctorSchema.metadata(result[:id])
|
|
336
423
|
|
|
337
424
|
{
|
|
338
425
|
id: result[:id],
|
|
339
426
|
title: result[:title],
|
|
340
427
|
status:,
|
|
341
|
-
|
|
342
|
-
|
|
428
|
+
severity:,
|
|
429
|
+
message:,
|
|
430
|
+
fix_command: nil,
|
|
431
|
+
docs_url: DoctorSchema.docs_url(result[:id]),
|
|
432
|
+
remediation: build_remediation(result[:id], severity, message, metadata, status),
|
|
433
|
+
details: messages.map { |detail| { level: detail[:type].to_s, content: detail[:content] } }
|
|
343
434
|
}
|
|
344
435
|
end
|
|
345
436
|
|
|
437
|
+
def build_remediation(check_id, severity, message, metadata, status)
|
|
438
|
+
return nil if status == "pass"
|
|
439
|
+
|
|
440
|
+
files = metadata.fetch(:files)
|
|
441
|
+
expected_end_state = metadata.fetch(:expected_end_state)
|
|
442
|
+
prompt_lines = [
|
|
443
|
+
"Fix React on Rails doctor check `#{check_id}` (#{severity}).",
|
|
444
|
+
"Diagnosis: #{message}",
|
|
445
|
+
"Inspect these paths: #{files.join(', ')}.",
|
|
446
|
+
"Expected end state: #{expected_end_state}",
|
|
447
|
+
"Re-run `bin/rails react_on_rails:doctor FORMAT=json` and confirm this check reports `pass`."
|
|
448
|
+
]
|
|
449
|
+
{ prompt: prompt_lines.join("\n"), files:, expected_end_state: }
|
|
450
|
+
end
|
|
451
|
+
|
|
346
452
|
def check_status(messages)
|
|
347
453
|
if messages.any? { |message| message[:type] == :error }
|
|
348
454
|
"fail"
|
|
@@ -353,16 +459,6 @@ module ReactOnRails
|
|
|
353
459
|
end
|
|
354
460
|
end
|
|
355
461
|
|
|
356
|
-
def overall_status(statuses)
|
|
357
|
-
if statuses.include?("fail")
|
|
358
|
-
"fail"
|
|
359
|
-
elsif statuses.include?("warn")
|
|
360
|
-
"warn"
|
|
361
|
-
else
|
|
362
|
-
"pass"
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
|
|
366
462
|
def primary_message(messages, status)
|
|
367
463
|
severity = { "fail" => :error, "warn" => :warning }[status]
|
|
368
464
|
return nil unless severity
|
|
@@ -1154,12 +1250,16 @@ module ReactOnRails
|
|
|
1154
1250
|
|
|
1155
1251
|
layout_name = File.basename(layout_file, ".html.erb")
|
|
1156
1252
|
|
|
1253
|
+
# Report detected pack helpers informationally. A JavaScript-only entrypoint
|
|
1254
|
+
# (no emitted CSS), a CSS-only pack, or a hybrid layout that loads CSS via
|
|
1255
|
+
# Propshaft `stylesheet_link_tag` are all valid, so helper asymmetry is not a
|
|
1256
|
+
# warning. See https://github.com/shakacode/react_on_rails/issues/4619.
|
|
1157
1257
|
if has_stylesheet && has_javascript
|
|
1158
1258
|
checker.add_info(" ✅ #{layout_name}: has both stylesheet_pack_tag and javascript_pack_tag")
|
|
1159
1259
|
elsif has_stylesheet
|
|
1160
|
-
checker.
|
|
1260
|
+
checker.add_info(" ℹ️ #{layout_name}: has stylesheet_pack_tag")
|
|
1161
1261
|
elsif has_javascript
|
|
1162
|
-
checker.
|
|
1262
|
+
checker.add_info(" ℹ️ #{layout_name}: has javascript_pack_tag")
|
|
1163
1263
|
else
|
|
1164
1264
|
checker.add_info(" ℹ️ #{layout_name}: no pack tags found")
|
|
1165
1265
|
end
|
|
@@ -1951,7 +2051,14 @@ module ReactOnRails
|
|
|
1951
2051
|
end
|
|
1952
2052
|
|
|
1953
2053
|
def diagnosis_exit_code
|
|
1954
|
-
checker.errors?
|
|
2054
|
+
status = if checker.errors?
|
|
2055
|
+
:fail
|
|
2056
|
+
elsif checker.warnings?
|
|
2057
|
+
:warn
|
|
2058
|
+
else
|
|
2059
|
+
:pass
|
|
2060
|
+
end
|
|
2061
|
+
DoctorSchema::EXIT_CODES.fetch(status)
|
|
1955
2062
|
end
|
|
1956
2063
|
|
|
1957
2064
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -2019,7 +2126,7 @@ module ReactOnRails
|
|
|
2019
2126
|
end
|
|
2020
2127
|
|
|
2021
2128
|
def pro_initializer_has_node_renderer?
|
|
2022
|
-
config_path = "config/initializers/react_on_rails_pro.rb"
|
|
2129
|
+
config_path = doctor_app_path("config/initializers/react_on_rails_pro.rb")
|
|
2023
2130
|
return false unless File.exist?(config_path)
|
|
2024
2131
|
|
|
2025
2132
|
File.read(config_path).match?(/server_renderer\s*=\s*["']NodeRenderer["']/)
|
|
@@ -2956,10 +3063,10 @@ module ReactOnRails
|
|
|
2956
3063
|
|
|
2957
3064
|
@rails_environment_attempted = true
|
|
2958
3065
|
|
|
2959
|
-
env_file = "config/environment.rb"
|
|
3066
|
+
env_file = doctor_app_path("config/environment.rb")
|
|
2960
3067
|
return false unless File.exist?(env_file)
|
|
2961
3068
|
|
|
2962
|
-
require
|
|
3069
|
+
require env_file
|
|
2963
3070
|
@rails_environment_loaded = true
|
|
2964
3071
|
rescue StandardError, LoadError => e
|
|
2965
3072
|
checker.add_warning(<<~MSG.strip)
|
|
@@ -3048,7 +3155,13 @@ module ReactOnRails
|
|
|
3048
3155
|
# ── React on Rails Pro Setup ──────────────────────────────────────
|
|
3049
3156
|
|
|
3050
3157
|
def check_pro_setup
|
|
3051
|
-
|
|
3158
|
+
unless ReactOnRails::Utils.react_on_rails_pro?
|
|
3159
|
+
checker.add_info(<<~MSG.strip)
|
|
3160
|
+
💎 React on Rails Pro adds the Node Renderer, streaming SSR, and RSC.
|
|
3161
|
+
Upgrade guide: https://reactonrails.com/docs/pro/upgrading-to-pro/
|
|
3162
|
+
MSG
|
|
3163
|
+
return
|
|
3164
|
+
end
|
|
3052
3165
|
|
|
3053
3166
|
check_pro_initializer_existence
|
|
3054
3167
|
ensure_rails_environment_loaded
|
|
@@ -3085,6 +3198,705 @@ module ReactOnRails
|
|
|
3085
3198
|
checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
|
|
3086
3199
|
end
|
|
3087
3200
|
|
|
3201
|
+
def check_node_renderer_rollout_capacity
|
|
3202
|
+
unless ReactOnRails::Utils.react_on_rails_pro?
|
|
3203
|
+
checker.add_info("ℹ️ NodeRenderer rollout capacity check not applicable — React on Rails Pro is not installed")
|
|
3204
|
+
return
|
|
3205
|
+
end
|
|
3206
|
+
|
|
3207
|
+
rails_environment_loaded = ensure_rails_environment_loaded
|
|
3208
|
+
unless resolved_pro_server_renderer == "NodeRenderer"
|
|
3209
|
+
checker.add_info("ℹ️ NodeRenderer rollout capacity check not applicable — NodeRenderer is not configured")
|
|
3210
|
+
return
|
|
3211
|
+
end
|
|
3212
|
+
|
|
3213
|
+
contexts_per_generation, rsc_evidence = node_renderer_contexts_per_generation(rails_environment_loaded)
|
|
3214
|
+
required_capacity = NODE_RENDERER_ROLLOUT_GENERATIONS * contexts_per_generation
|
|
3215
|
+
topology = node_renderer_endpoint_topology
|
|
3216
|
+
evidence = node_renderer_vm_pool_capacity_evidence(topology)
|
|
3217
|
+
|
|
3218
|
+
checker.add_info(
|
|
3219
|
+
"ℹ️ VM pool formula: generations=#{NODE_RENDERER_ROLLOUT_GENERATIONS} × " \
|
|
3220
|
+
"contexts_per_generation=#{contexts_per_generation} = required_capacity=#{required_capacity} per worker " \
|
|
3221
|
+
"(RSC evidence=#{rsc_evidence})."
|
|
3222
|
+
)
|
|
3223
|
+
if topology == :loopback_endpoint
|
|
3224
|
+
checker.add_info(
|
|
3225
|
+
"ℹ️ topology=loopback_endpoint does not prove whether NodeRenderer shares a container, pod, " \
|
|
3226
|
+
"process environment, or deploy lifecycle with Rails."
|
|
3227
|
+
)
|
|
3228
|
+
end
|
|
3229
|
+
report_node_renderer_capacity_evidence(evidence, required_capacity, topology)
|
|
3230
|
+
rescue StandardError, LoadError
|
|
3231
|
+
checker.add_warning(
|
|
3232
|
+
"⚠️ VM pool rollout capacity is unverified " \
|
|
3233
|
+
"(evidence=unverified, topology=unknown, reason=inspection_error)."
|
|
3234
|
+
)
|
|
3235
|
+
add_node_renderer_capacity_guidance(NODE_RENDERER_ROLLOUT_GENERATIONS * 2)
|
|
3236
|
+
end
|
|
3237
|
+
|
|
3238
|
+
def node_renderer_contexts_per_generation(rails_environment_loaded)
|
|
3239
|
+
if rails_environment_loaded
|
|
3240
|
+
runtime_enabled = node_renderer_runtime_rsc_enabled
|
|
3241
|
+
unless runtime_enabled.nil?
|
|
3242
|
+
enabled = runtime_enabled
|
|
3243
|
+
return [enabled ? 2 : 1, "observed_#{enabled ? 'enabled' : 'disabled'}"]
|
|
3244
|
+
end
|
|
3245
|
+
end
|
|
3246
|
+
|
|
3247
|
+
[2, "unverified_conservative_enabled"]
|
|
3248
|
+
end
|
|
3249
|
+
|
|
3250
|
+
def node_renderer_runtime_rsc_enabled
|
|
3251
|
+
return nil unless defined?(ReactOnRailsPro) && ReactOnRailsPro.respond_to?(:configuration)
|
|
3252
|
+
|
|
3253
|
+
ReactOnRailsPro.configuration.enable_rsc_support
|
|
3254
|
+
end
|
|
3255
|
+
|
|
3256
|
+
def node_renderer_endpoint_topology
|
|
3257
|
+
return :unknown unless defined?(ReactOnRailsPro) && ReactOnRailsPro.respond_to?(:configuration)
|
|
3258
|
+
|
|
3259
|
+
host = URI.parse(ReactOnRailsPro.configuration.renderer_url.to_s).host&.downcase
|
|
3260
|
+
return :unknown unless host
|
|
3261
|
+
|
|
3262
|
+
host = host.delete_prefix("[").delete_suffix("]")
|
|
3263
|
+
return :loopback_endpoint if %w[localhost 127.0.0.1 ::1].include?(host)
|
|
3264
|
+
|
|
3265
|
+
:separate
|
|
3266
|
+
rescue URI::InvalidURIError
|
|
3267
|
+
:unknown
|
|
3268
|
+
end
|
|
3269
|
+
|
|
3270
|
+
def node_renderer_vm_pool_capacity_evidence(topology)
|
|
3271
|
+
return { state: :unverified, reason: "separate_renderer_workload" } if topology == :separate
|
|
3272
|
+
return { state: :unverified, reason: "endpoint_topology_unknown" } unless topology == :loopback_endpoint
|
|
3273
|
+
|
|
3274
|
+
config_path, selection_error = node_renderer_config_path
|
|
3275
|
+
return { state: :unverified, reason: selection_error } unless config_path
|
|
3276
|
+
return { state: :unverified, reason: "canonical_renderer_script_too_large", config_path: } if
|
|
3277
|
+
File.size(doctor_app_path(config_path)) > NODE_RENDERER_CONFIG_MAX_BYTES
|
|
3278
|
+
|
|
3279
|
+
active_content = node_renderer_active_config_content(
|
|
3280
|
+
File.read(doctor_app_path(config_path), NODE_RENDERER_CONFIG_MAX_BYTES).force_encoding(Encoding::UTF_8)
|
|
3281
|
+
)
|
|
3282
|
+
evidence =
|
|
3283
|
+
node_renderer_static_capacity_evidence(active_content) ||
|
|
3284
|
+
node_renderer_env_or_default_capacity_evidence(config_path)
|
|
3285
|
+
evidence = node_renderer_launcher_capacity_with_syntax_evidence(evidence, active_content)
|
|
3286
|
+
evidence.merge(
|
|
3287
|
+
config_path:,
|
|
3288
|
+
current_generation_declaration: node_renderer_current_generation_declaration_evidence(active_content)
|
|
3289
|
+
)
|
|
3290
|
+
end
|
|
3291
|
+
|
|
3292
|
+
def node_renderer_launcher_capacity_with_syntax_evidence(evidence, active_content)
|
|
3293
|
+
return evidence unless evidence[:state] == :observed &&
|
|
3294
|
+
evidence[:source] == "selected_launcher_static_assignment"
|
|
3295
|
+
|
|
3296
|
+
syntax_failure_reason = node_renderer_javascript_syntax_failure_reason(active_content)
|
|
3297
|
+
return evidence unless syntax_failure_reason
|
|
3298
|
+
|
|
3299
|
+
{ state: :unverified, reason: syntax_failure_reason }
|
|
3300
|
+
end
|
|
3301
|
+
|
|
3302
|
+
def node_renderer_current_generation_declaration_evidence(active_content)
|
|
3303
|
+
return :unverified if active_content.include?("`")
|
|
3304
|
+
|
|
3305
|
+
config_object = node_renderer_call_config_object(active_content)
|
|
3306
|
+
return :unverified unless config_object
|
|
3307
|
+
|
|
3308
|
+
top_level_content = node_renderer_top_level_config_content(config_object)
|
|
3309
|
+
return :unverified unless top_level_content
|
|
3310
|
+
|
|
3311
|
+
literal_match = top_level_content.match(
|
|
3312
|
+
/
|
|
3313
|
+
(?:\A|,)\s*
|
|
3314
|
+
(?:currentGenerationManifestPath|["']currentGenerationManifestPath["'])\s*:\s*
|
|
3315
|
+
(?<literal>#{NODE_RENDERER_JS_STRING_PATTERN})\s*(?=,|\z)
|
|
3316
|
+
/xo
|
|
3317
|
+
)
|
|
3318
|
+
return :unverified unless literal_match
|
|
3319
|
+
|
|
3320
|
+
literal = literal_match[:literal]
|
|
3321
|
+
return :unverified unless node_renderer_supported_current_generation_declaration_path?(literal)
|
|
3322
|
+
|
|
3323
|
+
:observed
|
|
3324
|
+
end
|
|
3325
|
+
|
|
3326
|
+
def node_renderer_supported_current_generation_declaration_path?(literal)
|
|
3327
|
+
return false unless literal.length > 2
|
|
3328
|
+
|
|
3329
|
+
declaration_path = literal[1...-1]
|
|
3330
|
+
return false if declaration_path.include?("\\")
|
|
3331
|
+
|
|
3332
|
+
path = Pathname.new(declaration_path)
|
|
3333
|
+
path.absolute? &&
|
|
3334
|
+
path.basename.to_s.match?(NODE_RENDERER_CURRENT_GENERATION_MANIFEST_FILENAME) &&
|
|
3335
|
+
path.dirname.basename.to_s == ".current-generations"
|
|
3336
|
+
end
|
|
3337
|
+
|
|
3338
|
+
def node_renderer_config_path
|
|
3339
|
+
existing_paths = [
|
|
3340
|
+
NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH,
|
|
3341
|
+
NodeRendererProcfile::LEGACY_RENDERER_SCRIPT_PATH
|
|
3342
|
+
].select { |path| File.file?(doctor_app_path(path)) }
|
|
3343
|
+
return [nil, "canonical_renderer_script_missing"] if existing_paths.empty?
|
|
3344
|
+
return [existing_paths.first, nil] if existing_paths.one?
|
|
3345
|
+
|
|
3346
|
+
launched_paths = node_renderer_direct_launcher_script_paths & existing_paths
|
|
3347
|
+
unproven_paths = node_renderer_unproven_launcher_script_paths & existing_paths
|
|
3348
|
+
return [launched_paths.first, nil] if launched_paths.one? && (unproven_paths - launched_paths).empty?
|
|
3349
|
+
|
|
3350
|
+
[nil, "renderer_script_selection_not_proven"]
|
|
3351
|
+
end
|
|
3352
|
+
|
|
3353
|
+
def node_renderer_direct_launcher_script_paths
|
|
3354
|
+
node_renderer_launcher_classifications.filter_map do |classification|
|
|
3355
|
+
classification[:path] if classification[:state] == :proven
|
|
3356
|
+
end.uniq
|
|
3357
|
+
end
|
|
3358
|
+
|
|
3359
|
+
def node_renderer_unproven_launcher_script_paths
|
|
3360
|
+
node_renderer_launcher_classifications.filter_map do |classification|
|
|
3361
|
+
classification[:paths] if classification[:state] == :unproven
|
|
3362
|
+
end.flatten.uniq
|
|
3363
|
+
end
|
|
3364
|
+
|
|
3365
|
+
def node_renderer_launcher_classifications
|
|
3366
|
+
NODE_RENDERER_LAUNCHER_PATHS.flat_map do |path|
|
|
3367
|
+
launcher_path = doctor_app_path(path)
|
|
3368
|
+
next [] unless File.file?(launcher_path)
|
|
3369
|
+
next [] if File.size(launcher_path) > NODE_RENDERER_CONFIG_MAX_BYTES
|
|
3370
|
+
|
|
3371
|
+
File.read(launcher_path, NODE_RENDERER_CONFIG_MAX_BYTES).each_line.filter_map do |line|
|
|
3372
|
+
node_renderer_launcher_line_classification(line)
|
|
3373
|
+
end
|
|
3374
|
+
rescue StandardError
|
|
3375
|
+
[]
|
|
3376
|
+
end
|
|
3377
|
+
end
|
|
3378
|
+
|
|
3379
|
+
def node_renderer_launcher_line_classification(line)
|
|
3380
|
+
return if line.match?(/^\s*#/)
|
|
3381
|
+
|
|
3382
|
+
active_line = line.sub(/[ \t]+#.*$/, "")
|
|
3383
|
+
direct_match = active_line.match(NODE_RENDERER_DIRECT_LAUNCHER_PATTERN)
|
|
3384
|
+
if direct_match
|
|
3385
|
+
return {
|
|
3386
|
+
state: :proven,
|
|
3387
|
+
path: direct_match[:path],
|
|
3388
|
+
assignment: direct_match[:assignment]
|
|
3389
|
+
}
|
|
3390
|
+
end
|
|
3391
|
+
|
|
3392
|
+
referenced_paths = active_line.scan(NODE_RENDERER_SCRIPT_REFERENCE_PATTERN).flatten.uniq
|
|
3393
|
+
return if referenced_paths.empty?
|
|
3394
|
+
|
|
3395
|
+
{ state: :unproven, paths: referenced_paths }
|
|
3396
|
+
end
|
|
3397
|
+
|
|
3398
|
+
def node_renderer_static_capacity_evidence(active_content)
|
|
3399
|
+
return { state: :unverified, reason: "ambiguous_javascript_configuration" } if active_content.include?("`")
|
|
3400
|
+
if node_renderer_regexp_call_lookalike?(active_content)
|
|
3401
|
+
return { state: :unverified, reason: "ambiguous_javascript_configuration" }
|
|
3402
|
+
end
|
|
3403
|
+
|
|
3404
|
+
mentions = active_content.scan(/\bmaxVMPoolSize\b/)
|
|
3405
|
+
return nil if mentions.empty?
|
|
3406
|
+
|
|
3407
|
+
config_object = node_renderer_call_config_object(active_content)
|
|
3408
|
+
return { state: :unverified, reason: "renderer_configuration_binding_not_proven" } unless config_object
|
|
3409
|
+
|
|
3410
|
+
syntax_failure_reason = node_renderer_javascript_syntax_failure_reason(active_content)
|
|
3411
|
+
return { state: :unverified, reason: syntax_failure_reason } if syntax_failure_reason
|
|
3412
|
+
|
|
3413
|
+
node_renderer_config_object_capacity_evidence(config_object, mentions.length)
|
|
3414
|
+
end
|
|
3415
|
+
|
|
3416
|
+
def node_renderer_javascript_syntax_failure_reason(content)
|
|
3417
|
+
package_binding = node_renderer_canonical_package_binding(content)
|
|
3418
|
+
return "renderer_configuration_binding_not_proven" unless package_binding
|
|
3419
|
+
|
|
3420
|
+
input_type = package_binding[0].match?(/\bimport\s*\{/) ? "module" : "commonjs"
|
|
3421
|
+
syntax_status = node_renderer_javascript_syntax_status(content, input_type)
|
|
3422
|
+
return "renderer_javascript_syntax_invalid" if syntax_status == :invalid
|
|
3423
|
+
return "renderer_javascript_syntax_not_proven" unless syntax_status == :valid
|
|
3424
|
+
|
|
3425
|
+
nil
|
|
3426
|
+
rescue StandardError
|
|
3427
|
+
# Static capacity is positive evidence. If Node is absent, too old for
|
|
3428
|
+
# this check, or unexpectedly fails, keep the diagnosis conservative.
|
|
3429
|
+
"renderer_javascript_syntax_not_proven"
|
|
3430
|
+
end
|
|
3431
|
+
|
|
3432
|
+
def node_renderer_javascript_syntax_status(content, input_type)
|
|
3433
|
+
suffix = input_type == "module" ? ".mjs" : ".cjs"
|
|
3434
|
+
Tempfile.create(["react-on-rails-doctor-renderer", suffix]) do |file|
|
|
3435
|
+
file.binmode
|
|
3436
|
+
file.write(content)
|
|
3437
|
+
file.flush
|
|
3438
|
+
node_renderer_syntax_check_status(file.path)
|
|
3439
|
+
end
|
|
3440
|
+
end
|
|
3441
|
+
|
|
3442
|
+
def node_renderer_syntax_check_status(path)
|
|
3443
|
+
pid = nil
|
|
3444
|
+
process_reaped = false
|
|
3445
|
+
pid = Process.spawn(
|
|
3446
|
+
{ "NODE_OPTIONS" => nil },
|
|
3447
|
+
*node_renderer_syntax_check_command(path),
|
|
3448
|
+
out: File::NULL,
|
|
3449
|
+
err: File::NULL,
|
|
3450
|
+
pgroup: true
|
|
3451
|
+
)
|
|
3452
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + NODE_RENDERER_SYNTAX_CHECK_TIMEOUT_SECONDS
|
|
3453
|
+
|
|
3454
|
+
loop do
|
|
3455
|
+
_waited_pid, status = Process.wait2(pid, Process::WNOHANG)
|
|
3456
|
+
if status
|
|
3457
|
+
process_reaped = true
|
|
3458
|
+
return status.success? ? :valid : :invalid
|
|
3459
|
+
end
|
|
3460
|
+
|
|
3461
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
3462
|
+
terminate_node_renderer_syntax_check(pid)
|
|
3463
|
+
process_reaped = true
|
|
3464
|
+
return :unavailable
|
|
3465
|
+
end
|
|
3466
|
+
|
|
3467
|
+
sleep 0.05
|
|
3468
|
+
end
|
|
3469
|
+
ensure
|
|
3470
|
+
terminate_node_renderer_syntax_check(pid) if pid && !process_reaped
|
|
3471
|
+
end
|
|
3472
|
+
|
|
3473
|
+
def node_renderer_syntax_check_command(path)
|
|
3474
|
+
["node", "--check", path]
|
|
3475
|
+
end
|
|
3476
|
+
|
|
3477
|
+
def terminate_node_renderer_syntax_check(pid)
|
|
3478
|
+
signal_node_renderer_syntax_check("TERM", pid)
|
|
3479
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
3480
|
+
NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS
|
|
3481
|
+
while node_renderer_syntax_check_process_group_alive?(pid) &&
|
|
3482
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
3483
|
+
sleep 0.05
|
|
3484
|
+
end
|
|
3485
|
+
|
|
3486
|
+
if node_renderer_syntax_check_process_group_alive?(pid)
|
|
3487
|
+
signal_node_renderer_syntax_check("KILL", pid)
|
|
3488
|
+
wait_for_node_renderer_syntax_check_process_group_exit(pid)
|
|
3489
|
+
end
|
|
3490
|
+
reap_node_renderer_syntax_check(pid)
|
|
3491
|
+
end
|
|
3492
|
+
|
|
3493
|
+
def reap_node_renderer_syntax_check(pid)
|
|
3494
|
+
Process.detach(pid).join(NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS)
|
|
3495
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
3496
|
+
nil
|
|
3497
|
+
end
|
|
3498
|
+
|
|
3499
|
+
def node_renderer_syntax_check_process_group_alive?(pid)
|
|
3500
|
+
Process.kill(0, -pid)
|
|
3501
|
+
true
|
|
3502
|
+
rescue Errno::ESRCH
|
|
3503
|
+
false
|
|
3504
|
+
rescue Errno::EPERM
|
|
3505
|
+
node_renderer_syntax_check_group_alive_after_eperm?(pid)
|
|
3506
|
+
end
|
|
3507
|
+
|
|
3508
|
+
def node_renderer_syntax_check_group_alive_after_eperm?(pid)
|
|
3509
|
+
Process.getpgid(pid)
|
|
3510
|
+
false
|
|
3511
|
+
rescue Errno::ESRCH
|
|
3512
|
+
# A competing waiter reaped the leader while a descendant can still own
|
|
3513
|
+
# the process group. Preserve cleanup for that group-only state.
|
|
3514
|
+
true
|
|
3515
|
+
rescue Errno::EPERM
|
|
3516
|
+
false
|
|
3517
|
+
end
|
|
3518
|
+
|
|
3519
|
+
def wait_for_node_renderer_syntax_check_process_group_exit(pid)
|
|
3520
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
3521
|
+
NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS
|
|
3522
|
+
while node_renderer_syntax_check_process_group_alive?(pid) &&
|
|
3523
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
3524
|
+
sleep 0.01
|
|
3525
|
+
end
|
|
3526
|
+
end
|
|
3527
|
+
|
|
3528
|
+
def signal_node_renderer_syntax_check(signal, pid)
|
|
3529
|
+
Process.kill(signal, -pid)
|
|
3530
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
3531
|
+
# The syntax checker was spawned as its own process-group leader. Once
|
|
3532
|
+
# that group is gone or unsignalable, never fall back to the positive
|
|
3533
|
+
# PID: it may be reused by an unrelated process.
|
|
3534
|
+
nil
|
|
3535
|
+
end
|
|
3536
|
+
|
|
3537
|
+
def node_renderer_config_object_capacity_evidence(config_object, total_mentions)
|
|
3538
|
+
top_level_content = node_renderer_top_level_config_content(config_object)
|
|
3539
|
+
return { state: :unverified, reason: "renderer_configuration_binding_not_proven" } unless top_level_content
|
|
3540
|
+
|
|
3541
|
+
top_level_syntax = top_level_content.gsub(NODE_RENDERER_JS_STRING_PATTERN, " string ")
|
|
3542
|
+
top_level_mentions = top_level_content.scan(/\bmaxVMPoolSize\b/)
|
|
3543
|
+
return { state: :unverified, reason: "renderer_configuration_binding_not_proven" } if top_level_mentions.empty?
|
|
3544
|
+
return { state: :unverified, reason: "ambiguous_javascript_configuration" } unless
|
|
3545
|
+
top_level_mentions.one? && top_level_mentions.length == total_mentions
|
|
3546
|
+
return { state: :unverified, reason: "ambiguous_javascript_configuration" } if
|
|
3547
|
+
node_renderer_config_object_ambiguous?(config_object, top_level_syntax)
|
|
3548
|
+
return { state: :unverified, reason: "dynamic_javascript_configuration" } if top_level_syntax.include?("...")
|
|
3549
|
+
|
|
3550
|
+
literal_match = top_level_content.match(
|
|
3551
|
+
/(?:\A|,)\s*(?:maxVMPoolSize|["']maxVMPoolSize["'])\s*:\s*(0|[1-9]\d*)\s*(?=,|\z)/
|
|
3552
|
+
)
|
|
3553
|
+
return { state: :observed, value: literal_match[1].to_i, source: "canonical_renderer_script" } if literal_match
|
|
3554
|
+
|
|
3555
|
+
{ state: :unverified, reason: "dynamic_javascript_configuration" }
|
|
3556
|
+
end
|
|
3557
|
+
|
|
3558
|
+
def node_renderer_config_object_ambiguous?(config_object, top_level_syntax)
|
|
3559
|
+
accessor_syntax = top_level_syntax.gsub(%r{/\*.*?\*/|//[^\n\r\u2028\u2029]*}m, " ")
|
|
3560
|
+
|
|
3561
|
+
top_level_syntax.include?("[") ||
|
|
3562
|
+
config_object.include?("\\") ||
|
|
3563
|
+
accessor_syntax.match?(/(?:\A|,)\s*(?:get|set)\s+[^\s,():]+\s*\(/)
|
|
3564
|
+
end
|
|
3565
|
+
|
|
3566
|
+
def node_renderer_call_config_object(active_content)
|
|
3567
|
+
package_binding = node_renderer_canonical_package_binding(active_content)
|
|
3568
|
+
return nil unless package_binding
|
|
3569
|
+
return nil if node_renderer_local_renderer_binding?(active_content)
|
|
3570
|
+
|
|
3571
|
+
call = node_renderer_single_reachable_call(active_content)
|
|
3572
|
+
return nil unless call && package_binding.end(0) <= call.begin(0)
|
|
3573
|
+
|
|
3574
|
+
argument = active_content[call.end(0)..]
|
|
3575
|
+
|
|
3576
|
+
argument.match(/\A(#{NODE_RENDERER_CONFIG_OBJECT_PATTERN})\s*\)/o)&.[](1)
|
|
3577
|
+
end
|
|
3578
|
+
|
|
3579
|
+
def node_renderer_single_reachable_call(content)
|
|
3580
|
+
masked_content = node_renderer_mask_quoted_string_contents(content)
|
|
3581
|
+
return unless masked_content
|
|
3582
|
+
|
|
3583
|
+
delimiters = node_renderer_delimiter_stack(masked_content)
|
|
3584
|
+
return unless delimiters&.empty?
|
|
3585
|
+
|
|
3586
|
+
candidate_calls = node_renderer_bare_call_candidates(masked_content)
|
|
3587
|
+
calls = candidate_calls.filter_map do |call|
|
|
3588
|
+
next if node_renderer_constructor_call?(masked_content, call)
|
|
3589
|
+
|
|
3590
|
+
call if node_renderer_call_reachability_proven?(masked_content, call)
|
|
3591
|
+
end
|
|
3592
|
+
|
|
3593
|
+
calls.first if calls.one?
|
|
3594
|
+
end
|
|
3595
|
+
|
|
3596
|
+
def node_renderer_bare_call_candidates(content)
|
|
3597
|
+
candidates = []
|
|
3598
|
+
content.to_enum(:scan, NODE_RENDERER_BARE_CALL_PATTERN).each do
|
|
3599
|
+
return [] if candidates.length >= MAX_NODE_RENDERER_BARE_CALL_CANDIDATES
|
|
3600
|
+
|
|
3601
|
+
candidates << Regexp.last_match
|
|
3602
|
+
end
|
|
3603
|
+
candidates
|
|
3604
|
+
end
|
|
3605
|
+
|
|
3606
|
+
def node_renderer_canonical_package_binding(content)
|
|
3607
|
+
masked_content = node_renderer_mask_quoted_string_contents(content)
|
|
3608
|
+
return unless masked_content
|
|
3609
|
+
|
|
3610
|
+
bindings = content.to_enum(:scan, NODE_RENDERER_PACKAGE_BINDING_PATTERN).filter_map do
|
|
3611
|
+
binding = Regexp.last_match
|
|
3612
|
+
binding if node_renderer_proven_package_binding_match?(binding, masked_content)
|
|
3613
|
+
end
|
|
3614
|
+
return unless bindings.one?
|
|
3615
|
+
|
|
3616
|
+
binding = bindings.first
|
|
3617
|
+
return if node_renderer_commonjs_require_rebound?(binding, masked_content)
|
|
3618
|
+
|
|
3619
|
+
prefix = masked_content[...binding.begin(0)]
|
|
3620
|
+
delimiters = node_renderer_delimiter_stack(prefix)
|
|
3621
|
+
binding if delimiters&.empty?
|
|
3622
|
+
end
|
|
3623
|
+
|
|
3624
|
+
def node_renderer_commonjs_require_rebound?(binding, masked_content)
|
|
3625
|
+
return false unless binding[0].match?(/\brequire\s*\(/)
|
|
3626
|
+
|
|
3627
|
+
return true if masked_content.match?(NODE_RENDERER_DIRECT_EVAL_PATTERN)
|
|
3628
|
+
return true if masked_content.match?(NODE_RENDERER_REQUIRE_FUNCTION_DECLARATION_PATTERN)
|
|
3629
|
+
return true if masked_content.match?(NODE_RENDERER_ARGUMENTS_IDENTIFIER_PATTERN)
|
|
3630
|
+
|
|
3631
|
+
masked_content.to_enum(:scan, NODE_RENDERER_REQUIRE_IDENTIFIER_PATTERN).any? do
|
|
3632
|
+
require_identifier = Regexp.last_match
|
|
3633
|
+
!masked_content[require_identifier.end(0)..].match?(/\A\s*\(/)
|
|
3634
|
+
end
|
|
3635
|
+
end
|
|
3636
|
+
|
|
3637
|
+
def node_renderer_proven_package_binding_match?(binding, masked_content)
|
|
3638
|
+
return false if !binding.begin(0).zero? && masked_content[binding.begin(0)] == " "
|
|
3639
|
+
|
|
3640
|
+
binding[0].scan(NODE_RENDERER_IDENTIFIER_PATTERN).one?
|
|
3641
|
+
end
|
|
3642
|
+
|
|
3643
|
+
def node_renderer_local_renderer_binding?(content)
|
|
3644
|
+
without_package_binding = content.gsub(NODE_RENDERER_PACKAGE_BINDING_PATTERN, " ")
|
|
3645
|
+
masked_content = node_renderer_mask_quoted_string_contents(without_package_binding)
|
|
3646
|
+
return true unless masked_content
|
|
3647
|
+
|
|
3648
|
+
node_renderer_global_object_reference?(without_package_binding) ||
|
|
3649
|
+
masked_content.match?(NODE_RENDERER_LOCAL_BINDING_PATTERN) ||
|
|
3650
|
+
node_renderer_non_call_identifier_reference?(masked_content)
|
|
3651
|
+
end
|
|
3652
|
+
|
|
3653
|
+
def node_renderer_global_object_reference?(content)
|
|
3654
|
+
masked_content = node_renderer_mask_quoted_string_contents(content)
|
|
3655
|
+
return true unless masked_content
|
|
3656
|
+
|
|
3657
|
+
masked_content.include?("\\u") || masked_content.match?(NODE_RENDERER_GLOBAL_OBJECT_PATTERN)
|
|
3658
|
+
end
|
|
3659
|
+
|
|
3660
|
+
def node_renderer_non_call_identifier_reference?(content)
|
|
3661
|
+
identifier_positions = content.to_enum(:scan, NODE_RENDERER_IDENTIFIER_PATTERN).map do
|
|
3662
|
+
Regexp.last_match.begin(0)
|
|
3663
|
+
end
|
|
3664
|
+
call_positions = content.to_enum(:scan, NODE_RENDERER_BARE_CALL_PATTERN).map do
|
|
3665
|
+
Regexp.last_match.begin(0)
|
|
3666
|
+
end
|
|
3667
|
+
|
|
3668
|
+
(identifier_positions - call_positions).any?
|
|
3669
|
+
end
|
|
3670
|
+
|
|
3671
|
+
def node_renderer_call_reachability_proven?(content, call)
|
|
3672
|
+
prefix = content[...call.begin(0)]
|
|
3673
|
+
return false unless node_renderer_call_at_top_level?(prefix)
|
|
3674
|
+
return false if node_renderer_call_in_unproven_initializer?(prefix)
|
|
3675
|
+
|
|
3676
|
+
!prefix.match?(NODE_RENDERER_UNPROVEN_CALL_CONTROL_PATTERN)
|
|
3677
|
+
end
|
|
3678
|
+
|
|
3679
|
+
def node_renderer_call_at_top_level?(prefix)
|
|
3680
|
+
brace_depth = 0
|
|
3681
|
+
prefix.each_char do |character|
|
|
3682
|
+
brace_depth += 1 if character == "{"
|
|
3683
|
+
brace_depth -= 1 if character == "}"
|
|
3684
|
+
return false if brace_depth.negative?
|
|
3685
|
+
end
|
|
3686
|
+
|
|
3687
|
+
brace_depth.zero?
|
|
3688
|
+
end
|
|
3689
|
+
|
|
3690
|
+
def node_renderer_delimiter_stack(content)
|
|
3691
|
+
delimiters = []
|
|
3692
|
+
|
|
3693
|
+
content.each_char.with_index do |character, index|
|
|
3694
|
+
delimiters << [character, index] if NODE_RENDERER_OPENING_DELIMITERS.include?(character)
|
|
3695
|
+
next unless NODE_RENDERER_MATCHING_OPENING_DELIMITERS.key?(character)
|
|
3696
|
+
|
|
3697
|
+
opening = delimiters.pop
|
|
3698
|
+
return nil unless opening&.first == NODE_RENDERER_MATCHING_OPENING_DELIMITERS.fetch(character)
|
|
3699
|
+
end
|
|
3700
|
+
|
|
3701
|
+
delimiters
|
|
3702
|
+
end
|
|
3703
|
+
|
|
3704
|
+
def node_renderer_call_in_unproven_initializer?(prefix)
|
|
3705
|
+
delimiters = node_renderer_delimiter_stack(prefix)
|
|
3706
|
+
return true unless delimiters
|
|
3707
|
+
|
|
3708
|
+
delimiters.any? do |opening, index|
|
|
3709
|
+
next false unless NODE_RENDERER_UNPROVEN_INITIALIZER_OPENINGS.include?(opening)
|
|
3710
|
+
|
|
3711
|
+
prefix[(index + 1)..].match?(/(?<![=!<>])=(?!=|>)/)
|
|
3712
|
+
end
|
|
3713
|
+
end
|
|
3714
|
+
|
|
3715
|
+
def node_renderer_constructor_call?(content, call)
|
|
3716
|
+
return false if call.begin(0).zero?
|
|
3717
|
+
|
|
3718
|
+
operator_end = node_renderer_constructor_operator_end(content, call.begin(0))
|
|
3719
|
+
return false unless operator_end && operator_end >= 2
|
|
3720
|
+
|
|
3721
|
+
operator_start = operator_end - 2
|
|
3722
|
+
return false unless content[operator_start, 3] == "new"
|
|
3723
|
+
|
|
3724
|
+
preceding_character = content[operator_start - 1] if operator_start.positive?
|
|
3725
|
+
!preceding_character&.match?(/[.\p{ID_Continue}$#]/)
|
|
3726
|
+
end
|
|
3727
|
+
|
|
3728
|
+
def node_renderer_constructor_operator_end(content, call_start)
|
|
3729
|
+
operator_end = content.rindex(/\S/, call_start - 1)
|
|
3730
|
+
while operator_end&.positive? && content[operator_end] == "("
|
|
3731
|
+
operator_end = content.rindex(/\S/, operator_end - 1)
|
|
3732
|
+
end
|
|
3733
|
+
return nil if operator_end&.zero? && content[operator_end] == "("
|
|
3734
|
+
|
|
3735
|
+
operator_end
|
|
3736
|
+
end
|
|
3737
|
+
|
|
3738
|
+
def node_renderer_mask_quoted_string_contents(content)
|
|
3739
|
+
masked_content = content.dup
|
|
3740
|
+
quote = nil
|
|
3741
|
+
escaped = false
|
|
3742
|
+
|
|
3743
|
+
content.each_char.with_index do |character, index|
|
|
3744
|
+
quote, escaped = node_renderer_mask_quoted_character(masked_content, character, index, quote, escaped)
|
|
3745
|
+
end
|
|
3746
|
+
|
|
3747
|
+
masked_content unless quote
|
|
3748
|
+
end
|
|
3749
|
+
|
|
3750
|
+
def node_renderer_mask_quoted_character(masked_content, character, index, quote, escaped)
|
|
3751
|
+
return [character, false] if quote.nil? && NODE_RENDERER_QUOTE_CHARACTERS.include?(character)
|
|
3752
|
+
return [nil, false] unless quote
|
|
3753
|
+
return [nil, false] if character == quote && !escaped
|
|
3754
|
+
|
|
3755
|
+
masked_content[index] = " "
|
|
3756
|
+
return [quote, false] if escaped
|
|
3757
|
+
|
|
3758
|
+
[quote, character == "\\"]
|
|
3759
|
+
end
|
|
3760
|
+
|
|
3761
|
+
def node_renderer_regexp_call_lookalike?(content)
|
|
3762
|
+
masked_content = node_renderer_mask_quoted_string_contents(content)
|
|
3763
|
+
return true unless masked_content
|
|
3764
|
+
|
|
3765
|
+
masked_content.each_line.any? do |line|
|
|
3766
|
+
first_call = line.index("reactOnRailsProNodeRenderer")
|
|
3767
|
+
next false unless first_call
|
|
3768
|
+
|
|
3769
|
+
first_slash = line.index("/")
|
|
3770
|
+
last_slash = line.rindex("/")
|
|
3771
|
+
last_call = line.rindex("reactOnRailsProNodeRenderer")
|
|
3772
|
+
|
|
3773
|
+
first_slash && last_slash && first_slash < last_call && last_slash > first_call
|
|
3774
|
+
end
|
|
3775
|
+
end
|
|
3776
|
+
|
|
3777
|
+
def node_renderer_top_level_config_content(config_object)
|
|
3778
|
+
content = config_object[1...-1].gsub(NODE_RENDERER_NESTED_OBJECT_PATTERN, "")
|
|
3779
|
+
return nil if content.match?(/[{}]/)
|
|
3780
|
+
|
|
3781
|
+
content
|
|
3782
|
+
end
|
|
3783
|
+
|
|
3784
|
+
def node_renderer_env_or_default_capacity_evidence(config_path)
|
|
3785
|
+
launcher_evidence = node_renderer_launcher_vm_pool_assignment_evidence(config_path)
|
|
3786
|
+
return launcher_evidence if launcher_evidence
|
|
3787
|
+
|
|
3788
|
+
env_value = ENV.fetch("MAX_VM_POOL_SIZE", nil)
|
|
3789
|
+
if env_value
|
|
3790
|
+
unless env_value.match?(/\A[1-9]\d*\z/)
|
|
3791
|
+
return { state: :unverified, reason: "invalid_doctor_process_environment_value" }
|
|
3792
|
+
end
|
|
3793
|
+
|
|
3794
|
+
return { state: :unverified, reason: "renderer_process_environment_not_proven" }
|
|
3795
|
+
end
|
|
3796
|
+
|
|
3797
|
+
{ state: :unverified, reason: "package_default_not_proven" }
|
|
3798
|
+
end
|
|
3799
|
+
|
|
3800
|
+
def node_renderer_launcher_vm_pool_assignment_evidence(config_path)
|
|
3801
|
+
classifications = node_renderer_launcher_classifications
|
|
3802
|
+
if classifications.any? do |classification|
|
|
3803
|
+
classification[:state] == :unproven && classification[:paths].include?(config_path)
|
|
3804
|
+
end
|
|
3805
|
+
return { state: :unverified, reason: "dynamic_or_ambiguous_launcher_vm_pool_assignment" }
|
|
3806
|
+
end
|
|
3807
|
+
|
|
3808
|
+
selected_launchers = classifications.select do |classification|
|
|
3809
|
+
classification[:state] == :proven && classification[:path] == config_path
|
|
3810
|
+
end
|
|
3811
|
+
assignment_matches = selected_launchers.map { |classification| classification[:assignment] }
|
|
3812
|
+
node_renderer_launcher_assignment_values_evidence(assignment_matches)
|
|
3813
|
+
end
|
|
3814
|
+
|
|
3815
|
+
def node_renderer_launcher_assignment_values_evidence(assignment_matches)
|
|
3816
|
+
assignments = assignment_matches.compact
|
|
3817
|
+
return nil if assignments.empty?
|
|
3818
|
+
return { state: :unverified, reason: "dynamic_or_ambiguous_launcher_vm_pool_assignment" } unless
|
|
3819
|
+
assignments.length == assignment_matches.length &&
|
|
3820
|
+
assignments.all? { |assignment| assignment.match?(/\A[1-9]\d*\z/) } &&
|
|
3821
|
+
assignments.uniq.one?
|
|
3822
|
+
|
|
3823
|
+
{
|
|
3824
|
+
state: :observed,
|
|
3825
|
+
value: assignments.first.to_i,
|
|
3826
|
+
source: "selected_launcher_static_assignment"
|
|
3827
|
+
}
|
|
3828
|
+
end
|
|
3829
|
+
|
|
3830
|
+
def node_renderer_active_config_content(content)
|
|
3831
|
+
return content if content.include?("`")
|
|
3832
|
+
|
|
3833
|
+
content.gsub(
|
|
3834
|
+
%r{(#{NODE_RENDERER_JS_STRING_PATTERN})|/\*.*?\*/|//[^\n\r\u2028\u2029]*}mxo
|
|
3835
|
+
) { Regexp.last_match(1) || " " }
|
|
3836
|
+
end
|
|
3837
|
+
|
|
3838
|
+
def report_node_renderer_capacity_evidence(evidence, required_capacity, topology)
|
|
3839
|
+
evidence_state = evidence.fetch(:state)
|
|
3840
|
+
topology_label = topology.to_s
|
|
3841
|
+
|
|
3842
|
+
if evidence_state == :unverified
|
|
3843
|
+
checker.add_warning(
|
|
3844
|
+
"⚠️ VM pool rollout capacity is unverified " \
|
|
3845
|
+
"(evidence=unverified, topology=#{topology_label}, reason=#{evidence.fetch(:reason)}). " \
|
|
3846
|
+
"Doctor does not query the live renderer process."
|
|
3847
|
+
)
|
|
3848
|
+
add_node_renderer_unverified_capacity_guidance(evidence, required_capacity)
|
|
3849
|
+
return
|
|
3850
|
+
end
|
|
3851
|
+
|
|
3852
|
+
configured_capacity = evidence.fetch(:value)
|
|
3853
|
+
evidence_summary =
|
|
3854
|
+
"evidence=#{evidence_state}, configured=#{configured_capacity}, required=#{required_capacity}, " \
|
|
3855
|
+
"topology=#{topology_label}, source=#{evidence.fetch(:source)}, " \
|
|
3856
|
+
"current_generation_declaration=#{evidence.fetch(:current_generation_declaration, :unverified)}, " \
|
|
3857
|
+
"scope=configuration_not_live_process"
|
|
3858
|
+
if configured_capacity >= required_capacity
|
|
3859
|
+
if evidence[:current_generation_declaration] == :observed
|
|
3860
|
+
checker.add_success("✅ Declared-current VM pool rollout capacity is sufficient (#{evidence_summary}).")
|
|
3861
|
+
else
|
|
3862
|
+
checker.add_success("✅ VM pool rollout capacity is sufficient (#{evidence_summary}).")
|
|
3863
|
+
checker.add_warning(
|
|
3864
|
+
"⚠️ VM pool capacity is sufficient but declared-current prewarm is unverified (#{evidence_summary})."
|
|
3865
|
+
)
|
|
3866
|
+
add_node_renderer_current_generation_guidance(evidence[:config_path])
|
|
3867
|
+
end
|
|
3868
|
+
else
|
|
3869
|
+
checker.add_warning("⚠️ VM pool rollout capacity is insufficient (#{evidence_summary}).")
|
|
3870
|
+
add_node_renderer_capacity_guidance(required_capacity, evidence[:config_path])
|
|
3871
|
+
end
|
|
3872
|
+
end
|
|
3873
|
+
|
|
3874
|
+
def add_node_renderer_unverified_capacity_guidance(evidence, required_capacity)
|
|
3875
|
+
unless evidence.fetch(:reason).start_with?("renderer_javascript_syntax_")
|
|
3876
|
+
add_node_renderer_capacity_guidance(required_capacity, evidence[:config_path])
|
|
3877
|
+
return
|
|
3878
|
+
end
|
|
3879
|
+
|
|
3880
|
+
config_path = evidence[:config_path] || NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH
|
|
3881
|
+
checker.add_info("💡 Fix JavaScript syntax in #{config_path}, then rerun Doctor to prove capacity.")
|
|
3882
|
+
end
|
|
3883
|
+
|
|
3884
|
+
def add_node_renderer_current_generation_guidance(config_path = nil)
|
|
3885
|
+
config_path ||= NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH
|
|
3886
|
+
checker.add_info(
|
|
3887
|
+
"💡 Configure RENDERER_CURRENT_GENERATION_MANIFEST in each NodeRenderer revision, or set " \
|
|
3888
|
+
"currentGenerationManifestPath in #{config_path}, to the immutable declaration emitted by pre-seeding."
|
|
3889
|
+
)
|
|
3890
|
+
end
|
|
3891
|
+
|
|
3892
|
+
def add_node_renderer_capacity_guidance(required_capacity, config_path = nil)
|
|
3893
|
+
config_path ||= NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH
|
|
3894
|
+
checker.add_info(
|
|
3895
|
+
"💡 Set MAX_VM_POOL_SIZE=#{required_capacity} in the NodeRenderer workload, or set " \
|
|
3896
|
+
"maxVMPoolSize: #{required_capacity} in #{config_path}. The hard cap applies per worker."
|
|
3897
|
+
)
|
|
3898
|
+
end
|
|
3899
|
+
|
|
3088
3900
|
def check_deprecated_renderer_cache_task
|
|
3089
3901
|
# Resolve against Rails.root (not Dir.pwd) so the scan still fires when
|
|
3090
3902
|
# doctor is invoked from a subdirectory — otherwise the checks silently
|
|
@@ -4858,9 +5670,29 @@ module ReactOnRails
|
|
|
4858
5670
|
def doctor_app_root
|
|
4859
5671
|
rails_root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
|
|
4860
5672
|
rails_root = rails_root.to_s
|
|
4861
|
-
rails_root.empty?
|
|
5673
|
+
return rails_root unless rails_root.empty?
|
|
5674
|
+
|
|
5675
|
+
@doctor_app_root ||= doctor_app_root_from_cwd
|
|
4862
5676
|
rescue StandardError
|
|
4863
|
-
|
|
5677
|
+
@doctor_app_root ||= doctor_app_root_from_cwd
|
|
5678
|
+
end
|
|
5679
|
+
|
|
5680
|
+
def doctor_app_root_from_cwd
|
|
5681
|
+
start_path = File.expand_path(Dir.pwd)
|
|
5682
|
+
candidate = start_path
|
|
5683
|
+
loop do
|
|
5684
|
+
return candidate if File.file?(File.join(candidate, "config/environment.rb"))
|
|
5685
|
+
|
|
5686
|
+
parent = File.dirname(candidate)
|
|
5687
|
+
break if parent == candidate
|
|
5688
|
+
|
|
5689
|
+
candidate = parent
|
|
5690
|
+
end
|
|
5691
|
+
start_path
|
|
5692
|
+
end
|
|
5693
|
+
|
|
5694
|
+
def doctor_app_path(relative_path)
|
|
5695
|
+
File.expand_path(relative_path, doctor_app_root)
|
|
4864
5696
|
end
|
|
4865
5697
|
|
|
4866
5698
|
def report_missing_rsc_artifact(label, artifact_path)
|