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
@@ -5,11 +5,16 @@ require "ruact/server_functions/name_bridge"
5
5
 
6
6
  module Ruact
7
7
  module ServerFunctions
8
- # Renders the snapshot Hash into the TypeScript module emitted to
9
- # `app/javascript/.ruact/server-functions.ts`. Pure string-building plus a
10
- # single write-if-changed call.
8
+ # Renders the route-driven (version-2) snapshot Hash into the TypeScript
9
+ # module emitted to `app/javascript/.ruact/server-functions.ts`. Pure
10
+ # string-building plus a single write-if-changed call.
11
11
  #
12
- # The output of {.render} MUST be byte-identical to the JS-side codegen in
12
+ # Story 9.9 the v1 (registry / `_makeRef`) render path was demolished;
13
+ # {.render} now dispatches only the version-2 (route-driven) shape. The
14
+ # actual rendering lives in the nested {V2} module (kept separate so the
15
+ # singleton class stays within its size budget).
16
+ #
17
+ # The output of {V2.render} MUST be byte-identical to the JS-side codegen in
13
18
  # `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`.
14
19
  # The cross-implementation parity test under
15
20
  # `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs`
@@ -22,9 +27,7 @@ module Ruact
22
27
 
23
28
  # Story 9.3 — the route-driven snapshot schema. A version-2 snapshot
24
29
  # carries route-derived entries (`http_method` + `path` + `segments`,
25
- # no `ruby_symbol`) and renders `_makeServerFunction(descriptor)` calls
26
- # instead of `_makeRef("<sym>")`. {.render} dispatches on `version` so
27
- # the v1 (registry-driven) path stays byte-for-byte untouched.
30
+ # no `ruby_symbol`) and renders `_makeServerFunction(descriptor)` calls.
28
31
  VERSION_V2 = 2
29
32
 
30
33
  RUNTIME_IMPORT = '"ruact/server-functions-runtime"'
@@ -45,13 +48,6 @@ module Ruact
45
48
  # against the codegen-emitted module — no call-site cast, no
46
49
  # wrapper closure.
47
50
  #
48
- # Runtime behavior is unchanged — `_makeRef` always resolves with the
49
- # JSON-decoded value (or `null` for empty bodies). The intersection is
50
- # a TYPE-ONLY surface: when callers `await` the result, they see
51
- # `Promise<unknown>`; when React invokes the function from a
52
- # `<form action>` prop, the `Promise<void>` overload is selected and
53
- # the return value is discarded by React.
54
- #
55
51
  # See the 2026-05-17 entry in `gem/docs/internal/decisions/server-functions-api.md`
56
52
  # ("R1 — intersection-type refinement") for the option (a)→(a′)
57
53
  # evolution and the empirical typecheck-probe that motivated it.
@@ -66,10 +62,27 @@ module Ruact
66
62
  # params) gets the param-accepting signature; one with no kwargs keeps
67
63
  # the bare {QUERY_SIGNATURE}. Queries are read-only (never reachable via
68
64
  # `<form action>`), so neither widens to the action intersection.
65
+ #
66
+ # Story 13.4 — {QUERY_PARAMS_SIGNATURE}'s open `Record<string, unknown>`
67
+ # is now the FALLBACK only: emitted for a pre-13.4 snapshot entry that
68
+ # carries the `accepts_params` boolean but no structured `params`, and for
69
+ # a `**keyrest`-only query (open by design). A query whose entry carries
70
+ # the structured `params` metadata gets a typed object literal built from
71
+ # the declared keys instead (see {V2.render_query_export}).
69
72
  QUERY_PARAMS_SIGNATURE = "(params: Record<string, unknown>) => Promise<unknown>"
70
73
 
