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
@@ -3,18 +3,18 @@
3
3
  module Ruact
4
4
  # Include this module in any Ruby object you want to pass as a prop to a
5
5
  # client component. Declare which attributes are safe to serialize with
6
- # +rsc_props+; only those attributes will be included in the wire payload.
6
+ # +ruact_props+; only those attributes will be included in the wire payload.
7
7
  #
8
8
  # @example
9
9
  # class Post
10
10
  # include Ruact::Serializable
11
11
  # attr_reader :id, :title, :secret
12
- # rsc_props :id, :title # :secret is never sent to the client
12
+ # ruact_props :id, :title # :secret is never sent to the client
13
13
  # end
14
14
  module Serializable
15
15
  def self.included(base)
16
16
  base.extend(ClassMethods)
17
- base.instance_variable_set(:@rsc_props, [])
17
+ base.instance_variable_set(:@ruact_props, [])
18
18
  end
19
19
 
20
20
  module ClassMethods
@@ -23,24 +23,24 @@ module Ruact
23
23
  # corresponding method defined on the class.
24
24
  #
25
25
  # @param attrs [Array<Symbol>]
26
- def rsc_props(*attrs)
26
+ def ruact_props(*attrs)
27
27
  attrs.each do |attr|
28
28
  unless method_defined?(attr)
29
29
  raise ArgumentError,
30
- "rsc_props: method `#{attr}` is not defined on #{self}"
30
+ "ruact_props: method `#{attr}` is not defined on #{self}"
31
31
  end
32
32
  end
33
- @rsc_props = attrs
33
+ @ruact_props = attrs
34
34
  end
35
35
 
36
36
  # Returns the list of declared prop names as symbols.
37
37
  # Walks the ancestor chain so subclasses inherit parent declarations.
38
38
  #
39
39
  # @return [Array<Symbol>]
40
- def rsc_props_list
40
+ def ruact_props_list
41
41
  klass = self
42
42
  while klass
43
- return klass.instance_variable_get(:@rsc_props) if klass.instance_variable_defined?(:@rsc_props)
43
+ return klass.instance_variable_get(:@ruact_props) if klass.instance_variable_defined?(:@ruact_props)
44
44
 
45
45
  klass = klass.superclass
46
46
  end
@@ -48,11 +48,11 @@ module Ruact
48
48
  end
49
49
  end
50
50
 
51
- # Serialize only the attributes declared with +rsc_props+.
51
+ # Serialize only the attributes declared with +ruact_props+.
52
52
  #
53
53
  # @return [Hash{String => Object}]
54
- def rsc_serialize
55
- self.class.rsc_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
54
+ def ruact_serialize
55
+ self.class.ruact_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
56
56
  end
57
57
  end
58
58
  end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ # Review patch (2026-06-07) — a direct `require "ruact/server"` (without the
