ruact 0.0.5 → 0.0.7
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 +74 -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 +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- 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 +109 -0
- data/lib/ruact.rb +20 -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 +70 -328
- 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/manifest_resolver_spec.rb +159 -0
- 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 +15 -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 +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -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 +58 -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,23 @@ module Ruact
|
|
|
59
74
|
line = result[0...match_start].count("\n") + 1
|
|
60
75
|
|
|
61
76
|
begin
|
|
62
|
-
|
|
77
|
+
# lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
|
|
78
|
+
# manifest (same source the render path uses, so the boot-race doesn't
|
|
79
|
+
# silence FR100 contract checks in dev) and FAILS OPEN to nil when the
|
|
80
|
+
# manifest is unresolvable (contract validation is opt-in/fail-open;
|
|
81
|
+
# the render path surfaces the clear error). In prod this is the
|
|
82
|
+
# boot-loaded Ruact.manifest, unchanged.
|
|
83
|
+
registry = ManifestResolver.resolve_soft if registry == :default
|
|
84
|
+
pairs = parse_prop_pairs(attrs_string)
|
|
85
|
+
validate_contract(registry, component_name, pairs.map(&:first),
|
|
86
|
+
at: { file: identifier, line: line, snippet: match.strip })
|
|
87
|
+
props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
|
|
63
88
|
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
64
89
|
%(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
|
|
90
|
+
rescue ComponentContractError
|
|
91
|
+
# Already carries file:line + offending prop + suggestion — re-raise
|
|
92
|
+
# AS-IS (do NOT append the generic "at line N: snippet" tail).
|
|
93
|
+
raise
|
|
65
94
|
rescue PreprocessorError => e
|
|
66
95
|
raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
|
|
67
96
|
end
|
|
@@ -77,11 +106,41 @@ module Ruact
|
|
|
77
106
|
m&.[](1)
|
|
78
107
|
end
|
|
79
108
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
# Story 13.5 — run the opt-in contract check for +component_name+ against
|
|
110
|
+
# the parsed call-site prop +names+. Looks the contract up through the
|
|
111
|
+
# injected +registry+ seam and SKIPS ENTIRELY when there is none (no
|
|
112
|
+
# registry, registry without +contract_for+, or no contract for this
|
|
113
|
+
# component) — that is the AC2/AC6 fail-open path that keeps a contract-less
|
|
114
|
+
# component byte-identical. Invoked ONLY inside the component-tag block, so
|
|
115
|
+
# the no-tag fast path never reads the registry.
|
|
116
|
+
def validate_contract(registry, component_name, names, at:)
|
|
117
|
+
return unless registry.respond_to?(:contract_for)
|
|
118
|
+
|
|
119
|
+
contract = registry.contract_for(component_name, controller_path: controller_path_from(at[:file]))
|
|
120
|
+
return if contract.nil?
|
|
121
|
+
|
|
122
|
+
ComponentContract.validate(
|
|
123
|
+
component_name: component_name, prop_names: names, contract: contract, at: at
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Best-effort controller_path from a template identifier so a co-located
|
|
128
|
+
# component's contract resolves (e.g. ".../app/views/posts/show.html.erb"
|
|
129
|
+
# → "posts"). A wrong/absent guess is harmless: {ClientManifest#resolve_key}
|
|
130
|
+
# falls back to the shared PascalCase key.
|
|
131
|
+
def controller_path_from(identifier)
|
|
132
|
+
return nil unless identifier
|
|
133
|
+
|
|
134
|
+
m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
|
|
135
|
+
m && m[1]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Parses the attributes string of a component tag into ordered
|
|
139
|
+
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
|
|
140
|
+
# Honors nested braces in values (via {#extract_braced_expr}). The names
|
|
141
|
+
# feed the Story 13.5 contract check; the pairs render the props Hash.
|
|
142
|
+
def parse_prop_pairs(attrs_string)
|
|
143
|
+
return [] if attrs_string.empty?
|
|
85
144
|
|
|
86
145
|
pairs = []
|
|
87
146
|
remaining = attrs_string.dup
|
|
@@ -91,13 +150,13 @@ module Ruact
|
|
|
91
150
|
# Find the matching closing brace, respecting nesting
|
|
92
151
|
value_start = m.end(0)
|
|
93
152
|
value_expr = extract_braced_expr(remaining, value_start)
|
|
94
|
-
pairs <<
|
|
153
|
+
pairs << [prop_name, value_expr]
|
|
95
154
|
# Advance past this prop
|
|
96
155
|
remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
|
|
97
156
|
break if remaining.nil?
|
|
98
157
|
end
|
|
99
158
|
|
|
100
|
-
pairs
|
|
159
|
+
pairs
|
|
101
160
|
end
|
|
102
161
|
|
|
103
162
|
# 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)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Resolves the {Ruact::ClientManifest} to use for the current render /
|
|
7
|
+
# ERB-preprocess.
|
|
8
|
+
#
|
|
9
|
+
# PRODUCTION (unchanged): returns the boot-loaded +Ruact.manifest+ (set once by
|
|
10
|
+
# the Railtie's +config.to_prepare+; the Railtie raises at boot when the file is
|
|
11
|
+
# absent). No per-request I/O.
|
|
12
|
+
#
|
|
13
|
+
# DEVELOPMENT (the fix): the boot-time +to_prepare+ read of
|
|
14
|
+
# +public/react-client-manifest.json+ RACES the Vite dev server's first write —
|
|
15
|
+
# on a fresh app Rails can boot (and read the missing file) BEFORE Vite writes
|
|
16
|
+
# it, leaving +Ruact.manifest+ nil even though Vite is running and serving a
|
|
17
|
+
# current manifest. +public/+ is not watched, so +to_prepare+ never re-fires and
|
|
18
|
+
# the first request to a view with a component hits +nil.reference_for+ → 500.
|
|
19
|
+
#
|
|
20
|
+
# To kill that race, dev resolution fetches the LIVE manifest the Vite plugin
|
|
21
|
+
# serves in memory at +GET {vite_dev_server}/__ruact/manifest+ (always fresh,
|
|
22
|
+
# reflects HMR rebuilds), with graceful fallbacks:
|
|
23
|
+
#
|
|
24
|
+
# 1. HTTP fetch from the Vite dev server (short timeout).
|
|
25
|
+
# 2. Fallback: read +public/react-client-manifest.json+ from disk (the Vite
|
|
26
|
+
# plugin still writes it — prod needs it for the build, and it is the
|
|
27
|
+
# fallback when the dev server is down).
|
|
28
|
+
# 3. Neither available → a clear, actionable error (never a cryptic
|
|
29
|
+
# +NoMethodError+ on nil).
|
|
30
|
+
#
|
|
31
|
+
# Memoization grain: the resolver fetches ONCE PER CALL. The two call sites each
|
|
32
|
+
# invoke it once per their scope and reuse the result for every component —
|
|
33
|
+
# +RenderPipeline+ holds the returned manifest in +@manifest+ for the whole
|
|
34
|
+
# render, and +ErbPreprocessor#transform+ resolves it lazily into a local that
|
|
35
|
+
# is reused across every tag in the template. So a render does NOT re-fetch per
|
|
36
|
+
# component (the bug was many +reference_for+ calls all needing a manifest); at
|
|
37
|
+
# most one fetch for the render and one for a (re)compiled template.
|
|
38
|
+
module ManifestResolver
|
|
39
|
+
DEV_MANIFEST_PATH = "/__ruact/manifest"
|
|
40
|
+
# Dev is localhost; fail fast to the file/clear-error fallback. A refused
|
|
41
|
+
# connection (Vite simply down) raises immediately, so this timeout only
|
|
42
|
+
# bounds the pathological "listening but not answering" case.
|
|
43
|
+
HTTP_OPEN_TIMEOUT = 1
|
|
44
|
+
HTTP_READ_TIMEOUT = 1
|
|
45
|
+
|
|
46
|
+
# Resolve the manifest for a render. In dev, raises a clear
|
|
47
|
+
# {Ruact::ManifestError} when neither the dev server nor the on-disk file is
|
|
48
|
+
# available. In any non-development environment, returns +Ruact.manifest+
|
|
49
|
+
# verbatim (production behaviour is untouched).
|
|
50
|
+
#
|
|
51
|
+
# @return [Ruact::ClientManifest, nil]
|
|
52
|
+
def self.resolve
|
|
53
|
+
return Ruact.manifest unless development?
|
|
54
|
+
|
|
55
|
+
dev_manifest(soft: false)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Like {.resolve} but FAIL-OPEN in dev: returns +nil+ (rather than raising)
|
|
59
|
+
# when nothing is resolvable. Used by the ERB preprocessor's opt-in FR100
|
|
60
|
+
# contract validation, which is fail-open by design — a missing manifest must
|
|
61
|
+
# not crash template compilation; the render path then surfaces the clear
|
|
62
|
+
# error from {.resolve}.
|
|
63
|
+
#
|
|
64
|
+
# @return [Ruact::ClientManifest, nil]
|
|
65
|
+
def self.resolve_soft
|
|
66
|
+
return Ruact.manifest unless development?
|
|
67
|
+
|
|
68
|
+
dev_manifest(soft: true)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Ruact::ClientManifest, nil]
|
|
72
|
+
def self.dev_manifest(soft:)
|
|
73
|
+
manifest = fetch_over_http
|
|
74
|
+
return manifest if manifest
|
|
75
|
+
|
|
76
|
+
manifest = load_from_file
|
|
77
|
+
return manifest if manifest
|
|
78
|
+
|
|
79
|
+
return nil if soft
|
|
80
|
+
|
|
81
|
+
raise ManifestError, <<~MSG.strip
|
|
82
|
+
[ruact] Vite dev server inacessível em #{base_url} e nenhum \
|
|
83
|
+
react-client-manifest.json encontrado em #{file_path} — rode `bin/dev`.
|
|
84
|
+
MSG
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Fetch + parse the live manifest from the Vite dev server, or +nil+ when the
|
|
88
|
+
# server is unreachable / returns a non-200 / unparseable body. Never raises:
|
|
89
|
+
# any failure degrades to the file fallback.
|
|
90
|
+
#
|
|
91
|
+
# @return [Ruact::ClientManifest, nil]
|
|
92
|
+
def self.fetch_over_http
|
|
93
|
+
require "net/http"
|
|
94
|
+
require "uri"
|
|
95
|
+
|
|
96
|
+
# `chomp("/")` so a base configured WITH a trailing slash
|
|
97
|
+
# ("http://localhost:5173/") does not yield a double-slashed
|
|
98
|
+
# "//__ruact/manifest" that misses the Vite middleware mount.
|
|
99
|
+
uri = URI.parse("#{base_url.chomp('/')}#{DEV_MANIFEST_PATH}")
|
|
100
|
+
response = Net::HTTP.start(
|
|
101
|
+
uri.host, uri.port,
|
|
102
|
+
use_ssl: uri.scheme == "https",
|
|
103
|
+
open_timeout: HTTP_OPEN_TIMEOUT, read_timeout: HTTP_READ_TIMEOUT
|
|
104
|
+
) { |http| http.get(uri.request_uri) }
|
|
105
|
+
|
|
106
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
107
|
+
|
|
108
|
+
ClientManifest.from_hash(JSON.parse(response.body))
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Fallback: load the on-disk manifest the Vite plugin also writes, or +nil+
|
|
114
|
+
# when it is absent (the boot-race window) / unreadable.
|
|
115
|
+
#
|
|
116
|
+
# @return [Ruact::ClientManifest, nil]
|
|
117
|
+
def self.load_from_file
|
|
118
|
+
path = file_path
|
|
119
|
+
return nil unless path && File.exist?(path)
|
|
120
|
+
|
|
121
|
+
ClientManifest.load(path)
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [String] the configured Vite dev-server base URL.
|
|
127
|
+
def self.base_url
|
|
128
|
+
Ruact.config.vite_dev_server
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [String, nil] the on-disk manifest path (config override or default).
|
|
132
|
+
def self.file_path
|
|
133
|
+
configured = Ruact.config.manifest_path
|
|
134
|
+
return configured.to_s if configured
|
|
135
|
+
|
|
136
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
137
|
+
|
|
138
|
+
Rails.root.join("public", "react-client-manifest.json").to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Boolean] true only in a real Rails development environment. Any
|
|
142
|
+
# other env (production, test, or no Rails at all) takes the untouched
|
|
143
|
+
# +Ruact.manifest+ path.
|
|
144
|
+
def self.development?
|
|
145
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
|
|
146
|
+
Rails.env.development?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|