74
+ # Story 13.4 — the VALUE type of every typed query param. `Method#parameters`
75
+ # exposes only names + required/optional (never types or default values),
76
+ # so per-param scalar precision is not reflection-honest; this is the exact
77
+ # FR88 query-string wire contract instead (the kwargs sanitizer in
78
+ # `query_dispatch.rb` accepts only these). Keys and optionality ARE exact,
79
+ # which is what kills the `Record<string, unknown>`/`any` gap (named keys,
80
+ # missing-required and unknown-key become compile errors). Per-param scalar
81
+ # narrowing is deferred to a future explicit param-type DSL.
82
+ QUERY_PARAM_VALUE_TYPE = "string | number | boolean | null"
83
+
71
84
  # Story 8.2 — fixed re-export appended AFTER the per-function block.
72
- # Emitted in BOTH branches (empty + populated registry) so
85
+ # Emitted in BOTH branches (empty + populated) so
73
86
  # `import { revalidate } from "@/.ruact/server-functions"` works on
74
87
  # day one of any host app. Ruby + JS codegens emit byte-identically.
75
88
  REVALIDATE_REEXPORT = "export { revalidate } from #{RUNTIME_IMPORT};\n".freeze
@@ -86,8 +99,7 @@ module Ruact
86
99
  # in JS-identifier terms (leading letter / underscore / `$`, then alnum
87
100
  # / underscore / `$`). The codegen validates every entry it consumes
88
101
  # because the JSON bridge is a trust boundary — a malformed snapshot
89
- # (`functions[].js_identifier == ");\nevil();_makeRef("foo`) would
90
- # otherwise inject TS at module top level.
102
+ # would otherwise inject TS at module top level.
91
103
  VALID_JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
92
104
 
93
105
  ALLOWED_KINDS = %w[action query].freeze
@@ -95,24 +107,21 @@ module Ruact
95
107
  # JS comments (both `//` line comments and `/* … */` block comments via
96
108
  # the spec's LineTerminator production) end on LF, CR, U+2028, and U+2029.
97
109
  # A snapshot value that smuggles any of these would break out of the
98
- # leading comment header in the emitted module. The reviewer's Pass-2
99
- # finding noted that an earlier `/[\r\n]/` guard missed the two Unicode
100
- # line separators; the regex is widened here and a parity test in
101
- # `server-functions-codegen.test.mjs` keeps both renderers in sync.
102
- LINE_TERMINATORS = /[\r\n

]/
110
+ # leading comment header in the emitted module. The regex covers both
111
+ # Unicode line separators (written as explicit escapes); a parity test
112
+ # keeps both renderers in sync.
113
+ LINE_TERMINATORS = /[\r\n\u2028\u2029]/
103
114
 
104
115
  class << self
105
- # Renders +snapshot+ into the TS module text. Pure; no I/O.
116
+ # Renders +snapshot+ into the TS module text. Pure; no I/O. Story 9.9 —
117
+ # only the route-driven (version-2) shape is supported.
106
118
  #
107
- # @param snapshot [Hash] result of {Ruact::ServerFunctions::Snapshot.dump};
108
- # must contain `:version`, `:generated_at`, `:functions` (with string-keyed
109
- # entries).
119
+ # @param snapshot [Hash] result of {Snapshot.dump_v2}; must contain
120
+ # `:version`, `:generated_at`, `:functions` (string-keyed entries).
110
121
  # @return [String] TS module bytes, terminated by a single trailing newline.
111
- # @raise [Ruact::ConfigurationError] when an entry fails any of the
112
- # snapshot-trust-boundary guards (line-break in version /
113
- # generated_at, invalid identifier shape, reserved JS word, kind
114
- # outside {ALLOWED_KINDS}, or duplicate `js_identifier` — mirror of
115
- # the JS-side `validateSnapshot` per the 2026-05-14 Re-run patch).
122
+ # @raise [Ruact::ConfigurationError] on a non-Hash snapshot, a missing
123
+ # required key, a non-v2 `version`, or any trust-boundary violation in
124
+ # {V2.render}.
116
125
  def render(snapshot)
117
126
  unless snapshot.is_a?(Hash)
118
127
  raise Ruact::ConfigurationError,
@@ -125,41 +134,15 @@ module Ruact
125
134
 
126
135
  validate_metadata!(version, generated_at)
127
136
 
