ruact 0.0.5 → 0.0.6
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
data/lib/ruact/doctor.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Ruact
|
|
|
7
7
|
# Runs a suite of installation health checks and prints ✓/✗ per check.
|
|
8
8
|
# Extracted from the ruact:doctor Rake task for direct testability (FR27).
|
|
9
9
|
class Doctor
|
|
10
|
-
CHECKS = %i[manifest vite controller layout streaming legacy_constant].freeze
|
|
10
|
+
CHECKS = %i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
|
|
11
11
|
# Built via Array#join so the gem-CI `name-propagation` guard does not
|
|
12
12
|
# match these literals against itself (Story 5.1 review F4 — the doctor
|
|
13
13
|
# file participates in the guard with no exclusion).
|
|
@@ -17,7 +17,61 @@ module Ruact
|
|
|
17
17
|
LEGACY_SCAN_GLOBS = ["config/initializers/**/*.rb", "app/**/*.rb"].freeze
|
|
18
18
|
RENAME_DOC_URL = "https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed"
|
|
19
19
|
|
|
20
|
-
#
|
|
20
|
+
# --- Story 13.1: serialize-only invariant tripwire (FR97) ---------------
|
|
21
|
+
#
|
|
22
|
+
# ruact EMITS React Flight (`text/x-component`) but must never DESERIALIZE
|
|
23
|
+
# externally-supplied Flight into live Ruby objects — that keeps it outside
|
|
24
|
+
# the React2Shell / CVE-2025-55182 class (a Flight-deserialization RCE). See
|
|
25
|
+
# the ADR addendum in docs/internal/decisions/server-functions-api.md.
|
|
26
|
+
#
|
|
27
|
+
# The signal literals are assembled from fragments via Array#join so this
|
|
28
|
+
# file does NOT itself contain the matched strings (mirrors the LEGACY_CONST
|
|
29
|
+
# F4 lesson). `doctor.rb` is also excluded from the scan as defense in depth.
|
|
30
|
+
# Only structural inbound-deserialization signals are used; the raw
|
|
31
|
+
# `text/x-component` token is deliberately NOT a signal because the gem
|
|
32
|
+
# legitimately EMITS that media type (controller.rb / server.rb) — matching
|
|
33
|
+
# it would false-fail the current, invariant-holding tree.
|
|
34
|
+
DESERIALIZE_SIGNALS = [
|
|
35
|
+
# a `Deserializer` constant reference (FlightDeserializer, Flight::Deserializer, …);
|
|
36
|
+
# matched as a substring so both the joined and namespaced forms trip it
|
|
37
|
+
/#{%w[Deser ializer].join}/,
|
|
38
|
+
# methods that turn inbound Flight into Ruby objects
|
|
39
|
+
/\b#{%w[deserialize flight].join('_')}\b/,
|
|
40
|
+
/\b#{%w[from flight].join('_')}\b/,
|
|
41
|
+
/\b#{%w[parse flight].join('_')}\b/,
|
|
42
|
+
/\b#{%w[decode flight].join('_')}\b/,
|
|
43
|
+
# React Flight reader entry points invoked from Ruby (NOT createFromFlightPayload,
|
|
44
|
+
# which is the client/browser deserializing the server's own trusted payload)
|
|
45
|
+
/\b#{%w[create From].join}(?:NodeStream|ReadableStream|Fetch)\b/
|
|
46
|
+
].freeze
|
|
47
|
+
DESERIALIZE_SIGNAL_RE = Regexp.union(DESERIALIZE_SIGNALS)
|
|
48
|
+
# A line carrying this annotation is a deliberate, reviewed deserializer and
|
|
49
|
+
# is treated as guarded (the check is a guard, not a blanket ban).
|
|
50
|
+
ALLOW_FLIGHT_DESERIALIZATION = ["# ruact:allow", "flight", "deserialization"].join("-")
|
|
51
|
+
# Exclude THIS file by its exact path (not basename) — its comments contain
|
|
52
|
+
# the literal `Deserializer` example, so it must not match its own scan; but
|
|
53
|
+
# a differently-located future file also named `doctor.rb` must still be
|
|
54
|
+
# scanned (review finding R1 — basename exclusion was too broad).
|
|
55
|
+
DOCTOR_FILE = File.expand_path(__FILE__)
|
|
56
|
+
SERIALIZE_ONLY_DOC = "docs/internal/decisions/server-functions-api.md (serialize-only invariant, FR97)"
|
|
57
|
+
|
|
58
|
+
# Response-transforming middleware that can mutate/recompress a streamed
|
|
59
|
+
# `text/x-component` Flight body and break the wire contract (React-on-Rails
|
|
60
|
+
# ops lesson). Matched by class name so the check needs no hard dependency.
|
|
61
|
+
RESPONSE_TRANSFORMING_MIDDLEWARE = %w[Rack::Deflater].freeze
|
|
62
|
+
|
|
63
|
+
# Statuses that do NOT fail the run. Anything else (including a malformed /
|
|
64
|
+
# future status) is treated as a failure (review finding R1).
|
|
65
|
+
SUCCESS_STATUSES = %i[pass warn].freeze
|
|
66
|
+
|
|
67
|
+
# @param serialize_only_root [String] directory whose `**/*.rb` is scanned
|
|
68
|
+
# for the serialize-only tripwire. Defaults to the gem's own `lib/`;
|
|
69
|
+
# injectable so specs can point it at a fixture tree.
|
|
70
|
+
def initialize(serialize_only_root: File.join(Ruact.gem_path, "lib"))
|
|
71
|
+
@serialize_only_root = serialize_only_root
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Runs all checks, prints results, returns true if none FAIL.
|
|
21
75
|
def self.run
|
|
22
76
|
new.run
|
|
23
77
|
end
|
|
@@ -26,7 +80,10 @@ module Ruact
|
|
|
26
80
|
puts "[ruact] Health check"
|
|
27
81
|
results = CHECKS.map { |check| send(:"check_#{check}") }
|
|
28
82
|
results.each { |status, message| puts format_result(status, message) }
|
|
29
|
-
|
|
83
|
+
# A :warn must NOT fail the run (Story 13.1 AC3); only :pass / :warn are
|
|
84
|
+
# success. An unexpected status (rendered `✗`) fails loudly rather than
|
|
85
|
+
# being silently treated as a pass (review finding R1).
|
|
86
|
+
passed = results.all? { |status, _| SUCCESS_STATUSES.include?(status) }
|
|
30
87
|
puts "Run rails generate ruact:install to fix configuration issues" unless passed
|
|
31
88
|
passed
|
|
32
89
|
end
|
|
@@ -96,6 +153,74 @@ module Ruact
|
|
|
96
153
|
"See #{RENAME_DOC_URL}."]
|
|
97
154
|
end
|
|
98
155
|
|
|
156
|
+
# Story 13.1 (AC2) — fails when ruact's OWN Ruby source introduces an
|
|
157
|
+
# inbound Flight-deserialization entry point that is not explicitly
|
|
158
|
+
# annotated `# ruact:allow-flight-deserialization <reason>`. Passes silently
|
|
159
|
+
# when none exists (the current tree). Scans `@serialize_only_root/**/*.rb`,
|
|
160
|
+
# excluding this file and the generators' client-side templates.
|
|
161
|
+
def check_serialize_only
|
|
162
|
+
offenses = Dir[File.join(@serialize_only_root, "**", "*.rb")].flat_map do |file|
|
|
163
|
+
next [] if File.expand_path(file) == DOCTOR_FILE
|
|
164
|
+
next [] if file.match?(%r{/generators/.+/templates/})
|
|
165
|
+
|
|
166
|
+
File.foreach(file).with_index(1).filter_map do |line, lineno|
|
|
167
|
+
next unless DESERIALIZE_SIGNAL_RE.match?(line)
|
|
168
|
+
next if line.include?(ALLOW_FLIGHT_DESERIALIZATION)
|
|
169
|
+
|
|
170
|
+
"#{file}:#{lineno}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if offenses.empty?
|
|
175
|
+
return [:pass, "Serialize-only invariant holds — no inbound Flight deserializer in ruact's Ruby source"]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
[:fail,
|
|
179
|
+
"Inbound Flight deserializer entry point found in #{offenses.length} location(s) " \
|
|
180
|
+
"(first: #{offenses.first}). ruact is serialize-only: it may emit `text/x-component` " \
|
|
181
|
+
"but must never deserialize externally-supplied Flight into live Ruby objects " \
|
|
182
|
+
"(React2Shell / CVE-2025-55182 class). Remove it, or — if deliberate and reviewed — " \
|
|
183
|
+
"annotate the line with `#{ALLOW_FLIGHT_DESERIALIZATION} <reason>`. See #{SERIALIZE_ONLY_DOC}."]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Story 13.1 (AC3) — WARNS (never fails) when a response-transforming
|
|
187
|
+
# middleware is mounted in the app's stack, since it may recompress/mutate a
|
|
188
|
+
# streamed `text/x-component` Flight body and break the wire contract.
|
|
189
|
+
def check_flight_middleware
|
|
190
|
+
stack = flight_middleware_stack
|
|
191
|
+
return [:pass, "No response-transforming middleware on the Flight wire path"] if stack.nil?
|
|
192
|
+
|
|
193
|
+
present = stack.filter_map { |mw| middleware_name(mw) }
|
|
194
|
+
.select { |name| RESPONSE_TRANSFORMING_MIDDLEWARE.include?(name) }
|
|
195
|
+
.uniq
|
|
196
|
+
return [:pass, "No response-transforming middleware on the Flight wire path"] if present.empty?
|
|
197
|
+
|
|
198
|
+
[:warn,
|
|
199
|
+
"#{present.join(', ')} is mounted and may transform `text/x-component` (Flight) responses, " \
|
|
200
|
+
"breaking the wire contract / streaming. Exclude Flight responses from compression " \
|
|
201
|
+
"(don't compress `text/x-component`) or mount it so it does not wrap the Flight routes."]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Returns the app middleware stack to scan, or nil when unavailable (no
|
|
205
|
+
# Rails application present — e.g. the non-Rails / full-stub edge context).
|
|
206
|
+
# At real `rails ruact:doctor` time the `:environment` task has booted the
|
|
207
|
+
# app, so `app.middleware` is the enumerable `ActionDispatch::MiddlewareStack`.
|
|
208
|
+
# Before `initialize!` it is a `Rails::Configuration::MiddlewareStackProxy`
|
|
209
|
+
# (not enumerable) — skip it rather than crash on `filter_map`.
|
|
210
|
+
def flight_middleware_stack
|
|
211
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:application)
|
|
212
|
+
|
|
213
|
+
app = Rails.application
|
|
214
|
+
return nil unless app.respond_to?(:middleware)
|
|
215
|
+
|
|
216
|
+
stack = app.middleware
|
|
217
|
+
stack.respond_to?(:each) ? stack : nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def middleware_name(middleware)
|
|
221
|
+
middleware.respond_to?(:name) ? middleware.name : middleware.to_s
|
|
222
|
+
end
|
|
223
|
+
|
|
99
224
|
def streaming_server_hint
|
|
100
225
|
return "Puma" if defined?(::Puma)
|
|
101
226
|
return "Unicorn" if defined?(::Unicorn)
|
|
@@ -110,7 +235,11 @@ module Ruact
|
|
|
110
235
|
end
|
|
111
236
|
|
|
112
237
|
def format_result(status, message)
|
|
113
|
-
status
|
|
238
|
+
case status
|
|
239
|
+
when :pass then "✓ #{message}"
|
|
240
|
+
when :warn then "⚠ #{message}"
|
|
241
|
+
else "✗ #{message}"
|
|
242
|
+
end
|
|
114
243
|
end
|
|
115
244
|
end
|
|
116
245
|
end
|
|
@@ -35,11 +35,22 @@ module Ruact
|
|
|
35
35
|
|
|
36
36
|
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
37
37
|
# Returns the transformed source string.
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
#
|
|
39
|
+
# +identifier+ is the template path (forwarded by {ErbPreprocessorHook} as
|
|
40
|
+
# +template.identifier+) so a Story 13.5 contract violation can name the
|
|
41
|
+
# call site's file:line. +registry+ is the component contract source — an
|
|
42
|
+
# injectable seam (Story 7.1 explicit-context grain); it defaults to the
|
|
43
|
+
# process-loaded {Ruact.manifest}. Pass +registry: nil+ (or a stub) in
|
|
44
|
+
# specs to control contract lookup; +nil+ forces fail-open (no validation).
|
|
45
|
+
def self.transform(source, identifier: nil, registry: :default)
|
|
46
|
+
new.transform(source, identifier: identifier, registry: registry)
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
def transform(source)
|
|
49
|
+
def transform(source, identifier: nil, registry: :default)
|
|
50
|
+
# NOTE: +registry+ stays the +:default+ sentinel here. It is resolved to
|
|
51
|
+
# +Ruact.manifest+ LAZILY, only inside the component-tag block below — a
|
|
52
|
+
# source with no PascalCase tags must touch the registry not at all
|
|
53
|
+
# (AC2/AC6 fast-path invariant).
|
|
43
54
|
# Step 1: transform <Suspense> paired tags into <ruact-suspense> HTML elements.
|
|
44
55
|
# This runs before the general component regex so Suspense isn't treated as a component.
|
|
45
56
|
result = source
|
|
@@ -47,7 +58,11 @@ module Ruact
|
|
|
47
58
|
attrs = ::Regexp.last_match(1)
|
|
48
59
|
fallback = extract_string_attr(attrs, "fallback") || ""
|
|
49
60
|
escaped = fallback.gsub('"', """)
|
|
50
|
-
|
|
61
|
+
# Optional `delay="2.5"` — the server-side wait (seconds) before
|
|
62
|
+
# the deferred chunk streams. Forwarded to SuspenseElement#delay.
|
|
63
|
+
delay = extract_string_attr(attrs, "delay")
|
|
64
|
+
delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '"')}") : ""
|
|
65
|
+
%(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
|
|
51
66
|
end
|
|
52
67
|
.gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
|
|
53
68
|
|
|
@@ -59,9 +74,17 @@ module Ruact
|
|
|
59
74
|
line = result[0...match_start].count("\n") + 1
|
|
60
75
|
|
|
61
76
|
begin
|
|
62
|
-
|
|
77
|
+
registry = Ruact.manifest if registry == :default # lazy — only when a tag exists
|
|
78
|
+
pairs = parse_prop_pairs(attrs_string)
|
|
79
|
+
validate_contract(registry, component_name, pairs.map(&:first),
|
|
80
|
+
at: { file: identifier, line: line, snippet: match.strip })
|
|
81
|
+
props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
|
|
63
82
|
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
64
83
|
%(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
|
|
84
|
+
rescue ComponentContractError
|
|
85
|
+
# Already carries file:line + offending prop + suggestion — re-raise
|
|
86
|
+
# AS-IS (do NOT append the generic "at line N: snippet" tail).
|
|
87
|
+
raise
|
|
65
88
|
rescue PreprocessorError => e
|
|
66
89
|
raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
|
|
67
90
|
end
|
|
@@ -77,11 +100,41 @@ module Ruact
|
|
|
77
100
|
m&.[](1)
|
|
78
101
|
end
|
|
79
102
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
# Story 13.5 — run the opt-in contract check for +component_name+ against
|
|
104
|
+
# the parsed call-site prop +names+. Looks the contract up through the
|
|
105
|
+
# injected +registry+ seam and SKIPS ENTIRELY when there is none (no
|
|
106
|
+
# registry, registry without +contract_for+, or no contract for this
|
|
107
|
+
# component) — that is the AC2/AC6 fail-open path that keeps a contract-less
|
|
108
|
+
# component byte-identical. Invoked ONLY inside the component-tag block, so
|
|
109
|
+
# the no-tag fast path never reads the registry.
|
|
110
|
+
def validate_contract(registry, component_name, names, at:)
|
|
111
|
+
return unless registry.respond_to?(:contract_for)
|
|
112
|
+
|
|
113
|
+
contract = registry.contract_for(component_name, controller_path: controller_path_from(at[:file]))
|
|
114
|
+
return if contract.nil?
|
|
115
|
+
|
|
116
|
+
ComponentContract.validate(
|
|
117
|
+
component_name: component_name, prop_names: names, contract: contract, at: at
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Best-effort controller_path from a template identifier so a co-located
|
|
122
|
+
# component's contract resolves (e.g. ".../app/views/posts/show.html.erb"
|
|
123
|
+
# → "posts"). A wrong/absent guess is harmless: {ClientManifest#resolve_key}
|
|
124
|
+
# falls back to the shared PascalCase key.
|
|
125
|
+
def controller_path_from(identifier)
|
|
126
|
+
return nil unless identifier
|
|
127
|
+
|
|
128
|
+
m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
|
|
129
|
+
m && m[1]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Parses the attributes string of a component tag into ordered
|
|
133
|
+
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
|
|
134
|
+
# Honors nested braces in values (via {#extract_braced_expr}). The names
|
|
135
|
+
# feed the Story 13.5 contract check; the pairs render the props Hash.
|
|
136
|
+
def parse_prop_pairs(attrs_string)
|
|
137
|
+
return [] if attrs_string.empty?
|
|
85
138
|
|
|
86
139
|
pairs = []
|
|
87
140
|
remaining = attrs_string.dup
|
|
@@ -91,13 +144,13 @@ module Ruact
|
|
|
91
144
|
# Find the matching closing brace, respecting nesting
|
|
92
145
|
value_start = m.end(0)
|
|
93
146
|
value_expr = extract_braced_expr(remaining, value_start)
|
|
94
|
-
pairs <<
|
|
147
|
+
pairs << [prop_name, value_expr]
|
|
95
148
|
# Advance past this prop
|
|
96
149
|
remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
|
|
97
150
|
break if remaining.nil?
|
|
98
151
|
end
|
|
99
152
|
|
|
100
|
-
pairs
|
|
153
|
+
pairs
|
|
101
154
|
end
|
|
102
155
|
|
|
103
156
|
# Given a string and a start position (just after the opening '{'),
|
|
@@ -14,7 +14,10 @@ module Ruact
|
|
|
14
14
|
# Called by ActionView for every ERB template. +source+ is the raw ERB
|
|
15
15
|
# text; the return value is Ruby code that ActionView will eval.
|
|
16
16
|
def call(template, source)
|
|
17
|
-
|
|
17
|
+
# Story 13.5 — forward +template.identifier+ (the template file path) so a
|
|
18
|
+
# component-contract violation can name the call site's file:line. The
|
|
19
|
+
# contract registry defaults to the process-loaded +Ruact.manifest+.
|
|
20
|
+
super(template, ErbPreprocessor.transform(source, identifier: template.identifier))
|
|
18
21
|
end
|
|
19
22
|
end
|
|
20
23
|
end
|
data/lib/ruact/errors.rb
CHANGED
|
@@ -12,6 +12,16 @@ module Ruact
|
|
|
12
12
|
# Raised when the ERB preprocessor encounters a malformed component tag.
|
|
13
13
|
class PreprocessorError < Error; end
|
|
14
14
|
|
|
15
|
+
# Story 13.5 (FR100) — raised by {Ruact::ComponentContract} at ERB
|
|
16
|
+
# preprocess-time when a `<Component .../>` call site violates the component's
|
|
17
|
+
# opt-in contract: a missing required prop/slot or an unknown prop name.
|
|
18
|
+
# Subclasses {PreprocessorError} so it flows through the same dev error
|
|
19
|
+
# overlay (NFR30 lineage) and the hook treats it uniformly — but the distinct
|
|
20
|
+
# class lets the preprocessor re-raise it AS-IS (its message already carries
|
|
21
|
+
# file:line + the offending prop + a "did you mean?" suggestion) instead of
|
|
22
|
+
# re-wrapping it with the generic "at line N: snippet" tail.
|
|
23
|
+
class ComponentContractError < PreprocessorError; end
|
|
24
|
+
|
|
15
25
|
# Raised when application code attempts to mutate Ruact::Configuration outside
|
|
16
26
|
# of a Ruact.configure block. The configuration is frozen after initialization
|
|
17
27
|
# to prevent runtime drift; see Story 7.3 for the rationale and the decision
|
|
@@ -26,43 +36,6 @@ module Ruact
|
|
|
26
36
|
# buried under a Nokogiri stack. See Story 7.4 for the rationale.
|
|
27
37
|
class HtmlConverterError < Error; end
|
|
28
38
|
|
|
29
|
-
# Story 8.3 — raised by a standalone server-action block (a module that
|
|
30
|
-
# `extend`s {Ruact::ServerAction}) when its body invokes
|
|
31
|
-
# {Ruact::ServerFunctions::StandaloneContext#current_user} but no
|
|
32
|
-
# {Ruact::Configuration#current_user_resolver} has been configured. The
|
|
33
|
-
# message names both worked examples (Devise + hand-rolled session) so
|
|
34
|
-
# the developer can wire the resolver without leaving the stack trace.
|
|
35
|
-
class CurrentUserNotConfiguredError < Error
|
|
36
|
-
DEFAULT_MESSAGE = "Ruact.current_user requires Ruact.config.current_user_resolver to be set. " \
|
|
37
|
-
"Example (Devise): Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }. " \
|
|
38
|
-
"Example (hand-rolled session): Ruact.configure { |c| c.current_user_resolver = " \
|
|
39
|
-
"->(env) { User.find_by(id: env['rack.session'][:user_id]) } }."
|
|
40
|
-
|
|
41
|
-
def initialize(message = DEFAULT_MESSAGE)
|
|
42
|
-
super
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Story 8.3 — raised inside a standalone server-action block to surface a
|
|
47
|
-
# non-2xx response without calling `render` (which the StandaloneContext
|
|
48
|
-
# does not expose). The endpoint dispatcher rescues this exception class
|
|
49
|
-
# and renders `status` + `body` verbatim, mirroring how a controller-hosted
|
|
50
|
-
# action would call `render(json: ..., status: ...)`.
|
|
51
|
-
class ActionError < Error
|
|
52
|
-
attr_reader :status, :body
|
|
53
|
-
|
|
54
|
-
# @param status [Symbol, Integer] HTTP status (e.g. :unprocessable_entity, 422).
|
|
55
|
-
# @param body [Object] the response payload. Hash/Array/scalar values are
|
|
56
|
-
# rendered as JSON; nil renders an empty body.
|
|
57
|
-
# @param message [String] optional message; defaults to "ruact action error
|
|
58
|
-
# (status=<status>)" so the exception is still legible in logs.
|
|
59
|
-
def initialize(status:, body: nil, message: nil)
|
|
60
|
-
@status = status
|
|
61
|
-
@body = body
|
|
62
|
-
super(message || "ruact action error (status=#{status.inspect})")
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
39
|
# Story 9.5 (FR88) — raised by the query dispatch controller when a
|
|
67
40
|
# `useQuery` request's parameters violate the kwargs allowlist: a complex
|
|
68
41
|
# value (array / object) where only `string | number | boolean | null` is
|
|
@@ -74,14 +47,27 @@ module Ruact
|
|
|
74
47
|
# fix the call site without reading the server source.
|
|
75
48
|
class BadRequestError < Error; end
|
|
76
49
|
|
|
77
|
-
# Story
|
|
78
|
-
#
|
|
50
|
+
# Story 13.2 (FR96) — raised by `Ruact.locate_signed` when a SignedGlobalID
|
|
51
|
+
# token fails verification: a tampered signature, an expired token, or a token
|
|
52
|
+
# scoped to a different `for:` purpose (`GlobalID::Locator.locate_signed`
|
|
53
|
+
# returns `nil`). Inherits from `Ruact::Error` so the Story 8.4
|
|
54
|
+
# `rescue_from StandardError` chain catches it cleanly; `__ruact_status_for`
|
|
55
|
+
# maps it to HTTP 400 (Bad Request) — a forged/expired reference is an
|
|
56
|
+
# invalid credential the client supplied, NOT a missing record. This keeps
|
|
57
|
+
# `ActiveRecord::RecordNotFound` from leaking and never reveals whether the
|
|
58
|
+
# target record exists (verification fails before any DB lookup). A *valid*
|
|
59
|
+
# token whose record was since deleted is NOT this error — the underlying
|
|
60
|
+
# finder (`ActiveRecord::RecordNotFound`) propagates and is the host's normal
|
|
61
|
+
# not-found concern, exactly as a raw `Model.find` would be.
|
|
62
|
+
class InvalidSignedGlobalIDError < Error; end
|
|
63
|
+
|
|
64
|
+
# Story 8.5 — raised by the `Ruact::Server` upload guard when an inbound
|
|
65
|
+
# multipart / urlencoded request's `Content-Length` exceeds
|
|
79
66
|
# `Ruact.config.max_upload_bytes`. The exception inherits from
|
|
80
|
-
# `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain
|
|
81
|
-
# `
|
|
82
|
-
# `
|
|
83
|
-
#
|
|
84
|
-
# `upload_limit` block alongside the four baseline keys.
|
|
67
|
+
# `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain catches
|
|
68
|
+
# it cleanly; `__ruact_status_for` maps it to HTTP 413, and
|
|
69
|
+
# `ErrorPayload.build` extracts the `received_bytes` / `limit_bytes` pair
|
|
70
|
+
# into a dev-only `upload_limit` block alongside the four baseline keys.
|
|
85
71
|
#
|
|
86
72
|
# The pair is exposed as `attr_reader` so the structured payload (and host
|
|
87
73
|
# log lines) can show both numbers without re-parsing the message string.
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -201,7 +201,28 @@ module Ruact
|
|
|
201
201
|
child_nodes = convert_children(node)
|
|
202
202
|
children = child_nodes.length == 1 ? child_nodes.first : child_nodes
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
# Optional `delay="2.5"` from the ERB → SuspenseElement#delay (the
|
|
205
|
+
# server-side wait, in seconds, before the deferred chunk streams). When
|
|
206
|
+
# absent, blank, unparseable, or non-finite, SuspenseElement falls back to
|
|
207
|
+
# its default.
|
|
208
|
+
delay_attr = node["data-ruact-delay"]
|
|
209
|
+
delay = begin
|
|
210
|
+
if delay_attr && !delay_attr.to_s.strip.empty?
|
|
211
|
+
# A string overflow like "1e309" parses to Infinity (Float() does not
|
|
212
|
+
# raise) — guard finiteness so a non-finite delay can't reach the
|
|
213
|
+
# renderer's sleep(delay) and raise RangeError.
|
|
214
|
+
parsed = Float(delay_attr)
|
|
215
|
+
parsed.finite? ? parsed : nil
|
|
216
|
+
end
|
|
217
|
+
rescue ArgumentError, TypeError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if delay
|
|
222
|
+
Flight::SuspenseElement.new(fallback: fallback, children: children, delay: delay)
|
|
223
|
+
else
|
|
224
|
+
Flight::SuspenseElement.new(fallback: fallback, children: children)
|
|
225
|
+
end
|
|
205
226
|
end
|
|
206
227
|
|
|
207
228
|
def convert_children(node)
|