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,761 @@
1
+ // Story 8.0a — server-functions codegen sidecar for vite-plugin-ruact.
2
+ //
3
+ // READ THIS COMMENT BEFORE EDITING. The TypeScript module emitted by this file
4
+ // MUST be byte-identical to what
5
+ // `gem/lib/ruact/server_functions/codegen.rb` produces. A cross-implementation
6
+ // parity test (`server-functions-codegen.test.mjs` → "Ruby parity") asserts
7
+ // this invariant. If you change emitted output here, change the Ruby side in
8
+ // lockstep — do NOT normalize differences in the assertion.
9
+ //
10
+ // The sidecar is wired into `index.js` via {@link installServerFunctionsHooks},
11
+ // which mutates the plugin's hook table to:
12
+ // - register `resolve.alias["@"]` and `resolve.alias["ruact/server-functions-runtime"]`
13
+ // - emit `app/javascript/.ruact/server-functions.ts` at buildStart
14
+ // - watch `tmp/cache/ruact/server-functions.json` and re-emit on mtime change
15
+ //
16
+ // All exports here are also importable for unit testing (see the test file).
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import crypto from "node:crypto";
22
+
23
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
24
+
25
+ export const RUNTIME_IMPORT_SPECIFIER = "ruact/server-functions-runtime";
26
+
27
+ export const VALID_JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
28
+
29
+ // JS comment terminators per ECMAScript LineTerminator production: LF (\n),
30
+ // CR (\r), U+2028 (LINE SEPARATOR), U+2029 (PARAGRAPH SEPARATOR). A snapshot
31
+ // value containing any of these would break out of the leading `//` comment
32
+ // header. The Ruby `LINE_TERMINATORS` constant in `codegen.rb` mirrors this
33
+ // list; a snapshot containing a Unicode line separator must be rejected
34
+ // identically by both renderers (Pass-2 patch 2026-05-14).
35
+ function containsLineTerminator(s) {
36
+ return /[\r\n\u2028\u2029]/.test(s);
37
+ }
38
+
39
+ // Mirror of Ruby `NameBridge::RESERVED_JS_IDENTIFIERS`. The Ruby side enforces
40
+ // this at controller load time (Story 8.1 / 9.1). The JS side re-enforces
41
+ // because the JSON bridge is an independent trust boundary — a hand-edited
42
+ // snapshot bypasses the Ruby check.
43
+ const RESERVED_JS_IDENTIFIERS = new Set([
44
+ "arguments", "async", "await", "break", "case", "catch", "class", "const", "continue",
45
+ "debugger", "default", "delete", "do", "else", "enum", "eval", "export", "extends", "false",
46
+ "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface",
47
+ "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super",
48
+ "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield",
49
+ ]);
50
+
51
+ // Story 8.2 R12 (2026-05-17) — names ALREADY bound at module top by the
52
+ // codegen itself: `_makeRef` (imported from the runtime) and `revalidate`
53
+ // (re-exported unconditionally from the runtime). A snapshot that
54
+ // declared either as an action `js_identifier` would emit a duplicate
55
+ // binding and crash at module-load time. Mirrors Ruby
56
+ // `NameBridge::RESERVED_BY_RUACT`.
57
+ const RESERVED_BY_RUACT = new Set(["_makeRef", "_makeServerFunction", "revalidate"]);
58
+
59
+ // Story 9.3 — the route-driven snapshot schema version + its verb allowlist.
60
+ // A version-2 snapshot renders `_makeServerFunction({...})` calls; `render`
61
+ // dispatches on `version` so the v1 path stays byte-for-byte untouched.
62
+ // Mirrors Ruby `Codegen::VERSION_V2` / `V2_HTTP_METHODS`.
63
+ export const VERSION_V2 = 2;
64
+ const V2_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
65
+
66
+ /**
67
+ * Absolute path to the placeholder runtime bundled inside the gem. Used as the
68
+ * target of the `ruact/server-functions-runtime` Vite alias so host apps
69
+ * resolve the import without any `npm install` step.
70
+ *
71
+ * @returns {string}
72
+ */
73
+ export function runtimePackagePath() {
74
+ return path.resolve(HERE, "..", "ruact-server-functions-runtime", "index.js");
75
+ }
76
+
77
+ /**
78
+ * Validates the snapshot shape before rendering. The bridge JSON is a trust
79
+ * boundary — a corrupted or hand-edited snapshot must fail loudly rather than
80
+ * silently emit invalid TS. Mirrors the Ruby-side guarantees (kind allowlist,
81
+ * reserved-word ban, duplicate js_identifier detection) so this side stays
82
+ * safe when consumed standalone (e.g., `vite build` without the rake task).
83
+ *
84
+ * @param {unknown} snapshot
85
+ */
86
+ // Story 9.3 — extracted so both the v1 ({@link validateSnapshot}) and v2
87
+ // ({@link validateSnapshotV2}) paths share the identical root-shape +
88
+ // metadata checks (and identical error messages → byte-stable across versions).
89
+ function validateMetadata(snapshot) {
90
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
91
+ throw new Error(
92
+ "ruact server-function codegen: snapshot is not an object — " +
93
+ "the bridge JSON is corrupted; regenerate via " +
94
+ "`bin/rails ruact:server_functions:generate`.",
95
+ );
96
+ }
97
+
98
+ // Pass-2 patch 2026-05-14 — fail explicitly on missing root keys rather
99
+ // than letting `undefined` reach the type/value checks below with an
100
+ // opaque "X must be a string, got undefined" message.
101
+ for (const k of ["version", "generated_at", "functions"]) {
102
+ if (!(k in snapshot)) {
103
+ throw new Error(
104
+ `ruact server-function codegen: snapshot is missing required key "${k}"; ` +
105
+ "the bridge JSON is corrupted — regenerate via " +
106
+ "`bin/rails ruact:server_functions:generate`.",
107
+ );
108
+ }
109
+ }
110
+
111
+ const { version, generated_at } = snapshot;
112
+
113
+ if (typeof version !== "number" && typeof version !== "string") {
114
+ throw new Error(
115
+ `ruact server-function codegen: snapshot.version must be a number or string, got ${typeof version}`,
116
+ );
117
+ }
118
+ const versionStr = String(version);
119
+ if (containsLineTerminator(versionStr)) {
120
+ throw new Error(
121
+ "ruact server-function codegen: snapshot.version contains a line break " +
122
+ "(LF, CR, U+2028, or U+2029) — would break out of the header comment; " +
123
+ "snapshot JSON is corrupted.",
124
+ );
125
+ }
126
+
127
+ if (typeof generated_at !== "string") {
128
+ throw new Error(
129
+ `ruact server-function codegen: snapshot.generated_at must be a string, got ${typeof generated_at}`,
130
+ );
131
+ }
132
+ if (containsLineTerminator(generated_at)) {
133
+ throw new Error(
134
+ "ruact server-function codegen: snapshot.generated_at contains a line break " +
135
+ "(LF, CR, U+2028, or U+2029) — would break out of the header comment; " +
136
+ "snapshot JSON is corrupted.",
137
+ );
138
+ }
139
+ }
140
+
141
+ function validateSnapshot(snapshot) {
142
+ validateMetadata(snapshot);
143
+ const { functions } = snapshot;
144
+
145
+ if (!Array.isArray(functions)) {
146
+ throw new Error(
147
+ `ruact server-function codegen: snapshot.functions must be an array, got ${typeof functions}`,
148
+ );
149
+ }
150
+
151
+ const seen = new Set();
152
+ for (const fn of functions) {
153
+ if (!fn || typeof fn !== "object" || Array.isArray(fn)) {
154
+ throw new Error(
155
+ `ruact server-function codegen: snapshot.functions entry is not an object: ${JSON.stringify(fn)}`,
156
+ );
157
+ }
158
+ if (typeof fn.ruby_symbol !== "string" || fn.ruby_symbol.length === 0) {
159
+ throw new Error(
160
+ "ruact server-function codegen: snapshot.functions entry has missing or " +
161
+ `empty ruby_symbol (got ${JSON.stringify(fn.ruby_symbol)}); the bridge ` +
162
+ "JSON is corrupted — regenerate via `bin/rails ruact:server_functions:generate`.",
163
+ );
164
+ }
165
+ if (fn.kind !== "action" && fn.kind !== "query") {
166
+ throw new Error(
167
+ "ruact server-function codegen: snapshot.functions entry has invalid kind " +
168
+ `${JSON.stringify(fn.kind)} (must be "action" or "query") for ` +
169
+ `ruby_symbol=${JSON.stringify(fn.ruby_symbol)}`,
170
+ );
171
+ }
172
+ if (typeof fn.js_identifier !== "string" || !VALID_JS_IDENTIFIER.test(fn.js_identifier)) {
173
+ throw new Error(
174
+ "ruact server-function codegen rejected a snapshot entry: " +
175
+ `ruby_symbol=${JSON.stringify(fn.ruby_symbol)} ` +
176
+ `js_identifier=${JSON.stringify(fn.js_identifier)} is not a valid JS identifier ` +
177
+ "(must match /^[A-Za-z_$][A-Za-z0-9_$]*$/). The snapshot JSON is " +
178
+ "corrupted or was hand-edited — regenerate via " +
179
+ "`bin/rails ruact:server_functions:generate`.",
180
+ );
181
+ }
182
+ if (RESERVED_JS_IDENTIFIERS.has(fn.js_identifier)) {
183
+ throw new Error(
184
+ `ruact server-function codegen: js_identifier "${fn.js_identifier}" is a reserved ` +
185
+ `JS word — ruby_symbol=${JSON.stringify(fn.ruby_symbol)} would emit an invalid ` +
186
+ "TS module. Ruby NameBridge should have rejected this; regenerate via " +
187
+ "`bin/rails ruact:server_functions:generate`.",
188
+ );
189
+ }
190
+ if (RESERVED_BY_RUACT.has(fn.js_identifier)) {
191
+ throw new Error(
192
+ `ruact server-function codegen: js_identifier "${fn.js_identifier}" is reserved by ` +
193
+ "the ruact runtime/codegen surface (would clash with the module's `revalidate` " +
194
+ `re-export or \`_makeRef\` import) — ruby_symbol=${JSON.stringify(fn.ruby_symbol)} ` +
195
+ "cannot be exported. NameBridge should have rejected this; regenerate via " +
196
+ "`bin/rails ruact:server_functions:generate`.",
197
+ );
198
+ }
199
+ if (seen.has(fn.js_identifier)) {
200
+ throw new Error(
201
+ `ruact server-function codegen: duplicate js_identifier "${fn.js_identifier}" in ` +
202
+ "snapshot — two entries would emit conflicting `export const` declarations. " +
203
+ "The snapshot JSON is corrupted or was hand-edited — regenerate via " +
204
+ "`bin/rails ruact:server_functions:generate`.",
205
+ );
206
+ }
207
+ seen.add(fn.js_identifier);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Renders the snapshot Hash into the TS module text. MUST stay byte-identical
213
+ * to {Ruact::ServerFunctions::Codegen.render}.
214
+ *
215
+ * @param {{ version: number, generated_at: string, functions: Array<{
216
+ * ruby_symbol: string, js_identifier: string, kind: string, controller?: string|null
217
+ * }> }} snapshot
218
+ * @returns {string}
219
+ */
220
+ export function render(snapshot) {
221
+ // Story 9.3 — dispatch on snapshot version. A version-2 (route-driven)
222
+ // snapshot renders `_makeServerFunction({...})` calls; the v1 path below is
223
+ // untouched. The version peek is shape-guarded so a corrupt non-object
224
+ // snapshot still falls through to validateSnapshot's loud failure.
225
+ if (
226
+ snapshot &&
227
+ typeof snapshot === "object" &&
228
+ !Array.isArray(snapshot) &&
229
+ String(snapshot.version) === String(VERSION_V2)
230
+ ) {
231
+ return renderV2(snapshot);
232
+ }
233
+
234
+ validateSnapshot(snapshot);
235
+ const { version, generated_at, functions } = snapshot;
236
+ let out = "";
237
+ out += "// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.\n";
238
+ out += `// Source: tmp/cache/ruact/server-functions.json (version ${version})\n`;
239
+ out += `// Generated at: ${generated_at}\n`;
240
+ out += `import { _makeRef } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
241
+
242
+ if (functions.length === 0) {
243
+ out += "\n// (no server functions registered yet — Stories 8.1 / 9.1 populate)\n";
244
+ // `noUnusedLocals` would otherwise flag the `_makeRef` import. The `void`
245
+ // discard pattern keeps the import alive at zero runtime cost; once an
246
+ // action / query is registered the export below references `_makeRef`
247
+ // directly and this line is omitted.
248
+ out += "void _makeRef;\n";
249
+ } else {
250
+ out += "\n";
251
+ for (const fn of functions) {
252
+ out += renderExport(fn);
253
+ }
254
+ }
255
+ // Story 8.2 — `revalidate()` re-export. Emitted in both branches
256
+ // (empty + populated registry) because the helper is unconditional;
257
+ // see codegen.rb's REVALIDATE_REEXPORT and the 2026-05-16 Decision-log
258
+ // entry for the rationale.
259
+ out += "\n";
260
+ out += `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
261
+ return out;
262
+ }
263
+
264
+ /**
265
+ * Story 9.3 — validates a version-2 (route-driven) snapshot. A v2 entry has no
266
+ * `ruby_symbol`; it carries `http_method` + `path` + `segments`. Mirrors the
267
+ * Ruby-side `validate_functions_v2!`.
268
+ *
269
+ * @param {unknown} snapshot
270
+ */
271
+ function validateSnapshotV2(snapshot) {
272
+ validateMetadata(snapshot);
273
+ const { functions } = snapshot;
274
+
275
+ if (!Array.isArray(functions)) {
276
+ throw new Error(
277
+ `ruact server-function codegen: snapshot.functions must be an array, got ${typeof functions}`,
278
+ );
279
+ }
280
+
281
+ const seen = new Set();
282
+ for (const fn of functions) {
283
+ if (!fn || typeof fn !== "object" || Array.isArray(fn)) {
284
+ throw new Error(
285
+ `ruact server-function codegen: snapshot.functions entry is not an object: ${JSON.stringify(fn)}`,
286
+ );
287
+ }
288
+ if (typeof fn.js_identifier !== "string" || !VALID_JS_IDENTIFIER.test(fn.js_identifier)) {
289
+ throw new Error(
290
+ "ruact server-function codegen rejected a v2 snapshot entry: " +
291
+ `js_identifier=${JSON.stringify(fn.js_identifier)} is not a valid JS identifier ` +
292
+ "(must match /^[A-Za-z_$][A-Za-z0-9_$]*$/). The snapshot JSON is corrupted.",
293
+ );
294
+ }
295
+ if (RESERVED_JS_IDENTIFIERS.has(fn.js_identifier) || RESERVED_BY_RUACT.has(fn.js_identifier)) {
296
+ throw new Error(
297
+ `ruact server-function codegen: js_identifier "${fn.js_identifier}" is reserved — ` +
298
+ "cannot be exported. The snapshot JSON is corrupted.",
299
+ );
300
+ }
301
+ if (seen.has(fn.js_identifier)) {
302
+ throw new Error(
303
+ `ruact server-function codegen: duplicate js_identifier "${fn.js_identifier}" in snapshot.`,
304
+ );
305
+ }
306
+ seen.add(fn.js_identifier);
307
+
308
+ if (fn.kind !== "action") {
309
+ throw new Error(
310
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
311
+ `kind ${JSON.stringify(fn.kind)} (v2 entries are always "action").`,
312
+ );
313
+ }
314
+ if (!V2_HTTP_METHODS.has(fn.http_method)) {
315
+ throw new Error(
316
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
317
+ `http_method ${JSON.stringify(fn.http_method)} (must be POST/PUT/PATCH/DELETE).`,
318
+ );
319
+ }
320
+ if (typeof fn.path !== "string" || !fn.path.startsWith("/")) {
321
+ throw new Error(
322
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
323
+ `path ${JSON.stringify(fn.path)} (must be a string beginning with "/").`,
324
+ );
325
+ }
326
+ if (containsLineTerminator(fn.path)) {
327
+ throw new Error(
328
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" path contains a ` +
329
+ "line break — would break out of the generated call; snapshot JSON is corrupted.",
330
+ );
331
+ }
332
+ if (!Array.isArray(fn.segments) || !fn.segments.every((s) => typeof s === "string" && s.length > 0)) {
333
+ throw new Error(
334
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
335
+ `segments ${JSON.stringify(fn.segments)} (must be an array of non-empty strings).`,
336
+ );
337
+ }
338
+ // Whole-token match (mirror Ruby) — `:id` must not satisfy `:id_extra`.
339
+ const missing = fn.segments.filter(
340
+ (s) => !new RegExp(`:${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_])`).test(fn.path),
341
+ );
342
+ if (missing.length > 0) {
343
+ throw new Error(
344
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" declares ` +
345
+ `segment(s) ${JSON.stringify(missing)} absent from path ${JSON.stringify(fn.path)}; ` +
346
+ "snapshot JSON is corrupted.",
347
+ );
348
+ }
349
+ // Bidirectional: every dynamic `:param` in the path must be declared.
350
+ const pathParams = (fn.path.match(/:[A-Za-z_][A-Za-z0-9_]*/g) || []).map((t) => t.slice(1));
351
+ const undeclared = pathParams.filter((p) => !fn.segments.includes(p));
352
+ if (undeclared.length > 0) {
353
+ throw new Error(
354
+ `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" path ${JSON.stringify(fn.path)} ` +
355
+ `has dynamic segment(s) ${JSON.stringify(undeclared)} not declared in segments; ` +
356
+ "snapshot JSON is corrupted.",
357
+ );
358
+ }
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Story 9.3 — renders a version-2 (route-driven) snapshot. MUST stay
364
+ * byte-identical to the Ruby-side `Codegen.render_v2`.
365
+ *
366
+ * @param {object} snapshot
367
+ * @returns {string}
368
+ */
369
+ function renderV2(snapshot) {
370
+ validateSnapshotV2(snapshot);
371
+ const { version, generated_at, functions } = snapshot;
372
+ let out = "";
373
+ out += "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n";
374
+ out += `// Source: Rails route table (version ${version})\n`;
375
+ out += `// Generated at: ${generated_at}\n`;
376
+ out += `import { _makeServerFunction } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
377
+
378
+ if (functions.length === 0) {
379
+ out += "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n";
380
+ out += "void _makeServerFunction;\n";
381
+ } else {
382
+ out += "\n";
383
+ for (const fn of functions) {
384
+ out += renderExportV2(fn);
385
+ }
386
+ }
387
+ out += "\n";
388
+ out += `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
389
+ return out;
390
+ }
391
+
392
+ function renderExportV2(fn) {
393
+ // Same intersection signature as v1 actions (Story 8.2) — every v2 entry is
394
+ // an action. Mirrors `Ruact::ServerFunctions::Codegen::ACTION_SIGNATURE`.
395
+ const signature =
396
+ "((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>)";
397
+ const method = JSON.stringify(String(fn.http_method));
398
+ const pathLit = JSON.stringify(String(fn.path));
399
+ const segs = (fn.segments || []).map((s) => JSON.stringify(String(s))).join(", ");
400
+ const descriptor = `{ method: ${method}, path: ${pathLit}, segments: [${segs}] }`;
401
+ return (
402
+ `export const ${fn.js_identifier}: ${signature} =\n` +
403
+ ` _makeServerFunction(${descriptor});\n`
404
+ );
405
+ }
406
+
407
+ function renderExport(fn) {
408
+ // Story 8.2 (refined 2026-05-17 per review patch R1) — action signature
409
+ // is an intersection of two call signatures so `<form action={fn}>`
410
+ // typechecks DIRECTLY against React 19's `(formData: FormData) =>
411
+ // void | Promise<void>` while direct callers keep the `Promise<unknown>`
412
+ // return surface for `await createPost(...)`. Mirrors
413
+ // `Ruact::ServerFunctions::Codegen::ACTION_SIGNATURE` byte-for-byte.
414
+ const signature =
415
+ fn.kind === "query"
416
+ ? "() => Promise<unknown>"
417
+ : "((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>)";
418
+ // JSON.stringify produces a JSON string literal — escaping backslashes,
419
+ // double quotes, and control characters — so an arbitrary `ruby_symbol`
420
+ // (from a corrupted or hand-edited snapshot) cannot break out of the
421
+ // `_makeRef("<here>")` argument.
422
+ const rubySymLiteral = JSON.stringify(String(fn.ruby_symbol));
423
+ return (
424
+ `export const ${fn.js_identifier}: ${signature} =\n` +
425
+ ` _makeRef(${rubySymLiteral});\n`
426
+ );
427
+ }
428
+
429
+ /**
430
+ * Writes `content` to `outputPath` atomically and only when it differs from
431
+ * the existing file. Returns true if the file was written.
432
+ *
433
+ * @param {string} outputPath
434
+ * @param {string} content
435
+ * @returns {boolean}
436
+ */
437
+ export function writeIfChanged(outputPath, content) {
438
+ if (fs.existsSync(outputPath)) {
439
+ const existing = fs.readFileSync(outputPath, "utf8");
440
+ if (existing === content) return false;
441
+ }
442
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
443
+ const tag = crypto.createHash("sha256").update(content).digest("hex").slice(0, 8);
444
+ const tmp = `${outputPath}.tmp.${process.pid}.${tag}`;
445
+ fs.writeFileSync(tmp, content);
446
+ fs.renameSync(tmp, outputPath);
447
+ return true;
448
+ }
449
+
450
+ /**
451
+ * Reads and parses the bridge JSON. Returns null when the file is missing or
452
+ * malformed so the caller can keep the last-known-good output in place. On
453
+ * malformed JSON (vs. simple absence), logs to stderr per AC9 so the developer
454
+ * gets a signal — the silent-swallow case was flagged in Chunk 2 review.
455
+ *
456
+ * @param {string} jsonPath
457
+ * @returns {object|null}
458
+ */
459
+ export function readSnapshot(jsonPath) {
460
+ if (!fs.existsSync(jsonPath)) return null;
461
+ try {
462
+ return JSON.parse(fs.readFileSync(jsonPath, "utf8"));
463
+ } catch (err) {
464
+ logError(
465
+ `[ruact] failed to parse server-functions bridge JSON at ${jsonPath}: ` +
466
+ `${err?.message ?? err}. The last-good generated module is left intact — ` +
467
+ "re-run `bin/rails ruact:server_functions:generate` to regenerate the snapshot.",
468
+ );
469
+ return null;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves the absolute paths the sidecar reads/writes. Both can be overridden
475
+ * via options so the unit tests can drive the codegen against tmpdir fixtures.
476
+ *
477
+ * @param {string} root
478
+ * @param {object} [opts]
479
+ * @returns {{ snapshotJson: string, generatedTs: string }}
480
+ */
481
+ export function resolvePaths(root, opts = {}) {
482
+ return {
483
+ snapshotJson:
484
+ opts.snapshotJson || path.resolve(root, "tmp/cache/ruact/server-functions.json"),
485
+ generatedTs:
486
+ opts.generatedTs || path.resolve(root, "app/javascript/.ruact/server-functions.ts"),
487
+ };
488
+ }
489
+
490
+ /**
491
+ * One-shot codegen step: read snapshot → render TS → write-if-changed.
492
+ *
493
+ * @param {string} root
494
+ * @param {object} [opts]
495
+ * @returns {{ wrote: boolean, snapshot: object|null }}
496
+ */
497
+ export function generateOnce(root, opts = {}) {
498
+ const { snapshotJson, generatedTs } = resolvePaths(root, opts);
499
+ const snapshot = readSnapshot(snapshotJson);
500
+ if (!snapshot) {
501
+ return { wrote: false, snapshot: null };
502
+ }
503
+ const content = render(snapshot);
504
+ const wrote = writeIfChanged(generatedTs, content);
505
+ return { wrote, snapshot };
506
+ }
507
+
508
+ /**
509
+ * Builds the partial Vite config the sidecar contributes. Sets two
510
+ * `resolve.alias` entries (Story 8.0a AC6 + runtime import path):
511
+ *
512
+ * - `"@"` → `<root>/app/javascript` (only if the host hasn't set it)
513
+ * - `"ruact/server-functions-runtime"` → bundled placeholder package
514
+ *
515
+ * Returns an object suitable as a return value from the Vite `config` hook.
516
+ * Vite merges this with the user-supplied config, so existing aliases survive.
517
+ *
518
+ * The `@` alias value is **best effort** here — `config()` runs before Vite
519
+ * has resolved its root, so the only roots we can read are `userConfig.root`
520
+ * (if the user set one) or `process.cwd()`. When the actual `config.root`
521
+ * differs from both (e.g., Vite launched from a sibling directory with the
522
+ * root passed as a CLI flag merged after `config()` returns), the alias is
523
+ * re-canonicalized against `config.root` in `configResolved` (Re-run patch
524
+ * 2026-05-14). The earlier `@PROJECT_APP_JAVASCRIPT@` sentinel approach
525
+ * violated AC6's "config hook returns the AC shape"; the inline-only
526
+ * approach broke when cwd ≠ Vite root. The two-stage approach satisfies both.
527
+ *
528
+ * @param {object} userConfig
529
+ * @returns {object}
530
+ */
531
+ export function buildConfigContribution(userConfig) {
532
+ return buildContributionInternal(userConfig).contribution;
533
+ }
534
+
535
+ function buildContributionInternal(userConfig) {
536
+ const bestRoot = path.resolve(userConfig?.root || process.cwd());
537
+ const hostAliases = readUserAliasMap(userConfig);
538
+ const aliases = {};
539
+ let bestEffortAtAlias = null;
540
+
541
+ if (hostAliases["@"] === undefined) {
542
+ bestEffortAtAlias = path.resolve(bestRoot, "app/javascript");
543
+ aliases["@"] = bestEffortAtAlias;
544
+ log(
545
+ '[ruact] registered Vite alias "@" → app/javascript ' +
546
+ '(override by setting resolve.alias["@"] in vite.config.js)',
547
+ );
548
+ } else {
549
+ log(
550
+ '[ruact] host vite.config.js defines resolve.alias["@"] — leaving it; ' +
551
+ "ensure it points to app/javascript or the server-functions import will fail",
552
+ );
553
+ }
554
+
555
+ aliases[RUNTIME_IMPORT_SPECIFIER] = runtimePackagePath();
556
+
557
+ return {
558
+ contribution: { resolve: { alias: aliases } },
559
+ bestEffortAtAlias,
560
+ };
561
+ }
562
+
563
+ // Re-run patch 2026-05-14 — when `config.root` differs from the
564
+ // best-effort root we used in `config()`, replace our placeholder with
565
+ // the canonical path. Only touches entries that match what WE wrote, so
566
+ // host-defined aliases are never overwritten.
567
+ function canonicalizeAtAlias(config, bestEffortAtAlias, rootDir) {
568
+ if (bestEffortAtAlias == null) return;
569
+ const canonical = path.resolve(rootDir, "app/javascript");
570
+ if (canonical === bestEffortAtAlias) return;
571
+
572
+ const alias = config?.resolve?.alias;
573
+ if (!alias) return;
574
+ if (Array.isArray(alias)) {
575
+ for (const entry of alias) {
576
+ if (entry?.find === "@" && entry.replacement === bestEffortAtAlias) {
577
+ entry.replacement = canonical;
578
+ return;
579
+ }
580
+ }
581
+ } else if (alias["@"] === bestEffortAtAlias) {
582
+ alias["@"] = canonical;
583
+ }
584
+ }
585
+
586
+ function readUserAliasMap(userConfig) {
587
+ const alias = userConfig?.resolve?.alias;
588
+ if (!alias) return {};
589
+ if (Array.isArray(alias)) {
590
+ const map = {};
591
+ for (const entry of alias) {
592
+ if (entry && typeof entry.find === "string") map[entry.find] = entry.replacement;
593
+ }
594
+ return map;
595
+ }
596
+ return alias;
597
+ }
598
+
599
+ /**
600
+ * Installs all server-functions hooks onto an existing Vite plugin object.
601
+ * Wraps the host plugin's `config`, `configResolved`, `buildStart`, and
602
+ * `configureServer` so the existing react-client-manifest behaviour stays
603
+ * intact while the sidecar's behaviour piggybacks.
604
+ *
605
+ * Each wrapper awaits its upstream handler — Vite hooks may legitimately
606
+ * return Promises, and dropping the await would race the sidecar's behaviour
607
+ * with the host plugin's.
608
+ *
609
+ * @param {object} plugin — the host plugin (mutated in place).
610
+ * @param {object} [options]
611
+ * @returns {object} same plugin, fluent return.
612
+ */
613
+ export function installServerFunctionsHooks(plugin, options = {}) {
614
+ let rootDir;
615
+ let bestEffortAtAlias = null;
616
+
617
+ const originalConfig = plugin.config;
618
+ plugin.config = async function (userConfig, env) {
619
+ const upstream =
620
+ typeof originalConfig === "function"
621
+ ? await originalConfig.call(this, userConfig, env)
622
+ : undefined;
623
+ const { contribution, bestEffortAtAlias: at } = buildContributionInternal(userConfig);
624
+ bestEffortAtAlias = at;
625
+ return mergeConfigs(upstream, contribution);
626
+ };
627
+
628
+ const originalConfigResolved = plugin.configResolved;
629
+ plugin.configResolved = async function (config) {
630
+ rootDir = config.root;
631
+ // Re-canonicalize the @ alias against the FINAL Vite root; the value
632
+ // we wrote in config() was best effort (userConfig.root || cwd) which
633
+ // can differ from the actual config.root.
634
+ canonicalizeAtAlias(config, bestEffortAtAlias, rootDir);
635
+ if (typeof originalConfigResolved === "function") {
636
+ return await originalConfigResolved.call(this, config);
637
+ }
638
+ return undefined;
639
+ };
640
+
641
+ const originalBuildStart = plugin.buildStart;
642
+ plugin.buildStart = async function (opts) {
643
+ let result;
644
+ if (typeof originalBuildStart === "function") {
645
+ result = await originalBuildStart.call(this, opts);
646
+ }
647
+ generateOnce(rootDir, options);
648
+ return result;
649
+ };
650
+
651
+ const originalConfigureServer = plugin.configureServer;
652
+ plugin.configureServer = async function (server) {
653
+ let result;
654
+ if (typeof originalConfigureServer === "function") {
655
+ result = await originalConfigureServer.call(this, server);
656
+ }
657
+ const { snapshotJson, generatedTs } = resolvePaths(rootDir, options);
658
+ const canonicalSnapshots = canonicalPathCandidates(snapshotJson, rootDir);
659
+ server.watcher.add(path.resolve(snapshotJson));
660
+ const onEvent = (file) => {
661
+ // Pass-2 patch 2026-05-14 — chokidar may emit event paths in any of
662
+ // these forms depending on how the watch was registered, the CWD, and
663
+ // whether the watched path crosses a symlink:
664
+ // • absolute, as-is
665
+ // • relative to the Vite root (root-relative)
666
+ // • relative to process.cwd() (cwd-relative)
667
+ // • the realpath of any of the above (symlink-resolved)
668
+ // Match against the full candidate set on each side so we never miss
669
+ // a legitimate snapshot event because of a path-form mismatch.
670
+ for (const c of canonicalPathCandidates(file, rootDir)) {
671
+ if (canonicalSnapshots.has(c)) {
672
+ generateOnce(rootDir, { ...options, snapshotJson, generatedTs });
673
+ return;
674
+ }
675
+ }
676
+ };
677
+ server.watcher.on("change", onEvent);
678
+ server.watcher.on("add", onEvent);
679
+ return result;
680
+ };
681
+
682
+ return plugin;
683
+ }
684
+
685
+ /**
686
+ * Returns the set of absolute path forms that may refer to the same file as
687
+ * `p`, given a Vite `rootDir` for relative-path disambiguation. Includes
688
+ * the path resolved against cwd, resolved against rootDir, and (when the
689
+ * underlying file exists) the realpath of each. Used on both sides of the
690
+ * watcher event comparison to tolerate every chokidar path form.
691
+ */
692
+ function canonicalPathCandidates(p, rootDir) {
693
+ const out = new Set();
694
+ const cwdResolved = path.resolve(p);
695
+ const rootResolved = path.resolve(rootDir, p);
696
+ out.add(cwdResolved);
697
+ out.add(rootResolved);
698
+ const r1 = tryRealpath(cwdResolved);
699
+ if (r1) out.add(r1);
700
+ if (rootResolved !== cwdResolved) {
701
+ const r2 = tryRealpath(rootResolved);
702
+ if (r2) out.add(r2);
703
+ }
704
+ return out;
705
+ }
706
+
707
+ function tryRealpath(p) {
708
+ try {
709
+ return fs.realpathSync(p);
710
+ } catch {
711
+ return null;
712
+ }
713
+ }
714
+
715
+ function mergeConfigs(a, b) {
716
+ if (!a) return b;
717
+ if (!b) return a;
718
+ return {
719
+ ...a,
720
+ ...b,
721
+ resolve: {
722
+ ...(a.resolve || {}),
723
+ ...(b.resolve || {}),
724
+ alias: mergeAliases((a.resolve || {}).alias, (b.resolve || {}).alias),
725
+ },
726
+ };
727
+ }
728
+
729
+ /**
730
+ * Merges two `resolve.alias` values, preserving array form when either side
731
+ * uses it. Earlier versions object-spread both sides, which corrupted upstream
732
+ * array-form aliases into numeric-keyed objects (`{0: entry, 1: entry, ...}`)
733
+ * — Vite then treated them as nothing and silently dropped the aliases.
734
+ */
735
+ function mergeAliases(a, b) {
736
+ if (a == null) return b;
737
+ if (b == null) return a;
738
+ if (Array.isArray(a) || Array.isArray(b)) {
739
+ const toEntries = (alias) => {
740
+ if (Array.isArray(alias)) return [...alias];
741
+ return Object.entries(alias).map(([find, replacement]) => ({ find, replacement }));
742
+ };
743
+ // Vite resolves array-form aliases top-down (first match wins). Our
744
+ // contribution (b) is prepended so it takes precedence — matching the
745
+ // object-merge semantics where `{...a, ...b}` lets b override.
746
+ return [...toEntries(b), ...toEntries(a)];
747
+ }
748
+ return { ...a, ...b };
749
+ }
750
+
751
+ function log(message) {
752
+ if (process.env.RUACT_SILENCE_LOG === "1") return;
753
+ // eslint-disable-next-line no-console
754
+ console.log(message);
755
+ }
756
+
757
+ function logError(message) {
758
+ if (process.env.RUACT_SILENCE_LOG === "1") return;
759
+ // eslint-disable-next-line no-console
760
+ console.error(message);
761
+ }