128
- return V2.render(version, generated_at, functions) if version.to_s == VERSION_V2.to_s
129
-
130
- validate_functions!(functions)
131
-
132
- io = +""
133
- io << "// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.\n"
134
- io << "// Source: tmp/cache/ruact/server-functions.json (version #{version})\n"
135
- io << "// Generated at: #{generated_at}\n"
136
- io << "import { _makeRef } from #{RUNTIME_IMPORT};\n"
137
-
138
- if functions.empty?
139
- io << "\n// (no server functions registered yet — Stories 8.1 / 9.1 populate)\n"
140
- # `noUnusedLocals` would otherwise flag the `_makeRef` import. The
141
- # `void` discard pattern keeps the import alive at zero runtime
142
- # cost; once an action/query is registered the export below
143
- # references `_makeRef` directly and this line is omitted.
144
- io << "void _makeRef;\n"
145
- else
146
- io << "\n"
147
- functions.each do |entry|
148
- io << render_export(entry)
149
- end
137
+ unless version.to_s == VERSION_V2.to_s
138
+ raise Ruact::ConfigurationError,
139
+ "ruact server-function codegen: unsupported snapshot version " \
140
+ "#{version.inspect} (only the route-driven version #{VERSION_V2} is " \
141
+ "supported as of Story 9.9); the bridge JSON is corrupted — " \
142
+ "regenerate via `bin/rails ruact:server_functions:generate`."
150
143
  end
151
144
 
152
- # Story 8.2 — `revalidate()` is always available, so the
153
- # re-export lands in both branches (empty registry + populated).
154
- # The codegen owns the canonical import path
155
- # `@/.ruact/server-functions` and is the only stable surface devs
156
- # are told to import from in the docs (per the Story 8.0 ADR);
157
- # without this line, devs would need a second import statement
158
- # from a less-stable runtime-package path.
159
- io << "\n"
160
- io << REVALIDATE_REEXPORT
161
-
162
- io
145
+ V2.render(version, generated_at, functions)
163
146
  end
164
147
 
165
148
  # Writes the rendered TS module to +output_path+, only if it changed.
@@ -174,19 +157,8 @@ module Ruact
174
157
 
175
158
  private
176
159
 
177
- def render_export(entry)
178
- js_id = entry["js_identifier"] || entry[:js_identifier]
179
- kind = (entry["kind"] || entry[:kind]).to_s
180
- ruby_sym = entry["ruby_symbol"] || entry[:ruby_symbol]
181
-
182
- signature = kind == "query" ? QUERY_SIGNATURE : ACTION_SIGNATURE
183
-
184
- "export const #{js_id}: #{signature} =\n _makeRef(#{json_escape(ruby_sym.to_s)});\n"
185
- end
186
-
187
- # Pass-2 patch 2026-05-14 — wrap raw `KeyError` from `Hash#fetch` so
188
- # the rake / Railtie call sites get a consistent `Ruact::ConfigurationError`
189
- # for every snapshot-shape failure, not a mixture of error classes.
160
+ # Wraps raw `KeyError` from `Hash#fetch` so call sites get a consistent
161
+ # `Ruact::ConfigurationError` for every snapshot-shape failure.
190
162
  def fetch_snapshot_key!(snapshot, sym_key, str_key)
191
163
  return snapshot[sym_key] if snapshot.key?(sym_key)
192
164
  return snapshot[str_key] if snapshot.key?(str_key)
@@ -197,10 +169,6 @@ module Ruact
197
169
  "corrupted — regenerate via `bin/rails ruact:server_functions:generate`."
198
170
  end
199
171
 
200
- # Mirror of the JS-side `validateSnapshot` (2026-05-14 Re-run parity
201
- # fix). The Ruby renderer also reads from the on-disk JSON bridge in
202
- # the rake-task and Railtie paths, so the same trust-boundary guards
203
- # belong here.
204
172
  def validate_metadata!(version, generated_at)
205
173
  unless version.is_a?(Integer) || version.is_a?(String)
206
174
  raise Ruact::ConfigurationError,