6
+ # host having required "ruact" first) must still resolve everything the
7
+ # salvaged chains touch at request time: `Ruact.config` (defined in ruact.rb,
8
+ # not configuration.rb), `Ruact::UploadTooLargeError`, the ErrorPayload
9
+ # pipeline. The gem root never requires this file back (the bare
10
+ # `require "ruact"` path stays ActionController-free; the Railtie loads the
11
+ # concern), so this is acyclic by construction.
12
+ require "uri"
13
+ require_relative "../ruact"
14
+ require_relative "server_functions/error_rendering"
15
+ require_relative "server_functions/bucket_two_payload"
16
+ require_relative "server_functions/name_bridge"
17
+
18
+ module Ruact
19
+ # Story 9.1 (route-driven redesign, Phase A) — the v2 server-functions
20
+ # marker concern.
21
+ #
22
+ # class PostsController < ApplicationController
23
+ # include Ruact::Server # the ONLY marker — no per-action DSL
24
+ #
25
+ # def create # non-GET routed action → callable server function
26
+ # @post = Post.create!(title: params[:title])
27
+ # redirect_to @post
28
+ # end
29
+ # end
30
+ #
31
+ # Per the 2026-06-02 ADR addendum (Story 9-0,
32
+ # `docs/internal/decisions/server-functions-api.md`), exposure is decided by
33
+ # `routes.rb` — the Story 9.3 codegen reads `Rails.application.routes`
34
+ # filtered to non-GET routes on controllers that include this concern. The
35
+ # concern itself registers NOTHING and emits NOTHING; at this story it is a
36
+ # pure marker plus the new home of the two salvaged Epic-8 subsystems,
37
+ # running on the host controller's own callback chain:
38
+ #
39
+ # - **Story 8.4 structured-error chain** — `rescue_from StandardError` (+ an
40
+ # explicit `ActionController::InvalidAuthenticityToken` registration that
41
+ # preempts Rails' default `handle_unverified_request`, Pitfall #1). On
42
+ # function-call requests ({#__ruact_function_call?}) an uncaught exception
43
+ # renders the structured JSON payload (discriminator
44
+ # `_ruact_server_action_error: true`, four baseline fields, dev/prod split
45
+ # via `Ruact.config.dev_error_payload_enabled`, status mapping 422/403/
46
+ # 413/500). On every other request shape — including GET/HEAD requests
47
+ # regardless of their Accept header — the handler re-raises, so GET
48
+ # pages, `default_render`, and Phase-1 behavior stay byte-for-byte
49
+ # untouched. Host `rescue_from` declarations — whether inherited from a
50
+ # parent class or declared in the host's own body — keep precedence:
51
+ # the chain only catches what the host did not.
52
+ # - **Story 8.5 upload guard** — `prepend_before_action` enforcing
53
+ # `Ruact.config.max_upload_bytes` against the wire `Content-Length`
54
+ # BEFORE Rack's multipart parser. The three carve-outs are preserved:
55
+ # nil limit, non-multipart/urlencoded content type, absent
56
+ # Content-Length. New here (D2): GET/HEAD requests skip the guard
57
+ # entirely. The 413 renders structured for ALL request shapes (D1) —
58
+ # a meaningful 413 beats a re-raised 500 for native form submits too.
59
+ # Contract simplification: the concern assumes the host includes it after
60
+ # `protect_from_forgery`; no runtime callback-order verifier runs here.
61
+ #
62
+ # Both bodies live in {Ruact::ServerFunctions::ErrorRendering}, shared with
63
+ # the v1 {Ruact::ServerFunctions::EndpointController} during the
64
+ # strangler-fig transition so the wire contract is identical by
65
+ # construction. Dual-bucket response negotiation (ivar serialization,
66
+ # `$redirect`, 204, `Vary: Accept`) is Story 9.2; this concern only
67
+ # contributes the discrimination predicate 9.2 will reuse.
68
+ module Server
69
+ extend ActiveSupport::Concern
70
+
71
+ include Ruact::ServerFunctions::ErrorRendering
72
+
73
+ included do
74
+ # Story 8.5 salvage — prepended so the size check wins the race against
75
+ # every other callback, including `verify_authenticity_token`.
76
+ prepend_before_action :__ruact_enforce_upload_limit!
77
+
78
+ # Story 9.2 AC6 — `Vary: Accept` on every non-GET SUCCESS shape. The same
79
+ # URL + verb serves two bodies discriminated solely on `Accept`, so a
80
+ # cache MUST vary on it (never serve Flight to a JSON caller or vice-versa).
81
+ # Set in BOTH a `before_action` and an `after_action` (review rounds 1–2),
82
+ # because either callback alone has a gap:
83
+ # - the `after_action` APPENDS to a host-set `Vary` (preserving it), but
84
+ # is skipped when a `before_action` performs the response (e.g. an auth
85
+ # `before_action` that `redirect_to`s a Bucket-2 call);
86
+ # - the `before_action` covers that halt case (it runs ahead of the
87
+ # host's own before-callbacks), but a later `response.headers["Vary"] =`
88
+ # in the action would clobber it.
89
+ # Together they cover the 200 ivar-JSON / 204 / `$redirect` / Bucket-1
90
+ # Flight shapes (incl. before-callback redirects). The method is
91
+ # idempotent. Error responses (413/403/500) may still omit it — they are
92
+ # non-cacheable, not dual representations.
93
+ #
94
+ # Documented limitation (accepted 2026-06-09): a host `before_action` that
95
+ # BOTH overwrites `Vary` AND performs the response (e.g.
96
+ # `response.headers["Vary"] = "Cookie"; redirect_to "/login"` in one
97
+ # before-callback) leaves the final response without `Accept` — the Ruact
98
+ # before-action set it first, the host clobbered it, and Rails skips the
99
+ # after-action on the before-halt. This combination is contrived (real
100
+ # auth callbacks don't reassign `Vary` while redirecting); a callback can't
101
+ # guarantee the final header unconditionally, and a Rack-level mechanism
102
+ # was judged not worth the complexity for this edge.
103
+ before_action :__ruact_set_vary_on_accept!
104
+ after_action :__ruact_set_vary_on_accept!
105
+
106
+ # Story 8.4 salvage — same registration order as v1 (Pitfall #1): the
107
+ # generic StandardError entry first, the explicit
108
+ # InvalidAuthenticityToken entry second so it wins Rails'
109
+ # most-recently-registered handler walk for CSRF failures.
110
+ #
111
+ # Review patch (2026-06-07) — handlers the host INHERITED from a parent
112
+ # class must keep precedence too, not just ones declared after the
113
+ # include: Rails walks `rescue_handlers` most-recently-registered
114
+ # first, and a plain `rescue_from` here would land the concern's
115
+ # entries AFTER the inherited ones. The concern's entries are therefore
116
+ # moved to the FRONT of the array, so every host handler — inherited or
117
+ # declared later in the class body — stays more recent and wins.
118
+ inherited_handlers = rescue_handlers
119
+ rescue_from StandardError, with: :__ruact_render_action_error
120
+ rescue_from ActionController::InvalidAuthenticityToken, with: :__ruact_render_action_error
121
+ self.rescue_handlers = (rescue_handlers - inherited_handlers) + inherited_handlers
122
+ end
123
+
124
+ # Story 9.3 — the JS-identifier rename escape hatch (AC4). Naming is
125
+ # derived from the route table by default ({Ruact::ServerFunctions::RouteSource});
126
+ # when two routes collide in the merged JS namespace the codegen fails loudly
127
+ # at boot, and this macro is how a host breaks the tie (or simply prefers a
128
+ # different name):
129
+ #
130
+ # class PostsController < ApplicationController
131
+ # include Ruact::Server
132
+ # ruact_function_name :publish_all, as: "publishEverything"
133
+ # end
134
+ #
135
+ # The override is keyed by ACTION name (string) and read by the codegen via
136
+ # {#__ruact_function_name_overrides}. The target identifier is validated at
137
+ # class-load time against the same JS-identifier shape + reserved-word rules
138
+ # the codegen enforces, so a bad override fails at boot, never at codegen.
139
+ module ClassMethods
140
+ # JS identifier shape — mirror of {Ruact::ServerFunctions::Codegen::VALID_JS_IDENTIFIER}
141
+ # (kept local so the concern does not depend on the codegen module).
142
+ RUACT_VALID_JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
143
+
144
+ # @param action [Symbol, String] the controller action whose generated
145
+ # server-function name is being overridden.
146
+ # @param as [Symbol, String] the JS identifier to emit instead of the
147
+ # derived one.
148
+ # @raise [Ruact::ConfigurationError] when +as+ is not a valid JS identifier
149
+ # or collides with a reserved word / a name the runtime already binds.
150
+ def ruact_function_name(action, as:)
151
+ js = as.to_s
152
+ unless js.match?(RUACT_VALID_JS_IDENTIFIER)
153
+ raise Ruact::ConfigurationError,
154
+ "ruact_function_name :#{action}, as: #{as.inspect} — " \
155
+ "\"#{js}\" is not a valid JS identifier (must match #{RUACT_VALID_JS_IDENTIFIER.inspect})"
156
+ end
157
+ if Ruact::ServerFunctions::NameBridge::RESERVED_JS_IDENTIFIERS.include?(js) ||
158
+ Ruact::ServerFunctions::NameBridge::RESERVED_BY_RUACT.include?(js)
159
+ raise Ruact::ConfigurationError,
160
+ "ruact_function_name :#{action}, as: #{as.inspect} — " \
161
+ "\"#{js}\" is a reserved JS word or is already bound by the ruact runtime " \
162
+ "(`_makeRef` / `_makeServerFunction` / `revalidate`); pick another name"
163
+ end
164
+
165
+ __ruact_function_name_overrides[action.to_s] = js
166
+ end
167
+
168
+ # The action-name → js-identifier override map for this controller,
169
+ # consulted by {Ruact::ServerFunctions::RouteSource}. Per-controller (not
170
+ # inherited) — overrides describe the host's own actions.
171
+ #
172
+ # @return [Hash{String=>String}]
173
+ def __ruact_function_name_overrides
174
+ @__ruact_function_name_overrides ||= {}
175
+ end
176
+ end
177
+
178
+ # Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the
179
+ # action finished without an explicit render on a function-call request
180
+ # ({#__ruact_function_call?} — `Accept: application/json`, non-GET), serialize
181
+ # the action's exposed instance variables (Rails `view_assigns`, verbatim —
182
+ # the same set a view would see) as a JSON object keyed by ivar name, or
183
+ # `204 No Content` when none were set. Any other request shape falls through
184
+ # to `super` so Bucket-1 rendering — the host's `Ruact::Controller` Flight
185
+ # re-render, then Rails — is byte-for-byte unchanged (AC1).
186
+ #
187
+ # The exposed-ivar set is Rails' own `view_assigns` with no custom filtering:
188
+ # Rails already excludes its protected `@_`-prefixed internals (including the
189
+ # CSRF `@_marked_for_same_origin_verification` flag), so what remains is
190
+ # exactly what the action assigned. Each value is serialized through the
191
+ # `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules
192
+ # ({Ruact::ServerFunctions::BucketTwoPayload}); a single ivar stays keyed
193
+ # (no magic unwrap).
194
+ def default_render(*)
195
+ return super unless __ruact_function_call?
196
+
197
+ assigns = view_assigns
198
+ return head(:no_content) if assigns.empty?
199
+
200
+ render json: ServerFunctions::BucketTwoPayload.build(
201
+ assigns, strict: Ruact.config.strict_serialization
202
+ )
203
+ end
204
+
205
+ # Story 9.2 AC3 (D2) — on a function-call request, `redirect_to` surfaces as
206
+ # a JSON redirect directive — body `"$redirect" => "<path>"` (the runtime
207
+ # follows it client-side; re-targeting/following is Story 9.3) instead of a
208
+ # 302 or a Flight redirect row. Any other request shape falls through to
209
+ # `super` so the Bucket-1 Flight redirect row / Rails 302 is unchanged (AC1).
210
+ #
211
+ # Review round 1 — reuses Rails' OWN redirect machinery
212
+ # (`_compute_redirect_to_location`, `_ensure_url_is_http_header_safe`,
213
+ # `_enforce_open_redirect_protection`) so the nil-check, header-safety, and
214
+ # open-redirect protection (`allow_other_host` / `raise_on_open_redirects`)
215
+ # match Bucket 1 / stock Rails exactly — a cross-host `redirect_to` raises
216
+ # `UnsafeRedirectError` instead of leaking an external `$redirect`. Same-
217
+ # origin targets collapse to a path; an allowed external origin keeps the
218
+ # absolute URL.
219
+ def redirect_to(options = {}, response_options = {})
220
+ return super unless __ruact_function_call?
221
+
222
+ raise ActionController::ActionControllerError, "Cannot redirect to nil!" unless options
223
+ raise AbstractController::DoubleRenderError if response_body
224
+
225
+ allow_other_host = response_options.delete(:allow_other_host)
226
+ location = _compute_redirect_to_location(request, options)
227
+ _ensure_url_is_http_header_safe(location)
228
+ location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
229
+
230
+ render json: { "$redirect" => __ruact_redirect_path(location) }
231
+ end
232
+
233
+ private
234
+
235
+ # AC6 — append `Accept` to the `Vary` response header for non-GET requests
236
+ # (idempotent, preserves any host-set `Vary`). A host `Vary: *` wildcard is
237
+ # left as-is (review round 2): per HTTP, `Vary` is either `*` (varies on
238
+ # everything) OR a field-name list — `*, Accept` is invalid/weaker.
239
+ def __ruact_set_vary_on_accept!
240
+ return if request.get? || request.head?
241
+
242
+ values = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
243
+ return if values.include?("*")
244
+
245
+ values << "Accept" unless values.any? { |value| value.casecmp?("Accept") }
246
+ response.headers["Vary"] = values.join(", ")
247
+ end
248
+
249
+ # Collapse a (already open-redirect-validated) location to a path for
250
+ # same-origin URLs (the common `redirect_to @record` case); keep the
251
+ # absolute URL for an allowed external origin so a cross-origin redirect
252
+ # (e.g. a payment provider, with `allow_other_host: true`) survives. Mirrors
253
+ # the same-origin handling in {Ruact::Controller#redirect_to}.
254
+ def __ruact_redirect_path(url)
255
+ uri = ::URI.parse(url)
256
+ if uri.host &&
257
+ (uri.host != request.host ||
258
+ (uri.port && uri.port != request.port) ||
259
+ (uri.scheme && uri.scheme != request.scheme))
260
+ return url
261
+ end
262
+
263
+ path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
264
+ path += "?#{uri.query}" if uri.query
265
+ path += "##{uri.fragment}" if uri.fragment
266
+ path
267
+ rescue ::URI::InvalidURIError
268
+ url
269
+ end
270
+
271
+ # Raw discriminator — does the request's `Accept` header equal
272
+ # `application/json`? This is exactly what the 8.1 runtime sends on every
273
+ # `_makeRef` fetch (Bucket 2 — imperative `await createPost(...)`).
274
+ # Browser navigation and `<form>` submits never use that exact header, and
275
+ # Flight requests send `text/x-component`.
276
+ #
277
+ # Deliberately NOT `request.format`: the Rails format negotiation is
278
+ # influenced by path extensions (`/posts.json`) and `params[:format]`,
279
+ # neither of which may flip the bucket. This is the verb-AGNOSTIC header
280
+ # check; the semantic predicate {#__ruact_function_call?} layers the verb
281
+ # rule on top.
282
+ def __ruact_json_accept?
283
+ request.headers["Accept"] == "application/json"
284
+ end
285
+
286
+ # Story 9.1 AC2 — THE discrimination point: "is this request a function
287
+ # call?". A function call is a JSON-Accept request that is ALSO non-GET/
288
+ # HEAD: function calls are non-GET by the verb rule (epic contract
289
+ # decision #1), so a GET/HEAD carrying `Accept: application/json` (a
290
+ # `fetch()` against a page action, an API probe) is NOT one.
291
+ #
292
+ # Review patch (2026-06-08) — the verb gate that used to live only in
293
+ # {#__ruact_render_structured_error?} now lives HERE, in the predicate
294
+ # itself, so Story 9.2 reuses the CORRECT contract verbatim as the
295
+ # dual-bucket discriminator (the raw header check is {#__ruact_json_accept?}).
296
+ # The predicate lives in one place only.
297
+ def __ruact_function_call?
298
+ __ruact_json_accept? && !(request.get? || request.head?)
299
+ end
300
+
301
+ # D1 (amended by the 2026-06-07 / 2026-06-08 review patches) — render the
302
+ # structured payload only for NON-GET/HEAD requests; within those, for a
303
+ # function call ({#__ruact_function_call?}) or any
304
+ # `Ruact::UploadTooLargeError`. Everything else re-raises so
305
+ # non-function-call requests keep Rails' default error behavior (AC1
306
+ # byte-for-byte).
307
+ #
308
+ # The verb gate is the FIRST thing checked so it covers the
309
+ # `UploadTooLargeError` branch too (2026-06-08 patch): the guard never
310
+ # produces that error on a GET/HEAD (it skips those — D2), so the only way
311
+ # one reaches this handler on a GET is a manual `raise` inside a page
312
+ # action — which must keep stock Rails behavior, not be swallowed into a
313
+ # structured 413. For the non-GET case the documented exception still
314
+ # holds: a `UploadTooLargeError` from a native multipart form submit
315
+ # (Bucket 1, no JSON Accept) renders a meaningful 413 rather than a
316
+ # re-raised 500.
317
+ # Review patch (2026-06-08, round 3) — `Ruact::ConfigurationError` is never
318
+ # rendered as a structured server-action error: configuration invariants
319
+ # (most notably the upload-guard ordering check) must stay LOUD setup
320
+ # failures. Folding one into an ordinary `_ruact_server_action_error` 500
321
+ # on a JSON function call would disguise a deploy-blocking misconfiguration
322
+ # as a transient runtime error. It re-raises so Rails' default error
323
+ # handling surfaces it.
324
+ def __ruact_render_structured_error?(error)
325
+ return false if request.get? || request.head?
326
+ return false if error.is_a?(Ruact::ConfigurationError)
327
+
328
+ error.is_a?(Ruact::UploadTooLargeError) || __ruact_function_call?
329
+ end
330
+
331
+ # D2 — the v1 endpoint was POST-only so the guard never saw GETs; on a
332
+ # host controller it must skip GET/HEAD so page actions stay
333
+ # byte-for-byte untouched (AC1) while every non-GET action — both
334
+ # buckets — is protected (AC4 says "non-GET action", not "function
335
+ # call": native multipart form submits are exactly where uploads come
336
+ # from).
337
+ def __ruact_upload_guard_applicable?
338
+ !(request.get? || request.head?)
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 8.3 — host module for STANDALONE server actions. The Ruby
5
+ # equivalent of React's `"use server"` directive: a module under
6
+ # `app/server_actions/` that `extend`s {Ruact::ServerAction} and declares
7
+ # one action body per file with the `ruact_action` macro.
8
+ #
9
+ # # app/server_actions/create_post.rb
10
+ # module CreatePost
11
+ # extend Ruact::ServerAction
12
+ #
13
+ # ruact_action :create_post do |params|
14
+ # Post.create!(title: params[:title], body: params[:body])
15
+ # end
16
+ # end
17
+ #
18
+ # The React side cannot tell standalone-hosted actions apart from
19
+ # controller-hosted ones — both export under the same accessor at
20
+ # `app/javascript/.ruact/server-functions.ts` (Story 8.0a codegen).
21
+ #
22
+ # Differences from {Ruact::Controller#ruact_action} (the controller-hosted
23
+ # path from Story 8.1):
24
+ #
25
+ # - NO instance method is defined on the host module. There is no
26
+ # `host.public_send(action_name)` call shape — the dispatcher
27
+ # ({Ruact::ServerFunctions::EndpointController}) detects the standalone
28
+ # host shape and routes through {Ruact::ServerFunctions::StandaloneDispatcher}
29
+ # instead of `host_class.dispatch`. Standalone modules are reachable
30
+ # ONLY through the gem's `POST /__ruact/fn/:name` endpoint; there is
31
+ # no public Ruby surface to invoke them, by design.
32
+ # - NO controller `before_action` chain runs. The dev opts out of the
33
+ # controller-DSL ergonomic by picking the standalone path; the
34
+ # trade-off is no implicit auth/scoping. The dev calls `current_user`
35
+ # (resolved via {Ruact::Configuration#current_user_resolver}) and
36
+ # Pundit / ActionPolicy directly inside the block when needed.
37
+ # - NO `method_added` hook, NO `Thread.current[:__ruact_dispatching]`
38
+ # sentinel. The standalone path has no routing surface that could
39
+ # reach the action body outside of the gem-managed endpoint, so the
40
+ # controller-only security guards are unnecessary (and would be
41
+ # misleading — the block is stored in the registry, not on the
42
+ # module as an instance method).
43
+ #
44
+ # Shared with {Ruact::Controller#ruact_action}:
45
+ #
46
+ # - {Ruact::ServerFunctions::Registry} via `Ruact.action_registry`.
47
+ # Symbols declared by standalone modules and by controller hosts live
48
+ # in the same registry instance; the existing collision detector
49
+ # ({Ruact::ServerFunctions::Registry#detect_collision!}) catches
50
+ # same-symbol-different-host collisions across both host shapes.
51
+ # - The naming-bridge rule ({Ruact::ServerFunctions::NameBridge}) and
52
+ # reserved-identifier sets (`RESERVED_JS_IDENTIFIERS`,
53
+ # `RESERVED_BY_RUACT`).
54
+ # - The block-parameter shape guard (one positional argument, no
55
+ # required keyword arguments) — same regex as Story 8.1 Re-run-5.
56
+ module ServerAction
57
+ # @param symbol [Symbol] the action name; same naming-bridge rule as
58
+ # {Ruact::Controller#ruact_action}.
59
+ # @yield [params] the block runs via `instance_exec` on a fresh
60
+ # {Ruact::ServerFunctions::StandaloneContext} at dispatch time.
61
+ # @return [Ruact::ServerFunctions::RegistryEntry] the entry just registered.
62
+ # @raise [ArgumentError] when +symbol+ is not a Symbol, the block is
63
+ # missing, or the block's parameter shape is rejected.
64
+ # @raise [Ruact::ConfigurationError] when the symbol fails the
65
+ # naming-bridge rule or collides with another `ruact_action` in the
66
+ # registry (mixed-host collisions are caught here too — see AC4).
67
+ def ruact_action(symbol, &block)
68
+ # Story 8.3 review R4 — reject Class hosts loudly. `extend Ruact::ServerAction`
69
+ # on a Class would work at extend time (Class < Module), but the
70
+ # endpoint dispatcher's `standalone_host?` predicate returns false for
71
+ # Class hosts (it requires Module-NOT-Class), and the controller-DSL
72
+ # branch would then try `host_class.dispatch(...)` against a class
73
+ # that has NO Rails dispatch surface — crashing at request time.
74
+ # Catch this at declaration time so the failure is visible at boot.
75
+ if is_a?(Class)
76
+ raise Ruact::ConfigurationError,
77
+ "Ruact::ServerAction is intended for standalone HOST MODULES under " \
78
+ "`app/server_actions/`. You extended it onto #{name || self} which " \
79
+ "is a Class — that's a controller-shape host. For controller-hosted " \
80
+ "actions use `include Ruact::Controller` and declare `ruact_action` " \
81
+ "inside the controller class body; for standalone actions declare a " \
82
+ "`module Foo; extend Ruact::ServerAction; ...; end` instead."
83
+ end
84
+
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} (standalone) requires a block — declare the " \
95
+ "implementation with `ruact_action :#{symbol} do |params| ... end`"
96
+ end
97
+
98
+ # Mirror the block-parameter shape guard from Story 8.1 Re-run-5
99
+ # (controller.rb:125-137). The standalone dispatcher invokes the
100
+ # block with one positional argument (the params shadow); blocks
101
+ # with wrong arity / required kwargs would crash at dispatch time.
102
+ req_count = block.parameters.count { |kind, _| kind == :req }
103
+ opt_count = block.parameters.count { |kind, _| kind == :opt }
104
+ rest_count = block.parameters.count { |kind, _| kind == :rest }
105
+ named_positional = req_count + opt_count
106
+ positional_total = named_positional + rest_count
107
+ has_required_kwarg = block.parameters.any? { |kind, _| kind == :keyreq }
108
+ if positional_total.zero? || named_positional > 1 || has_required_kwarg
109
+ raise ArgumentError,
110
+ "ruact_action :#{symbol} (standalone) block must accept exactly one " \
111
+ "positional parameter and no required keyword arguments " \
112
+ "(got parameters=#{block.parameters.inspect}). Use " \
113
+ "`ruact_action :#{symbol} do |params| ... end`."
114
+ end
115
+
116
+ # NOTE: no `FRAMEWORK_RESERVED_METHODS` check — standalone modules
117
+ # have no ActionController surface to clobber. NameBridge +
118
+ # RESERVED_JS_IDENTIFIERS + RESERVED_BY_RUACT still fire via the
119
+ # registry's `register` path below.
120
+ #
121
+ # NOTE: no `own_methods` / inherited-helper guard — there is no
122
+ # `define_method` step that could shadow anything; the block lives
123
+ # in the registry, not on the module.
124
+ #
125
+ # NOTE: no `method_added` hook — standalone modules don't define
126
+ # instance methods at all, so a same-name `def` cannot shadow
127
+ # anything (Pitfall #2 in the story spec).
128
+ Ruact.action_registry.register(symbol, kind: :action, controller: self, &block)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 8.4 — Splits an exception backtrace into application frames and
6
+ # gem-internal frames so the dev overlay can hide gem frames behind a
7
+ # toggle by default while still capturing them for the "Copy to clipboard"
8
+ # affordance.
9
+ #
10
+ # Pure function, no I/O. Anchors classification on {Ruact.gem_path} which
11
+ # is memoised at gem load time; the per-frame `start_with?` check is
12
+ # therefore constant-time.
13
+ #
14
+ # Deliberately NOT a thin wrapper over `ActiveSupport::BacktraceCleaner` —
15
+ # that class's silencer/filter API is heavyweight for this single-purpose
16
+ # need; this module is ~10 LoC with zero ActiveSupport dependency so it
17
+ # loads cleanly in AR-less specs (see Pitfall #4 in the story).
18
+ module BacktraceCleaner
19
+ # Split a raw backtrace into app and gem buckets.
20
+ #
21
+ # @param backtrace [Array<String>, nil] frames from `Exception#backtrace`
22
+ # @return [Hash{Symbol=>Array<String>}] `{ app: [...], gem: [...] }`
23
+ def self.split(backtrace)
24
+ return { app: [], gem: [] } if backtrace.nil? || backtrace.empty?
25
+
26
+ gem_prefix = Ruact.gem_path
27
+ gem_frames, app_frames = backtrace.partition { |frame| frame.start_with?(gem_prefix) }
28
+ { app: app_frames, gem: gem_frames }
29
+ end
30
+ end
31
+ end
32
+ end