ruact 0.0.5 → 0.0.7
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +74 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +109 -0
- data/lib/ruact.rb +20 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +70 -328
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/manifest_resolver_spec.rb +159 -0
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +15 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +58 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -1,237 +0,0 @@
|
|
|
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
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ruact
|
|
4
|
-
module ServerFunctions
|
|
5
|
-
# In-memory storage for server-function entries. One instance backs
|
|
6
|
-
# `Ruact.action_registry`; another backs `Ruact.query_registry` — kept
|
|
7
|
-
# separate so the JSON snapshot can emit a `kind` field per entry without
|
|
8
|
-
# the call sites having to thread an extra parameter through. Cross-registry
|
|
9
|
-
# JS-identifier collisions are detected by {Ruact::ServerFunctions::Snapshot}
|
|
10
|
-
# at snapshot time (a single registry only sees its own entries).
|
|
11
|
-
#
|
|
12
|
-
# Thread-safety: not thread-safe by design. Registration happens at
|
|
13
|
-
# controller-class load time (`config.to_prepare` in dev, eager-load in
|
|
14
|
-
# production), single-threaded. Reads from {#entries} return a frozen
|
|
15
|
-
# snapshot of the internal hash so concurrent readers cannot observe a
|
|
16
|
-
# partial registration.
|
|
17
|
-
class Registry
|
|
18
|
-
# The only kinds the codegen knows how to emit. Story 8.1 owns `:action`,
|
|
19
|
-
# Story 9.1 owns `:query`. Any other value is rejected at registration
|
|
20
|
-
# time — silent acceptance would otherwise let an unknown kind fall
|
|
21
|
-
# through and be emitted as an action signature.
|
|
22
|
-
ALLOWED_KINDS = %i[action query].freeze
|
|
23
|
-
|
|
24
|
-
def initialize
|
|
25
|
-
@entries = {}
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Adds +symbol+ to the registry.
|
|
29
|
-
#
|
|
30
|
-
# @param symbol [Symbol] the Ruby identifier (snake_case).
|
|
31
|
-
# @param kind [Symbol] `:action` or `:query`. Other values raise.
|
|
32
|
-
# @param controller [Class, nil] the controller class registering the
|
|
33
|
-
# function. Used in collision-error messages.
|
|
34
|
-
# @yield the implementation body; stored verbatim for Story 8.1 / 9.1 to
|
|
35
|
-
# invoke. May be nil for Story 8.0a's bootstrap (registries are empty
|
|
36
|
-
# until 8.1 and 9.1 land).
|
|
37
|
-
# @return [Ruact::ServerFunctions::RegistryEntry] the entry just inserted.
|
|
38
|
-
# @raise [Ruact::ConfigurationError] when +symbol+ fails the naming-bridge
|
|
39
|
-
# rule, when +kind+ is not in {ALLOWED_KINDS}, or when a different Ruby
|
|
40
|
-
# symbol already maps to the same JS identifier in THIS registry. Cross-
|
|
41
|
-
# registry collisions (one action + one query sharing a JS identifier)
|
|
42
|
-
# are detected later by {Ruact::ServerFunctions::Snapshot.functions_payload}.
|
|
43
|
-
def register(symbol, kind:, controller: nil, &block)
|
|
44
|
-
validate_kind!(symbol, kind, controller)
|
|
45
|
-
js_identifier = translate_symbol(symbol, controller)
|
|
46
|
-
detect_collision!(symbol, js_identifier, controller)
|
|
47
|
-
|
|
48
|
-
entry = RegistryEntry.new(
|
|
49
|
-
ruby_symbol: symbol,
|
|
50
|
-
js_identifier: js_identifier,
|
|
51
|
-
kind: kind,
|
|
52
|
-
controller: controller,
|
|
53
|
-
block: block
|
|
54
|
-
)
|
|
55
|
-
@entries[symbol] = entry
|
|
56
|
-
entry
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# @return [Hash{Symbol => Ruact::ServerFunctions::RegistryEntry}] frozen
|
|
60
|
-
# snapshot of the current entries, ordered by insertion.
|
|
61
|
-
def entries
|
|
62
|
-
@entries.dup.freeze
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Wipes the registry. Used by `config.to_prepare` (between dev reloads) and
|
|
66
|
-
# by tests that need a clean slate.
|
|
67
|
-
#
|
|
68
|
-
# @return [self]
|
|
69
|
-
def clear!
|
|
70
|
-
@entries.clear
|
|
71
|
-
self
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @return [Integer] number of registered entries.
|
|
75
|
-
def size
|
|
76
|
-
@entries.size
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# @return [Boolean] whether the registry has no entries.
|
|
80
|
-
def empty?
|
|
81
|
-
@entries.empty?
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
private
|
|
85
|
-
|
|
86
|
-
def validate_kind!(symbol, kind, controller)
|
|
87
|
-
return if ALLOWED_KINDS.include?(kind)
|
|
88
|
-
|
|
89
|
-
raise Ruact::ConfigurationError,
|
|
90
|
-
"invalid server-function symbol :#{symbol} in #{describe_controller(controller)}: " \
|
|
91
|
-
"kind #{kind.inspect} is not one of #{ALLOWED_KINDS.inspect}"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Wraps the NameBridge call to attach controller context to the raised
|
|
95
|
-
# error (the AC7 "invalid server-function symbol :SYMBOL in CONTROLLER"
|
|
96
|
-
# shape). NameBridge itself is controller-agnostic; the wrap lives at the
|
|
97
|
-
# registry boundary because that is where controller context exists.
|
|
98
|
-
def translate_symbol(symbol, controller)
|
|
99
|
-
NameBridge.to_js_identifier(symbol)
|
|
100
|
-
rescue Ruact::ConfigurationError => e
|
|
101
|
-
raise Ruact::ConfigurationError,
|
|
102
|
-
"invalid server-function symbol :#{symbol} in #{describe_controller(controller)} — #{e.message}"
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def detect_collision!(symbol, js_identifier, controller)
|
|
106
|
-
# Re-run-3 (2026-05-15) — TWO failure shapes:
|
|
107
|
-
#
|
|
108
|
-
# (a) Different Ruby symbols, same JS identifier (`:foo_bar` and
|
|
109
|
-
# `:fooBar` both → "fooBar"). Filtered by `js_identifier ==`.
|
|
110
|
-
# (b) Same Ruby symbol declared on TWO different controllers
|
|
111
|
-
# (e.g., `ruact_action :create_post` in both `PostsController`
|
|
112
|
-
# AND `AdminPostsController`). Pre-batch this silently
|
|
113
|
-
# overwrote `@entries[symbol]` with the last-loaded one, so
|
|
114
|
-
# dispatch routed to whichever controller Zeitwerk happened
|
|
115
|
-
# to load last — non-deterministic in dev, surprise breakage
|
|
116
|
-
# when refactoring. Detect by checking the existing entry's
|
|
117
|
-
# `controller` against the one trying to register.
|
|
118
|
-
existing = @entries[symbol]
|
|
119
|
-
if existing && existing.controller != controller
|
|
120
|
-
raise Ruact::ConfigurationError,
|
|
121
|
-
"server-function naming collision: " \
|
|
122
|
-
":#{symbol} is declared in BOTH " \
|
|
123
|
-
"#{describe_controller(existing.controller)} and " \
|
|
124
|
-
"#{describe_controller(controller)}. Each `ruact_action` " \
|
|
125
|
-
"symbol must be unique across the whole registry — pick a " \
|
|
126
|
-
"more specific name (e.g. :admin_create_post) on one side."
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
collision = @entries.values.find do |e|
|
|
130
|
-
e.js_identifier == js_identifier && e.ruby_symbol != symbol
|
|
131
|
-
end
|
|
132
|
-
return unless collision
|
|
133
|
-
|
|
134
|
-
raise Ruact::ConfigurationError,
|
|
135
|
-
"server-function naming collision: " \
|
|
136
|
-
":#{symbol} (in #{describe_controller(controller)}) and " \
|
|
137
|
-
":#{collision.ruby_symbol} (in #{describe_controller(collision.controller)}) " \
|
|
138
|
-
"both map to JS identifier \"#{js_identifier}\""
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def describe_controller(controller)
|
|
142
|
-
return "unknown controller" if controller.nil?
|
|
143
|
-
|
|
144
|
-
controller.respond_to?(:name) && controller.name ? controller.name : controller.inspect
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ruact
|
|
4
|
-
module ServerFunctions
|
|
5
|
-
# Immutable record describing a single registered server function. Stored by
|
|
6
|
-
# {Ruact::ServerFunctions::Registry}; serialized into the JSON snapshot by
|
|
7
|
-
# {Ruact::ServerFunctions::Snapshot}.
|
|
8
|
-
#
|
|
9
|
-
# @!attribute [r] ruby_symbol
|
|
10
|
-
# @return [Symbol] the symbol the controller registered (e.g. `:create_post`).
|
|
11
|
-
# @!attribute [r] js_identifier
|
|
12
|
-
# @return [String] result of {Ruact::ServerFunctions::NameBridge.to_js_identifier}
|
|
13
|
-
# — cached at registration time so the snapshot writer never re-derives it.
|
|
14
|
-
# @!attribute [r] kind
|
|
15
|
-
# @return [Symbol] `:action` or `:query`. Informational at codegen time
|
|
16
|
-
# (Story 8.0 decision 2A-i: both kinds POST through the same accessor).
|
|
17
|
-
# @!attribute [r] controller
|
|
18
|
-
# @return [Class, nil] the controller class that registered the function;
|
|
19
|
-
# used for collision-error messages and downstream tooling. Nil is allowed
|
|
20
|
-
# for tests / Rails-console registrations.
|
|
21
|
-
# @!attribute [r] block
|
|
22
|
-
# @return [Proc, nil] the implementation block supplied by the controller
|
|
23
|
-
# macro. Story 8.0a stores it untouched; Stories 8.1 / 9.1 invoke it.
|
|
24
|
-
RegistryEntry = Data.define(:ruby_symbol, :js_identifier, :kind, :controller, :block)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ruact
|
|
4
|
-
module ServerFunctions
|
|
5
|
-
# Story 8.3 — per-dispatch execution context for a standalone server
|
|
6
|
-
# action. The dispatcher allocates a fresh instance per request,
|
|
7
|
-
# `instance_exec`s the action block against it, and discards the
|
|
8
|
-
# instance once the response is written.
|
|
9
|
-
#
|
|
10
|
-
# Exposes:
|
|
11
|
-
# - `params` — the action-call args, as `ActionController::Parameters`
|
|
12
|
-
# (same shape as the controller-hosted path from Story 8.1).
|
|
13
|
-
# - `request` — the live `ActionDispatch::Request`.
|
|
14
|
-
# - `session` — the host middleware's session.
|
|
15
|
-
# - `cookies` — the live `ActionDispatch::Cookies::CookieJar`.
|
|
16
|
-
# - `headers` — `request.headers`.
|
|
17
|
-
# - `current_user` — memoized; reads `request.env['ruact.current_user']`
|
|
18
|
-
# when present, otherwise invokes
|
|
19
|
-
# {Ruact::Configuration#current_user_resolver} (a lambda taking
|
|
20
|
-
# `request.env`). Raises {Ruact::CurrentUserNotConfiguredError} when
|
|
21
|
-
# neither path yields a value AND the block actually reads it.
|
|
22
|
-
#
|
|
23
|
-
# Does NOT expose `render` / `redirect_to` / `head` — those are
|
|
24
|
-
# controller-context methods. The block's return value IS the response;
|
|
25
|
-
# raise {Ruact::ActionError} for non-2xx returns.
|
|
26
|
-
class StandaloneContext
|
|
27
|
-
attr_reader :params, :request
|
|
28
|
-
|
|
29
|
-
# @param params [ActionController::Parameters] action-call args.
|
|
30
|
-
# @param request [ActionDispatch::Request] the live request.
|
|
31
|
-
def initialize(params:, request:)
|
|
32
|
-
@params = params
|
|
33
|
-
@request = request
|
|
34
|
-
@current_user_read = false
|
|
35
|
-
@current_user_memo = nil
|
|
36
|
-
@current_user_resolved = false
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def session
|
|
40
|
-
@request.session
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def cookies
|
|
44
|
-
@request.cookie_jar
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def headers
|
|
48
|
-
@request.headers
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Memoized current_user accessor. Sets a flag so the dispatcher can
|
|
52
|
-
# emit a dev-only warning when a block never reads `current_user`
|
|
53
|
-
# (Pitfall #4 in the story spec).
|
|
54
|
-
def current_user
|
|
55
|
-
@current_user_read = true
|
|
56
|
-
return @current_user_memo if @current_user_resolved
|
|
57
|
-
|
|
58
|
-
env = @request.env
|
|
59
|
-
if env.key?("ruact.current_user")
|
|
60
|
-
@current_user_memo = env["ruact.current_user"]
|
|
61
|
-
@current_user_resolved = true
|
|
62
|
-
return @current_user_memo
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
resolver = Ruact.config.current_user_resolver
|
|
66
|
-
raise Ruact::CurrentUserNotConfiguredError unless resolver
|
|
67
|
-
|
|
68
|
-
@current_user_memo = resolver.call(env)
|
|
69
|
-
@current_user_resolved = true
|
|
70
|
-
@current_user_memo
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# @api private — Pitfall #4 dev-mode warning flag.
|
|
74
|
-
def __ruact_current_user_read?
|
|
75
|
-
@current_user_read
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Inhibits accidental controller-context calls inside a standalone
|
|
79
|
-
# block. The error message names the supported alternatives so the
|
|
80
|
-
# developer can immediately fix the call.
|
|
81
|
-
def render(*_args, **_kwargs)
|
|
82
|
-
raise NoMethodError,
|
|
83
|
-
"StandaloneContext does not expose `render` — return a value from " \
|
|
84
|
-
"the block (it becomes the JSON response) or raise " \
|
|
85
|
-
"`Ruact::ActionError.new(status:, body:)` for non-2xx responses."
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def redirect_to(*_args, **_kwargs)
|
|
89
|
-
raise NoMethodError,
|
|
90
|
-
"StandaloneContext does not expose `redirect_to` — return a value " \
|
|
91
|
-
"from the block (it becomes the JSON response) or raise " \
|
|
92
|
-
"`Ruact::ActionError.new(status:, body:)` for non-2xx responses."
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def head(*_args, **_kwargs)
|
|
96
|
-
raise NoMethodError,
|
|
97
|
-
"StandaloneContext does not expose `head` — return `nil` to render " \
|
|
98
|
-
"204 No Content, or raise `Ruact::ActionError.new(status:, body:)` " \
|
|
99
|
-
"for other non-2xx responses."
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|