@@ -226,113 +194,6 @@ module Ruact
226
194
  "a line break (LF, CR, U+2028, or U+2029) — would break out of " \
227
195
  "the header comment; snapshot JSON is corrupted."
228
196
  end
229
-
230
- def validate_functions!(functions)
231
- unless functions.is_a?(Array)
232
- raise Ruact::ConfigurationError,
233
- "ruact server-function codegen: snapshot.functions must be an " \
234
- "Array, got #{functions.class}"
235
- end
236
-
237
- seen = {}
238
- functions.each do |entry|
239
- unless entry.is_a?(Hash)
240
- raise Ruact::ConfigurationError,
241
- "ruact server-function codegen: snapshot.functions entry is " \
242
- "not a Hash: #{entry.inspect}"
243
- end
244
- js_id = entry["js_identifier"] || entry[:js_identifier]
245
- kind = (entry["kind"] || entry[:kind]).to_s
246
- ruby_sym = entry["ruby_symbol"] || entry[:ruby_symbol]
247
-
248
- validate_ruby_symbol!(ruby_sym)
249
- validate_js_identifier!(js_id, ruby_sym)
250
- validate_kind!(kind, ruby_sym)
251
- validate_not_reserved!(js_id, ruby_sym)
252
- validate_no_duplicate!(seen, js_id)
253
- seen[js_id] = true
254
- end
255
- end
256
-
257
- # Pass-2 patch 2026-05-14 — without this guard, a missing or empty
258
- # `ruby_symbol` on a snapshot entry would render `_makeRef("")` and
259
- # silently emit an export that can never resolve at runtime (the
260
- # placeholder rejects on call but the empty string is a meaningless
261
- # registration name). Treat as a corrupt-snapshot signal.
262
- def validate_ruby_symbol!(ruby_sym)
263
- return if ruby_sym.is_a?(String) && !ruby_sym.empty?
264
- return if ruby_sym.is_a?(Symbol) && !ruby_sym.empty?
265
-
266
- raise Ruact::ConfigurationError,
267
- "ruact server-function codegen: snapshot.functions entry has " \
268
- "missing or empty ruby_symbol (got #{ruby_sym.inspect}); the " \
269
- "bridge JSON is corrupted — regenerate via " \
270
- "`bin/rails ruact:server_functions:generate`."
271
- end
272
-
273
- def validate_js_identifier!(js_id, ruby_sym)
274
- return if js_id.is_a?(String) && js_id.match?(VALID_JS_IDENTIFIER)
275
-
276
- raise Ruact::ConfigurationError,
277
- "ruact server-function codegen rejected a snapshot entry: " \
278
- "ruby_symbol=#{ruby_sym.inspect} js_identifier=#{js_id.inspect} " \
279
- "is not a valid JS identifier (must match #{VALID_JS_IDENTIFIER.inspect}). " \
280
- "The snapshot JSON is corrupted or was hand-edited — regenerate via " \
281
- "`bin/rails ruact:server_functions:generate`."
282
- end
283
-
284
- def validate_kind!(kind, ruby_sym)
285
- return if ALLOWED_KINDS.include?(kind)
286
-
287
- raise Ruact::ConfigurationError,
288
- "ruact server-function codegen: snapshot.functions entry has " \
289
- "invalid kind #{kind.inspect} (must be \"action\" or \"query\") " \
290
- "for ruby_symbol=#{ruby_sym.inspect}"
291
- end
292
-
293
- def validate_not_reserved!(js_id, ruby_sym)
294
- if NameBridge::RESERVED_JS_IDENTIFIERS.include?(js_id)
295
- raise Ruact::ConfigurationError,
296
- "ruact server-function codegen: js_identifier #{js_id.inspect} " \
297
- "is a reserved JS word — ruby_symbol=#{ruby_sym.inspect} would " \
298
- "emit an invalid TS module. NameBridge should have rejected this; " \
299
- "regenerate via `bin/rails ruact:server_functions:generate`."
300
- end
301
-
302
- # Story 8.2 R12 — even if NameBridge somehow lets a reserved
303
- # ruact name through (e.g. a hand-edited bridge JSON), the
304
- # codegen MUST refuse — otherwise the rendered module would
305
- # bind `revalidate` / `_makeRef` twice (once via the
306
- # re-export / import, once via the action `export const`)
307
- # and crash at module load.
308
- return unless NameBridge::RESERVED_BY_RUACT.include?(js_id)
309
-
310
- raise Ruact::ConfigurationError,
311
- "ruact server-function codegen: js_identifier #{js_id.inspect} " \
312
- "is reserved by the ruact runtime/codegen surface (would clash " \
313
- "with the module's `revalidate` re-export or `_makeRef` import) — " \
314
- "ruby_symbol=#{ruby_sym.inspect} cannot be exported. NameBridge " \
315
- "should have rejected this; regenerate via " \
316
- "`bin/rails ruact:server_functions:generate`."
317
- end
318
-
319
- def validate_no_duplicate!(seen, js_id)
320
- return unless seen.key?(js_id)
321
-
322
- raise Ruact::ConfigurationError,
323
- "ruact server-function codegen: duplicate js_identifier " \
324
- "#{js_id.inspect} in snapshot — two entries would emit " \
325
- "conflicting `export const` declarations. The snapshot JSON is " \
326
- "corrupted or was hand-edited — regenerate via " \
327
- "`bin/rails ruact:server_functions:generate`."
328
- end
329
-
330
- # Wraps `ruby_symbol` in a JSON-escaped string literal so backslashes,
331
- # double quotes, and control characters cannot break out of the
332
- # `_makeRef("<here>")` argument.
333
- def json_escape(str)
334
- JSON.dump(str)
335
- end
336
197
  end
