ruact 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) 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 +86 -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 +1680 -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 +89 -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 +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. 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,330 @@
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 8.2 — fixed re-export appended AFTER the per-function block.
66
+ # Emitted in BOTH branches (empty + populated registry) so
67
+ # `import { revalidate } from "@/.ruact/server-functions"` works on
68
+ # day one of any host app. Ruby + JS codegens emit byte-identically.
69
+ REVALIDATE_REEXPORT = "export { revalidate } from #{RUNTIME_IMPORT};\n".freeze
70
+
71
+ # JS identifier shape — same as `NameBridge::VALID_SYMBOL` but expressed
72
+ # in JS-identifier terms (leading letter / underscore / `$`, then alnum
73
+ # / underscore / `$`). The codegen validates every entry it consumes
74
+ # because the JSON bridge is a trust boundary — a malformed snapshot
75
+ # (`functions[].js_identifier == ");\nevil();_makeRef("foo`) would
76
+ # otherwise inject TS at module top level.
77
+ VALID_JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
78
+
79
+ ALLOWED_KINDS = %w[action query].freeze
80
+
81
+ # JS comments (both `//` line comments and `/* … */` block comments via
82
+ # the spec's LineTerminator production) end on LF, CR, U+2028, and U+2029.
83
+ # A snapshot value that smuggles any of these would break out of the
84
+ # leading comment header in the emitted module. The reviewer's Pass-2
85
+ # finding noted that an earlier `/[\r\n]/` guard missed the two Unicode
86
+ # line separators; the regex is widened here and a parity test in
87
+ # `server-functions-codegen.test.mjs` keeps both renderers in sync.
88
+ LINE_TERMINATORS = /[\r\n

]/
89
+
90
+ class << self
91
+ # Renders +snapshot+ into the TS module text. Pure; no I/O.
92
+ #
93
+ # @param snapshot [Hash] result of {Ruact::ServerFunctions::Snapshot.dump};
94
+ # must contain `:version`, `:generated_at`, `:functions` (with string-keyed
95
+ # entries).
96
+ # @return [String] TS module bytes, terminated by a single trailing newline.
97
+ # @raise [Ruact::ConfigurationError] when an entry fails any of the
98
+ # snapshot-trust-boundary guards (line-break in version /
99
+ # generated_at, invalid identifier shape, reserved JS word, kind
100
+ # outside {ALLOWED_KINDS}, or duplicate `js_identifier` — mirror of
101
+ # the JS-side `validateSnapshot` per the 2026-05-14 Re-run patch).
102
+ def render(snapshot)
103
+ unless snapshot.is_a?(Hash)
104
+ raise Ruact::ConfigurationError,
105
+ "ruact server-function codegen: snapshot must be a Hash, got #{snapshot.class}"
106
+ end
107
+
108
+ version = fetch_snapshot_key!(snapshot, :version, "version")
109
+ generated_at = fetch_snapshot_key!(snapshot, :generated_at, "generated_at")
110
+ functions = fetch_snapshot_key!(snapshot, :functions, "functions")
111
+
112
+ validate_metadata!(version, generated_at)
113
+
114
+ return V2.render(version, generated_at, functions) if version.to_s == VERSION_V2.to_s
115
+
116
+ validate_functions!(functions)
117
+
118
+ io = +""
119
+ io << "// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.\n"
120
+ io << "// Source: tmp/cache/ruact/server-functions.json (version #{version})\n"
121
+ io << "// Generated at: #{generated_at}\n"
122
+ io << "import { _makeRef } from #{RUNTIME_IMPORT};\n"
123
+
124
+ if functions.empty?
125
+ io << "\n// (no server functions registered yet — Stories 8.1 / 9.1 populate)\n"
126
+ # `noUnusedLocals` would otherwise flag the `_makeRef` import. The
127
+ # `void` discard pattern keeps the import alive at zero runtime
128
+ # cost; once an action/query is registered the export below
129
+ # references `_makeRef` directly and this line is omitted.
130
+ io << "void _makeRef;\n"
131
+ else
132
+ io << "\n"
133
+ functions.each do |entry|
134
+ io << render_export(entry)
135
+ end
136
+ end
137
+
138
+ # Story 8.2 — `revalidate()` is always available, so the
139
+ # re-export lands in both branches (empty registry + populated).
140
+ # The codegen owns the canonical import path
141
+ # `@/.ruact/server-functions` and is the only stable surface devs
142
+ # are told to import from in the docs (per the Story 8.0 ADR);
143
+ # without this line, devs would need a second import statement
144
+ # from a less-stable runtime-package path.
145
+ io << "\n"
146
+ io << REVALIDATE_REEXPORT
147
+
148
+ io
149
+ end
150
+
151
+ # Writes the rendered TS module to +output_path+, only if it changed.
152
+ # See {Ruact::ServerFunctions::SnapshotWriter.write_if_changed!}.
153
+ #
154
+ # @param snapshot [Hash]
155
+ # @param output_path [String, Pathname]
156
+ # @return [Boolean] true if the file was written; false if unchanged.
157
+ def generate_ts!(snapshot:, output_path:)
158
+ SnapshotWriter.write_if_changed!(path: output_path, content: render(snapshot))
159
+ end
160
+
161
+ private
162
+
163
+ def render_export(entry)
164
+ js_id = entry["js_identifier"] || entry[:js_identifier]
165
+ kind = (entry["kind"] || entry[:kind]).to_s
166
+ ruby_sym = entry["ruby_symbol"] || entry[:ruby_symbol]
167
+
168
+ signature = kind == "query" ? QUERY_SIGNATURE : ACTION_SIGNATURE
169
+
170
+ "export const #{js_id}: #{signature} =\n _makeRef(#{json_escape(ruby_sym.to_s)});\n"
171
+ end
172
+
173
+ # Pass-2 patch 2026-05-14 — wrap raw `KeyError` from `Hash#fetch` so
174
+ # the rake / Railtie call sites get a consistent `Ruact::ConfigurationError`
175
+ # for every snapshot-shape failure, not a mixture of error classes.
176
+ def fetch_snapshot_key!(snapshot, sym_key, str_key)
177
+ return snapshot[sym_key] if snapshot.key?(sym_key)
178
+ return snapshot[str_key] if snapshot.key?(str_key)
179
+
180
+ raise Ruact::ConfigurationError,
181
+ "ruact server-function codegen: snapshot is missing required key " \
182
+ "#{sym_key.inspect} (or #{str_key.inspect}); the bridge JSON is " \
183
+ "corrupted — regenerate via `bin/rails ruact:server_functions:generate`."
184
+ end
185
+
186
+ # Mirror of the JS-side `validateSnapshot` (2026-05-14 Re-run parity
187
+ # fix). The Ruby renderer also reads from the on-disk JSON bridge in
188
+ # the rake-task and Railtie paths, so the same trust-boundary guards
189
+ # belong here.
190
+ def validate_metadata!(version, generated_at)
191
+ unless version.is_a?(Integer) || version.is_a?(String)
192
+ raise Ruact::ConfigurationError,
193
+ "ruact server-function codegen: snapshot.version must be an " \
194
+ "Integer or String, got #{version.class}"
195
+ end
196
+ if version.to_s.match?(LINE_TERMINATORS)
197
+ raise Ruact::ConfigurationError,
198
+ "ruact server-function codegen: snapshot.version contains a " \
199
+ "line break (LF, CR, U+2028, or U+2029) — would break out of " \
200
+ "the header comment; snapshot JSON is corrupted."
201
+ end
202
+
203
+ unless generated_at.is_a?(String)
204
+ raise Ruact::ConfigurationError,
205
+ "ruact server-function codegen: snapshot.generated_at must be " \
206
+ "a String, got #{generated_at.class}"
207
+ end
208
+ return unless generated_at.match?(LINE_TERMINATORS)
209
+
210
+ raise Ruact::ConfigurationError,
211
+ "ruact server-function codegen: snapshot.generated_at contains " \
212
+ "a line break (LF, CR, U+2028, or U+2029) — would break out of " \
213
+ "the header comment; snapshot JSON is corrupted."
214
+ end
215
+
216
+ def validate_functions!(functions)
217
+ unless functions.is_a?(Array)
218
+ raise Ruact::ConfigurationError,
219
+ "ruact server-function codegen: snapshot.functions must be an " \
220
+ "Array, got #{functions.class}"
221
+ end
222
+
223
+ seen = {}
224
+ functions.each do |entry|
225
+ unless entry.is_a?(Hash)
226
+ raise Ruact::ConfigurationError,
227
+ "ruact server-function codegen: snapshot.functions entry is " \
228
+ "not a Hash: #{entry.inspect}"
229
+ end
230
+ js_id = entry["js_identifier"] || entry[:js_identifier]
231
+ kind = (entry["kind"] || entry[:kind]).to_s
232
+ ruby_sym = entry["ruby_symbol"] || entry[:ruby_symbol]
233
+
234
+ validate_ruby_symbol!(ruby_sym)
235
+ validate_js_identifier!(js_id, ruby_sym)
236
+ validate_kind!(kind, ruby_sym)
237
+ validate_not_reserved!(js_id, ruby_sym)
238
+ validate_no_duplicate!(seen, js_id)
239
+ seen[js_id] = true
240
+ end
241
+ end
242
+
243
+ # Pass-2 patch 2026-05-14 — without this guard, a missing or empty
244
+ # `ruby_symbol` on a snapshot entry would render `_makeRef("")` and
245
+ # silently emit an export that can never resolve at runtime (the
246
+ # placeholder rejects on call but the empty string is a meaningless
247
+ # registration name). Treat as a corrupt-snapshot signal.
248
+ def validate_ruby_symbol!(ruby_sym)
249
+ return if ruby_sym.is_a?(String) && !ruby_sym.empty?
250
+ return if ruby_sym.is_a?(Symbol) && !ruby_sym.empty?
251
+
252
+ raise Ruact::ConfigurationError,
253
+ "ruact server-function codegen: snapshot.functions entry has " \
254
+ "missing or empty ruby_symbol (got #{ruby_sym.inspect}); the " \
255
+ "bridge JSON is corrupted — regenerate via " \
256
+ "`bin/rails ruact:server_functions:generate`."
257
+ end
258
+
259
+ def validate_js_identifier!(js_id, ruby_sym)
260
+ return if js_id.is_a?(String) && js_id.match?(VALID_JS_IDENTIFIER)
261
+
262
+ raise Ruact::ConfigurationError,
263
+ "ruact server-function codegen rejected a snapshot entry: " \
264
+ "ruby_symbol=#{ruby_sym.inspect} js_identifier=#{js_id.inspect} " \
265
+ "is not a valid JS identifier (must match #{VALID_JS_IDENTIFIER.inspect}). " \
266
+ "The snapshot JSON is corrupted or was hand-edited — regenerate via " \
267
+ "`bin/rails ruact:server_functions:generate`."
268
+ end
269
+
270
+ def validate_kind!(kind, ruby_sym)
271
+ return if ALLOWED_KINDS.include?(kind)
272
+
273
+ raise Ruact::ConfigurationError,
274
+ "ruact server-function codegen: snapshot.functions entry has " \
275
+ "invalid kind #{kind.inspect} (must be \"action\" or \"query\") " \
276
+ "for ruby_symbol=#{ruby_sym.inspect}"
277
+ end
278
+
279
+ def validate_not_reserved!(js_id, ruby_sym)
280
+ if NameBridge::RESERVED_JS_IDENTIFIERS.include?(js_id)
281
+ raise Ruact::ConfigurationError,
282
+ "ruact server-function codegen: js_identifier #{js_id.inspect} " \
283
+ "is a reserved JS word — ruby_symbol=#{ruby_sym.inspect} would " \
284
+ "emit an invalid TS module. NameBridge should have rejected this; " \
285
+ "regenerate via `bin/rails ruact:server_functions:generate`."
286
+ end
287
+
288
+ # Story 8.2 R12 — even if NameBridge somehow lets a reserved
289
+ # ruact name through (e.g. a hand-edited bridge JSON), the
290
+ # codegen MUST refuse — otherwise the rendered module would
291
+ # bind `revalidate` / `_makeRef` twice (once via the
292
+ # re-export / import, once via the action `export const`)
293
+ # and crash at module load.
294
+ return unless NameBridge::RESERVED_BY_RUACT.include?(js_id)
295
+
296
+ raise Ruact::ConfigurationError,
297
+ "ruact server-function codegen: js_identifier #{js_id.inspect} " \
298
+ "is reserved by the ruact runtime/codegen surface (would clash " \
299
+ "with the module's `revalidate` re-export or `_makeRef` import) — " \
300
+ "ruby_symbol=#{ruby_sym.inspect} cannot be exported. NameBridge " \
301
+ "should have rejected this; regenerate via " \
302
+ "`bin/rails ruact:server_functions:generate`."
303
+ end
304
+
305
+ def validate_no_duplicate!(seen, js_id)
306
+ return unless seen.key?(js_id)
307
+
308
+ raise Ruact::ConfigurationError,
309
+ "ruact server-function codegen: duplicate js_identifier " \
310
+ "#{js_id.inspect} in snapshot — two entries would emit " \
311
+ "conflicting `export const` declarations. The snapshot JSON is " \
312
+ "corrupted or was hand-edited — regenerate via " \
313
+ "`bin/rails ruact:server_functions:generate`."
314
+ end
315
+
316
+ # Wraps `ruby_symbol` in a JSON-escaped string literal so backslashes,
317
+ # double quotes, and control characters cannot break out of the
318
+ # `_makeRef("<here>")` argument.
319
+ def json_escape(str)
320
+ JSON.dump(str)
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
326
+
327
+ # Story 9.3 — the route-driven (version-2) renderer lives in its own module so
328
+ # the v1 singleton class stays within its size budget. Required after the
329
+ # constants above are defined; `Codegen.render` delegates to it on version 2.
330
+ require_relative "codegen_v2"
@@ -0,0 +1,176 @@
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 entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
24
+ HTTP_METHODS = %w[POST PUT PATCH DELETE].to_set.freeze
25
+
26
+ class << self
27
+ # @param version [Integer, String]
28
+ # @param generated_at [String]
29
+ # @param functions [Array<Hash>] route-derived entries.
30
+ # @return [String] TS module bytes (single trailing newline).
31
+ # @raise [Ruact::ConfigurationError] on any trust-boundary violation.
32
+ def render(version, generated_at, functions)
33
+ validate_functions!(functions)
34
+
35
+ io = +""
36
+ io << "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n"
37
+ io << "// Source: Rails route table (version #{version})\n"
38
+ io << "// Generated at: #{generated_at}\n"
39
+ io << "import { _makeServerFunction } from #{RUNTIME_IMPORT};\n"
40
+
41
+ if functions.empty?
42
+ io << "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n"
43
+ io << "void _makeServerFunction;\n"
44
+ else
45
+ io << "\n"
46
+ functions.each { |entry| io << render_export(entry) }
47
+ end
48
+
49
+ io << "\n"
50
+ io << REVALIDATE_REEXPORT
51
+ io
52
+ end
53
+
54
+ private
55
+
56
+ def render_export(entry)
57
+ js_id = fetch(entry, "js_identifier")
58
+ method = fetch(entry, "http_method")
59
+ path = fetch(entry, "path")
60
+ segments = fetch(entry, "segments") || []
61
+
62
+ descriptor =
63
+ "{ method: #{JSON.dump(method)}, path: #{JSON.dump(path)}, " \
64
+ "segments: [#{segments.map { |s| JSON.dump(s) }.join(', ')}] }"
65
+
66
+ "export const #{js_id}: #{ACTION_SIGNATURE} =\n _makeServerFunction(#{descriptor});\n"
67
+ end
68
+
69
+ def validate_functions!(functions)
70
+ unless functions.is_a?(Array)
71
+ raise Ruact::ConfigurationError,
72
+ "ruact server-function codegen: snapshot.functions must be an Array, got #{functions.class}"
73
+ end
74
+
75
+ seen = {}
76
+ functions.each { |entry| validate_entry!(entry, seen) }
77
+ end
78
+
79
+ def validate_entry!(entry, seen)
80
+ unless entry.is_a?(Hash)
81
+ raise Ruact::ConfigurationError,
82
+ "ruact server-function codegen: snapshot.functions entry is not a Hash: #{entry.inspect}"
83
+ end
84
+
85
+ js_id = fetch(entry, "js_identifier")
86
+ validate_identifier!(js_id, seen)
87
+ validate_kind!(js_id, fetch(entry, "kind").to_s)
88
+ validate_method!(js_id, fetch(entry, "http_method"))
89
+ path = fetch(entry, "path")
90
+ validate_path!(js_id, path)
91
+ validate_segments!(js_id, path, fetch(entry, "segments"))
92
+ seen[js_id] = true
93
+ end
94
+
95
+ def validate_identifier!(js_id, seen)
96
+ unless js_id.is_a?(String) && js_id.match?(VALID_JS_IDENTIFIER)
97
+ raise Ruact::ConfigurationError,
98
+ "ruact server-function codegen rejected a v2 snapshot entry: " \
99
+ "js_identifier=#{js_id.inspect} is not a valid JS identifier " \
100
+ "(must match #{VALID_JS_IDENTIFIER.inspect}); snapshot JSON is corrupted."
101
+ end
102
+ if NameBridge::RESERVED_JS_IDENTIFIERS.include?(js_id) ||
103
+ NameBridge::RESERVED_BY_RUACT.include?(js_id)
104
+ raise Ruact::ConfigurationError,
105
+ "ruact server-function codegen: js_identifier #{js_id.inspect} is reserved — " \
106
+ "cannot be exported; snapshot JSON is corrupted."
107
+ end
108
+ return unless seen.key?(js_id)
109
+
110
+ raise Ruact::ConfigurationError,
111
+ "ruact server-function codegen: duplicate js_identifier #{js_id.inspect} in snapshot."
112
+ end
113
+
114
+ def validate_kind!(js_id, kind)
115
+ return if kind == "action"
116
+
117
+ raise Ruact::ConfigurationError,
118
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
119
+ "kind #{kind.inspect} (v2 entries are always \"action\")."
120
+ end
121
+
122
+ def validate_method!(js_id, method)
123
+ return if HTTP_METHODS.include?(method)
124
+
125
+ raise Ruact::ConfigurationError,
126
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
127
+ "http_method #{method.inspect} (must be one of #{HTTP_METHODS.to_a.inspect})."
128
+ end
129
+
130
+ def validate_path!(js_id, path)
131
+ unless path.is_a?(String) && path.start_with?("/")
132
+ raise Ruact::ConfigurationError,
133
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
134
+ "path #{path.inspect} (must be a String beginning with \"/\")."
135
+ end
136
+ return unless path.match?(LINE_TERMINATORS)
137
+
138
+ raise Ruact::ConfigurationError,
139
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} path contains a " \
140
+ "line break — would break out of the generated call; snapshot JSON is corrupted."
141
+ end
142
+
143
+ def validate_segments!(js_id, path, segments)
144
+ unless segments.is_a?(Array) && segments.all? { |s| s.is_a?(String) && !s.empty? }
145
+ raise Ruact::ConfigurationError,
146
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
147
+ "segments #{segments.inspect} (must be an Array of non-empty Strings)."
148
+ end
149
+ # Whole-token match: `:id` must NOT satisfy a declared segment when
150
+ # the path only has `:id_extra` (a substring `include?` would).
151
+ missing = segments.reject { |s| path.match?(/:#{Regexp.escape(s)}(?![A-Za-z0-9_])/) }
152
+ unless missing.empty?
153
+ raise Ruact::ConfigurationError,
154
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} declares " \
155
+ "segment(s) #{missing.inspect} absent from path #{path.inspect}; snapshot JSON is corrupted."
156
+ end
157
+
158
+ # Bidirectional: every dynamic `:param` in the path MUST be declared
159
+ # in segments, else the runtime would fetch a literal `:param` URL.
160
+ undeclared = path.scan(/:([A-Za-z_][A-Za-z0-9_]*)/).flatten - segments
161
+ return if undeclared.empty?
162
+
163
+ raise Ruact::ConfigurationError,
164
+ "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} path #{path.inspect} " \
165
+ "has dynamic segment(s) #{undeclared.inspect} not declared in segments; snapshot JSON is corrupted."
166
+ end
167
+
168
+ # v2 snapshots use string keys on disk; specs may pass symbol keys.
169
+ def fetch(entry, key)
170
+ entry[key] || entry[key.to_sym]
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end