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.
Files changed (131) 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 +88 -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 +1779 -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 +100 -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 +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ require_relative "error_payload"
6
+ require_relative "error_rendering"
7
+
8
+ module Ruact
9
+ module ServerFunctions
10
+ # Story 8.1 — the single gem-mounted Rails controller backing
11
+ # `POST /__ruact/fn/:name`. It resolves the URL `:name` parameter to a
12
+ # registered {Ruact::ServerFunctions::RegistryEntry}, allocates a fresh
13
+ # instance of the entry's host controller class, and delegates dispatch
14
+ # to that instance via Rails' standard `dispatch(action_name, request,
15
+ # response)` plumbing.
16
+ #
17
+ # This indirection is what gives `ruact_action` blocks access to the host
18
+ # controller's `current_user`, `session`, `before_action` chain, Pundit /
19
+ # ActionPolicy authorization, and `rescue_from` handlers — the block runs
20
+ # inside an honest controller instance, not in some gem-internal context.
21
+ #
22
+ # The `dispatch_action` action below is the ONLY public action on this
23
+ # controller — there is no `:create`, `:update`, etc.; the host's actions
24
+ # are reached indirectly via the wrapper method
25
+ # `__ruact_action_<symbol>` that {Ruact::Controller#ruact_action} defines.
26
+ class EndpointController < ActionController::Base
27
+ # Story 9.1 — the Story 8.4 error chain + Story 8.5 upload guard bodies
28
+ # were extracted into the shared {ErrorRendering} module so the
29
+ # {Ruact::Server} concern (v2) hosts the IDENTICAL implementation during
30
+ # the strangler-fig transition. This controller keeps v1 semantics via
31
+ # the module's hook defaults (always render the structured payload,
32
+ # guard always applicable) plus the `__ruact_error_action_name` override
33
+ # below. Behavior is byte-for-byte unchanged.
34
+ include Ruact::ServerFunctions::ErrorRendering
35
+
36
+ # Story 8.1 AC8 — for controller-hosted actions the gem does NOT impose
37
+ # its own CSRF protection: the host's `ApplicationController` is what
38
+ # enforces `protect_from_forgery`; since those requests are dispatched
39
+ # THROUGH a fresh host-controller instance below, the host's CSRF rules
40
+ # apply. EndpointController itself only routes — never renders the
41
+ # host's content directly.
42
+ #
43
+ # Story 8.3 — STANDALONE actions have no host controller, so there is
44
+ # no host-side CSRF callback to delegate to. The endpoint enforces
45
+ # CSRF for the standalone branch itself, gated by
46
+ # `dispatching_standalone?` (resolved EARLY via prepend_before_action).
47
+ # The controller-action branch keeps `skip_forgery_protection`-equivalent
48
+ # behavior (the verify callback skips because the entry's host is a
49
+ # Class, not a Module).
50
+ skip_forgery_protection if respond_to?(:skip_forgery_protection)
51
+
52
+ prepend_before_action :resolve_ruact_entry!
53
+
54
+ # Story 8.5 — enforce `Ruact.config.max_upload_bytes` on multipart /
55
+ # urlencoded bodies BEFORE the registry lookup and BEFORE Rack's
56
+ # multipart parser runs. `prepend_before_action` is what makes this the
57
+ # very first callback: the size check is dispatch-independent and the
58
+ # cheapest possible reject is "look at Content-Length and bail" — so
59
+ # the upload guard wins the race against `resolve_ruact_entry!` and
60
+ # (for the standalone branch) the conditional CSRF callback. A
61
+ # consequence: an oversized request from a CSRF-attacker on a standalone
62
+ # action is 413'd before CSRF is even checked — correct (the attacker
63
+ # learns nothing about CSRF state from a 413).
64
+ prepend_before_action :__ruact_enforce_upload_limit!
65
+
66
+ # Story 8.3 — install a strategy + conditional callback so
67
+ # `verify_authenticity_token` only fires on the standalone-dispatch
68
+ # branch. `protect_from_forgery with: :exception, if: ...` is the
69
+ # idiomatic Rails way to wire BOTH the forgery_protection_strategy
70
+ # AND the before_action — using `before_action :verify_authenticity_token`
71
+ # directly would crash because the strategy class would be nil. The
72
+ # callback's `if:` proc resolves at request time after
73
+ # `resolve_ruact_entry!` populates `@__ruact_entry`. Rails' own
74
+ # `verified_request?` short-circuits when the host app sets
75
+ # `config.action_controller.allow_forgery_protection = false` (API
76
+ # mode), so the check is a no-op in that case — same observable
77
+ # behavior as the controller-hosted branch under the same setting.
78
+ protect_from_forgery with: :exception, if: :dispatching_standalone?
79
+
80
+ # Story 8.4 — OUTERMOST rescue chain so any StandardError that bubbled
81
+ # past the host's `rescue_from` chain (controller-hosted branch) or out
82
+ # of {StandaloneDispatcher} (standalone branch) is rendered as a
83
+ # structured JSON payload instead of Rails' default HTML error page.
84
+ # Most-specific entries come last because Rails resolves handlers in
85
+ # registration order (last registration wins for the same class), but
86
+ # because both handlers route to the same private method, the order is
87
+ # only relevant for the EXPLICIT InvalidAuthenticityToken entry — that
88
+ # one preempts Rails' auto-installed `handle_unverified_request`
89
+ # (Pitfall #1).
90
+ rescue_from StandardError, with: :__ruact_render_action_error
91
+ rescue_from ActionController::InvalidAuthenticityToken, with: :__ruact_render_action_error
92
+
93
+ # `POST /__ruact/fn/:name` (mounted by `Ruact::Railtie`).
94
+ def dispatch_action
95
+ entry = @__ruact_entry
96
+ return render_unknown(@__ruact_name_sym) unless entry
97
+
98
+ host = entry.controller
99
+ if Ruact::ServerFunctions::EndpointController.standalone_host?(host)
100
+ # Call StandaloneDispatcher WITHOUT passing the response so Rails'
101
+ # `ImplicitRender` does not see an uncommitted response (writing
102
+ # directly to `response.body =` would otherwise be silently
103
+ # overwritten by the implicit-render 204). Apply the dispatcher's
104
+ # Result directive via render/head, which Rails recognises as
105
+ # rendered output.
106
+ result = Ruact::ServerFunctions::StandaloneDispatcher.dispatch(entry, request)
107
+ return apply_standalone_result(result)
108
+ end
109
+
110
+ unless host.is_a?(Class)
111
+ return render(
112
+ json: { error: "ruact action :#{@__ruact_name_sym} has an invalid host shape — " \
113
+ "expected a Controller class or a Module that extends Ruact::ServerAction" },
114
+ status: :internal_server_error
115
+ )
116
+ end
117
+
118
+ host_class = host
119
+
120
+ # Re-run-2 (2026-05-14) — rebuild `request.path_parameters` so that
121
+ # the host action sees `controller`/`action` keys describing ITSELF,
122
+ # not the gem-endpoint route. Without this, `params[:controller]`
123
+ # inside the host's action body returns
124
+ # `"ruact/server_functions/endpoint"` and `params[:action]` returns
125
+ # `"dispatch_action"` — which breaks `controller_name` /
126
+ # `controller_path` / Pundit policy resolution / any code that reads
127
+ # the routing identity. Restore after dispatch so the endpoint
128
+ # response can be rendered with its own identity intact.
129
+ # Re-run-4 (2026-05-15) — DROP `name: raw_name` from the swap.
130
+ # The host action does not need the routing function name (it's
131
+ # already inferable from `action_name`), and keeping it in
132
+ # `path_parameters` made `params[:name]` inside the host action /
133
+ # before_action chain return the route function name instead of
134
+ # a legitimate submitted body field named `:name`. Only
135
+ # `controller`/`action` are swapped — those are required for
136
+ # `controller_name` / `controller_path` / Pundit / instrumentation.
137
+ original_path_parameters = request.path_parameters.dup
138
+ host_path_parameters = {
139
+ controller: host_class.controller_path,
140
+ action: @__ruact_name_sym.to_s
141
+ }
142
+ request.path_parameters = host_path_parameters
143
+
144
+ # Thread-local sentinel allows the public action method to be
145
+ # invoked only here, not from a wildcard route the host may have
146
+ # set up — see the guard inside the defined method.
147
+ Thread.current[:__ruact_dispatching] = @__ruact_name_sym
148
+ host_class.dispatch(@__ruact_name_sym.to_s, request, response)
149
+ ensure
150
+ Thread.current[:__ruact_dispatching] = nil
151
+ request.path_parameters = original_path_parameters if original_path_parameters
152
+ end
153
+
154
+ # Story 8.3 — positive check for the standalone host shape. A host is
155
+ # standalone iff it's a Module (and not a Class) that extends
156
+ # `Ruact::ServerAction`. The class hierarchy `Class < Module` means
157
+ # `is_a?(Module)` also matches Classes; we exclude Classes explicitly.
158
+ def self.standalone_host?(host)
159
+ return false if host.nil?
160
+ return false if host.is_a?(Class)
161
+ return false unless host.is_a?(Module)
162
+
163
+ host.singleton_class.include?(Ruact::ServerAction)
164
+ end
165
+
166
+ private
167
+
168
+ # Translates a `StandaloneDispatcher::Result` into the appropriate
169
+ # render call. Calling `render` / `head` is what marks the response
170
+ # as performed (`performed? == true`); writing to `response.body =`
171
+ # directly would be overwritten by Rails' `ImplicitRender`.
172
+ def apply_standalone_result(result)
173
+ if result.body.nil? || result.body.empty?
174
+ head(result.status)
175
+ else
176
+ render(
177
+ body: result.body,
178
+ status: result.status,
179
+ content_type: result.content_type
180
+ )
181
+ end
182
+ end
183
+
184
+ # Resolves the registry entry BEFORE Rails' before_action chain runs
185
+ # the conditional `verify_authenticity_token` callback — the CSRF
186
+ # decision depends on knowing whether the host is standalone, which
187
+ # is only knowable after we have the entry in hand. Stashes the
188
+ # entry + name on instance ivars so `dispatch_action` and
189
+ # `dispatching_standalone?` can both read them.
190
+ def resolve_ruact_entry!
191
+ @__ruact_name_sym = request.path_parameters[:name].to_s.to_sym
192
+ @__ruact_entry = lookup_entry(@__ruact_name_sym)
193
+ end
194
+
195
+ # Story 8.5 — the upload-guard body lives in {ErrorRendering}
196
+ # (`__ruact_enforce_upload_limit!`); this controller uses it via the
197
+ # `prepend_before_action` above with the module's "always applicable"
198
+ # default (the endpoint route is POST-only — no GET carve-out needed).
199
+
200
+ def dispatching_standalone?
201
+ return false unless @__ruact_entry
202
+
203
+ Ruact::ServerFunctions::EndpointController.standalone_host?(@__ruact_entry.controller)
204
+ end
205
+
206
+ def lookup_entry(name_sym)
207
+ # Story 8.1 only routes through the action registry. Story 9.1 will
208
+ # extend this lookup to also check the query registry; until then,
209
+ # query-only symbols return 404 here.
210
+ Ruact.action_registry.entries[name_sym]
211
+ end
212
+
213
+ def render_unknown(name_sym)
214
+ render(
215
+ json: { error: "unknown ruact action: :#{name_sym}" },
216
+ status: :not_found
217
+ )
218
+ end
219
+
220
+ # Story 8.4 / 9.1 — the structured-error renderer body lives in
221
+ # {ErrorRendering} (`__ruact_render_action_error` + status mapping +
222
+ # payload-mode resolution + logging). This override supplies the v1
223
+ # `action_name` source.
224
+ #
225
+ # Story 8.5 — the upload-limit guard runs BEFORE `resolve_ruact_entry!`,
226
+ # so `@__ruact_name_sym` may still be nil when a 413 fires. Fall back
227
+ # to `request.path_parameters[:name]` (the URL `:name` segment Rails
228
+ # routed on) so the structured payload still carries a meaningful
229
+ # `action_name` instead of "(unknown)".
230
+ def __ruact_error_action_name
231
+ @__ruact_name_sym ||
232
+ request.path_parameters[:name]&.to_s&.to_sym ||
233
+ :"(unknown)"
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backtrace_cleaner"
4
+ require_relative "error_suggestion"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ # Story 8.4 — Builds the structured JSON body returned by
9
+ # {EndpointController#__ruact_render_action_error} for any server-action
10
+ # exception that bubbles past a host's `rescue_from` chain.
11
+ #
12
+ # The function is pure (no `Rails.env`, no `Ruact.config` reads) — the
13
+ # caller resolves `mode` (`:development` or `:production`) and passes it
14
+ # in. That keeps the module trivially testable without stubbing Rails env.
15
+ #
16
+ # In `:development` mode the payload carries the full surface:
17
+ # action name, error class, message, split backtrace (first 25 frames per
18
+ # bucket), contextual suggestion, and (for `ActiveRecord::RecordInvalid`)
19
+ # the model's `full_messages`.
20
+ #
21
+ # In `:production` mode the payload is reduced to four baseline keys:
22
+ # `_ruact_server_action_error`, `action_name`, `error_class`, `message`.
23
+ # React components can render their own UI from those four fields without
24
+ # any accidental backtrace leakage on the wire.
25
+ module ErrorPayload
26
+ # Maximum frames preserved per bucket. The full backtrace is still in
27
+ # the server log; the wire payload is for the overlay, which is
28
+ # unreadable past a couple of dozen frames anyway.
29
+ MAX_FRAMES_PER_BUCKET = 25
30
+
31
+ # @param action_name [Symbol, String]
32
+ # @param error [Exception]
33
+ # @param mode [Symbol] :development or :production
34
+ # @return [Hash{String=>Object}]
35
+ def self.build(action_name:, error:, mode:)
36
+ # Pitfall #5: defensive dup against frozen-string `Exception#message`
37
+ # implementations.
38
+ message = error.message.to_s.dup
39
+ payload = {
40
+ "_ruact_server_action_error" => true,
41
+ "action_name" => action_name.to_s,
42
+ "error_class" => error.class.name,
43
+ "message" => message
44
+ }
45
+ return payload if mode == :production
46
+
47
+ frames = BacktraceCleaner.split(error.backtrace)
48
+ payload["app_frames"] = frames[:app].first(MAX_FRAMES_PER_BUCKET)
49
+ payload["gem_frames"] = frames[:gem].first(MAX_FRAMES_PER_BUCKET)
50
+ payload["suggestion"] = ErrorSuggestion.for(error)
51
+ validation_errors = extract_validation_errors(error)
52
+ payload["validation_errors"] = validation_errors if validation_errors
53
+ upload_limit = extract_upload_limit(error)
54
+ payload["upload_limit"] = upload_limit if upload_limit
55
+ payload
56
+ end
57
+
58
+ # Story 8.5 — for `Ruact::UploadTooLargeError`, surface the
59
+ # `received_bytes` / `limit_bytes` pair as a dev-only block so the
60
+ # overlay can render both numbers without re-parsing the message.
61
+ # Returns nil for any other error class so the caller can omit the
62
+ # key entirely (preserves the "four baseline keys" prod contract).
63
+ def self.extract_upload_limit(error)
64
+ return nil unless error.class.name == "Ruact::UploadTooLargeError"
65
+
66
+ {
67
+ "received_bytes" => error.received_bytes,
68
+ "limit_bytes" => error.limit_bytes
69
+ }
70
+ end
71
+ private_class_method :extract_upload_limit
72
+
73
+ # Returns `full_messages` for `ActiveRecord::RecordInvalid` (or any
74
+ # error that exposes `.record.errors.full_messages`); `[]` when the
75
+ # record is nil; `nil` for unrelated exception classes (so the caller
76
+ # can omit the key entirely).
77
+ def self.extract_validation_errors(error)
78
+ return nil unless error.class.name == "ActiveRecord::RecordInvalid"
79
+ return [] unless error.respond_to?(:record)
80
+
81
+ record = error.record
82
+ return [] if record.nil?
83
+ return [] unless record.respond_to?(:errors)
84
+
85
+ errors = record.errors
86
+ return [] unless errors.respond_to?(:full_messages)
87
+
88
+ errors.full_messages.to_a
89
+ end
90
+ private_class_method :extract_validation_errors
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error_payload"
4
+
5
+ module Ruact
6
+ module ServerFunctions
7
+ # Story 9.1 — the shared salvage core for the Story 8.4 structured-error
8
+ # rendering and the Story 8.5 upload guard. Extracted from
9
+ # {EndpointController} so the SAME implementation serves both homes during
10
+ # the strangler-fig transition:
11
+ #
12
+ # - {EndpointController} (v1 — synthetic `POST /__ruact/fn/:name`
13
+ # endpoint; removed by Story 9.9)
14
+ # - {Ruact::Server} (v2 — the route-driven concern hosts include)
15
+ #
16
+ # Keeping one source guarantees the 8.4/8.5 wire contract is byte-for-byte
17
+ # identical across both homes by construction; behavioral differences are
18
+ # expressed exclusively through the three private hooks below, which each
19
+ # home may override:
20
+ #
21
+ # - {#__ruact_error_action_name} — where the payload's `action_name`
22
+ # comes from. Default: the controller's own `action_name` (correct for
23
+ # v2 host controllers). The v1 endpoint overrides it with its
24
+ # registry-symbol / `path_parameters[:name]` fallback chain.
25
+ # - {#__ruact_render_structured_error?} — whether the rescue handler
26
+ # renders the structured JSON payload for this request, or re-raises so
27
+ # Rails' default error handling proceeds. Default: always render
28
+ # (v1 endpoint semantics — every request there is a function call).
29
+ # {Ruact::Server} gates this on the function-call predicate.
30
+ # - {#__ruact_upload_guard_applicable?} — whether the upload guard
31
+ # applies to this request at all. Default: always (the v1 endpoint is
32
+ # POST-only). {Ruact::Server} skips GET/HEAD so page actions stay
33
+ # byte-for-byte untouched.
34
+ #
35
+ # All methods are private on the including controller; nothing here is
36
+ # public API surface.
37
+ module ErrorRendering
38
+ private
39
+
40
+ # Story 8.5 — `prepend_before_action` callback. Rejects requests whose
41
+ # wire `Content-Length` exceeds `Ruact.config.max_upload_bytes`. The
42
+ # check uses `Content-Length` (not body inspection) so it fires BEFORE
43
+ # Rack's multipart parser would touch the body — the cheapest possible
44
+ # reject. It only fires for `multipart/form-data` and
45
+ # `application/x-www-form-urlencoded`; JSON bodies have their own
46
+ # operational caps (host middleware / reverse proxy) and aren't a
47
+ # "max upload" concern. Chunked-transfer clients (`Content-Length`
48
+ # absent) bypass the guard because we cannot know the size up-front; the
49
+ # action body is responsible for any belt-and-suspenders check via
50
+ # `params[:file].size` / `params[:file].byte_size`. A nil
51
+ # `Ruact.config.max_upload_bytes` short-circuits the guard entirely —
52
+ # the gem-side knob has been opted out and the host's reverse proxy /
53
+ # middleware owns the cap.
54
+ #
55
+ # The reported `received_bytes` is the WIRE Content-Length, which
56
+ # includes multipart boundary overhead (a 9.5 MB file uploaded via
57
+ # multipart reports `received_bytes ≈ 9.5 MB + a few KB`). The 10 MB
58
+ # default has enough headroom that this is invisible for the common
59
+ # case; the docs page calls it out for the edge.
60
+ def __ruact_enforce_upload_limit!
61
+ return unless __ruact_upload_guard_applicable?
62
+
63
+ limit = Ruact.config.max_upload_bytes
64
+ return if limit.nil?
65
+
66
+ content_type = request.content_mime_type&.to_s
67
+ return unless ["multipart/form-data", "application/x-www-form-urlencoded"].include?(content_type)
68
+
69
+ received = request.content_length
70
+ return if received.nil?
71
+ return if received <= limit
72
+
73
+ raise Ruact::UploadTooLargeError.new(received_bytes: received, limit_bytes: limit)
74
+ end
75
+
76
+ # Story 8.4 — Structured server-action error renderer. Resolves the
77
+ # mode from {Ruact.config.dev_error_payload_enabled} (falling back to
78
+ # `Rails.env.development? || Rails.env.test?` when nil), builds the
79
+ # JSON body via {ErrorPayload.build}, logs the failure server-side
80
+ # (always — the prod constraint is "do not leak via the wire", not
81
+ # "do not log"), then renders `json: payload, status: <mapped>`.
82
+ #
83
+ # Story 9.1 — when {#__ruact_render_structured_error?} returns false
84
+ # (a non-function-call request on a {Ruact::Server} host), the error is
85
+ # re-raised instead: re-raising inside a `rescue_from` handler
86
+ # propagates out of `process_action` without re-entering the rescue
87
+ # chain (the handler IS the rescue clause), so Rails' default error
88
+ # handling — debug page in development, public 500 in production —
89
+ # proceeds exactly as if the concern were not installed.
90
+ def __ruact_render_action_error(error)
91
+ raise error unless __ruact_render_structured_error?(error)
92
+
93
+ action_name = __ruact_error_action_name
94
+ mode = __ruact_payload_mode
95
+ payload = ErrorPayload.build(action_name: action_name, error: error, mode: mode)
96
+ __ruact_log_action_error(action_name, error)
97
+ render(json: payload, status: __ruact_status_for(error))
98
+ end
99
+
100
+ # Hook — where the structured payload's `action_name` field comes from.
101
+ # The controller's own `action_name` is correct for v2 host controllers
102
+ # (it is populated by routing before any callback runs, including the
103
+ # prepended upload guard — no early-rejection fallback dance needed).
104
+ def __ruact_error_action_name
105
+ action_name
106
+ end
107
+
108
+ # Hook — render the structured payload for this request? The v1
109
+ # endpoint's answer is "always" (every request hitting
110
+ # `POST /__ruact/fn/:name` is a function call by construction).
111
+ def __ruact_render_structured_error?(_error)
112
+ true
113
+ end
114
+
115
+ # Hook — does the upload guard apply to this request? The v1 endpoint's
116
+ # answer is "always" (the route is POST-only).
117
+ def __ruact_upload_guard_applicable?
118
+ true
119
+ end
120
+
121
+ # Story 8.4 — Status mapping per AC1:
122
+ # - `Ruact::BadRequestError` → 400 (Story 9.5 — FR88 kwargs rejection)
123
+ # - `ActiveRecord::RecordInvalid` → 422
124
+ # - `ActionController::InvalidAuthenticityToken` → 403
125
+ # - `Ruact::UploadTooLargeError` → 413
126
+ # - any other StandardError → 500
127
+ # Uses class-name string match so the gem does NOT require ActiveRecord
128
+ # at load time (parity with {ErrorSuggestion}).
129
+ def __ruact_status_for(error)
130
+ case error.class.name
131
+ when "Ruact::BadRequestError" then 400
132
+ when "ActiveRecord::RecordInvalid" then 422
133
+ when "ActionController::InvalidAuthenticityToken" then 403
134
+ when "Ruact::UploadTooLargeError" then 413
135
+ else 500
136
+ end
137
+ end
138
+
139
+ # Story 8.4 — Resolve the payload mode from configuration with a Rails
140
+ # env fallback. The fallback keeps the Configuration trivially
141
+ # constructible in non-Rails specs while ensuring production hosts that
142
+ # never call `Ruact.configure` still see the reduced wire shape.
143
+ #
144
+ # Strict-boolean handling (review follow-up): only the literals `true`
145
+ # and `false` count as an explicit configuration. Any other value
146
+ # (strings like `"true"`, numerics, Symbols, etc.) falls back to the
147
+ # env-driven default rather than being coerced via Ruby truthiness —
148
+ # otherwise a misconfigured `c.dev_error_payload_enabled = "false"`
149
+ # would silently leak the verbose payload in production.
150
+ def __ruact_payload_mode
151
+ case Ruact.config.dev_error_payload_enabled
152
+ when true then :development
153
+ when false then :production
154
+ else __ruact_default_dev_mode? ? :development : :production
155
+ end
156
+ end
157
+
158
+ def __ruact_default_dev_mode?
159
+ return false unless defined?(Rails) && Rails.respond_to?(:env)
160
+
161
+ Rails.env.development? || Rails.env.test?
162
+ end
163
+
164
+ # Story 8.4 AC6 — log a single error line + the full backtrace, both at
165
+ # `error` severity. When `Rails.logger` responds to `tagged` (the
166
+ # ActiveSupport::TaggedLogging extension; Rails 6+ default for the
167
+ # request logger), wrap the entry in a `ruact action:<name>` tag for
168
+ # log-aggregator indexing. The full backtrace is emitted regardless of
169
+ # the wire-payload mode — server-side logs always carry the full
170
+ # picture.
171
+ def __ruact_log_action_error(action_name, error)
172
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
173
+
174
+ line = "[ruact] server action :#{action_name} failed — #{error.class.name}: #{error.message}"
175
+ backtrace_text = Array(error.backtrace).join("\n")
176
+
177
+ logger = Rails.logger
178
+ if logger.respond_to?(:tagged)
179
+ logger.tagged("ruact action:#{action_name}") do
180
+ logger.error(line)
181
+ logger.error(backtrace_text) unless backtrace_text.empty?
182
+ end
183
+ else
184
+ logger.error(line)
185
+ logger.error(backtrace_text) unless backtrace_text.empty?
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 8.4 — Maps an exception class (by its String name) to a short
6
+ # corrective suggestion shown in the dev overlay's structured view.
7
+ #
8
+ # The class-name match runs against `error.class.name` (a String) so the
9
+ # module does NOT need `require "active_record"` or
10
+ # `require "action_controller"` to operate; it works in AR-less specs and
11
+ # bare-Rack hosts (see Pitfall #4 in the story).
12
+ #
13
+ # The {SUGGESTIONS} table is a gem-published surface — new entries land
14
+ # via an ADR amendment + a constant update, NOT via runtime registration.
15
+ module ErrorSuggestion
16
+ SUGGESTIONS = {
17
+ "ActiveRecord::RecordInvalid" =>
18
+ "Validation failed — check the model's `validates` rules",
19
+ "ActionController::InvalidAuthenticityToken" =>
20
+ "CSRF token mismatch — ensure the page was rendered after the most recent server restart and the session cookie is intact",
21
+ # Story 8.5 — multipart-upload reject. Routes devs to either the
22
+ # config knob (for "raise the limit by a few MB") or the streaming
23
+ # upload pipelines (for "this should never have been a server-action
24
+ # request in the first place").
25
+ "Ruact::UploadTooLargeError" =>
26
+ "Upload exceeded the configured size limit. Increase Ruact.config.max_upload_bytes or use Active Storage Direct Upload / a presigned S3 URL for large files."
27
+ }.freeze
28
+
29
+ # Suggestion string for the given error, or nil for unknown classes.
30
+ #
31
+ # @param error [Exception]
32
+ # @return [String, nil]
33
+ def self.for(error)
34
+ SUGGESTIONS[error.class.name]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Translates a Ruby symbol into the JS identifier exported from
6
+ # `app/javascript/.ruact/server-functions.ts`. The bridge is Ruby-side only;
7
+ # the Vite plugin reads the already-translated identifier from the JSON
8
+ # snapshot and emits it verbatim (Story 8.0a design decision: one source of
9
+ # truth for naming).
10
+ #
11
+ # Rules (locked by Story 8.0 ADR + 2026-05-13 review-patch tightening):
12
+ # - Symbol must match `/\A[a-z_][a-z0-9_]*\z/`; otherwise raises
13
+ # {Ruact::ConfigurationError} at controller-class load time.
14
+ # - A single leading underscore is preserved (e.g. `:_internal_dump` →
15
+ # `"_internalDump"`).
16
+ # - Runs of underscores collapse and uppercase the following alphanumeric
17
+ # (e.g. `:foo__bar` → `"fooBar"`).
18
+ # - The source symbol cannot be made of underscores alone (`:_`, `:__`, …) —
19
+ # would otherwise emit `"_"`, which carries no semantic content and
20
+ # collides with the common "ignored value" lint convention.
21
+ # - The translated JS identifier cannot match a JavaScript reserved word
22
+ # or future-reserved word (ES2020+ list plus the module-top-level
23
+ # `await` / `async`). `:class` → `"class"` would compile under Babel
24
+ # loose-mode but break under `tsc --noEmit` and most ESLint configs.
25
+ #
26
+ # @see docs/internal/decisions/server-functions-api.md "Naming bridge"
27
+ module NameBridge
28
+ VALID_SYMBOL = /\A[a-z_][a-z0-9_]*\z/
29
+
30
+ UNDERSCORE_ONLY = /\A_+\z/
31
+
32
+ # ES2020+ reserved + strict-mode reserved + contextually-reserved at
33
+ # module top level + strict-mode invalid binding names. The codegen emits
34
+ # in a module context (the generated `app/javascript/.ruact/server-functions.ts`
35
+ # is `"type": "module"`, so all code runs in strict mode), so `await`,
36
+ # `eval`, and `arguments` are all reserved as identifier names.
37
+ # Keep this list sorted; matches the MDN reference plus the contextual
38
+ # additions and the strict-mode `eval`/`arguments` ban from the
39
+ # 2026-05-13 Re-run review patch.
40
+ RESERVED_JS_IDENTIFIERS = %w[
41
+ arguments async await break case catch class const continue
42
+ debugger default delete do else enum eval export extends false
43
+ finally for function if implements import in instanceof interface
44
+ let new null package private protected public return static super
45
+ switch this throw true try typeof var void while with yield
46
+ ].to_set.freeze
47
+
48
+ # Story 8.2 (2026-05-17 review patches R2 + R12) — names already
49
+ # bound at the top of `app/javascript/.ruact/server-functions.ts`,
50
+ # either by a helper re-export (`revalidate`, `useQuery`) or a runtime
51
+ # import (`_makeRef`, `_makeServerFunction`, `_makeQuery`). A
52
+ # `ruact_action :revalidate` / a query method `use_query` would emit a
53
+ # clashing `export const` / duplicate export next to the existing
54
+ # binding and crash the generated module at load time. The rule fires
55
+ # at controller-class load / route-draw so the failure surfaces during
56
+ # boot, not at first request.
57
+ # Story 9.5 added `_makeQuery` (the v2 query import) and `useQuery` (the
58
+ # query hook re-export) to this list.
59
+ RESERVED_BY_RUACT = %w[
60
+ _makeQuery
61
+ _makeRef
62
+ _makeServerFunction
63
+ revalidate
64
+ useQuery
65
+ ].to_set.freeze
66
+
67
+ class << self
68
+ # @param symbol [Symbol, String] the Ruby identifier registered via
69
+ # `ruact_action` / `ruact_query` (Phase 2 stories 8.1 and 9.1).
70
+ # @return [String] the corresponding JS identifier.
71
+ # @raise [Ruact::ConfigurationError] when +symbol+ does not match the
72
+ # allowed shape, is all-underscores, or maps to a JS reserved word —
73
+ # caught at controller load time so misnamed routes never reach
74
+ # production.
75
+ # @example
76
+ # Ruact::ServerFunctions::NameBridge.to_js_identifier(:create_post)
77
+ # # => "createPost"
78
+ # @example leading underscore preserved
79
+ # Ruact::ServerFunctions::NameBridge.to_js_identifier(:_internal_dump)
80
+ # # => "_internalDump"
81
+ def to_js_identifier(symbol)
82
+ str = symbol.to_s
83
+
84
+ unless str.match?(VALID_SYMBOL)
85
+ raise Ruact::ConfigurationError,
86
+ "ruact_action / ruact_query symbol :#{symbol} must match /^[a-z_][a-z0-9_]*$/"
87
+ end
88
+
89
+ if str.match?(UNDERSCORE_ONLY)
90
+ raise Ruact::ConfigurationError,
91
+ "ruact_action / ruact_query symbol :#{symbol} cannot be composed " \
92
+ "entirely of underscores (no semantic content)"
93
+ end
94
+
95
+ leading = str.start_with?("_") ? "_" : ""
96
+ body = str.sub(/\A_+/, "")
97
+ js_id = leading + body.gsub(/_+([a-z0-9])/) { Regexp.last_match(1).upcase }
98
+
99
+ if RESERVED_JS_IDENTIFIERS.include?(js_id)
100
+ raise Ruact::ConfigurationError,
101
+ "ruact_action / ruact_query symbol :#{symbol} maps to JS reserved " \
102
+ "word \"#{js_id}\" — pick a different Ruby symbol (e.g. :#{symbol}_action)"
103
+ end
104
+
105
+ if RESERVED_BY_RUACT.include?(js_id)
106
+ raise Ruact::ConfigurationError,
107
+ "ruact_action / ruact_query symbol :#{symbol} maps to \"#{js_id}\", " \
108
+ "which is already exported by the ruact runtime from " \
109
+ "`@/.ruact/server-functions` and would emit a duplicate export. " \
110
+ "Pick a different Ruby symbol (e.g. :#{symbol}_action)."
111
+ end
112
+
113
+ js_id
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end