337
198
  end
338
199
  end
@@ -25,8 +25,8 @@ module Ruact
25
25
 
26
26
  # Story 9.5 — the verb a v2 QUERY entry may carry. Queries are reads,
27
27
  # mounted by {Ruact::Routing#ruact_queries} as named GET routes; the
28
- # 2026-06-02 ADR addendum voided the old `POST /__ruact/fn/:id` query
29
- # mechanism, so a query entry is GET-only.
28
+ # 2026-06-02 ADR addendum restored HTTP GET semantics for queries, so a
29
+ # query entry is GET-only.
30
30
  QUERY_HTTP_METHODS = %w[GET].to_set.freeze
31
31
 
32
32
  class << self
@@ -87,13 +87,19 @@ module Ruact
87
87
 
88
88
  # Story 9.5 — a query export binds a `_makeQuery` accessor carrying
89
89
  # its GET descriptor `{ path, kind: "query" }`. `useQuery(<id>, …)`
90
- # consumes it (the SUPERSEDED `POST /__ruact/fn/:id` mechanism is
91
- # gone reads go to `GET /q/<jsId>`). The signature accepts params
92
- # only when the query method declares kwargs (FR88).
90
+ # consumes it reads go to `GET /q/<jsId>`. The signature accepts
91
+ # params only when the query method declares kwargs (FR88).
93
92
  def render_query_export(entry)
94
93
  js_id = fetch(entry, "js_identifier")
95
94
  path = fetch(entry, "path")
96
- signature = fetch(entry, "accepts_params") ? QUERY_PARAMS_SIGNATURE : QUERY_SIGNATURE
95
+ # Story 13.4 the typed `params` signature (or the back-compat
96
+ # fallback) is built by {QueryParams}, kept separate so this
97
+ # singleton class stays within its size budget.
98
+ signature = QueryParams.signature(
99
+ params: fetch(entry, "params"),
100
+ params_rest: fetch(entry, "params_rest"),
101
+ accepts_params: fetch(entry, "accepts_params")
102
+ )
97
103
 
98
104
  descriptor = "{ path: #{JSON.dump(path)}, kind: \"query\" }"
99
105
  "export const #{js_id}: #{signature} =\n _makeQuery(#{descriptor});\n"
@@ -123,6 +129,11 @@ module Ruact
123
129
  path = fetch(entry, "path")
