ruact 0.0.5 → 0.0.6

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/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. 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