ruact 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../serializable"
5
+ require_relative "../errors"
6
+
7
+ module Ruact
8
+ module ServerFunctions
9
+ # Story 9.2 — pure serializer for the Bucket-2 (imperative `await fn()`)
10
+ # response body. Takes the host action's exposed instance variables (Rails
11
+ # `view_assigns`, resolved by the caller) and produces a JSON-ready Ruby
12
+ # Hash, keyed by ivar name, applying the SAME prop-exposure policy as the
13
+ # Flight serializer ({Ruact::Flight::Serializer#serialize_unknown}):
14
+ #
15
+ # - {Ruact::Serializable} values expose ONLY their `ruact_props` (secrets
16
+ # never leak), recursing into nested Serializables / collections.
17
+ # - Under `strict_serialization`, a non-Serializable domain object raises
18
+ # {Ruact::SerializationError} (no accidental full-record dump).
19
+ # - Otherwise a vetted `as_json` fallback applies (guards against
20
+ # `as_json` returning self / raising).
21
+ #
22
+ # Unlike the Flight serializer this produces PLAIN JSON-ready values (Hash /
23
+ # Array / scalar) — `render json:` does the final encoding, so JSON
24
+ # primitives (incl. Time/Date) pass through untouched rather than being
25
+ # Flight-encoded.
26
+ #
27
+ # Pure — no Rails / request / `Ruact.config` reads. The caller resolves the
28
+ # exposed-ivar set and the `strict` flag (mirroring the {ErrorPayload}
29
+ # caller/builder split, NFR26 / AC8).
30
+ module BucketTwoPayload
31
+ # JSON scalar + date/time primitives pass through untouched (Rails'
32
+ # `render json:` renders them — e.g. Time → ISO8601). They are NOT
33
+ # subject to the strict prop-exposure policy, matching the Flight
34
+ # serializer's primitive handling.
35
+ PRIMITIVES = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Time, Date, DateTime].freeze
36
+ private_constant :PRIMITIVES
37
+
38
+ class << self
39
+ # @param assigns [Hash] exposed-ivar name (String, no `@`) => value
40
+ # @param strict [Boolean] the resolved `strict_serialization` mode
41
+ # @return [Hash{String=>Object}] JSON-ready hash keyed by ivar name
42
+ # @raise [Ruact::SerializationError] per the strict policy
43
+ def build(assigns, strict:)
44
+ assigns.to_h { |name, value| [name.to_s, serialize(value, strict)] }
45
+ end
46
+
47
+ # Story 9.4 (D6) — the per-value branch of the policy, extracted so the
48
+ # query dispatch controller serializes a method's single RETURN VALUE
49
+ # (Array / Hash / scalar / Serializable / nil) through the exact rules
50
+ # {.build} applies to each exposed ivar. One policy, two callers.
51
+ #
52
+ # @param value [Object] the query method's return value
53
+ # @param strict [Boolean] the resolved `strict_serialization` mode
54
+ # @return [Object] JSON-ready value (`nil` stays `nil` → JSON `null`)
55
+ # @raise [Ruact::SerializationError] per the strict policy
56
+ def serialize_value(value, strict:)
57
+ serialize(value, strict)
58
+ end
59
+
60
+ private
61
+
62
+ def serialize(value, strict)
63
+ case value
64
+ when *PRIMITIVES then value
65
+ when Hash then value.to_h { |k, v| [k.to_s, serialize(v, strict)] }
66
+ when Array then value.map { |v| serialize(v, strict) }
67
+ when Ruact::Serializable then serialize(value.ruact_serialize, strict)
68
+ else serialize_object(value, strict)
69
+ end
70
+ end
71
+
72
+ # Non-Serializable, non-primitive object: mirror
73
+ # Flight::Serializer#serialize_unknown's strict/as_json policy.
74
+ def serialize_object(value, strict)
75
+ unless value.respond_to?(:as_json)
76
+ raise Ruact::SerializationError,
77
+ "Cannot serialize #{value.class.name} — include Ruact::Serializable"
78
+ end
79
+
80
+ if strict
81
+ raise Ruact::SerializationError,
82
+ "Cannot serialize #{value.class.name} — " \
83
+ "include Ruact::Serializable or set strict_serialization: false"
84
+ end
85
+
86
+ serialize(as_json_value(value), strict)
87
+ end
88
+
89
+ def as_json_value(value)
90
+ data =
91
+ begin
92
+ value.as_json
93
+ rescue StandardError => e
94
+ raise Ruact::SerializationError,
95
+ "#{value.class.name}#as_json raised #{e.class}: #{e.message}"
96
+ end
97
+
98
+ if data.equal?(value)
99
+ raise Ruact::SerializationError,
100
+ "#{value.class.name}#as_json returned self — would cause infinite recursion. " \
101
+ "Include Ruact::Serializable and declare ruact_props instead"
102
+ end
103
+
104
+ data
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruact/server_functions/name_bridge"
5
+
6
+ module Ruact
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.
11
+ #
12
+ # The output of {.render} MUST be byte-identical to the JS-side codegen in
13
+ # `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`.
14
+ # The cross-implementation parity test under
15
+ # `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs`
16
+ # asserts this invariant; if it fails, fix the offending side rather than
17
+ # normalizing in the assertion (Story 8.0a Task 8.5).
18
+ module Codegen
19
+ # Bumped only when the rendered shape changes. Used by tests to assert
20
+ # cross-implementation parity without coupling to the literal byte string.
21
+ VERSION = 1
22
+
23
+ # Story 9.3 — the route-driven snapshot schema. A version-2 snapshot
24
+ # 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.
28
+ VERSION_V2 = 2
29
+
30
+ RUNTIME_IMPORT = '"ruact/server-functions-runtime"'
31
+
32
+ # Story 8.2 (2026-05-16, refined 2026-05-17 per review patch R1) —
33
+ # ACTION_SIGNATURE is a TS intersection type with TWO call signatures:
34
+ #
35
+ # 1. `(args?: FormData | Record<string, unknown>) => Promise<unknown>`
36
+ # — for direct callers (`await createPost({...})` /
37
+ # `await createPost(formData)` / event handlers), preserving the
38
+ # JSON-decoded response value.
39
+ # 2. `(formData: FormData) => Promise<void>` — assignable to
40
+ # `@types/react@19.x`'s `<form action>` prop, which is typed as
41
+ # `(formData: FormData) => void | Promise<void>`. TS rejects
42
+ # `Promise<unknown>` → `Promise<void>` even via the void-discard
43
+ # rule (Promise generics are invariant), so the intersection is
44
+ # required to make `<form action={createPost}>` typecheck DIRECTLY
45
+ # against the codegen-emitted module — no call-site cast, no
46
+ # wrapper closure.
47
+ #
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
+ # See the 2026-05-17 entry in `gem/docs/internal/decisions/server-functions-api.md`
56
+ # ("R1 — intersection-type refinement") for the option (a)→(a′)
57
+ # evolution and the empirical typecheck-probe that motivated it.
58
+ # Query signatures stay narrow because queries are never reachable via
59
+ # `<form action>` (read-only via `useQuery`).
60
+ ACTION_SIGNATURE =
61
+ "((args?: FormData | Record<string, unknown>) => Promise<unknown>) " \
62
+ "& ((formData: FormData) => Promise<void>)"
63
+ QUERY_SIGNATURE = "() => Promise<unknown>"
64
+
65
+ # Story 9.5 — a query method that declares keyword arguments (FR88
66
+ # params) gets the param-accepting signature; one with no kwargs keeps
67
+ # the bare {QUERY_SIGNATURE}. Queries are read-only (never reachable via
68
+ # `<form action>`), so neither widens to the action intersection.
69
+ QUERY_PARAMS_SIGNATURE = "(params: Record<string, unknown>) => Promise<unknown>"
70
+
71
+ # Story 8.2 — fixed re-export appended AFTER the per-function block.
72
+ # Emitted in BOTH branches (empty + populated registry) so
73
+ # `import { revalidate } from "@/.ruact/server-functions"` works on
74
+ # day one of any host app. Ruby + JS codegens emit byte-identically.
75
+ REVALIDATE_REEXPORT = "export { revalidate } from #{RUNTIME_IMPORT};\n".freeze
76
+
77
+ # Story 9.5 — the `useQuery` hook re-export, appended (after
78
+ # {REVALIDATE_REEXPORT}) ONLY when the v2 snapshot carries query entries.
79
+ # Gating on query presence keeps the action-only and empty v2 modules
80
+ # byte-identical to their Story 9.3 output (minimal churn); a host that
81
+ # has no queries cannot call `useQuery` on anything anyway. Ruby + JS
82
+ # codegens emit this byte-identically.
83
+ USEQUERY_REEXPORT = "export { useQuery } from #{RUNTIME_IMPORT};\n".freeze
84
+
85
+ # JS identifier shape — same as `NameBridge::VALID_SYMBOL` but expressed
86
+ # in JS-identifier terms (leading letter / underscore / `$`, then alnum
87
+ # / underscore / `$`). The codegen validates every entry it consumes
88
+ # 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.
91
+ VALID_JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
92
+
93
+ ALLOWED_KINDS = %w[action query].freeze
94
+
95
+ # JS comments (both `//` line comments and `/* … */` block comments via
96
+ # the spec's LineTerminator production) end on LF, CR, U+2028, and U+2029.
97
+ # 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

]/
103
+
104
+ class << self
105
+ # Renders +snapshot+ into the TS module text. Pure; no I/O.
106
+ #
107
+ # @param snapshot [Hash] result of {Ruact::ServerFunctions::Snapshot.dump};
108
+ # must contain `:version`, `:generated_at`, `:functions` (with string-keyed
109
+ # entries).
110
+ # @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).
116
+ def render(snapshot)
117
+ unless snapshot.is_a?(Hash)
118
+ raise Ruact::ConfigurationError,
119
+ "ruact server-function codegen: snapshot must be a Hash, got #{snapshot.class}"
120
+ end
121
+
122
+ version = fetch_snapshot_key!(snapshot, :version, "version")
123
+ generated_at = fetch_snapshot_key!(snapshot, :generated_at, "generated_at")
124
+ functions = fetch_snapshot_key!(snapshot, :functions, "functions")
125
+
126
+ validate_metadata!(version, generated_at)
127
+
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
150
+ end
151
+
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
163
+ end
164
+
165
+ # Writes the rendered TS module to +output_path+, only if it changed.
166
+ # See {Ruact::ServerFunctions::SnapshotWriter.write_if_changed!}.
167
+ #
168
+ # @param snapshot [Hash]
169
+ # @param output_path [String, Pathname]
170
+ # @return [Boolean] true if the file was written; false if unchanged.
171
+ def generate_ts!(snapshot:, output_path:)
172
+ SnapshotWriter.write_if_changed!(path: output_path, content: render(snapshot))
173
+ end
174
+
175
+ private
176
+
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.
190
+ def fetch_snapshot_key!(snapshot, sym_key, str_key)
191
+ return snapshot[sym_key] if snapshot.key?(sym_key)
192
+ return snapshot[str_key] if snapshot.key?(str_key)
193
+
194
+ raise Ruact::ConfigurationError,
195
+ "ruact server-function codegen: snapshot is missing required key " \
196
+ "#{sym_key.inspect} (or #{str_key.inspect}); the bridge JSON is " \
197
+ "corrupted — regenerate via `bin/rails ruact:server_functions:generate`."
198
+ end
199
+
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
+ def validate_metadata!(version, generated_at)
205
+ unless version.is_a?(Integer) || version.is_a?(String)
206
+ raise Ruact::ConfigurationError,
207
+ "ruact server-function codegen: snapshot.version must be an " \
208
+ "Integer or String, got #{version.class}"
209
+ end
210
+ if version.to_s.match?(LINE_TERMINATORS)
211
+ raise Ruact::ConfigurationError,
212
+ "ruact server-function codegen: snapshot.version contains a " \
213
+ "line break (LF, CR, U+2028, or U+2029) — would break out of " \
214
+ "the header comment; snapshot JSON is corrupted."
215
+ end
216
+
217
+ unless generated_at.is_a?(String)
218
+ raise Ruact::ConfigurationError,
219
+ "ruact server-function codegen: snapshot.generated_at must be " \
220
+ "a String, got #{generated_at.class}"
221
+ end
222
+ return unless generated_at.match?(LINE_TERMINATORS)
223
+
224
+ raise Ruact::ConfigurationError,
225
+ "ruact server-function codegen: snapshot.generated_at contains " \
226
+ "a line break (LF, CR, U+2028, or U+2029) — would break out of " \
227
+ "the header comment; snapshot JSON is corrupted."
228
+ 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
+ end
337
+ end
338
+ end
339
+ end
340
+
341
+ # Story 9.3 — the route-driven (version-2) renderer lives in its own module so
342
+ # the v1 singleton class stays within its size budget. Required after the
343
+ # constants above are defined; `Codegen.render` delegates to it on version 2.
344
+ require_relative "codegen_v2"
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruact/server_functions/name_bridge"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ module Codegen
9
+ # Story 9.3 — renders a version-2 (route-driven) snapshot into the TS
10
+ # module. Each entry is an action targeting a real path + verb, emitted as
11
+ # `_makeServerFunction({ method, path, segments })` instead of v1's
12
+ # `_makeRef("<sym>")`. Lives in its own module (nested in {Codegen}) so the
13
+ # v1 singleton class stays within its size budget; {Codegen.render}
14
+ # delegates here when `snapshot.version == 2`.
15
+ #
16
+ # The output MUST stay byte-identical to the JS-side `renderV2` in
17
+ # `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`;
18
+ # the parity test ("Story 9.3 — route-driven (v2) render + parity") asserts
19
+ # this. Constants (ACTION_SIGNATURE, RUNTIME_IMPORT, REVALIDATE_REEXPORT,
20
+ # LINE_TERMINATORS, VALID_JS_IDENTIFIER) are reused from {Codegen} via
21
+ # lexical scope.
22
+ module V2
23
+ # Verbs a v2 ACTION entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
24
+ HTTP_METHODS = %w[POST PUT PATCH DELETE].to_set.freeze
25
+
26
+ # Story 9.5 — the verb a v2 QUERY entry may carry. Queries are reads,
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.
30
+ QUERY_HTTP_METHODS = %w[GET].to_set.freeze
31
+
32
+ class << self
33
+ # @param version [Integer, String]
34
+ # @param generated_at [String]
35
+ # @param functions [Array<Hash>] route-derived entries (actions AND,
36
+ # Story 9.5, queries).
37
+ # @return [String] TS module bytes (single trailing newline).
38
+ # @raise [Ruact::ConfigurationError] on any trust-boundary violation.
39
+ def render(version, generated_at, functions)
40
+ validate_functions!(functions)
41
+
42
+ has_query = functions.any? { |entry| fetch(entry, "kind").to_s == "query" }
43
+ has_action = functions.any? { |entry| fetch(entry, "kind").to_s == "action" }
44
+
45
+ # Import only what is used. Keep `_makeServerFunction` in the empty
46
+ # case so the no-functions module stays byte-identical to Story 9.3.
47
+ imports = []
48
+ imports << "_makeServerFunction" if has_action || functions.empty?
49
+ imports << "_makeQuery" if has_query
50
+
51
+ io = +""
52
+ io << "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n"
53
+ io << "// Source: Rails route table (version #{version})\n"
54
+ io << "// Generated at: #{generated_at}\n"
55
+ io << "import { #{imports.join(', ')} } from #{RUNTIME_IMPORT};\n"
56
+
57
+ if functions.empty?
58
+ io << "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n"
59
+ io << "void _makeServerFunction;\n"
60
+ else
61
+ io << "\n"
62
+ functions.each { |entry| io << render_export(entry) }
63
+ end
64
+
65
+ io << "\n"
66
+ io << REVALIDATE_REEXPORT
67
+ io << USEQUERY_REEXPORT if has_query
68
+ io
69
+ end
70
+
71
+ private
72
+
73
+ def render_export(entry)
74
+ return render_query_export(entry) if fetch(entry, "kind").to_s == "query"
75
+
76
+ js_id = fetch(entry, "js_identifier")
77
+ method = fetch(entry, "http_method")
78
+ path = fetch(entry, "path")
79
+ segments = fetch(entry, "segments") || []
80
+
81
+ descriptor =
82
+ "{ method: #{JSON.dump(method)}, path: #{JSON.dump(path)}, " \
83
+ "segments: [#{segments.map { |s| JSON.dump(s) }.join(', ')}] }"
84
+
85
+ "export const #{js_id}: #{ACTION_SIGNATURE} =\n _makeServerFunction(#{descriptor});\n"
86
+ end
87
+
88
+ # Story 9.5 — a query export binds a `_makeQuery` accessor carrying
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).
93
+ def render_query_export(entry)
94
+ js_id = fetch(entry, "js_identifier")
95
+ path = fetch(entry, "path")
96
+ signature = fetch(entry, "accepts_params") ? QUERY_PARAMS_SIGNATURE : QUERY_SIGNATURE
97
+
98
+ descriptor = "{ path: #{JSON.dump(path)}, kind: \"query\" }"
99
+ "export const #{js_id}: #{signature} =\n _makeQuery(#{descriptor});\n"
100
+ end
101
+
102
+ def validate_functions!(functions)
103
+ unless functions.is_a?(Array)
104
+ raise Ruact::ConfigurationError,
105
+ "ruact server-function codegen: snapshot.functions must be an Array, got #{functions.class}"
106
+ end
107
+
108
+ seen = {}
109
+ functions.each { |entry| validate_entry!(entry, seen) }
110
+ end
111
+
112
+ def validate_entry!(entry, seen)
113
+ unless entry.is_a?(Hash)
114
+ raise Ruact::ConfigurationError,
115
+ "ruact server-function codegen: snapshot.functions entry is not a Hash: #{entry.inspect}"
116
+ end
117
+
118
+ js_id = fetch(entry, "js_identifier")
119
+ validate_identifier!(js_id, seen)
120
+ kind = fetch(entry, "kind").to_s
121
+ validate_kind!(js_id, kind)
122
+ validate_method!(js_id, kind, fetch(entry, "http_method"))
123
+ path = fetch(entry, "path")
124
+ validate_path!(js_id, path)
125
+ validate_segments!(js_id, path, fetch(entry, "segments"))
126
+ seen[js_id] = true
127
+ end
128
+
129
+ def validate_identifier!(js_id, seen)
130
+ unless js_id.is_a?(String) && js_id.match?(VALID_JS_IDENTIFIER)
131
+ raise Ruact::ConfigurationError,
132
+ "ruact server-function codegen rejected a v2 snapshot entry: " \
133
+ "js_identifier=#{js_id.inspect} is not a valid JS identifier " \
134
+ "(must match #{VALID_JS_IDENTIFIER.inspect}); snapshot JSON is corrupted."
135
+ end
136
+ if NameBridge::RESERVED_JS_IDENTIFIERS.include?(js_id) ||
137
+ NameBridge::RESERVED_BY_RUACT.include?(js_id)
138
+ raise Ruact::ConfigurationError,
139
+ "ruact server-function codegen: js_identifier #{js_id.inspect} is reserved — " \
140
+ "cannot be exported; snapshot JSON is corrupted."
141
+ end
142
+ return unless seen.key?(js_id)
143
+
144
+ raise Ruact::ConfigurationError,
145
+ "ruact server-function codegen: duplicate js_identifier #{js_id.inspect} in snapshot."
146
+ end
147
+
148
+ def validate_kind!(js_id, kind)
149
+ return if %w[action query].include?(kind)
150
+
151
+ raise Ruact::ConfigurationError,
152
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
153
+ "kind #{kind.inspect} (v2 entries are \"action\" or \"query\")."
154
+ end
155
+
156
+ # Story 9.5 — actions carry a mutation verb; queries are GET-only.
157
+ def validate_method!(js_id, kind, method)
158
+ allowed = kind == "query" ? QUERY_HTTP_METHODS : HTTP_METHODS
159
+ return if allowed.include?(method)
160
+
161
+ raise Ruact::ConfigurationError,
162
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
163
+ "http_method #{method.inspect} (must be one of #{allowed.to_a.inspect})."
164
+ end
165
+
166
+ def validate_path!(js_id, path)
167
+ unless path.is_a?(String) && path.start_with?("/")
168
+ raise Ruact::ConfigurationError,
169
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
170
+ "path #{path.inspect} (must be a String beginning with \"/\")."
171
+ end
172
+ return unless path.match?(LINE_TERMINATORS)
173
+
174
+ raise Ruact::ConfigurationError,
175
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} path contains a " \
176
+ "line break — would break out of the generated call; snapshot JSON is corrupted."
177
+ end
178
+
179
+ def validate_segments!(js_id, path, segments)
180
+ unless segments.is_a?(Array) && segments.all? { |s| s.is_a?(String) && !s.empty? }
181
+ raise Ruact::ConfigurationError,
182
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
183
+ "segments #{segments.inspect} (must be an Array of non-empty Strings)."
184
+ end
185
+ # Whole-token match: `:id` must NOT satisfy a declared segment when
186
+ # the path only has `:id_extra` (a substring `include?` would).
187
+ missing = segments.reject { |s| path.match?(/:#{Regexp.escape(s)}(?![A-Za-z0-9_])/) }
188
+ unless missing.empty?
189
+ raise Ruact::ConfigurationError,
190
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} declares " \
191
+ "segment(s) #{missing.inspect} absent from path #{path.inspect}; snapshot JSON is corrupted."
192
+ end
193
+
194
+ # Bidirectional: every dynamic `:param` in the path MUST be declared
195
+ # in segments, else the runtime would fetch a literal `:param` URL.
196
+ undeclared = path.scan(/:([A-Za-z_][A-Za-z0-9_]*)/).flatten - segments
197
+ return if undeclared.empty?
198
+
199
+ raise Ruact::ConfigurationError,
200
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} path #{path.inspect} " \
201
+ "has dynamic segment(s) #{undeclared.inspect} not declared in segments; snapshot JSON is corrupted."
202
+ end
203
+
204
+ # v2 snapshots use string keys on disk; specs may pass symbol keys.
205
+ def fetch(entry, key)
206
+ entry[key] || entry[key.to_sym]
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end