124
130
  validate_path!(js_id, path)
125
131
  validate_segments!(js_id, path, fetch(entry, "segments"))
132
+ # Story 13.4 — the structured query `params` metadata is a trust
133
+ # boundary (it becomes TS object keys); {QueryParams} rejects a
134
+ # malformed/corrupted snapshot rather than emit injected — or
135
+ # silently over-widened — TS.
136
+ QueryParams.validate!(js_id, fetch(entry, "params"), fetch(entry, "params_rest")) if kind == "query"
126
137
  seen[js_id] = true
127
138
  end
128
139
 
@@ -210,3 +221,9 @@ module Ruact
210
221
  end
211
222
  end
212
223
  end
224
+
225
+ # Story 13.4 — the typed query `params` signature builder + its trust-boundary
226
+ # validation, kept in their own module so {V2}'s singleton class stays within
227
+ # its size budget (same split rationale as V2 itself vs {Codegen}). Required
228
+ # after V2 + the signature constants are defined; consumed by V2 above.
229
+ require_relative "codegen_v2_query_params"
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ruact
6
+ module ServerFunctions
7
+ module Codegen
8
+ module V2
9
+ # Story 13.4 (FR99) — builds the TYPED query accessor `params` signature
10
+ # from the per-kwarg metadata {QuerySource} derives, and validates that
11
+ # metadata as a trust boundary. Extracted from {V2} for the same reason
12
+ # {V2} was extracted from {Codegen}: keep each singleton class within its
13
+ # size budget. Constants ({QUERY_SIGNATURE}, {QUERY_PARAMS_SIGNATURE},
14
+ # {QUERY_PARAM_VALUE_TYPE}, {VALID_JS_IDENTIFIER}, {LINE_TERMINATORS}) are
15
+ # reached from {Codegen} via lexical scope.
16
+ #
17
+ # MUST stay byte-identical to the JS-side `querySignatureV2` /
18
+ # `validateQueryParams` in
19
+ # `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`.
20
+ module QueryParams
21
+ class << self
22
+ # Picks the query accessor's `params` signature.
23
+ #
24
+ # When +params+ is an Array (the Story 13.4 structured metadata),
25
+ # build a typed object literal — one property per declared keyword,
26
+ # required/optional exact, value type the FR88 wire union (AC1).
27
+ # Empty params + no `**keyrest` → the bare {QUERY_SIGNATURE}
28
+ # (byte-identical to a no-kwargs query). A `**keyrest` keeps the open
29
+ # `Record<string, unknown>` (intersected with any named keys) so a
30
+ # legitimate dynamic key is never narrowed away (fail open).
31
+ #
32
+ # Falls back to the pre-13.4 +accepts_params+ boolean when no
33
+ # +params+ metadata is present (an older or hand-written snapshot)
34
+ # so no consumer breaks.
35
+ #
36
+ # @param params [Array<Hash>, nil] per-kwarg `{ "name", "required" }`.
37
+ # @param params_rest [Boolean, nil] whether a `**keyrest` is declared.
38
+ # @param accepts_params [Boolean, nil] the back-compat fallback flag.
39
+ # @return [String] the TS signature.
40
+ def signature(params:, params_rest:, accepts_params:)
41
+ return fallback(accepts_params) unless params.is_a?(Array)
42
+
43
+ rest = params_rest ? true : false
44
+ return QUERY_SIGNATURE if params.empty? && !rest
45
+
46
+ build(params, rest)
47
+ end
48
+
49
+ # Validates the structured `params` metadata. Reject a malformed or
50
+ # corrupted snapshot loudly rather than emit broken — or silently
51
+ # over-widened — TS. Absent `params`/`params_rest` is valid (the
52
+ # {signature} fallback handles it).
53
+ #
54
+ # @param js_id [String]
55
+ # @param params [Array<Hash>, nil]
56
+ # @param params_rest [Object, nil] expected Boolean or nil; a
57
+ # non-Boolean truthy value would otherwise silently flip a query to
58
+ # the open `& Record<string, unknown>` shape (Story 13.4 review R1).
59
+ # @raise [Ruact::ConfigurationError]
60
+ def validate!(js_id, params, params_rest = nil)
61
+ validate_rest!(js_id, params_rest)
62
+ return if params.nil?
63
+
64
+ unless params.is_a?(Array)
65
+ raise Ruact::ConfigurationError,
66
+ "ruact server-function codegen: v2 query entry #{js_id.inspect} has invalid " \
67
+ "params #{params.inspect} (must be an Array of { name, required } objects)."
68
+ end
69
+ params.each { |param| validate_entry!(js_id, param) }
70
+ end
71
+
72
+ private
73
+
74
+ def validate_rest!(js_id, params_rest)
75
+ return if params_rest.nil? || params_rest == true || params_rest == false
76
+
77
+ raise Ruact::ConfigurationError,
78
+ "ruact server-function codegen: v2 query entry #{js_id.inspect} has a non-Boolean " \
79
+ "params_rest #{params_rest.inspect}; snapshot JSON is corrupted."
80
+ end
81
+
82
+ def fallback(accepts_params)
83
+ accepts_params ? QUERY_PARAMS_SIGNATURE : QUERY_SIGNATURE
84
+ end
85
+
86
+ # Builds `(params: { q: <UNION>; limit?: <UNION> }) => Promise<unknown>`
87
+ # in declaration order. A `**keyrest`-only query (no named keys)
88
+ # reuses the open {QUERY_PARAMS_SIGNATURE}; with named keys present
89
+ # the rest is an intersection so the named keys still autocomplete.
90
+ def build(params, rest)
91
+ props = params.map do |param|
92
+ name = param["name"] || param[:name]
93
+ required = param.key?("required") ? param["required"] : param[:required]
94
+ optional = required ? "" : "?"
95
+ "#{format_key(name)}#{optional}: #{QUERY_PARAM_VALUE_TYPE}"
96
+ end
97
+
98
+ return QUERY_PARAMS_SIGNATURE if props.empty? # keyrest-only
99
+
100
+ object = "{ #{props.join('; ')} }"
101
+ object += " & Record<string, unknown>" if rest
102
+ "(params: #{object}) => Promise<unknown>"
103
+ end
104
+
105
+ # Kwarg names are Ruby symbols → almost always valid bare TS keys;
106
+ # quote (via JSON) any that are not so a corrupted snapshot cannot
107
+ # break out of the object literal. Mirrors the JS `formatParamKey`.
108
+ def format_key(name)
109
+ name.to_s.match?(VALID_JS_IDENTIFIER) ? name.to_s : JSON.dump(name.to_s)
110
+ end
111
+
112
+ def validate_entry!(js_id, param)
113
+ unless param.is_a?(Hash)
114
+ raise Ruact::ConfigurationError,
115
+ "ruact server-function codegen: v2 query entry #{js_id.inspect} has a params " \
116
+ "element that is not an object: #{param.inspect}; snapshot JSON is corrupted."
117
+ end
118
+
119
+ name = param["name"] || param[:name]
120
+ unless name.is_a?(String) && !name.empty? && !name.match?(LINE_TERMINATORS)
121
+ raise Ruact::ConfigurationError,
122
+ "ruact server-function codegen: v2 query entry #{js_id.inspect} has a params " \
123
+ "name #{name.inspect} that is not a non-empty single-line String; snapshot JSON is corrupted."
124
+ end
125
+
126
+ # Presence-aware (a plain `||` would mis-read a legitimate
127
+ # `required: false` as the absent key).
128
+ required = param.key?("required") ? param["required"] : param[:required]
129
+ return if [true, false].include?(required)
130
+
131
+ raise Ruact::ConfigurationError,
132
+ "ruact server-function codegen: v2 query entry #{js_id.inspect} params name " \
133
+ "#{name.inspect} has a non-Boolean `required` #{required.inspect}; snapshot JSON is corrupted."
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -6,7 +6,7 @@ require_relative "error_suggestion"
6
6
  module Ruact
