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,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
|
+
}
|