ruact 0.0.2 → 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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. data/lib/tasks/rsc.rake +0 -9
@@ -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 rsc_manifest
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 rsc_template_exists? && (request.format.html? || rsc_request?)
33
- rsc_render
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 rsc_render(template: nil, locals: {})
50
- pipeline = RenderPipeline.new(rsc_manifest, controller_path: controller_path, logger: logger)
51
- streaming = rsc_request? && self.class.ancestors.include?(ActionController::Live)
52
-
53
- # ComponentRegistry is started before ActionView renders the template.
54
- # ViewHelper's __rsc_component__ registers components during rendering.
55
- # from_html eagerly captures the registry before the ensure block resets it.
56
- ComponentRegistry.start
57
- enumerator = begin
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.from_html(html, streaming: streaming)
61
- ensure
62
- ComponentRegistry.reset
434
+ emit_ruact_response(pipeline, html, render_context, streaming: streaming)
63
435
  end
436
+ end
64
437
 
65
- if rsc_request?
66
- if streaming
67
- response.headers["Content-Type"] = "text/x-component; charset=utf-8"
68
- response.headers["Cache-Control"] = "no-cache"
69
- response.headers["X-Accel-Buffering"] = "no"
70
- begin
71
- enumerator.each { |row| response.stream.write(row) }
72
- ensure
73
- response.stream.close
74
- end
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
- render plain: enumerator.to_a.join, content_type: "text/x-component"
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: rsc_html_shell(enumerator.to_a.join).html_safe, layout: false
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 rsc_request?
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 rsc_request?
519
+ def ruact_request?
115
520
  request.headers["Accept"]&.include?("text/x-component") ||
116
- request.headers["RSC-Request"] == "1"
521
+ request.headers["Ruact-Request"] == "1"
117
522
  end
118
523
 
119
- def rsc_template_exists?
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 rsc_html_shell(flight_payload)
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 rsc:doctor Rake task for direct testability (FR27).
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
- # <%= __rsc_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
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
- # <!-- __RSC_COMPONENT_0__ -->
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 <rsc-suspense> HTML elements.
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('"', "&quot;")
50
- %(<rsc-suspense data-rsc-fallback="#{escaped}">)
50
+ %(<ruact-suspense data-ruact-fallback="#{escaped}">)
51
51
  end
52
- .gsub(SUSPENSE_CLOSE_RE, "</rsc-suspense>")
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
- %(<%= __rsc_component__(#{component_name.inspect}, #{props_hash}) %>)
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