ruact 0.0.2 → 0.0.4
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 +88 -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 +1779 -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 +100 -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 +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -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 +111 -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 +598 -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 +508 -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 +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -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 +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- 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 +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
data/lib/ruact/controller.rb
CHANGED
|
@@ -17,11 +17,376 @@ module Ruact
|
|
|
17
17
|
module Controller
|
|
18
18
|
extend ActiveSupport::Concern
|
|
19
19
|
|
|
20
|
+
# Story 8.1 review-batch 1 (2026-05-14) — symbols a host MUST NOT use
|
|
21
|
+
# as `ruact_action` / `ruact_query` names because overriding them
|
|
22
|
+
# would corrupt request handling. Keep sorted; documented per name:
|
|
23
|
+
# `:params`, `:request`, `:response`, `:headers`, `:session`, `:flash`,
|
|
24
|
+
# `:cookies` — request/response accessors; overriding breaks reads
|
|
25
|
+
# `:render`, `:redirect_to`, `:head`, `:send_file`, `:send_data` —
|
|
26
|
+
# response producers; overriding breaks the response path
|
|
27
|
+
# `:action_name`, `:controller_name`, `:controller_path` — routing
|
|
28
|
+
# identification; overriding breaks `before_action :foo, only:` matching
|
|
29
|
+
# `:url_for`, `:url_options` — URL generation
|
|
30
|
+
# `:dispatch`, `:process`, `:process_action` — Rails dispatch internals
|
|
31
|
+
# `:form_authenticity_token`, `:verified_request?` — CSRF plumbing
|
|
32
|
+
# Re-run-4 (2026-05-15) — list expanded with the additional
|
|
33
|
+
# ActionController instance methods reviewers flagged as silently
|
|
34
|
+
# clobberable: `:render_to_string` / `:render_to_body` (response
|
|
35
|
+
# producers used by templating), `:send_action` (Rails dispatch
|
|
36
|
+
# internal), `:logger` / `:logger=` (clobbering breaks every log
|
|
37
|
+
# statement on the controller), `:default_render` (the gem hook
|
|
38
|
+
# method itself — overriding would disable RSC rendering).
|
|
39
|
+
FRAMEWORK_RESERVED_METHODS = %i[
|
|
40
|
+
__send__ action_name controller_name controller_path cookies
|
|
41
|
+
default_render dispatch flash form_authenticity_token head headers
|
|
42
|
+
instance_eval instance_exec logger logger= method params process
|
|
43
|
+
process_action protect_from_forgery public_send redirect_to render
|
|
44
|
+
render_to_body render_to_string request response send send_action
|
|
45
|
+
send_data send_file session skip_forgery_protection url_for
|
|
46
|
+
url_options verified_request? verify_authenticity_token
|
|
47
|
+
].to_set.freeze
|
|
48
|
+
|
|
49
|
+
# Story 8.1 — class-level DSL surface. The `ruact_action` macro registers
|
|
50
|
+
# a server-callable symbol with `Ruact.action_registry` (so the Vite-plugin
|
|
51
|
+
# codegen from Story 8.0a picks it up at the next `config.to_prepare`) AND
|
|
52
|
+
# defines a matching public instance method that is reachable ONLY via the
|
|
53
|
+
# gem's `POST /__ruact/fn/:name` endpoint dispatch path. The defined method
|
|
54
|
+
# body checks a thread-local sentinel (`Thread.current[:__ruact_dispatching]`)
|
|
55
|
+
# set by `Ruact::ServerFunctions::EndpointController#dispatch_action` and
|
|
56
|
+
# raises `Ruact::Error` for any direct call from in-controller code, a
|
|
57
|
+
# wildcard route, or `host_class.action(...).call` — closing the
|
|
58
|
+
# GET-without-CSRF attack surface that a host's `get ":controller/:action"`
|
|
59
|
+
# route would otherwise create. If a developer needs the block body from
|
|
60
|
+
# another controller method, they should refactor the body into a PORO that
|
|
61
|
+
# both the action and the controller call.
|
|
62
|
+
#
|
|
63
|
+
# Validation (naming-bridge rule + within-registry / cross-registry
|
|
64
|
+
# collision detection) fires from {Ruact::ServerFunctions::Registry#register}
|
|
65
|
+
# at controller-class load time — the failure is loud at boot, not at
|
|
66
|
+
# request-dispatch time.
|
|
67
|
+
class_methods do
|
|
68
|
+
# @param symbol [Symbol] the action name (snake_case; bridged to JS
|
|
69
|
+
# camelCase by {Ruact::ServerFunctions::NameBridge}).
|
|
70
|
+
# @yield [params] the block runs via `instance_exec` on a fresh
|
|
71
|
+
# controller instance at dispatch time; `params` shadows the request's
|
|
72
|
+
# `params` accessor and is an `ActionController::Parameters` instance
|
|
73
|
+
# wrapping the action-call arguments (NOT the request's query/form params).
|
|
74
|
+
# @return [Ruact::ServerFunctions::RegistryEntry] the entry just registered.
|
|
75
|
+
# @raise [Ruact::ConfigurationError] when the symbol fails the
|
|
76
|
+
# naming-bridge rule or collides with another `ruact_action` in this
|
|
77
|
+
# registry (cross-registry collisions with `ruact_query` are caught
|
|
78
|
+
# later by {Ruact::ServerFunctions::Snapshot.functions_payload}).
|
|
79
|
+
def ruact_action(symbol, &block)
|
|
80
|
+
# Re-run-2 (2026-05-14) — `ruact_action` strictly requires a Symbol.
|
|
81
|
+
# A String slips through the naming-bridge regex but stores a
|
|
82
|
+
# String key in `@entries`, while `EndpointController#dispatch_action`
|
|
83
|
+
# looks the entry up by `:name.to_sym` — net effect: silent 404 on
|
|
84
|
+
# every dispatch. Refuse Strings (and anything else) loudly.
|
|
85
|
+
unless symbol.is_a?(Symbol)
|
|
86
|
+
raise ArgumentError,
|
|
87
|
+
"ruact_action requires a Symbol argument, got " \
|
|
88
|
+
"#{symbol.inspect} (#{symbol.class}). Use " \
|
|
89
|
+
"`ruact_action :#{symbol}` not `ruact_action #{symbol.inspect}`."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless block
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"ruact_action :#{symbol} requires a block — declare the " \
|
|
95
|
+
"implementation with `ruact_action :#{symbol} do |params| ... end`"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Re-run-4 (2026-05-15) — parameter-shape guard (replaces the
|
|
99
|
+
# earlier `block.arity` check). `block.arity` is negative for
|
|
100
|
+
# ALL signatures that include any optional/keyword/splat
|
|
101
|
+
# parameter — including footguns like `do |params, required:|`
|
|
102
|
+
# which would crash at dispatch time when the macro invokes the
|
|
103
|
+
# block with one positional argument and no keyword arguments.
|
|
104
|
+
# We use `block.parameters` for full inspection: the block must
|
|
105
|
+
# accept exactly one positional argument (`:req`, `:opt`, or
|
|
106
|
+
# `:rest`) AND have no required keyword parameters (`:keyreq`).
|
|
107
|
+
# Optional keyword params (`:key`) and double-splat (`:keyrest`)
|
|
108
|
+
# are fine.
|
|
109
|
+
# Re-run-5 (2026-05-15) — block must accept exactly one
|
|
110
|
+
# positional argument. The macro invokes the block with one
|
|
111
|
+
# positional arg (`instance_exec(args, &block)`), so:
|
|
112
|
+
#
|
|
113
|
+
# - `do |a, b|` would have `b` silently set to `nil`.
|
|
114
|
+
# Parameters report `[[:opt, :a], [:opt, :b]]` for blocks
|
|
115
|
+
# (proc-arity-coercion). Reject `:opt+:req > 1`.
|
|
116
|
+
# - `do |p, required:|` (kwarg case): block.arity stays
|
|
117
|
+
# negative, dispatch later raises. Reject `:keyreq`.
|
|
118
|
+
# - `do |*args|` accepts any count including 1; `:rest`
|
|
119
|
+
# entry counts as 1.
|
|
120
|
+
# - `do |params, opt: nil|` is fine (optional kwarg).
|
|
121
|
+
#
|
|
122
|
+
# Allowed shapes: `do |p|`, `do |*args|`, `do |p, key: nil|`.
|
|
123
|
+
# Rejected: `do ||`, `do |a, b|`, `do |p, required:|`.
|
|
124
|
+
req_count = block.parameters.count { |kind, _| kind == :req }
|
|
125
|
+
opt_count = block.parameters.count { |kind, _| kind == :opt }
|
|
126
|
+
rest_count = block.parameters.count { |kind, _| kind == :rest }
|
|
127
|
+
named_positional = req_count + opt_count
|
|
128
|
+
positional_total = named_positional + rest_count
|
|
129
|
+
has_required_kwarg = block.parameters.any? { |kind, _| kind == :keyreq }
|
|
130
|
+
if positional_total.zero? || named_positional > 1 || has_required_kwarg
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"ruact_action :#{symbol} block must accept exactly one " \
|
|
133
|
+
"positional parameter and no required keyword arguments " \
|
|
134
|
+
"(got parameters=#{block.parameters.inspect}). Use " \
|
|
135
|
+
"`ruact_action :#{symbol} do |params| ... end`."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Review-batch 1 (2026-05-14) — framework-method-clobber guard.
|
|
139
|
+
# Refuse to define if the symbol matches one of the well-known
|
|
140
|
+
# ActionController instance methods that would corrupt request
|
|
141
|
+
# handling if overridden (the `:params`, `:render`, `:session`,
|
|
142
|
+
# `:redirect_to`, `:dispatch`, etc. footgun). The hardcoded list
|
|
143
|
+
# is the canonical set documented in the Rails Guides; it's used
|
|
144
|
+
# in place of a dynamic `ActionController::Base.method_defined?`
|
|
145
|
+
# check because (a) the gem can be loaded before ActionController
|
|
146
|
+
# (e.g., in a non-Rails context) and (b) the dynamic list would
|
|
147
|
+
# include too many low-risk inherited methods (`:object_id`,
|
|
148
|
+
# `:respond_to?`) and produce confusing error messages.
|
|
149
|
+
if FRAMEWORK_RESERVED_METHODS.include?(symbol)
|
|
150
|
+
raise Ruact::ConfigurationError,
|
|
151
|
+
"ruact_action :#{symbol} would clobber a framework method — " \
|
|
152
|
+
"#{symbol.inspect} is a reserved ActionController instance " \
|
|
153
|
+
"method. Pick a different symbol (e.g. :#{symbol}_action) so " \
|
|
154
|
+
"the host's CSRF / params / render plumbing remains intact."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Re-run-6 (2026-05-15) — also reject ANY symbol that names a method
|
|
158
|
+
# inherited from `ActionController::Base` (or its ancestors UP TO but
|
|
159
|
+
# NOT INCLUDING `Object`). The hardcoded `FRAMEWORK_RESERVED_METHODS`
|
|
160
|
+
# list is the documented surface, but Rails keeps adding/renaming
|
|
161
|
+
# internal methods (`:status`, `:send_action`, `:render_to_string`,
|
|
162
|
+
# `:logger`, `:default_render`, …). Anything that lives on
|
|
163
|
+
# `ActionController::Base` but NOT on plain `Object` is, by
|
|
164
|
+
# definition, framework plumbing — overriding it is unsafe.
|
|
165
|
+
# We carve `Object`/`Kernel`/`BasicObject` off so genuinely-generic
|
|
166
|
+
# methods (`:object_id`, `:respond_to?`, `:hash`, `:tap`) don't
|
|
167
|
+
# trip the guard — those are safe to coexist with as block-arg
|
|
168
|
+
# shadow inside `instance_exec`.
|
|
169
|
+
baseline_class = defined?(ActionController::Base) ? ActionController::Base : nil
|
|
170
|
+
if baseline_class
|
|
171
|
+
object_methods = Object.instance_methods + Object.private_instance_methods
|
|
172
|
+
framework_methods = baseline_class.instance_methods + baseline_class.private_instance_methods
|
|
173
|
+
if (framework_methods - object_methods).include?(symbol)
|
|
174
|
+
raise Ruact::ConfigurationError,
|
|
175
|
+
"ruact_action :#{symbol} would clobber an inherited " \
|
|
176
|
+
"ActionController::Base method. Overriding framework " \
|
|
177
|
+
"plumbing (`#{symbol.inspect}`) is unsafe — pick a " \
|
|
178
|
+
"different symbol (e.g. :#{symbol}_action)."
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Re-run-2 (2026-05-14) — refuse to clobber a method ALREADY defined
|
|
183
|
+
# on the host controller class itself. Common case: a controller has
|
|
184
|
+
# a normal `def index` action and the dev mistakenly writes
|
|
185
|
+
# `ruact_action :index do ... end`. Pre-batch this silently
|
|
186
|
+
# overrode :index with the action body + thread-local guard — the
|
|
187
|
+
# standard `GET /widgets` would then raise the security guard, since
|
|
188
|
+
# it's not a /__ruact/fn/index call. We check `instance_methods(false)
|
|
189
|
+
# + private_instance_methods(false)` (own class only — inherited
|
|
190
|
+
# framework methods are already caught by FRAMEWORK_RESERVED_METHODS).
|
|
191
|
+
#
|
|
192
|
+
# A method previously defined BY `ruact_action` on this same class
|
|
193
|
+
# is NOT a clobber — re-registration is legitimate (dev-mode reload,
|
|
194
|
+
# test re-registration after registry reset). We track our own
|
|
195
|
+
# define_method calls in `@__ruact_defined_methods` and skip the
|
|
196
|
+
# guard for those.
|
|
197
|
+
@__ruact_defined_methods ||= Set.new
|
|
198
|
+
own_methods = instance_methods(false) + private_instance_methods(false)
|
|
199
|
+
if own_methods.include?(symbol) && !@__ruact_defined_methods.include?(symbol)
|
|
200
|
+
raise Ruact::ConfigurationError,
|
|
201
|
+
"ruact_action :#{symbol} would clobber an existing method " \
|
|
202
|
+
"on #{name || self}. If #{symbol.inspect} is meant to be " \
|
|
203
|
+
"a server action, remove the existing definition first; " \
|
|
204
|
+
"if it's a regular controller action, pick a different " \
|
|
205
|
+
"ruact_action symbol (e.g. :#{symbol}_remote)."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Re-run-3 (2026-05-15) — refuse to clobber an INHERITED app helper.
|
|
209
|
+
# Common case: `ApplicationController` defines `current_user` /
|
|
210
|
+
# `authenticate_user!` / `authorize` and a subclass mistakenly
|
|
211
|
+
# writes `ruact_action :current_user`. The above own-class check
|
|
212
|
+
# misses these because the method lives on a superclass; the
|
|
213
|
+
# FRAMEWORK_RESERVED_METHODS check misses them because they are
|
|
214
|
+
# app-defined, not part of `ActionController::Base`. Detect by
|
|
215
|
+
# asking the class hierarchy MINUS the ActionController baseline:
|
|
216
|
+
# any method that responds on `self` but NOT on
|
|
217
|
+
# `ActionController::Base` is an app-defined helper inherited
|
|
218
|
+
# from `ApplicationController` (or a concern mixed in there).
|
|
219
|
+
baseline = defined?(ActionController::Base) ? ActionController::Base : nil
|
|
220
|
+
if baseline &&
|
|
221
|
+
(method_defined?(symbol) || private_method_defined?(symbol)) &&
|
|
222
|
+
!(baseline.method_defined?(symbol) || baseline.private_method_defined?(symbol)) &&
|
|
223
|
+
!@__ruact_defined_methods.include?(symbol)
|
|
224
|
+
raise Ruact::ConfigurationError,
|
|
225
|
+
"ruact_action :#{symbol} would clobber an inherited helper " \
|
|
226
|
+
"on #{name || self} (likely defined on " \
|
|
227
|
+
"ApplicationController or a concern). Overriding " \
|
|
228
|
+
"#{symbol.inspect} would break callers that rely on it — " \
|
|
229
|
+
"pick a different ruact_action symbol (e.g. :#{symbol}_remote)."
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
Ruact.action_registry.register(symbol, kind: :action, controller: self, &block)
|
|
233
|
+
|
|
234
|
+
# Review-batch 1 (2026-05-14) — define `<symbol>` directly (no
|
|
235
|
+
# separate `__ruact_action_*` wrapper). This makes `before_action
|
|
236
|
+
# :foo, only: :create_post` match the actual action name. The
|
|
237
|
+
# method is public so `ActionController#process` dispatches to it
|
|
238
|
+
# through the standard callback chain.
|
|
239
|
+
#
|
|
240
|
+
# Defense in depth: the method body raises unless invoked under the
|
|
241
|
+
# gem's endpoint dispatch path (a thread-local sentinel set by
|
|
242
|
+
# `Ruact::ServerFunctions::EndpointController#dispatch_action`).
|
|
243
|
+
# Without the sentinel, a wildcard route like `get ":controller/
|
|
244
|
+
# :action"` could otherwise reach `create_post` as a GET (no CSRF).
|
|
245
|
+
@__ruact_defined_methods << symbol
|
|
246
|
+
|
|
247
|
+
# Re-run-6 (2026-05-15) — install a `method_added` hook so a LATER
|
|
248
|
+
# `def #{symbol}; ...; end` in the same controller body (or a reopen)
|
|
249
|
+
# cannot silently override the macro-defined action method. Without
|
|
250
|
+
# this guard, the registry/codegen still export the symbol but
|
|
251
|
+
# `host_class.dispatch` would run the user's later definition,
|
|
252
|
+
# producing a confusing 500 (the sentinel check is gone) instead of
|
|
253
|
+
# a loud class-load-time error. The hook is installed once per class
|
|
254
|
+
# and ignores re-definitions performed by the macro itself
|
|
255
|
+
# (tracked via `@__ruact_being_defined_by_ruact_action`).
|
|
256
|
+
#
|
|
257
|
+
# Re-run-7 (2026-05-15) — install the hook by `prepend`-ing a Module
|
|
258
|
+
# into the host's singleton class instead of `define_method`-ing
|
|
259
|
+
# `:method_added` directly on it. `define_method` REPLACES any
|
|
260
|
+
# `def self.method_added` (or `class << self; def method_added`)
|
|
261
|
+
# the host or its concerns already defined; `super(meth)` would
|
|
262
|
+
# then chain to `Module#method_added` (the default no-op), not the
|
|
263
|
+
# original implementation — silently breaking instrumentation and
|
|
264
|
+
# DSL bookkeeping concerns. Prepending a hook module keeps the
|
|
265
|
+
# original `method_added` in the ancestor chain so `super(meth)`
|
|
266
|
+
# invokes it after our check.
|
|
267
|
+
unless @__ruact_method_added_hook_installed
|
|
268
|
+
@__ruact_method_added_hook_installed = true
|
|
269
|
+
ruact_class = self
|
|
270
|
+
hook = Module.new do
|
|
271
|
+
define_method(:method_added) do |meth|
|
|
272
|
+
defined_set = ruact_class.instance_variable_get(:@__ruact_defined_methods)
|
|
273
|
+
being_defined = ruact_class.instance_variable_get(:@__ruact_being_defined_by_ruact_action)
|
|
274
|
+
if defined_set&.include?(meth) && !being_defined
|
|
275
|
+
defined_set.delete(meth)
|
|
276
|
+
raise Ruact::ConfigurationError,
|
|
277
|
+
"method :#{meth} on #{ruact_class.name || ruact_class} was " \
|
|
278
|
+
"registered by `ruact_action :#{meth}` and then re-defined " \
|
|
279
|
+
"in the same class body. The later definition would " \
|
|
280
|
+
"silently shadow the macro-defined action and break " \
|
|
281
|
+
"endpoint dispatch. Either remove the explicit `def " \
|
|
282
|
+
"#{meth}` (the macro already defines it) or rename the " \
|
|
283
|
+
"ruact_action."
|
|
284
|
+
end
|
|
285
|
+
super(meth)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
singleton_class.prepend(hook)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
@__ruact_being_defined_by_ruact_action = true
|
|
292
|
+
define_method(symbol) do
|
|
293
|
+
unless Thread.current[:__ruact_dispatching] == symbol
|
|
294
|
+
raise Ruact::Error,
|
|
295
|
+
"ruact action :#{symbol} can only be invoked through " \
|
|
296
|
+
"POST /__ruact/fn/:name. Direct method calls or wildcard " \
|
|
297
|
+
"routes are rejected for security reasons."
|
|
298
|
+
end
|
|
299
|
+
begin
|
|
300
|
+
args = ruact_action_params
|
|
301
|
+
rescue JSON::ParserError => e
|
|
302
|
+
# Re-run-4 (2026-05-15) — return a structured 400 instead of
|
|
303
|
+
# surfacing the raw `JSON::ParserError`. The host's
|
|
304
|
+
# `rescue_from` chain may not have a handler for it (Rails'
|
|
305
|
+
# default is a 500), and even when it does the response
|
|
306
|
+
# shape is not the bad-request contract the JS runtime
|
|
307
|
+
# expects (`{error}` JSON body + 400 status).
|
|
308
|
+
return render(
|
|
309
|
+
json: { error: "ruact action :#{symbol} received malformed JSON body: #{e.message}" },
|
|
310
|
+
status: :bad_request
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
result = instance_exec(args, &block)
|
|
314
|
+
return if performed?
|
|
315
|
+
|
|
316
|
+
# AC2: a nil block return renders 204 No Content (no body). A non-nil
|
|
317
|
+
# return renders 200 + JSON.
|
|
318
|
+
if result.nil?
|
|
319
|
+
head(:no_content)
|
|
320
|
+
else
|
|
321
|
+
render(json: result)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
@__ruact_being_defined_by_ruact_action = false
|
|
325
|
+
|
|
326
|
+
# ActionController caches `action_methods` lazily; clear the cache so
|
|
327
|
+
# the newly-defined action is dispatchable in the same boot cycle
|
|
328
|
+
# (matters in tests and in dev where `ruact_action` declarations
|
|
329
|
+
# accumulate after the controller class first loads).
|
|
330
|
+
clear_action_methods! if respond_to?(:clear_action_methods!, true)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
20
334
|
private
|
|
21
335
|
|
|
336
|
+
# Story 8.1 — extracts the action-call arguments from the request body for
|
|
337
|
+
# a `ruact_action` invocation. The result becomes the `params` block-arg
|
|
338
|
+
# the dev wrote in `ruact_action :foo do |params| ... end`, shadowing the
|
|
339
|
+
# request's own `params` accessor. Returns an `ActionController::Parameters`
|
|
340
|
+
# so `params.require(:title).permit(...)` continues to work inside the block.
|
|
341
|
+
#
|
|
342
|
+
# Wire shape (from the JS runtime):
|
|
343
|
+
# - `Content-Type: application/json` → `JSON.parse(request.body)` as a Hash
|
|
344
|
+
# - `Content-Type: multipart/form-data` or `application/x-www-form-urlencoded`
|
|
345
|
+
# → Rails has already parsed `request.request_parameters` for us
|
|
346
|
+
# - Other / missing → empty Hash (callers must validate themselves)
|
|
347
|
+
def ruact_action_params
|
|
348
|
+
raw = ruact_action_raw_args
|
|
349
|
+
ActionController::Parameters.new(raw)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def ruact_action_raw_args
|
|
353
|
+
content_type = request.content_mime_type&.to_s ||
|
|
354
|
+
request.headers["Content-Type"]&.split(";")&.first
|
|
355
|
+
case content_type
|
|
356
|
+
when "application/json"
|
|
357
|
+
# Re-run-3 (2026-05-15) — use `request.raw_post` instead of
|
|
358
|
+
# `request.body.read`. Rack caches `raw_post` after the first
|
|
359
|
+
# read of the request body, so a host `before_action` that
|
|
360
|
+
# already touched `request.body` would otherwise leave the
|
|
361
|
+
# IO at EOF and our `.read` would return `""` — silently
|
|
362
|
+
# coercing the action call to an empty hash. `raw_post` is
|
|
363
|
+
# safe to call multiple times and returns the full POST body.
|
|
364
|
+
body = request.raw_post
|
|
365
|
+
return {} if body.nil? || body.empty?
|
|
366
|
+
|
|
367
|
+
# Review-batch 2 (2026-05-14) — raise on malformed JSON instead of
|
|
368
|
+
# silently coercing to {}. A request with `Content-Type:
|
|
369
|
+
# application/json` and an unparseable body is corrupted; running
|
|
370
|
+
# the action on `{}` would mask real client bugs. Rails' standard
|
|
371
|
+
# 400 handler surfaces this as a clean error response.
|
|
372
|
+
parsed = JSON.parse(body)
|
|
373
|
+
parsed.is_a?(Hash) ? parsed : { "_value" => parsed }
|
|
374
|
+
when "multipart/form-data", "application/x-www-form-urlencoded"
|
|
375
|
+
# Review-batch 2 (2026-05-14) — `request.request_parameters` is the
|
|
376
|
+
# POST body ONLY (routing params like `:name`, `:action`,
|
|
377
|
+
# `:controller` live in `request.path_parameters`, NOT here). The
|
|
378
|
+
# earlier `.except(:name, :action, :controller)` was a bug — it
|
|
379
|
+
# would silently drop legitimate body fields named `name`,
|
|
380
|
+
# `action`, or `controller` from forms.
|
|
381
|
+
request.request_parameters
|
|
382
|
+
else
|
|
383
|
+
{}
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
22
387
|
# Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
|
|
23
388
|
# No per-request file I/O (AC#6).
|
|
24
|
-
def
|
|
389
|
+
def ruact_manifest
|
|
25
390
|
Ruact.manifest
|
|
26
391
|
end
|
|
27
392
|
|
|
@@ -29,8 +394,8 @@ module Ruact
|
|
|
29
394
|
# JSON, XML, and other formats bypass RSC entirely so respond_to blocks
|
|
30
395
|
# and explicit render calls work without interference.
|
|
31
396
|
def default_render
|
|
32
|
-
if
|
|
33
|
-
|
|
397
|
+
if ruact_template_exists? && (request.format.html? || ruact_request?)
|
|
398
|
+
ruact_render
|
|
34
399
|
else
|
|
35
400
|
super
|
|
36
401
|
end
|
|
@@ -46,37 +411,77 @@ module Ruact
|
|
|
46
411
|
# +template+: logical template name (e.g. "posts/custom"), or nil to use
|
|
47
412
|
# the current action's default template.
|
|
48
413
|
# +locals+: hash of local variables to pass to the template.
|
|
49
|
-
def
|
|
50
|
-
pipeline = RenderPipeline.new(
|
|
51
|
-
streaming =
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
414
|
+
def ruact_render(template: nil, locals: {})
|
|
415
|
+
pipeline = RenderPipeline.new(ruact_manifest, controller_path: controller_path, logger: logger)
|
|
416
|
+
streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
|
|
417
|
+
|
|
418
|
+
# Allocate a per-render context and expose it to the view via a normal
|
|
419
|
+
# (non-`@_`-prefixed) instance variable on the controller. Rails 8's
|
|
420
|
+
# `render_to_string` allocates a fresh `ActionView::Base` distinct from
|
|
421
|
+
# `controller.view_context`; setting the ivar on `view_context` does not
|
|
422
|
+
# reach that view. By contrast, controller ivars *not* matching
|
|
423
|
+
# `AbstractController::Base::DEFAULT_PROTECTED_INSTANCE_VARIABLES`
|
|
424
|
+
# (i.e. anything not prefixed with `@_`) are copied to the view via
|
|
425
|
+
# `_assigns_for_view_context`, so the view evaluated inside
|
|
426
|
+
# `render_to_string` receives `@ruact_render_context` populated.
|
|
427
|
+
# ViewHelper#__ruact_component__ reads it during ERB evaluation. The
|
|
428
|
+
# controller instance is per-request (Rails allocates a new one per
|
|
429
|
+
# action), so this is per-request safe under multi-threaded servers
|
|
430
|
+
# (NFR8). See Story 7.9 / Bug 7.8-B.
|
|
431
|
+
with_render_context do |render_context|
|
|
58
432
|
opts = template ? { template: template } : { action: action_name }
|
|
59
433
|
html = render_to_string(opts.merge(layout: false, locals: locals))
|
|
60
|
-
pipeline
|
|
61
|
-
ensure
|
|
62
|
-
ComponentRegistry.reset
|
|
434
|
+
emit_ruact_response(pipeline, html, render_context, streaming: streaming)
|
|
63
435
|
end
|
|
436
|
+
end
|
|
64
437
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
438
|
+
# Allocates a fresh `Ruact::RenderContext`, exposes it as the
|
|
439
|
+
# `@ruact_render_context` ivar for the duration of the block, then
|
|
440
|
+
# restores the controller's prior ivar state. When the ivar wasn't
|
|
441
|
+
# defined before this call, `remove_instance_variable` puts the
|
|
442
|
+
# controller back in its original state — restoring it as a defined
|
|
443
|
+
# `nil` would leak a phantom assignment into `view_assigns`
|
|
444
|
+
# (`{"ruact_render_context" => nil}`) on any subsequent error/rescue
|
|
445
|
+
# render in the same request.
|
|
446
|
+
def with_render_context
|
|
447
|
+
had_previous = instance_variable_defined?(:@ruact_render_context)
|
|
448
|
+
previous = @ruact_render_context if had_previous
|
|
449
|
+
render_context = RenderContext.new
|
|
450
|
+
@ruact_render_context = render_context
|
|
451
|
+
begin
|
|
452
|
+
yield render_context
|
|
453
|
+
ensure
|
|
454
|
+
if had_previous
|
|
455
|
+
@ruact_render_context = previous
|
|
75
456
|
else
|
|
76
|
-
|
|
457
|
+
remove_instance_variable(:@ruact_render_context)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Build the wire output and write it to the response. Streaming responses
|
|
463
|
+
# only fire after `pipeline.render` returns without raising — that way a
|
|
464
|
+
# missing-component error can still surface as a normal 500 response
|
|
465
|
+
# (matching the legacy `#from_html` ordering) before any streaming
|
|
466
|
+
# response headers are mutated.
|
|
467
|
+
def emit_ruact_response(pipeline, html, render_context, streaming:)
|
|
468
|
+
if ruact_request? && streaming
|
|
469
|
+
enumerator = pipeline.render({ html: html, render_context: render_context }, mode: :stream)
|
|
470
|
+
response.headers["Content-Type"] = "text/x-component; charset=utf-8"
|
|
471
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
472
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
473
|
+
begin
|
|
474
|
+
enumerator.each { |row| response.stream.write(row) }
|
|
475
|
+
ensure
|
|
476
|
+
response.stream.close
|
|
77
477
|
end
|
|
78
478
|
else
|
|
79
|
-
render html:
|
|
479
|
+
payload = pipeline.render({ html: html, render_context: render_context }, mode: :string)
|
|
480
|
+
if ruact_request?
|
|
481
|
+
render plain: payload, content_type: "text/x-component"
|
|
482
|
+
else
|
|
483
|
+
render html: ruact_html_shell(payload).html_safe, layout: false
|
|
484
|
+
end
|
|
80
485
|
end
|
|
81
486
|
end
|
|
82
487
|
|
|
@@ -86,7 +491,7 @@ module Ruact
|
|
|
86
491
|
# HTTP round-trip. Non-RSC requests and external-origin redirects fall through
|
|
87
492
|
# to the standard Rails implementation.
|
|
88
493
|
def redirect_to(options = {}, response_options = {})
|
|
89
|
-
return super unless
|
|
494
|
+
return super unless ruact_request?
|
|
90
495
|
|
|
91
496
|
url = url_for(options)
|
|
92
497
|
|
|
@@ -111,12 +516,12 @@ module Ruact
|
|
|
111
516
|
content_type: "text/x-component"
|
|
112
517
|
end
|
|
113
518
|
|
|
114
|
-
def
|
|
519
|
+
def ruact_request?
|
|
115
520
|
request.headers["Accept"]&.include?("text/x-component") ||
|
|
116
|
-
request.headers["
|
|
521
|
+
request.headers["Ruact-Request"] == "1"
|
|
117
522
|
end
|
|
118
523
|
|
|
119
|
-
def
|
|
524
|
+
def ruact_template_exists?
|
|
120
525
|
File.exist?(default_template_path)
|
|
121
526
|
end
|
|
122
527
|
|
|
@@ -126,7 +531,7 @@ module Ruact
|
|
|
126
531
|
Rails.root.join("app", "views", controller, "#{action}.html.erb")
|
|
127
532
|
end
|
|
128
533
|
|
|
129
|
-
def
|
|
534
|
+
def ruact_html_shell(flight_payload)
|
|
130
535
|
escaped_payload = flight_payload.gsub("</script>", '<\/script>')
|
|
131
536
|
<<~HTML
|
|
132
537
|
<!DOCTYPE html>
|
|
@@ -134,6 +539,7 @@ module Ruact
|
|
|
134
539
|
<head>
|
|
135
540
|
<meta charset="UTF-8" />
|
|
136
541
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
542
|
+
#{ruact_csrf_meta_tag}
|
|
137
543
|
<title>Rails RSC</title>
|
|
138
544
|
#{vite_tags}
|
|
139
545
|
</head>
|
|
@@ -150,6 +556,27 @@ module Ruact
|
|
|
150
556
|
HTML
|
|
151
557
|
end
|
|
152
558
|
|
|
559
|
+
# Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
|
|
560
|
+
# into the shell so the JS runtime's `<meta>` lookup can forward a
|
|
561
|
+
# valid `X-CSRF-Token` on every `_makeRef` call. Without this, hosts
|
|
562
|
+
# that route `ruact_render` through the gem's HTML shell (the
|
|
563
|
+
# standard path) have no token in the document and standalone
|
|
564
|
+
# actions' gem-side CSRF check (Story 8.3 AC5) always rejects.
|
|
565
|
+
#
|
|
566
|
+
# Returns an empty string when CSRF protection isn't available
|
|
567
|
+
# (non-Rails specs, or hosts that have deliberately stripped
|
|
568
|
+
# `form_authenticity_token` from the controller surface).
|
|
569
|
+
def ruact_csrf_meta_tag
|
|
570
|
+
return "" unless respond_to?(:form_authenticity_token, true)
|
|
571
|
+
|
|
572
|
+
token = form_authenticity_token
|
|
573
|
+
return "" if token.nil? || token.empty?
|
|
574
|
+
|
|
575
|
+
%(<meta name="csrf-token" content="#{ERB::Util.html_escape(token)}" />)
|
|
576
|
+
rescue StandardError
|
|
577
|
+
""
|
|
578
|
+
end
|
|
579
|
+
|
|
153
580
|
def vite_tags
|
|
154
581
|
if Rails.env.development? && vite_dev_running?
|
|
155
582
|
# @vitejs/plugin-react normally injects this preamble by processing index.html.
|
data/lib/ruact/doctor.rb
CHANGED
|
@@ -5,9 +5,17 @@ require "pathname"
|
|
|
5
5
|
|
|
6
6
|
module Ruact
|
|
7
7
|
# Runs a suite of installation health checks and prints ✓/✗ per check.
|
|
8
|
-
# Extracted from the
|
|
8
|
+
# Extracted from the ruact:doctor Rake task for direct testability (FR27).
|
|
9
9
|
class Doctor
|
|
10
|
-
CHECKS = %i[manifest vite controller layout streaming].freeze
|
|
10
|
+
CHECKS = %i[manifest vite controller layout streaming legacy_constant].freeze
|
|
11
|
+
# Built via Array#join so the gem-CI `name-propagation` guard does not
|
|
12
|
+
# match these literals against itself (Story 5.1 review F4 — the doctor
|
|
13
|
+
# file participates in the guard with no exclusion).
|
|
14
|
+
LEGACY_CONST = %w[Rails Rsc].join
|
|
15
|
+
LEGACY_GEM = %w[rails rsc].join("_")
|
|
16
|
+
LEGACY_CONSTANT_RE = /(?<![A-Za-z_])(?:#{LEGACY_CONST}|#{LEGACY_GEM})(?![A-Za-z_])/
|
|
17
|
+
LEGACY_SCAN_GLOBS = ["config/initializers/**/*.rb", "app/**/*.rb"].freeze
|
|
18
|
+
RENAME_DOC_URL = "https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed"
|
|
11
19
|
|
|
12
20
|
# Runs all checks, prints results, returns true if all pass.
|
|
13
21
|
def self.run
|
|
@@ -15,6 +23,7 @@ module Ruact
|
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
def run
|
|
26
|
+
puts "[ruact] Health check"
|
|
18
27
|
results = CHECKS.map { |check| send(:"check_#{check}") }
|
|
19
28
|
results.each { |status, message| puts format_result(status, message) }
|
|
20
29
|
passed = results.all? { |status, _| status == :pass }
|
|
@@ -64,6 +73,29 @@ module Ruact
|
|
|
64
73
|
[:pass, "streaming: #{label} (#{streaming_server_hint})"]
|
|
65
74
|
end
|
|
66
75
|
|
|
76
|
+
# Detects host-app references to the legacy gem constant or require path
|
|
77
|
+
# left over from the rename to `ruact`. Literal names are interpolated
|
|
78
|
+
# from LEGACY_CONST / LEGACY_GEM so this file passes the gem-CI
|
|
79
|
+
# `name-propagation` guard without an exclusion (Story 5.1 review F4).
|
|
80
|
+
def check_legacy_constant
|
|
81
|
+
offenses = LEGACY_SCAN_GLOBS.flat_map do |glob|
|
|
82
|
+
Dir[Rails.root.join(glob)].flat_map do |file|
|
|
83
|
+
File.foreach(file).with_index(1).filter_map do |line, lineno|
|
|
84
|
+
next unless LEGACY_CONSTANT_RE.match?(line)
|
|
85
|
+
|
|
86
|
+
"#{file}:#{lineno}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
return [:pass, "No legacy `#{LEGACY_CONST}` / `#{LEGACY_GEM}` references found"] if offenses.empty?
|
|
91
|
+
|
|
92
|
+
[:fail,
|
|
93
|
+
"Legacy `#{LEGACY_CONST}` / `#{LEGACY_GEM}` references found in #{offenses.length} location(s) " \
|
|
94
|
+
"(first: #{offenses.first}). Replace `#{LEGACY_CONST}` with `Ruact` and " \
|
|
95
|
+
"`require \"#{LEGACY_GEM}\"` with `require \"ruact\"` (gem renamed in v0.0.x). " \
|
|
96
|
+
"See #{RENAME_DOC_URL}."]
|
|
97
|
+
end
|
|
98
|
+
|
|
67
99
|
def streaming_server_hint
|
|
68
100
|
return "Puma" if defined?(::Puma)
|
|
69
101
|
return "Unicorn" if defined?(::Unicorn)
|
|
@@ -9,10 +9,10 @@ module Ruact
|
|
|
9
9
|
#
|
|
10
10
|
# becomes a placeholder that evaluates the props as Ruby:
|
|
11
11
|
#
|
|
12
|
-
# <%=
|
|
12
|
+
# <%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
|
|
13
13
|
#
|
|
14
14
|
# The placeholder is replaced by an HTML comment with a unique token:
|
|
15
|
-
# <!--
|
|
15
|
+
# <!-- __RUACT_0__ -->
|
|
16
16
|
#
|
|
17
17
|
# The actual ClientReference + props are registered in the binding and
|
|
18
18
|
# collected by HtmlConverter after the ERB renders.
|
|
@@ -40,16 +40,16 @@ module Ruact
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def transform(source)
|
|
43
|
-
# Step 1: transform <Suspense> paired tags into <
|
|
43
|
+
# Step 1: transform <Suspense> paired tags into <ruact-suspense> HTML elements.
|
|
44
44
|
# This runs before the general component regex so Suspense isn't treated as a component.
|
|
45
45
|
result = source
|
|
46
46
|
.gsub(SUSPENSE_OPEN_RE) do
|
|
47
47
|
attrs = ::Regexp.last_match(1)
|
|
48
48
|
fallback = extract_string_attr(attrs, "fallback") || ""
|
|
49
49
|
escaped = fallback.gsub('"', """)
|
|
50
|
-
%(<
|
|
50
|
+
%(<ruact-suspense data-ruact-fallback="#{escaped}">)
|
|
51
51
|
end
|
|
52
|
-
.gsub(SUSPENSE_CLOSE_RE, "</
|
|
52
|
+
.gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
|
|
53
53
|
|
|
54
54
|
# Step 2: transform remaining PascalCase self-closing / opening component tags.
|
|
55
55
|
result.gsub(COMPONENT_TAG_RE) do |match|
|
|
@@ -61,7 +61,7 @@ module Ruact
|
|
|
61
61
|
begin
|
|
62
62
|
props_ruby = parse_props(attrs_string)
|
|
63
63
|
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
64
|
-
%(<%=
|
|
64
|
+
%(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
|
|
65
65
|
rescue PreprocessorError => e
|
|
66
66
|
raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
|
|
67
67
|
end
|