ruact 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,614 @@
1
+ // Story 8.1 — real server-functions runtime.
2
+ //
3
+ // Replaces the Story 8.0a placeholder. Each export of the generated module
4
+ // `app/javascript/.ruact/server-functions.ts` calls `_makeRef("<symbol>")`
5
+ // and gets back a function that, when invoked, POSTs to
6
+ // `/__ruact/fn/<symbol>` with the args serialized as JSON or FormData.
7
+ //
8
+ // Wire contract (locked by Story 8.0 ADR Decision-log clarification of
9
+ // 2026-05-13, items 2–3): POST for everything (actions AND queries),
10
+ // request body carries the args, response is JSON. CSRF symmetric — the
11
+ // runtime forwards the `<meta name="csrf-token">` value as `X-CSRF-Token`
12
+ // if the meta tag is present in the document (the gem does not impose its
13
+ // own CSRF; the host's `protect_from_forgery` is what enforces).
14
+ //
15
+ // The `_makeRef` export surface and the `"ruact/server-functions-runtime"`
16
+ // import path are part of the locked API — do NOT change without coordinated
17
+ // codegen + Vite-plugin updates.
18
+
19
+ // Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
20
+ // React is declared as a `peerDependency` (every host already has it; the
21
+ // runtime never bundles its own copy). This is the runtime's first React
22
+ // import — the mutation path (`_makeRef` / `_makeServerFunction`) stays
23
+ // React-free.
24
+ import { useState, useEffect } from "react";
25
+
26
+ const RUNTIME_VERSION = 1;
27
+
28
+ // Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
29
+ // in API mode (no session cookie / no CSRF meta tag) need a way to
30
+ // inject auth headers (`Authorization: Bearer …`) on every call.
31
+ // `_makeRef`'s signature is locked by the codegen so we don't widen
32
+ // it; instead, hosts call `configureRuactRuntime` once at app boot
33
+ // to register a headers-producing function. The function runs on
34
+ // every fetch so dynamic tokens (refreshed at runtime) are picked up.
35
+ const runtimeOptions = {
36
+ defaultHeaders: null,
37
+ };
38
+ export function configureRuactRuntime(options) {
39
+ if (options && Object.prototype.hasOwnProperty.call(options, "defaultHeaders")) {
40
+ const value = options.defaultHeaders;
41
+ if (value === null || typeof value === "function" || (typeof value === "object" && value !== null)) {
42
+ runtimeOptions.defaultHeaders = value;
43
+ } else {
44
+ throw new TypeError(
45
+ "configureRuactRuntime: defaultHeaders must be a plain object or a () => object function",
46
+ );
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Re-run-4 (2026-05-15) — structured error class for 4xx/5xx responses.
53
+ *
54
+ * Pre-batch the runtime threw a plain `Error` whose message embedded
55
+ * the status + body. Callers wanting to branch on status (`error.status
56
+ * === 422`) or parse the body had to scrape the message — fragile and
57
+ * not the AC4/AC10 contract. `RuactActionError` exposes the structured
58
+ * fields directly while still carrying a human-readable `message`.
59
+ *
60
+ * The constructor accepts the parsed body (already JSON-decoded if the
61
+ * Content-Type said so) so callers don't re-parse.
62
+ */
63
+ export class RuactActionError extends Error {
64
+ constructor({ name, status, body, response }) {
65
+ const bodyForMessage = typeof body === "string" ? body : JSON.stringify(body);
66
+ super(`ruact action :${name} failed: ${status} ${bodyForMessage}`);
67
+ this.name = "RuactActionError";
68
+ this.actionName = name;
69
+ this.status = status;
70
+ this.body = body;
71
+ this.response = response;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Returns a callable accessor for a server function registered with the
77
+ * given Ruby symbol name. The accessor, when invoked, POSTs the args to
78
+ * `/__ruact/fn/${name}` and resolves with the response (JSON-decoded for
79
+ * application/json responses, text for everything else).
80
+ *
81
+ * Story 8.2 — the returned function accepts up to TWO positional
82
+ * arguments to support React 19's `useActionState` shape:
83
+ *
84
+ * useActionState(action, initialState)
85
+ *
86
+ * calls `action(prevState, formData)` on every submit. `_makeRef` picks
87
+ * the FormData-typed candidate from the call and discards the prevState
88
+ * argument silently — prev-state is a client-only concern, never
89
+ * transmitted to the server. The single-arg shape (`fn(args)` from event
90
+ * handlers; `<form action={fn}>` passing FormData directly) is preserved.
91
+ *
92
+ * Argument shape selection rules (first match wins):
93
+ * - 0 args → JSON body, `{}`
94
+ * - 1 arg, FormData → multipart
95
+ * - 1 arg, plain object / null / undefined → JSON body
96
+ * - 2 args, FormData in either slot → multipart (FormData wins;
97
+ * the other arg is discarded)
98
+ * - 2 args, neither FormData → JSON body of the SECOND arg
99
+ * (the `useActionState` payload
100
+ * slot); first arg discarded
101
+ * as prev-state
102
+ * - 3+ args → TypeError
103
+ *
104
+ * @param {string} name
105
+ * @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
106
+ */
107
+ export function _makeRef(name) {
108
+ return function ruactServerFunctionCall(...callArgs) {
109
+ if (callArgs.length > 2) {
110
+ throw new TypeError(
111
+ `ruact action :${name} called with ${callArgs.length} arguments — ` +
112
+ "expected 0, 1, or 2 (the useActionState shape)",
113
+ );
114
+ }
115
+ return ruactPost(name, pickWirePayload(callArgs));
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Story 9.3 — the route-driven (v2) accessor. The codegen emits
121
+ * `_makeServerFunction({ method, path, segments })` for every non-GET routed
122
+ * action on a `Ruact::Server` controller (instead of v1's `_makeRef("<sym>")`).
123
+ * The returned callable targets the REAL Rails route + verb (e.g. `POST /posts`,
124
+ * `PUT /posts/:id`) rather than the v1 synthetic `POST /__ruact/fn/:name`.
125
+ *
126
+ * It shares the exact fetch core (`ruactInvoke`) with `_makeRef` — FormData
127
+ * branching, CSRF meta injection, text-first parsing, `RuactActionError`,
128
+ * `redirect: "error"` — so all the salvaged 8.1/8.2 behaviors are preserved.
129
+ * Two additions over v1:
130
+ * - **Path-param interpolation (D7):** dynamic `:id`-style segments are read
131
+ * BY NAME from the single call argument (FormData.get / object property) and
132
+ * substituted into the path; the full argument is still sent as the body
133
+ * (Rails reads `params[:id]` from the path, ignores the duplicate). The
134
+ * single-arg shape (8.2 `<form action>` / `useActionState`) is unchanged.
135
+ * - **`$redirect` follow (9.2 → 9.3):** when the Bucket-2 response is
136
+ * `{ "$redirect": "<path>" }`, the runtime navigates via the router handoff
137
+ * (`globalThis.__ruact_navigate`, `window.location.assign` fallback) and
138
+ * resolves `null`.
139
+ *
140
+ * @param {{ method: string, path: string, segments?: string[] }} descriptor
141
+ * @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
142
+ */
143
+ export function _makeServerFunction(descriptor) {
144
+ const { method, path, segments = [] } = descriptor || {};
145
+ return async function ruactServerFunctionCall(...callArgs) {
146
+ if (callArgs.length > 2) {
147
+ throw new TypeError(
148
+ `ruact server function ${method} ${path} called with ${callArgs.length} arguments — ` +
149
+ "expected 0, 1, or 2 (the useActionState shape)",
150
+ );
151
+ }
152
+ const args = pickWirePayload(callArgs);
153
+ const url = interpolatePath(path, segments, args);
154
+ const parsed = await ruactInvoke({ method, url, args, label: path });
155
+ return followRedirectIfPresent(parsed);
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Story 9.5 — the read-side (query) accessor. The codegen emits
161
+ * `_makeQuery({ path, kind: "query" })` for every method on a mounted
162
+ * `Ruact::Query` subclass. The returned callable issues a **GET** to the
163
+ * named query route (`GET /q/<jsId>`), serializing `params` into the query
164
+ * string.
165
+ *
166
+ * Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum voids the old
167
+ * `POST /__ruact/fn/:id` query mechanism and restores HTTP GET semantics):
168
+ * no request body, no `X-CSRF-Token`. It shares `parseResponse` /
169
+ * `RuactActionError` / `redirect: "error"` with the mutation path so the
170
+ * success + failure shapes are identical.
171
+ *
172
+ * Usually consumed through {useQuery}, but the returned function is a plain
173
+ * `(params?) => Promise<unknown>` so it also works in imperative code.
174
+ *
175
+ * FR88 wire format: only primitives (string / number / boolean / null) are
176
+ * encoded into the query string; arrays and objects throw a `TypeError`
177
+ * client-side too — though the server-side sanitization is authoritative.
178
+ *
179
+ * @param {{ path: string, kind?: string }} descriptor
180
+ * @returns {(params?: Record<string, unknown>) => Promise<unknown>}
181
+ */
182
+ export function _makeQuery(descriptor) {
183
+ const { path } = descriptor || {};
184
+ return function ruactQueryCall(params) {
185
+ const url = buildQueryUrl(path, params);
186
+ return ruactQueryGet(url, path);
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Story 9.5 — React hook for reading a server query. Pass a query reference
192
+ * (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
193
+ * issues `GET /q/<jsId>` on mount (and whenever the serialized params change)
194
+ * and returns `{ data, loading, error }`:
195
+ *
196
+ * - `loading` is true until the first resolution;
197
+ * - `data` carries the JSON-decoded response on success (and the last
198
+ * successful value while a subsequent refetch is in flight);
199
+ * - `error` carries the structured `RuactActionError` (or a transport
200
+ * `Error`) on failure, and is reset to `null` on a successful refetch.
201
+ *
202
+ * A superseded in-flight response (params changed, or the component
203
+ * unmounted) is dropped — the hook never sets state for a stale request.
204
+ *
205
+ * Request de-duplication across components is Story 9.6; this hook fetches
206
+ * once per mount.
207
+ *
208
+ * @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
209
+ * @param {Record<string, unknown>} [params]
210
+ * @returns {{ data: unknown, loading: boolean, error: unknown }}
211
+ */
212
+ export function useQuery(reference, params) {
213
+ const [state, setState] = useState({ data: undefined, loading: true, error: null });
214
+
215
+ // Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
216
+ // is a fresh object every render, which would refetch on every render if used
217
+ // directly as a dependency. Serializing collapses equal params to one key.
218
+ const paramsKey = params == null ? "" : JSON.stringify(params);
219
+
220
+ useEffect(() => {
221
+ let active = true;
222
+ setState((prev) => ({ data: prev.data, loading: true, error: null }));
223
+ // Wrap in Promise.resolve so a synchronous throw inside `reference`
224
+ // (e.g. a non-primitive param rejected by `buildQueryUrl`) lands on the
225
+ // error branch instead of escaping the effect.
226
+ Promise.resolve()
227
+ .then(() => reference(params))
228
+ .then((data) => {
229
+ if (active) setState({ data, loading: false, error: null });
230
+ })
231
+ .catch((error) => {
232
+ if (active) setState((prev) => ({ data: prev.data, loading: false, error }));
233
+ });
234
+ return () => {
235
+ active = false;
236
+ };
237
+ // `params` is intentionally tracked via the serialized `paramsKey`.
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [reference, paramsKey]);
240
+
241
+ return state;
242
+ }
243
+
244
+ // Story 8.2 — picks the argument the wire request should serialize from
245
+ // `_makeRef`'s call-args, following the rules documented in the JSDoc
246
+ // above. Exported through `__internals` for the vitest suite (AC10) — it
247
+ // is intentionally NOT part of the public runtime surface.
248
+ function pickWirePayload(callArgs) {
249
+ const isFD = (v) => typeof FormData !== "undefined" && v instanceof FormData;
250
+ if (callArgs.length === 0) return undefined;
251
+ if (callArgs.length === 1) return callArgs[0];
252
+ // 2 args. Prefer a FormData candidate regardless of position.
253
+ if (isFD(callArgs[1])) return callArgs[1];
254
+ if (isFD(callArgs[0])) return callArgs[0];
255
+ // Defensive: useActionState's normal payload is in slot 1 (slot 0 is
256
+ // prev-state); echo that ordering for the non-FormData case too. The
257
+ // prevState shape is opaque (any React-state value) — never sent to
258
+ // the server.
259
+ return callArgs[1];
260
+ }
261
+
262
+ export const __RUNTIME_VERSION__ = RUNTIME_VERSION;
263
+
264
+ /**
265
+ * Story 8.2 — issues a Flight refetch of the supplied path (or the
266
+ * current URL when omitted) and swaps the React tree in place. The
267
+ * runtime side is intentionally thin: it reads a globally-published
268
+ * handle (`globalThis.__ruact_revalidate`) that `ruact-router.js`'s
269
+ * `setupRouter()` registers at app boot, and delegates the actual
270
+ * refetch to the router's existing `navigate()` machinery (push: false,
271
+ * scroll: false — semantically: refetch in place, no history entry).
272
+ *
273
+ * Why globalThis instead of a direct import: the runtime ships inside
274
+ * the gem (`gem/vendor/javascript/...`) and the router lives inside the
275
+ * host app (`app/javascript/ruact-router.js`). Direct imports would
276
+ * couple the two packages and force the router into the gem; the
277
+ * globalThis handoff keeps the runtime portable.
278
+ *
279
+ * Throws when called without an installed router so a misconfigured
280
+ * app fails LOUDLY at the first call rather than silently no-op'ing.
281
+ *
282
+ * @param {string} [path] Optional path to refetch. Defaults to the
283
+ * current `location.pathname + location.search`.
284
+ * @returns {Promise<void>}
285
+ */
286
+ export async function revalidate(path) {
287
+ const handle = typeof globalThis !== "undefined" ? globalThis.__ruact_revalidate : undefined;
288
+ if (typeof handle !== "function") {
289
+ throw new Error(
290
+ "ruact: revalidate() called but no router is installed — wire setupRouter() in your application.jsx",
291
+ );
292
+ }
293
+ const target =
294
+ path != null
295
+ ? path
296
+ : typeof location !== "undefined"
297
+ ? location.pathname + location.search
298
+ : "/";
299
+ return handle(target);
300
+ }
301
+
302
+ // Exported for tests; intentionally NOT part of the public API surface
303
+ // the codegen consumes. The vitest suite stubs `globalThis.fetch` and
304
+ // asserts the request shape — exporting these helpers keeps the tests
305
+ // honest without leaking surface to host apps.
306
+ export const __internals = {
307
+ buildFetchInit,
308
+ resolveCsrfToken,
309
+ parseResponse,
310
+ pickWirePayload,
311
+ interpolatePath,
312
+ followRedirectIfPresent,
313
+ buildQueryUrl,
314
+ buildQueryFetchInit,
315
+ };
316
+
317
+ // v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
318
+ // shared `ruactInvoke` core; the URL, verb, and error label are exactly what
319
+ // 8.1 used so the v1 path stays byte-behavior-identical (Story 9.3 AC6).
320
+ //
321
+ // Re-run-3 (2026-05-15) — `encodeURIComponent(name)` so a stray `/`, `?`, or
322
+ // `#` in a name (only reachable through direct/buggy `_makeRef` calls — the
323
+ // gem-side route constraint and the codegen validator both refuse
324
+ // non-identifier characters) cannot rewrite the path or hijack the
325
+ // query/fragment of the request URL.
326
+ function ruactPost(name, args) {
327
+ return ruactInvoke({
328
+ method: "POST",
329
+ url: `/__ruact/fn/${encodeURIComponent(name)}`,
330
+ args,
331
+ label: name,
332
+ });
333
+ }
334
+
335
+ // Story 9.3 — the shared fetch core for BOTH v1 (`_makeRef`) and v2
336
+ // (`_makeServerFunction`). Extracted verbatim from the original `ruactPost` so
337
+ // neither path drifts: same FormData branching, CSRF injection, `redirect:
338
+ // "error"`, text-first parsing, and structured `RuactActionError`. The only
339
+ // parameters are the verb + URL + the wire payload + a human label for errors.
340
+ async function ruactInvoke({ method, url, args, label }) {
341
+ const init = buildFetchInit(args, method);
342
+ let response;
343
+ try {
344
+ response = await fetch(url, init);
345
+ } catch (err) {
346
+ throw new Error(
347
+ `ruact action :${label} request failed: ${err?.message ?? err}`,
348
+ );
349
+ }
350
+ if (!response.ok) {
351
+ // Re-run-4 (2026-05-15) — throw a structured `RuactActionError` so
352
+ // callers can branch on `status` / `body` instead of scraping the
353
+ // message string. We parse the body the same way as a successful
354
+ // response (JSON when CT says so, otherwise text) so the shape is
355
+ // consistent across the success and failure paths.
356
+ const body = await safeParseBody(response);
357
+ throw new RuactActionError({ name: label, status: response.status, body, response });
358
+ }
359
+ return parseResponse(response);
360
+ }
361
+
362
+ // Story 9.5 — the GET fetch core for queries. Mirrors `ruactInvoke`'s
363
+ // structure (loud transport error, structured `RuactActionError` on !ok,
364
+ // shared `parseResponse`) but for a read: GET, no body, no CSRF.
365
+ async function ruactQueryGet(url, label) {
366
+ let response;
367
+ try {
368
+ response = await fetch(url, buildQueryFetchInit());
369
+ } catch (err) {
370
+ throw new Error(
371
+ `ruact query ${label} request failed: ${err?.message ?? err}`,
372
+ );
373
+ }
374
+ if (!response.ok) {
375
+ const body = await safeParseBody(response);
376
+ throw new RuactActionError({ name: label, status: response.status, body, response });
377
+ }
378
+ return parseResponse(response);
379
+ }
380
+
381
+ // Story 9.5 — serialize the params object into the GET query string. FR88
382
+ // wire format: only primitives (string / number / boolean / null) are
383
+ // encoded. Arrays and objects throw a `TypeError` client-side for immediate
384
+ // local feedback; the server-side sanitization in `query_dispatch.rb` is the
385
+ // authoritative gate. `null` is encoded as an empty value (`key=`).
386
+ function buildQueryUrl(path, params) {
387
+ if (params == null) return path;
388
+ if (typeof params !== "object" || Array.isArray(params)) {
389
+ throw new TypeError(
390
+ `ruact useQuery for ${path}: params must be a plain object of ` +
391
+ "string / number / boolean / null values",
392
+ );
393
+ }
394
+ const search = new URLSearchParams();
395
+ // `null` is sent as a BARE key (`?q`, no `=`) — Rack parses that as `nil`,
396
+ // whereas `?q=` parses as `""`. This keeps `null` distinguishable from the
397
+ // empty string at the Ruby query-method boundary (the server allowlist
398
+ // accepts both `nil` and `String`). Bare keys are collected separately
399
+ // because `URLSearchParams` always emits `key=`; their order relative to
400
+ // the `=`-valued params is irrelevant server-side.
401
+ const bareKeys = [];
402
+ for (const [key, value] of Object.entries(params)) {
403
+ if (value === undefined) continue;
404
+ if (value === null) {
405
+ bareKeys.push(encodeURIComponent(key));
406
+ } else if (
407
+ typeof value === "string" ||
408
+ typeof value === "number" ||
409
+ typeof value === "boolean"
410
+ ) {
411
+ search.append(key, String(value));
412
+ } else {
413
+ throw new TypeError(
414
+ `ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
415
+ "boolean, or null — arrays and objects are rejected",
416
+ );
417
+ }
418
+ }
419
+ const qs = [search.toString(), ...bareKeys].filter((s) => s.length > 0).join("&");
420
+ return qs.length === 0 ? path : `${path}?${qs}`;
421
+ }
422
+
423
+ // Story 9.5 — fetch init for a query GET. No body, no `X-CSRF-Token` (reads
424
+ // are CSRF-free). `defaultHeaders` still apply (e.g. an API-mode
425
+ // `Authorization: Bearer …`), with the gem's own `Accept` winning. Keeps
426
+ // `redirect: "error"` so an auth `redirect_to "/login"` surfaces as a loud
427
+ // failure rather than resolving with the login page HTML.
428
+ function buildQueryFetchInit() {
429
+ const RESERVED = new Set(["accept"]);
430
+ const extra =
431
+ typeof runtimeOptions.defaultHeaders === "function"
432
+ ? runtimeOptions.defaultHeaders()
433
+ : runtimeOptions.defaultHeaders;
434
+ const headers = {};
435
+ if (extra && typeof extra === "object") {
436
+ for (const [key, value] of Object.entries(extra)) {
437
+ if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
438
+ }
439
+ }
440
+ headers.Accept = "application/json";
441
+ return { method: "GET", credentials: "same-origin", redirect: "error", headers };
442
+ }
443
+
444
+ // Story 9.3 (D7) — substitute dynamic path segments (`:id`, …) from the single
445
+ // call argument. Values are read BY NAME (FormData.get / object property); the
446
+ // argument is still sent as the body. A missing required segment fails loudly
447
+ // rather than silently POSTing to a malformed URL.
448
+ function interpolatePath(path, segments, args) {
449
+ let url = path;
450
+ for (const seg of segments) {
451
+ const value = readSegment(args, seg);
452
+ if (value == null || value === "") {
453
+ throw new TypeError(
454
+ `ruact server function for ${path} requires path segment ":${seg}", ` +
455
+ "but it was missing from the call argument",
456
+ );
457
+ }
458
+ // Whole-token replace so `:id` does not clobber the prefix of a longer
459
+ // segment name (e.g. `:id` within `:id_extra`).
460
+ const token = new RegExp(`:${seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_])`);
461
+ url = url.replace(token, encodeURIComponent(String(value)));
462
+ }
463
+ return url;
464
+ }
465
+
466
+ function readSegment(args, seg) {
467
+ if (typeof FormData !== "undefined" && args instanceof FormData) return args.get(seg);
468
+ if (args && typeof args === "object") return args[seg];
469
+ return undefined;
470
+ }
471
+
472
+ // Story 9.3 (AC8 / D2) — the client half of the `$redirect` contract 9.2
473
+ // deferred. A Bucket-2 mutation that `redirect_to`s returns
474
+ // `{ "$redirect": "<path>" }`; follow it via the router handoff and resolve
475
+ // `null` (consistent with the 204→null contract). Only applied on the v2 path
476
+ // — the v1 endpoint never emits `$redirect`, so `_makeRef` is unaffected (AC6).
477
+ function followRedirectIfPresent(parsed) {
478
+ if (
479
+ parsed &&
480
+ typeof parsed === "object" &&
481
+ !Array.isArray(parsed) &&
482
+ typeof parsed.$redirect === "string"
483
+ ) {
484
+ navigateTo(parsed.$redirect);
485
+ return null;
486
+ }
487
+ return parsed;
488
+ }
489
+
490
+ function navigateTo(target) {
491
+ const nav = typeof globalThis !== "undefined" ? globalThis.__ruact_navigate : undefined;
492
+ if (typeof nav === "function") {
493
+ nav(target);
494
+ return;
495
+ }
496
+ // No router installed — hard-navigate so the redirect is never silently
497
+ // dropped (mirrors `revalidate()`'s loud-by-default stance, but a redirect
498
+ // CAN fall back to a full page load where a refetch cannot).
499
+ if (typeof window !== "undefined" && window.location && typeof window.location.assign === "function") {
500
+ window.location.assign(target);
501
+ }
502
+ }
503
+
504
+ function buildFetchInit(args, method = "POST") {
505
+ // Re-run-2 (2026-05-14) — `Accept: application/json` so the host's
506
+ // `respond_to` / `before_action` / `rescue_from` logic sees a JSON
507
+ // request format. Without it, Rails' default for a POST to an HTML
508
+ // controller would select the HTML branch — surprising in actions
509
+ // expected to return structured JSON.
510
+ // Re-run-5 — start the headers map with defaultHeaders (so the gem's
511
+ // own keys, set below, OVERRIDE them) and then layer the gem's own
512
+ // keys on top. `Accept`, `Content-Type`, and `X-CSRF-Token` are the
513
+ // gem's responsibility — `configureRuactRuntime({ defaultHeaders })`
514
+ // can't silently downgrade CSRF or swap the response negotiation.
515
+ // Re-run-6 (2026-05-15) — match header names case-insensitively when
516
+ // filtering reserved keys from the caller-provided `defaultHeaders`.
517
+ // HTTP header names are case-insensitive (RFC 9110 §5.1), so a host
518
+ // passing `{ accept: "text/html" }` or `{ "content-type": "..." }`
519
+ // would otherwise survive the gem's own assignment (object keys are
520
+ // case-sensitive in JS) and either downgrade the Accept negotiation
521
+ // or — for the FormData branch — kill the multipart boundary the
522
+ // browser sets automatically.
523
+ const RESERVED = new Set(["accept", "content-type", "x-csrf-token"]);
524
+ const extra = typeof runtimeOptions.defaultHeaders === "function"
525
+ ? runtimeOptions.defaultHeaders()
526
+ : runtimeOptions.defaultHeaders;
527
+ const headers = {};
528
+ if (extra && typeof extra === "object") {
529
+ for (const [key, value] of Object.entries(extra)) {
530
+ if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
531
+ }
532
+ }
533
+ headers.Accept = "application/json";
534
+ const csrf = resolveCsrfToken();
535
+ if (csrf) headers["X-CSRF-Token"] = csrf;
536
+
537
+ let body;
538
+ if (typeof FormData !== "undefined" && args instanceof FormData) {
539
+ // Let the browser set the `Content-Type: multipart/form-data; boundary=...`
540
+ // header — don't set it manually. The case-insensitive filter above
541
+ // already stripped any `Content-Type` from defaultHeaders, so the
542
+ // browser is in control here.
543
+ body = args;
544
+ } else {
545
+ headers["Content-Type"] = "application/json";
546
+ body = JSON.stringify(args ?? {});
547
+ }
548
+
549
+ return {
550
+ method,
551
+ credentials: "same-origin",
552
+ // Re-run-5 (2026-05-15) — `redirect: "error"` so the runtime
553
+ // FAILS LOUDLY when a host `before_action` `redirect_to "/login"`
554
+ // (auth filter) issues a 302. Default fetch follows redirects
555
+ // silently and would resolve with the eventual HTML login page
556
+ // body — masking auth failures from the caller. The structured
557
+ // `RuactActionError` path is the right surface for "not allowed".
558
+ redirect: "error",
559
+ headers,
560
+ body,
561
+ };
562
+ }
563
+
564
+ function resolveCsrfToken() {
565
+ if (typeof document === "undefined") return null;
566
+ const meta = document.querySelector('meta[name="csrf-token"]');
567
+ return meta?.getAttribute("content") || null;
568
+ }
569
+
570
+ async function parseResponse(response) {
571
+ // Re-run-2 (2026-05-14) — read the response as TEXT first, then attempt
572
+ // JSON parse if the body is non-empty AND the Content-Type indicates
573
+ // JSON. This handles ALL empty-body cases (`head :no_content` (204),
574
+ // `head :ok` (200 + empty body), `head :reset_content` (205), etc.)
575
+ // uniformly: empty body → null, regardless of Content-Type. Earlier
576
+ // versions parsed JSON eagerly and failed `SyntaxError` on these.
577
+ const text = await response.text();
578
+ if (text.length === 0) return null;
579
+ // Re-run-3 (2026-05-15) — Content-Type matching is case-insensitive
580
+ // per RFC 9110 §8.3.1 (`Application/JSON` and `application/json` are
581
+ // the same media type).
582
+ // Re-run-4 (2026-05-15) — also accept structured-syntax-suffix JSON
583
+ // types per RFC 6838 §4.2.8: `application/problem+json`,
584
+ // `application/vnd.api+json`, etc. The regex matches the literal
585
+ // `application/json` and any `+json` suffix.
586
+ const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
587
+ if (/^application\/(json|.*\+json)\b/.test(contentType)) {
588
+ return JSON.parse(text);
589
+ }
590
+ return text;
591
+ }
592
+
593
+ async function safeParseBody(response) {
594
+ // Mirror `parseResponse`'s logic but never let a parse failure crash
595
+ // the error path — if the JSON parse fails we fall back to the raw
596
+ // text. The error path is already a sad path; surfacing a second
597
+ // exception inside it would hide the real failure from the caller.
598
+ let text;
599
+ try {
600
+ text = await response.text();
601
+ } catch {
602
+ return "<unreadable body>";
603
+ }
604
+ if (text.length === 0) return null;
605
+ const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
606
+ if (/^application\/(json|.*\+json)\b/.test(contentType)) {
607
+ try {
608
+ return JSON.parse(text);
609
+ } catch {
610
+ return text;
611
+ }
612
+ }
613
+ return text;
614
+ }