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