7
7
  module ServerFunctions
8
8
  # Story 8.4 — Builds the structured JSON body returned by
9
- # {EndpointController#__ruact_render_action_error} for any server-action
9
+ # {ErrorRendering#__ruact_render_action_error} for any server-action
10
10
  # exception that bubbles past a host's `rescue_from` chain.
11
11
  #
12
12
  # The function is pure (no `Rails.env`, no `Ruact.config` reads) — the
@@ -4,33 +4,23 @@ require_relative "error_payload"
4
4
 
5
5
  module Ruact
6
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:
7
+ # Story 9.1 — the shared core for the Story 8.4 structured-error rendering
8
+ # and the Story 8.5 upload guard, included by {Ruact::Server} (the
9
+ # route-driven concern hosts include). Story 9.9 demolished the v1 endpoint
10
+ # that previously shared this code, so {Ruact::Server} is now the sole home.
11
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:
12
+ # Behavioral specialization is expressed through three private hooks, which
13
+ # the including controller may override:
20
14
  #
21
15
  # - {#__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.
16
+ # comes from. Default: the controller's own `action_name`.
25
17
  # - {#__ruact_render_structured_error?} — whether the rescue handler
26
18
  # 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.
19
+ # Rails' default error handling proceeds. {Ruact::Server} gates this on
20
+ # its function-call predicate.
21
+ # - {#__ruact_upload_guard_applicable?} whether the upload guard applies
22
+ # to this request at all. {Ruact::Server} skips GET/HEAD so page actions
23
+ # stay byte-for-byte untouched.
34
24
  #
