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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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,188 @@
|
|
|
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
|
+
# - `ActiveRecord::RecordInvalid` → 422
|
|
123
|
+
# - `ActionController::InvalidAuthenticityToken` → 403
|
|
124
|
+
# - `Ruact::UploadTooLargeError` → 413
|
|
125
|
+
# - any other StandardError → 500
|
|
126
|
+
# Uses class-name string match so the gem does NOT require ActiveRecord
|
|
127
|
+
# at load time (parity with {ErrorSuggestion}).
|
|
128
|
+
def __ruact_status_for(error)
|
|
129
|
+
case error.class.name
|
|
130
|
+
when "ActiveRecord::RecordInvalid" then 422
|
|
131
|
+
when "ActionController::InvalidAuthenticityToken" then 403
|
|
132
|
+
when "Ruact::UploadTooLargeError" then 413
|
|
133
|
+
else 500
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Story 8.4 — Resolve the payload mode from configuration with a Rails
|
|
138
|
+
# env fallback. The fallback keeps the Configuration trivially
|
|
139
|
+
# constructible in non-Rails specs while ensuring production hosts that
|
|
140
|
+
# never call `Ruact.configure` still see the reduced wire shape.
|
|
141
|
+
#
|
|
142
|
+
# Strict-boolean handling (review follow-up): only the literals `true`
|
|
143
|
+
# and `false` count as an explicit configuration. Any other value
|
|
144
|
+
# (strings like `"true"`, numerics, Symbols, etc.) falls back to the
|
|
145
|
+
# env-driven default rather than being coerced via Ruby truthiness —
|
|
146
|
+
# otherwise a misconfigured `c.dev_error_payload_enabled = "false"`
|
|
147
|
+
# would silently leak the verbose payload in production.
|
|
148
|
+
def __ruact_payload_mode
|
|
149
|
+
case Ruact.config.dev_error_payload_enabled
|
|
150
|
+
when true then :development
|
|
151
|
+
when false then :production
|
|
152
|
+
else __ruact_default_dev_mode? ? :development : :production
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def __ruact_default_dev_mode?
|
|
157
|
+
return false unless defined?(Rails) && Rails.respond_to?(:env)
|
|
158
|
+
|
|
159
|
+
Rails.env.development? || Rails.env.test?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Story 8.4 AC6 — log a single error line + the full backtrace, both at
|
|
163
|
+
# `error` severity. When `Rails.logger` responds to `tagged` (the
|
|
164
|
+
# ActiveSupport::TaggedLogging extension; Rails 6+ default for the
|
|
165
|
+
# request logger), wrap the entry in a `ruact action:<name>` tag for
|
|
166
|
+
# log-aggregator indexing. The full backtrace is emitted regardless of
|
|
167
|
+
# the wire-payload mode — server-side logs always carry the full
|
|
168
|
+
# picture.
|
|
169
|
+
def __ruact_log_action_error(action_name, error)
|
|
170
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
171
|
+
|
|
172
|
+
line = "[ruact] server action :#{action_name} failed — #{error.class.name}: #{error.message}"
|
|
173
|
+
backtrace_text = Array(error.backtrace).join("\n")
|
|
174
|
+
|
|
175
|
+
logger = Rails.logger
|
|
176
|
+
if logger.respond_to?(:tagged)
|
|
177
|
+
logger.tagged("ruact action:#{action_name}") do
|
|
178
|
+
logger.error(line)
|
|
179
|
+
logger.error(backtrace_text) unless backtrace_text.empty?
|
|
180
|
+
end
|
|
181
|
+
else
|
|
182
|
+
logger.error(line)
|
|
183
|
+
logger.error(backtrace_text) unless backtrace_text.empty?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
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,113 @@
|
|
|
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 the helper re-export (`revalidate`) or the runtime
|
|
51
|
+
# import (`_makeRef`). A `ruact_action :revalidate` or
|
|
52
|
+
# `ruact_action :_make_ref` would emit a clashing `export const`
|
|
53
|
+
# next to the existing binding and crash at module-load time.
|
|
54
|
+
# The rule fires at controller-class load so the failure
|
|
55
|
+
# surfaces during boot, not at first request.
|
|
56
|
+
RESERVED_BY_RUACT = %w[
|
|
57
|
+
_makeRef
|
|
58
|
+
_makeServerFunction
|
|
59
|
+
revalidate
|
|
60
|
+
].to_set.freeze
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# @param symbol [Symbol, String] the Ruby identifier registered via
|
|
64
|
+
# `ruact_action` / `ruact_query` (Phase 2 stories 8.1 and 9.1).
|
|
65
|
+
# @return [String] the corresponding JS identifier.
|
|
66
|
+
# @raise [Ruact::ConfigurationError] when +symbol+ does not match the
|
|
67
|
+
# allowed shape, is all-underscores, or maps to a JS reserved word —
|
|
68
|
+
# caught at controller load time so misnamed routes never reach
|
|
69
|
+
# production.
|
|
70
|
+
# @example
|
|
71
|
+
# Ruact::ServerFunctions::NameBridge.to_js_identifier(:create_post)
|
|
72
|
+
# # => "createPost"
|
|
73
|
+
# @example leading underscore preserved
|
|
74
|
+
# Ruact::ServerFunctions::NameBridge.to_js_identifier(:_internal_dump)
|
|
75
|
+
# # => "_internalDump"
|
|
76
|
+
def to_js_identifier(symbol)
|
|
77
|
+
str = symbol.to_s
|
|
78
|
+
|
|
79
|
+
unless str.match?(VALID_SYMBOL)
|
|
80
|
+
raise Ruact::ConfigurationError,
|
|
81
|
+
"ruact_action / ruact_query symbol :#{symbol} must match /^[a-z_][a-z0-9_]*$/"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if str.match?(UNDERSCORE_ONLY)
|
|
85
|
+
raise Ruact::ConfigurationError,
|
|
86
|
+
"ruact_action / ruact_query symbol :#{symbol} cannot be composed " \
|
|
87
|
+
"entirely of underscores (no semantic content)"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
leading = str.start_with?("_") ? "_" : ""
|
|
91
|
+
body = str.sub(/\A_+/, "")
|
|
92
|
+
js_id = leading + body.gsub(/_+([a-z0-9])/) { Regexp.last_match(1).upcase }
|
|
93
|
+
|
|
94
|
+
if RESERVED_JS_IDENTIFIERS.include?(js_id)
|
|
95
|
+
raise Ruact::ConfigurationError,
|
|
96
|
+
"ruact_action / ruact_query symbol :#{symbol} maps to JS reserved " \
|
|
97
|
+
"word \"#{js_id}\" — pick a different Ruby symbol (e.g. :#{symbol}_action)"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if RESERVED_BY_RUACT.include?(js_id)
|
|
101
|
+
raise Ruact::ConfigurationError,
|
|
102
|
+
"ruact_action / ruact_query symbol :#{symbol} maps to \"#{js_id}\", " \
|
|
103
|
+
"which is already exported by the ruact runtime from " \
|
|
104
|
+
"`@/.ruact/server-functions` and would emit a duplicate export. " \
|
|
105
|
+
"Pick a different Ruby symbol (e.g. :#{symbol}_action)."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
js_id
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|