ruact 0.0.1 → 0.0.3
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
data/lib/ruact/railtie.rb
CHANGED
|
@@ -6,10 +6,73 @@ module Ruact
|
|
|
6
6
|
class Railtie < Rails::Railtie
|
|
7
7
|
initializer "ruact.load_controller" do
|
|
8
8
|
require_relative "controller"
|
|
9
|
+
# Story 9.1 — the v2 route-driven marker concern (`include Ruact::Server`).
|
|
10
|
+
require_relative "server"
|
|
11
|
+
require_relative "server_action"
|
|
12
|
+
# Story 9.4 (D8) — requiring ruact/routing installs the `ruact_queries`
|
|
13
|
+
# macro into ActionDispatch::Routing::Mapper (a Mapper extension, NOT a
|
|
14
|
+
# mounted route — the host's routes.rb explicitly mounts each query
|
|
15
|
+
# class). Initializers all run before the routes file is loaded, so the
|
|
16
|
+
# macro is in place for the first draw.
|
|
17
|
+
require_relative "routing"
|
|
9
18
|
end
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
# Story 8.3 — register `app/server_actions/` as a Rails `paths` entry
|
|
21
|
+
# AND as an autoload + eager-load path so Zeitwerk discovers standalone
|
|
22
|
+
# `Ruact::ServerAction` modules the same way it discovers
|
|
23
|
+
# controllers/models/jobs. Registering via `config.paths.add` (vs.
|
|
24
|
+
# `config.autoload_paths <<` only) is what makes
|
|
25
|
+
# `Rails.application.config.paths["app/server_actions"].existent`
|
|
26
|
+
# work — that path enumerator is what
|
|
27
|
+
# {.server_actions_paths_for} consumes during force-load.
|
|
28
|
+
initializer "ruact.register_server_actions_path", before: :set_autoload_paths do |app|
|
|
29
|
+
app.config.paths.add "app/server_actions", with: "app/server_actions"
|
|
30
|
+
candidate = app.root.join("app/server_actions")
|
|
31
|
+
if candidate.directory?
|
|
32
|
+
app.config.autoload_paths << candidate.to_s
|
|
33
|
+
app.config.eager_load_paths << candidate.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
|
|
38
|
+
|
|
39
|
+
# Story 8.1 — clear the action/query registries on every code reload so
|
|
40
|
+
# removed `ruact_action` declarations don't linger in the registry. We hook
|
|
41
|
+
# `before_class_unload` (BEFORE Zeitwerk tears down constants) rather than
|
|
42
|
+
# `to_prepare` (AFTER reload): controller class bodies re-evaluate during
|
|
43
|
+
# the reload itself, and clearing in `to_prepare` would wipe their fresh
|
|
44
|
+
# registrations.
|
|
45
|
+
#
|
|
46
|
+
# First-boot is naturally safe — registries start empty, so there's nothing
|
|
47
|
+
# to clear; the very first controller class-body evaluation populates them.
|
|
48
|
+
# In production this hook never fires (no reloads), which is correct.
|
|
49
|
+
initializer "ruact.attach_registry_clear_hook" do |app|
|
|
50
|
+
app.reloader.before_class_unload do
|
|
51
|
+
Ruact.action_registry.clear!
|
|
52
|
+
Ruact.query_registry.clear!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Story 8.1 — mount the single gem-managed endpoint that dispatches all
|
|
57
|
+
# `ruact_action` calls. The route is `POST /__ruact/fn/:name`; the
|
|
58
|
+
# controller resolves `:name` against `Ruact.action_registry` (and, once
|
|
59
|
+
# Story 9.1 lands, `Ruact.query_registry`) and delegates execution to the
|
|
60
|
+
# entry's host controller class via Rails' normal `dispatch` flow.
|
|
61
|
+
#
|
|
62
|
+
# Story 8.1 Re-run-2 (2026-05-14) — `routes.prepend` (not `.append`) so
|
|
63
|
+
# the gem's reserved `/__ruact/fn/:name` endpoint wins ahead of any host
|
|
64
|
+
# catch-all (e.g. `match "*path", to: "errors#not_found"` or a wildcard
|
|
65
|
+
# POST handler) that would otherwise swallow every server-function call
|
|
66
|
+
# before Ruact saw it. The `/__ruact/fn/` URL prefix is the gem's
|
|
67
|
+
# documented reserved namespace; hosts that route under `/__ruact/` are
|
|
68
|
+
# using the same prefix at their own risk.
|
|
69
|
+
initializer "ruact.mount_server_functions_route" do |app|
|
|
70
|
+
app.routes.prepend do
|
|
71
|
+
post "/__ruact/fn/:name",
|
|
72
|
+
to: "ruact/server_functions/endpoint#dispatch_action",
|
|
73
|
+
as: :ruact_server_function,
|
|
74
|
+
constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
|
|
75
|
+
end
|
|
13
76
|
end
|
|
14
77
|
|
|
15
78
|
# Load the client manifest at boot (and on each code reload in development).
|
|
@@ -22,7 +85,7 @@ module Ruact
|
|
|
22
85
|
# - production: raises ManifestError; app does not start
|
|
23
86
|
#
|
|
24
87
|
# Also registers ActionView integration:
|
|
25
|
-
# - ViewHelper provides
|
|
88
|
+
# - ViewHelper provides __ruact_component__ in every view context
|
|
26
89
|
# - ErbPreprocessorHook applies the RSC preprocessor to all ERB templates
|
|
27
90
|
# (layouts, views, partials) transparently via prepend.
|
|
28
91
|
config.to_prepare do
|
|
@@ -40,6 +103,26 @@ module Ruact
|
|
|
40
103
|
require_relative "erb_preprocessor_hook"
|
|
41
104
|
ActionView::Base.include(Ruact::ViewHelper)
|
|
42
105
|
ActionView::Template::Handlers::ERB.prepend(Ruact::ErbPreprocessorHook)
|
|
106
|
+
|
|
107
|
+
# Story 8.1 review-batch 3 (2026-05-14) — force-load all controller
|
|
108
|
+
# files BEFORE writing the snapshot so the registry sees every
|
|
109
|
+
# `ruact_action` declaration. Without this, lazy autoload (Rails dev's
|
|
110
|
+
# default for `eager_load = false`) means a controller that hasn't
|
|
111
|
+
# been requested yet isn't loaded, so its `ruact_action` calls don't
|
|
112
|
+
# populate the registry — the codegen would then emit a stale TS
|
|
113
|
+
# module missing those exports until the controller is hit at least
|
|
114
|
+
# once. The endpoint controller would also 404 those names.
|
|
115
|
+
#
|
|
116
|
+
# Story 8.3 — the force-loader was renamed from
|
|
117
|
+
# `force_load_controllers!` to `force_load_server_function_hosts!`
|
|
118
|
+
# and now walks BOTH `app/controllers/**/*_controller.rb` and
|
|
119
|
+
# `app/server_actions/**/*.rb` so standalone modules also register
|
|
120
|
+
# at boot. The old method name is kept as an alias for back-compat
|
|
121
|
+
# with anything outside the gem calling it.
|
|
122
|
+
Ruact::Railtie.force_load_server_function_hosts!
|
|
123
|
+
|
|
124
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
125
|
+
Ruact::ServerFunctions.write_v2_snapshot!(route_set: Rails.application.routes, root: Rails.root)
|
|
43
126
|
end
|
|
44
127
|
|
|
45
128
|
# Detect streaming capability at boot and log the active mode (AC#1–3).
|
|
@@ -83,6 +166,140 @@ module Ruact
|
|
|
83
166
|
"— run npm run dev for HMR"
|
|
84
167
|
end
|
|
85
168
|
|
|
169
|
+
# Story 8.1 review-batch 3 (2026-05-14) — force-loads every controller
|
|
170
|
+
# file under `Rails.application.config.paths["app/controllers"]` so the
|
|
171
|
+
# `ruact_action` registrations populate the registry on a clean boot.
|
|
172
|
+
#
|
|
173
|
+
# Without this, Rails' dev-mode lazy autoload only loads a controller
|
|
174
|
+
# when it's first referenced (typically the first request that routes
|
|
175
|
+
# to it). That means the codegen snapshot in `to_prepare` would miss
|
|
176
|
+
# any controller not yet touched.
|
|
177
|
+
#
|
|
178
|
+
# Implementation: glob the `app/controllers` directories listed in the
|
|
179
|
+
# Rails paths configuration and `require_dependency` each
|
|
180
|
+
# `*_controller.rb` file. `require_dependency` works in both Zeitwerk
|
|
181
|
+
# (Rails 7+) and the classic autoloader. On Zeitwerk it is implemented
|
|
182
|
+
# as `Rails.autoloaders.main.load_file(path)` under the hood.
|
|
183
|
+
#
|
|
184
|
+
# Errors are surfaced as `Ruact::Error` with a controller hint so the
|
|
185
|
+
# developer sees a meaningful boot failure instead of a silent skip.
|
|
186
|
+
#
|
|
187
|
+
# Re-run-6 (2026-05-15) — also walks every mounted `Rails::Engine`
|
|
188
|
+
# (and `Rails::Railtie` with `paths["app/controllers"]`) so engine-
|
|
189
|
+
# owned controllers that declare `ruact_action` populate the registry
|
|
190
|
+
# at boot. Without this an engine's `ruact_action` declarations would
|
|
191
|
+
# only register on first request that touches the engine controller —
|
|
192
|
+
# codegen + endpoint dispatch would lag behind boot.
|
|
193
|
+
#
|
|
194
|
+
# @return [Integer] number of controller files loaded.
|
|
195
|
+
def self.force_load_server_function_hosts!
|
|
196
|
+
loaded = 0
|
|
197
|
+
|
|
198
|
+
controller_paths_for(Rails.application).each do |dir|
|
|
199
|
+
loaded += force_load_dir(dir, glob: "**/*_controller.rb")
|
|
200
|
+
end
|
|
201
|
+
server_actions_paths_for(Rails.application).each do |dir|
|
|
202
|
+
loaded += force_load_dir(dir, glob: "**/*.rb")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if defined?(Rails::Engine)
|
|
206
|
+
Rails::Engine.subclasses.each do |engine_class|
|
|
207
|
+
# Skip the host app itself; `Rails.application.class` is a
|
|
208
|
+
# `Rails::Engine` subclass and was already covered above.
|
|
209
|
+
next if engine_class == Rails.application.class
|
|
210
|
+
|
|
211
|
+
engine = safe_engine_instance(engine_class)
|
|
212
|
+
next unless engine
|
|
213
|
+
|
|
214
|
+
controller_paths_for(engine).each { |dir| loaded += force_load_dir(dir, glob: "**/*_controller.rb") }
|
|
215
|
+
server_actions_paths_for(engine).each { |dir| loaded += force_load_dir(dir, glob: "**/*.rb") }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
loaded
|
|
220
|
+
rescue LoadError, NameError => e
|
|
221
|
+
raise Ruact::Error,
|
|
222
|
+
"ruact: failed to force-load a server-function host while populating " \
|
|
223
|
+
"Ruact.action_registry: #{e.class}: #{e.message}. The gem " \
|
|
224
|
+
"force-loads `app/controllers/**/*_controller.rb` and " \
|
|
225
|
+
"`app/server_actions/**/*.rb` at `config.to_prepare` so " \
|
|
226
|
+
"registries are complete on first boot."
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Story 8.3 — back-compat alias. Older code paths (rake tasks shipped
|
|
230
|
+
# in previous gem versions, downstream tooling) may call the old
|
|
231
|
+
# name; the new name describes the wider behavior accurately.
|
|
232
|
+
class << self
|
|
233
|
+
alias force_load_controllers! force_load_server_function_hosts!
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @param engine [Rails::Engine] either `Rails.application` or a mounted engine
|
|
237
|
+
# @return [Array<String>] existing controller directory paths
|
|
238
|
+
def self.controller_paths_for(engine)
|
|
239
|
+
return [] unless engine.respond_to?(:config) && engine.config.respond_to?(:paths)
|
|
240
|
+
|
|
241
|
+
paths = engine.config.paths["app/controllers"]
|
|
242
|
+
return [] unless paths.respond_to?(:existent)
|
|
243
|
+
|
|
244
|
+
paths.existent
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @param dir [String]
|
|
248
|
+
# @param glob [String] glob pattern relative to +dir+; defaults to
|
|
249
|
+
# `**/*_controller.rb` for back-compat with pre-Story-8.3 callers.
|
|
250
|
+
# @return [Integer] number of files loaded
|
|
251
|
+
def self.force_load_dir(dir, glob: "**/*_controller.rb")
|
|
252
|
+
Dir.glob("#{dir}/#{glob}").each do |file|
|
|
253
|
+
require_dependency(file)
|
|
254
|
+
end.length
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Story 8.3 — locates `app/server_actions/` for the host application
|
|
258
|
+
# or a mounted engine. Uses the Rails `paths` enumerator (populated by
|
|
259
|
+
# the Railtie initializer) when present, falling back to a direct
|
|
260
|
+
# `engine.root.join` lookup for engines that haven't registered the
|
|
261
|
+
# path. Returns an empty array when the directory doesn't exist —
|
|
262
|
+
# silent no-op for hosts that don't use standalone actions.
|
|
263
|
+
def self.server_actions_paths_for(engine)
|
|
264
|
+
if engine.respond_to?(:config) && engine.config.respond_to?(:paths)
|
|
265
|
+
paths = engine.config.paths["app/server_actions"]
|
|
266
|
+
if paths.respond_to?(:existent)
|
|
267
|
+
existent = paths.existent
|
|
268
|
+
return existent unless existent.empty?
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
return [] unless engine.respond_to?(:root) && engine.root
|
|
273
|
+
|
|
274
|
+
candidate = engine.root.join("app/server_actions")
|
|
275
|
+
candidate.directory? ? [candidate.to_s] : []
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Some engine subclasses are abstract (no `.instance` defined yet) or fail
|
|
279
|
+
# at `.instance` if their config block raises. Swallow those quietly —
|
|
280
|
+
# the gem's responsibility is to load whatever it can; engines whose
|
|
281
|
+
# `.instance` blows up will surface on first request anyway.
|
|
282
|
+
def self.safe_engine_instance(engine_class)
|
|
283
|
+
engine_class.instance
|
|
284
|
+
rescue StandardError
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Writes the server-functions JSON snapshot to tmp/cache/ruact/ on every
|
|
289
|
+
# config.to_prepare. The write is short-circuited when the registry payload
|
|
290
|
+
# is unchanged (Story 8.0a — pitfall #1: dev mode fires to_prepare per
|
|
291
|
+
# request; a naive rewrite would burn IOPS and confuse the Vite plugin's
|
|
292
|
+
# chokidar watcher).
|
|
293
|
+
#
|
|
294
|
+
# @return [Boolean] true if a fresh file was written, false if unchanged.
|
|
295
|
+
def self.write_server_functions_snapshot!
|
|
296
|
+
Ruact::ServerFunctions::Snapshot.generate!(
|
|
297
|
+
action_registry: Ruact.action_registry,
|
|
298
|
+
query_registry: Ruact.query_registry,
|
|
299
|
+
path: Rails.root.join("tmp/cache/ruact/server-functions.json")
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
86
303
|
# Checks whether the manifest exists and either warns (dev) or raises (prod).
|
|
87
304
|
# Extracted as a class method for direct testability without a full Rails app.
|
|
88
305
|
def self.check_manifest!(manifest_path)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Per-render mutable state holding the components encountered during ERB
|
|
5
|
+
# evaluation. One instance is allocated by the controller per render, passed
|
|
6
|
+
# explicitly through the pipeline, and discarded when the response is sent.
|
|
7
|
+
#
|
|
8
|
+
# No shared state, no thread-local lookup — the instance lives only as long
|
|
9
|
+
# as the render call. Satisfies NFR8 and the `Ruact/NoSharedState` cop with
|
|
10
|
+
# zero exceptions in `lib/`.
|
|
11
|
+
#
|
|
12
|
+
# Internal API: not part of the public compatibility contract.
|
|
13
|
+
class RenderContext
|
|
14
|
+
def initialize
|
|
15
|
+
@components = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :components
|
|
19
|
+
|
|
20
|
+
def register(name, props)
|
|
21
|
+
token = "__RUACT_#{@components.length}__"
|
|
22
|
+
@components << { token: token, name: name, props: props }
|
|
23
|
+
token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def by_token(token)
|
|
27
|
+
@components.find { |c| c[:token] == token }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,47 +3,204 @@
|
|
|
3
3
|
require "erb"
|
|
4
4
|
|
|
5
5
|
module Ruact
|
|
6
|
-
#
|
|
6
|
+
# Internal — orchestrates the full server component render:
|
|
7
7
|
# ERB source → (preprocessor) → evaluated HTML → (HtmlConverter) → ReactElement tree
|
|
8
8
|
# → (Flight::Renderer) → wire bytes
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# External code should use `Ruact::Controller#ruact_render` instead. `Ruact::RenderPipeline`
|
|
11
|
+
# (and the rest of `Ruact::Flight::*` / `Ruact::Internal::*`) are not part of the public API
|
|
12
|
+
# and may change between minor versions.
|
|
13
|
+
#
|
|
14
|
+
# Single entry point: {#render}.
|
|
13
15
|
class RenderPipeline
|
|
16
|
+
VALID_MODES = %i[string stream].freeze
|
|
17
|
+
|
|
14
18
|
def initialize(manifest, controller_path: nil, logger: nil)
|
|
15
19
|
@manifest = manifest
|
|
16
20
|
@controller_path = controller_path
|
|
17
21
|
@logger = logger
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
# Render
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
# Render a server component tree to Flight wire format.
|
|
25
|
+
#
|
|
26
|
+
# **Internal API.** External code should call `Ruact::Controller#ruact_render` instead.
|
|
27
|
+
# `Ruact::RenderPipeline` is not part of the public API and may change between minor
|
|
28
|
+
# versions without deprecation. Reach into it only when extending the gem itself.
|
|
29
|
+
#
|
|
30
|
+
# @param input [Hash] selects the input shape — exactly one of:
|
|
31
|
+
# - `{ erb: String, binding: Binding }` — render an ERB template within the given binding.
|
|
32
|
+
# Both keys required; `:erb` must be a `String`, `:binding` must be a `Binding`.
|
|
33
|
+
# - `{ html: String, render_context: Ruact::RenderContext }` — convert pre-rendered HTML
|
|
34
|
+
# (from ActionView) using a render context populated during ERB evaluation.
|
|
35
|
+
# Both keys required; `:html` must be a `String`, `:render_context` must be a
|
|
36
|
+
# `Ruact::RenderContext`.
|
|
37
|
+
# Extra keys beyond the two-key shapes above are rejected with `ArgumentError`.
|
|
38
|
+
# @param mode [Symbol] one of:
|
|
39
|
+
# - `:string` — returns the full serialized `String`. Deferred chunks are inlined
|
|
40
|
+
# eagerly (no Suspense delay). Suitable for buffered HTTP responses.
|
|
41
|
+
# - `:stream` — returns an `Enumerator` that yields Flight rows one at a time.
|
|
42
|
+
# Deferred chunks delay. Suitable for `ActionController::Live` streaming.
|
|
43
|
+
# @return [String, Enumerator] depending on `mode`.
|
|
44
|
+
# @raise [ArgumentError] when:
|
|
45
|
+
# - `input` is not a `Hash`,
|
|
46
|
+
# - `input` mixes `:erb` and `:html` keys,
|
|
47
|
+
# - `input` omits both `:erb` and `:html`,
|
|
48
|
+
# - the required sibling key is missing or has the wrong type
|
|
49
|
+
# (`:binding` must be a `Binding`; `:render_context` must be a `Ruact::RenderContext`),
|
|
50
|
+
# - `:erb` or `:html` is not a `String`,
|
|
51
|
+
# - `input` contains extra keys beyond the documented shapes,
|
|
52
|
+
# - `mode` is not in {VALID_MODES}.
|
|
53
|
+
# All `ArgumentError` messages name the offending input and reference
|
|
54
|
+
# `RenderPipeline#render` for the canonical contract.
|
|
55
|
+
#
|
|
56
|
+
# @example ERB source, buffered output
|
|
57
|
+
# pipeline.render({ erb: "<NavBar />", binding: ctx.instance_eval { binding } }, mode: :string)
|
|
58
|
+
# # => "0:[\"$\",\"div\",null,...]\n"
|
|
59
|
+
#
|
|
60
|
+
# @example Pre-rendered HTML, streaming output
|
|
61
|
+
# ctx = Ruact::RenderContext.new
|
|
62
|
+
# pipeline.render({ html: "<!-- __RUACT_0__ -->", render_context: ctx }, mode: :stream)
|
|
63
|
+
# # => #<Enumerator: ...> (yields Flight rows lazily)
|
|
64
|
+
def render(input, mode: :string)
|
|
65
|
+
validate_mode!(mode)
|
|
66
|
+
enum = build_enum(input, streaming: mode == :stream)
|
|
67
|
+
mode == :string ? enum.to_a.join : enum
|
|
24
68
|
end
|
|
25
69
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
ALLOWED_INPUT_KEYS = %i[erb binding html render_context].freeze
|
|
73
|
+
private_constant :ALLOWED_INPUT_KEYS
|
|
74
|
+
|
|
75
|
+
DOCSTRING_REF = "see RenderPipeline#render docstring for the canonical contract"
|
|
76
|
+
private_constant :DOCSTRING_REF
|
|
77
|
+
|
|
78
|
+
def validate_mode!(mode)
|
|
79
|
+
return if VALID_MODES.include?(mode)
|
|
80
|
+
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"ruact: unknown render mode #{mode.inspect}; expected one of #{VALID_MODES.inspect} " \
|
|
83
|
+
"(#{DOCSTRING_REF})"
|
|
30
84
|
end
|
|
31
85
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
86
|
+
def build_enum(input, streaming:)
|
|
87
|
+
unless input.is_a?(Hash)
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"ruact: render input must be a Hash; got #{input.class} (#{DOCSTRING_REF})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
extra = input.keys - ALLOWED_INPUT_KEYS
|
|
93
|
+
unless extra.empty?
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"ruact: render input contains unsupported keys: #{extra.inspect}; " \
|
|
96
|
+
"expected only #{ALLOWED_INPUT_KEYS.inspect} (#{DOCSTRING_REF})"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
has_erb = input.key?(:erb)
|
|
100
|
+
has_html = input.key?(:html)
|
|
101
|
+
|
|
102
|
+
if has_erb && has_html
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"ruact: render input cannot mix :erb and :html keys (got both); choose one source shape " \
|
|
105
|
+
"(#{DOCSTRING_REF})"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
unless has_erb || has_html
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"ruact: render input must include either :erb (with sibling :binding) " \
|
|
111
|
+
"or :html (with sibling :render_context) (#{DOCSTRING_REF})"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if has_erb
|
|
115
|
+
validate_erb_shape!(input)
|
|
116
|
+
render_erb_enum(input[:erb], input[:binding], streaming: streaming)
|
|
117
|
+
else
|
|
118
|
+
validate_html_shape!(input)
|
|
119
|
+
render_html_enum(input[:html], input[:render_context], streaming: streaming)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_erb_shape!(input)
|
|
124
|
+
erb = input[:erb]
|
|
125
|
+
unless erb.is_a?(String)
|
|
126
|
+
raise ArgumentError,
|
|
127
|
+
"ruact: render input :erb must be a String; got #{erb.class} (#{DOCSTRING_REF})"
|
|
128
|
+
end
|
|
129
|
+
unless input.key?(:binding)
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"ruact: render input { erb: ... } requires sibling :binding (Binding) (#{DOCSTRING_REF})"
|
|
132
|
+
end
|
|
133
|
+
bnd = input[:binding]
|
|
134
|
+
return if bnd.is_a?(Binding)
|
|
135
|
+
|
|
136
|
+
raise ArgumentError,
|
|
137
|
+
"ruact: render input :binding must be a Binding; got #{bnd.class} (#{DOCSTRING_REF})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_html_shape!(input)
|
|
141
|
+
html = input[:html]
|
|
142
|
+
unless html.is_a?(String)
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"ruact: render input :html must be a String; got #{html.class} (#{DOCSTRING_REF})"
|
|
145
|
+
end
|
|
146
|
+
unless input.key?(:render_context)
|
|
147
|
+
raise ArgumentError,
|
|
148
|
+
"ruact: render input { html: ... } requires sibling :render_context " \
|
|
149
|
+
"(Ruact::RenderContext) (#{DOCSTRING_REF})"
|
|
150
|
+
end
|
|
151
|
+
ctx = input[:render_context]
|
|
152
|
+
return if ctx.is_a?(RenderContext)
|
|
153
|
+
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"ruact: render input :render_context must be a Ruact::RenderContext; " \
|
|
156
|
+
"got #{ctx.class} (#{DOCSTRING_REF})"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ERB-source path: evaluate ERB, collect components into a fresh RenderContext via
|
|
160
|
+
# injected `__ruact_component__` helper, then convert + serialize.
|
|
38
161
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
162
|
+
# NB: `Ruact.config.strict_serialization` and the `as_json` warning callback are read
|
|
163
|
+
# inside the Enumerator block (consumption time), preserving the legacy `_stream`
|
|
164
|
+
# behavior — config changes between `#render` returning and the Enumerator being
|
|
165
|
+
# consumed are observable.
|
|
166
|
+
def render_erb_enum(erb_source, binding_context, streaming:)
|
|
167
|
+
Enumerator.new do |y|
|
|
168
|
+
render_context = RenderContext.new
|
|
169
|
+
transformed = ErbPreprocessor.transform(erb_source)
|
|
170
|
+
receiver = binding_context.eval("self")
|
|
171
|
+
prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
|
|
172
|
+
inject_helper!(binding_context, render_context)
|
|
173
|
+
html =
|
|
174
|
+
begin
|
|
175
|
+
ERB.new(transformed).result(binding_context)
|
|
176
|
+
ensure
|
|
177
|
+
receiver.instance_variable_set(:@__ruact_render_context__, prev_ctx)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
registry = render_context.components.map do |entry|
|
|
181
|
+
ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
|
|
182
|
+
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
root_element = HtmlConverter.convert(html, registry)
|
|
186
|
+
|
|
187
|
+
Flight::Renderer.each(root_element, @manifest,
|
|
188
|
+
strict_serialization: Ruact.config.strict_serialization,
|
|
189
|
+
on_as_json_warning: as_json_warning_callback,
|
|
190
|
+
streaming: streaming) { |row| y << row }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# HTML path: input HTML is already rendered by ActionView; the supplied RenderContext
|
|
195
|
+
# holds the components encountered during ERB evaluation. Registry is captured eagerly so
|
|
196
|
+
# the caller may discard the RenderContext immediately after `render` returns — the
|
|
197
|
+
# returned Enumerator does not reference the RenderContext.
|
|
198
|
+
def render_html_enum(html, render_context, streaming:)
|
|
199
|
+
registry = render_context.components.map do |entry|
|
|
43
200
|
ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
|
|
44
201
|
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
45
202
|
end
|
|
46
|
-
strict
|
|
203
|
+
strict = Ruact.config.strict_serialization
|
|
47
204
|
warning_cb = as_json_warning_callback
|
|
48
205
|
|
|
49
206
|
Enumerator.new do |y|
|
|
@@ -55,33 +212,6 @@ module Ruact
|
|
|
55
212
|
end
|
|
56
213
|
end
|
|
57
214
|
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def _stream(erb_source, binding_context, streaming: false)
|
|
61
|
-
Enumerator.new do |y|
|
|
62
|
-
ComponentRegistry.start
|
|
63
|
-
begin
|
|
64
|
-
transformed = ErbPreprocessor.transform(erb_source)
|
|
65
|
-
inject_helper!(binding_context)
|
|
66
|
-
html = ERB.new(transformed).result(binding_context)
|
|
67
|
-
|
|
68
|
-
registry = ComponentRegistry.components.map do |entry|
|
|
69
|
-
ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
|
|
70
|
-
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
root_element = HtmlConverter.convert(html, registry)
|
|
74
|
-
|
|
75
|
-
Flight::Renderer.each(root_element, @manifest,
|
|
76
|
-
strict_serialization: Ruact.config.strict_serialization,
|
|
77
|
-
on_as_json_warning: as_json_warning_callback,
|
|
78
|
-
streaming: streaming) { |row| y << row }
|
|
79
|
-
ensure
|
|
80
|
-
ComponentRegistry.reset
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
215
|
def as_json_warning_callback
|
|
86
216
|
return nil if @logger.nil?
|
|
87
217
|
|
|
@@ -89,19 +219,31 @@ module Ruact
|
|
|
89
219
|
@logger.warn(
|
|
90
220
|
"[ruact] WARNING: #{class_name} serialized via as_json — " \
|
|
91
221
|
"ALL attributes exposed to client: #{attrs}. " \
|
|
92
|
-
"Use `include Ruact::Serializable` with `
|
|
222
|
+
"Use `include Ruact::Serializable` with `ruact_props` for explicit control"
|
|
93
223
|
)
|
|
94
224
|
end
|
|
95
225
|
end
|
|
96
226
|
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
227
|
+
# Install __ruact_component__ on the binding's receiver and stash the active
|
|
228
|
+
# render context on the receiver as @__ruact_render_context__. The singleton
|
|
229
|
+
# method reads the ivar dynamically, so nested renders sharing the same
|
|
230
|
+
# receiver (e.g., inner pipeline.render from inside ERB) see whatever context
|
|
231
|
+
# is current at invocation time — render_erb_enum wraps ERB evaluation in a
|
|
232
|
+
# save/restore block so the outer render's context is restored afterward.
|
|
233
|
+
# Defining the singleton method is idempotent — re-defining on a nested
|
|
234
|
+
# call would not change behaviour, but skipping it avoids needless work.
|
|
235
|
+
def inject_helper!(binding_context, render_context)
|
|
236
|
+
receiver = binding_context.eval("self")
|
|
237
|
+
receiver.instance_variable_set(:@__ruact_render_context__, render_context)
|
|
238
|
+
return if receiver.respond_to?(:__ruact_component__)
|
|
239
|
+
|
|
240
|
+
receiver.define_singleton_method(:__ruact_component__) do |name, props = {}|
|
|
241
|
+
ctx = instance_variable_get(:@__ruact_render_context__)
|
|
242
|
+
raise Ruact::Error, "ruact: __ruact_component__ called outside an active render context" if ctx.nil?
|
|
243
|
+
|
|
244
|
+
token = ctx.register(name, props)
|
|
245
|
+
"<!-- #{token} -->"
|
|
246
|
+
end
|
|
105
247
|
end
|
|
106
248
|
end
|
|
107
249
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_dispatch"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Story 9.4 (D8) — the `ruact_queries` routing macro. Included into
|
|
7
|
+
# `ActionDispatch::Routing::Mapper` by the Railtie, so it is available
|
|
8
|
+
# inside `Rails.application.routes.draw`:
|
|
9
|
+
#
|
|
10
|
+
# Rails.application.routes.draw do
|
|
11
|
+
# ruact_queries CatalogQuery # GET /q/categories, GET /q/searchUsers, …
|
|
12
|
+
# resources :posts
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# For each `public_instance_methods(false)` of the query class — methods
|
|
16
|
+
# inherited from `Ruact::Query` / `ApplicationQuery` or mixed in from user
|
|
17
|
+
# modules are NOT mounted (AC1) — one NAMED GET route is drawn at
|
|
18
|
+
# `GET <Ruact.config.query_route_prefix>/<jsIdentifier>` (default `/q`,
|
|
19
|
+
# contract decision #7), pointing at the query class's generated internal
|
|
20
|
+
# dispatch controller ({Ruact::ServerFunctions::QueryDispatch}). Every query
|
|
21
|
+
# is visible in `rails routes` — no hidden endpoint; the route table stays
|
|
22
|
+
# the single source of truth.
|
|
23
|
+
#
|
|
24
|
+
# The path segment reuses {Ruact::ServerFunctions::NameBridge} verbatim
|
|
25
|
+
# (D4): `def search_users` → `GET /q/searchUsers`, named
|
|
26
|
+
# `ruact_query_searchUsers`. Invalid or JS-reserved method names raise
|
|
27
|
+
# {Ruact::ConfigurationError} at route-draw time; two query classes mounting
|
|
28
|
+
# the same method name collide on the route NAME / path and fail Rails' own
|
|
29
|
+
# duplicate checks — both are loud boot failures, never request-time
|
|
30
|
+
# surprises.
|
|
31
|
+
#
|
|
32
|
+
# The generated dispatch controller PRESERVES the query class's namespace
|
|
33
|
+
# (review round 4) — `Admin::CatalogQuery` →
|
|
34
|
+
# `Ruact::ServerFunctions::QueryDispatch::Admin::CatalogQueryController` — so
|
|
35
|
+
# the controller constant is an injective function of the query class's
|
|
36
|
+
# fully-qualified name: two distinct query classes can never map to the same
|
|
37
|
+
# constant, and there is no flatten collision to detect (across any number
|
|
38
|
+
# of RouteSets / mounted engines sharing the global dispatch namespace).
|
|
39
|
+
module Routing
|
|
40
|
+
# Draws the named GET routes for one or more {Ruact::Query} subclasses.
|
|
41
|
+
#
|
|
42
|
+
# @param query_classes [Array<Class>] `Ruact::Query` subclasses to mount.
|
|
43
|
+
# @return [void]
|
|
44
|
+
def ruact_queries(*query_classes)
|
|
45
|
+
query_classes.each { |query_class| Ruact::Routing.draw_query_routes(self, query_class) }
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# @api private — the macro body, kept off the Mapper instance so the only
|
|
51
|
+
# method `ruact_queries` adds to the routing DSL surface is itself.
|
|
52
|
+
def draw_query_routes(mapper, query_class)
|
|
53
|
+
ServerFunctions::QueryDispatch.controller_for(query_class)
|
|
54
|
+
target = ServerFunctions::QueryDispatch.route_target_for(query_class)
|
|
55
|
+
prefix = Ruact.config.query_route_prefix
|
|
56
|
+
|
|
57
|
+
query_class.public_instance_methods(false).each do |query_method|
|
|
58
|
+
js_identifier = ServerFunctions::NameBridge.to_js_identifier(query_method)
|
|
59
|
+
mapper.get("#{prefix}/#{js_identifier}",
|
|
60
|
+
to: "#{target}##{query_method}",
|
|
61
|
+
as: :"ruact_query_#{js_identifier}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# D8 — installed at require time (the Railtie requires this file from its
|
|
69
|
+
# `ruact.load_controller` initializer; a direct `require "ruact/routing"`
|
|
70
|
+
# in a non-Railtie context gets the same one-shot, idempotent install).
|
|
71
|
+
# Written as a class reopening (rather than
|
|
72
|
+
# `ActionDispatch::Routing::Mapper.include Ruact::Routing`) so YARD's static
|
|
73
|
+
# MixinHandler resolves the named namespace instead of choking on the external
|
|
74
|
+
# constant receiver under `--fail-on-warning`.
|
|
75
|
+
module ActionDispatch # rubocop:disable Style/OneClassPerFile -- deliberate Mapper extension install (D8)
|
|
76
|
+
module Routing
|
|
77
|
+
class Mapper
|
|
78
|
+
include Ruact::Routing
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|