35
25
  # All methods are private on the including controller; nothing here is
36
26
  # public API surface.
@@ -98,28 +88,31 @@ module Ruact
98
88
  end
99
89
 
100
90
  # Hook — where the structured payload's `action_name` field comes from.
101
- # The controller's own `action_name` is correct for v2 host controllers
91
+ # The controller's own `action_name` is correct for host controllers
102
92
  # (it is populated by routing before any callback runs, including the
103
93
  # prepended upload guard — no early-rejection fallback dance needed).
104
94
  def __ruact_error_action_name
105
95
  action_name
106
96
  end
107
97
 
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).
98
+ # Hook — render the structured payload for this request? The default is
99
+ # "always"; {Ruact::Server} overrides it to gate on its function-call
100
+ # predicate (page/Flight requests re-raise so Rails' default error
101
+ # handling proceeds untouched).
111
102
  def __ruact_render_structured_error?(_error)
112
103
  true
113
104
  end
114
105
 
115
- # Hook — does the upload guard apply to this request? The v1 endpoint's
116
- # answer is "always" (the route is POST-only).
106
+ # Hook — does the upload guard apply to this request? The default is
107
+ # "always"; {Ruact::Server} overrides it to skip GET/HEAD.
117
108
  def __ruact_upload_guard_applicable?
118
109
  true
119
110
  end
120
111
 
121
112
  # Story 8.4 — Status mapping per AC1:
122
113
  # - `Ruact::BadRequestError` → 400 (Story 9.5 — FR88 kwargs rejection)
114
+ # - `Ruact::InvalidSignedGlobalIDError` → 400 (Story 13.2 — FR96 forged/
115
+ # expired/wrong-purpose signed reference; an invalid client credential)
123
116
  # - `ActiveRecord::RecordInvalid` → 422
124
117
  # - `ActionController::InvalidAuthenticityToken` → 403
125
118
  # - `Ruact::UploadTooLargeError` → 413
@@ -128,7 +121,7 @@ module Ruact
128
121
  # at load time (parity with {ErrorSuggestion}).
129
122
  def __ruact_status_for(error)
130
123
  case error.class.name
131
- when "Ruact::BadRequestError" then 400
124
+ when "Ruact::BadRequestError", "Ruact::InvalidSignedGlobalIDError" then 400
132
125
  when "ActiveRecord::RecordInvalid" then 422
133
126
  when "ActionController::InvalidAuthenticityToken" then 403
134
127
  when "Ruact::UploadTooLargeError" then 413