ruact 0.0.3 → 0.0.5
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/CHANGELOG.md +4 -0
- data/docs/internal/decisions/server-functions-api.md +99 -0
- data/lib/ruact/errors.rb +11 -0
- data/lib/ruact/server_functions/codegen.rb +14 -0
- data/lib/ruact/server_functions/codegen_v2.rb +46 -10
- data/lib/ruact/server_functions/error_rendering.rb +2 -0
- data/lib/ruact/server_functions/name_bridge.rb +11 -6
- data/lib/ruact/server_functions/query_dispatch.rb +73 -8
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions.rb +38 -2
- data/lib/ruact/version.rb +1 -1
- data/spec/ruact/query_request_spec.rb +154 -2
- data/spec/ruact/server_functions/codegen_spec.rb +82 -3
- data/spec/ruact/server_functions/name_bridge_spec.rb +24 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +67 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +36 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +276 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +9 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +368 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +56 -13
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +95 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +7 -1
- metadata +4 -1
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// import path are part of the locked API — do NOT change without coordinated
|
|
17
17
|
// codegen + Vite-plugin updates.
|
|
18
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
|
+
|
|
19
26
|
const RUNTIME_VERSION = 1;
|
|
20
27
|
|
|
21
28
|
// Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
|
|
@@ -149,6 +156,182 @@ export function _makeServerFunction(descriptor) {
|
|
|
149
156
|
};
|
|
150
157
|
}
|
|
151
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
|
+
// Story 9.6 — in-flight request de-duplication for `useQuery`. Identical
|
|
191
|
+
// concurrent calls (same query reference + same params) share ONE network
|
|
192
|
+
// request instead of each firing its own GET. The registry maps a query
|
|
193
|
+
// reference (the codegen-emitted `_makeQuery` accessor — stable module identity,
|
|
194
|
+
// so the same import is the same key) to a `Map` of canonical-params-key →
|
|
195
|
+
// in-flight Promise. Each promise is removed the instant it settles, so dedup is
|
|
196
|
+
// strictly IN-FLIGHT-ONLY: no TTL, no stale-while-revalidate (Story 9.7 documents
|
|
197
|
+
// that scope). A mount AFTER the previous request settled always refetches.
|
|
198
|
+
//
|
|
199
|
+
// Keyed by the reference FUNCTION via a WeakMap so dynamically-created references
|
|
200
|
+
// can be GC'd; the inner `Map` self-empties as requests settle. `let` (not
|
|
201
|
+
// `const`) so the test-only `__resetQueryDedup` can swap a fresh registry in
|
|
202
|
+
// between cases — `dedupedQuery` closes over the binding, so the swap is seen.
|
|
203
|
+
let inflightQueries = new WeakMap();
|
|
204
|
+
|
|
205
|
+
// Order-independent dedup key, used BOTH as the registry key and as the
|
|
206
|
+
// `useEffect` dependency. It mirrors the WIRE output of `buildQueryUrl` exactly
|
|
207
|
+
// (the per-param tokens, sorted so order doesn't matter), so two param objects
|
|
208
|
+
// share a request IFF they produce the SAME query string — the true "same
|
|
209
|
+
// network request" equivalence. Building from the wire (not `JSON.stringify`)
|
|
210
|
+
// is deliberate: `JSON.stringify` maps `NaN`/`Infinity`/`-Infinity` to `null`,
|
|
211
|
+
// which would collide those distinct wire values (`?q=NaN` vs bare `?q`) onto
|
|
212
|
+
// one key. Mirrors `buildQueryUrl`'s rules: `undefined` skipped, `null` → bare
|
|
213
|
+
// key, string/number/boolean → `key=String(value)`.
|
|
214
|
+
//
|
|
215
|
+
// Returns `null` to signal a NON-SHAREABLE shape (a non-object/array top level,
|
|
216
|
+
// or an array/object VALUE) — exactly the inputs `buildQueryUrl` rejects with a
|
|
217
|
+
// `TypeError`. `dedupedQuery` bypasses the registry for those so the reference
|
|
218
|
+
// is still invoked and the error surfaces (Story 9.5 behavior), instead of an
|
|
219
|
+
// invalid call wrongly joining an in-flight no-param request. `null`/no params
|
|
220
|
+
// both serialize to "".
|
|
221
|
+
function canonicalParamsKey(params) {
|
|
222
|
+
if (params == null) return "";
|
|
223
|
+
if (typeof params !== "object" || Array.isArray(params)) return null;
|
|
224
|
+
const tokens = [];
|
|
225
|
+
for (const key of Object.keys(params)) {
|
|
226
|
+
const value = params[key];
|
|
227
|
+
if (value === undefined) continue;
|
|
228
|
+
if (value === null) {
|
|
229
|
+
tokens.push(encodeURIComponent(key)); // bare key — mirrors buildQueryUrl
|
|
230
|
+
} else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
231
|
+
tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
232
|
+
} else {
|
|
233
|
+
return null; // array/object value — buildQueryUrl throws; non-shareable
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
tokens.sort();
|
|
237
|
+
return tokens.join("&");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Share the in-flight request for (reference, canonical params). The first
|
|
241
|
+
// caller creates the promise and registers it; concurrent callers with the same
|
|
242
|
+
// key get the SAME promise back — one fetch, shared resolution, shared error.
|
|
243
|
+
// The settle handler removes the entry, guarded by promise identity so a newer
|
|
244
|
+
// in-flight request for the same key (started after this one settled but before
|
|
245
|
+
// its handler ran) is never clobbered. A rejected promise is removed the same
|
|
246
|
+
// way, so a failed shared request does not poison the key — the next mount
|
|
247
|
+
// retries fresh.
|
|
248
|
+
function dedupedQuery(reference, params) {
|
|
249
|
+
const key = canonicalParamsKey(params);
|
|
250
|
+
// A `null` key marks a non-shareable shape (the inputs `buildQueryUrl`
|
|
251
|
+
// rejects). Bypass the registry entirely so the reference is invoked and its
|
|
252
|
+
// `TypeError` surfaces on the error path — never share onto another request.
|
|
253
|
+
if (key === null) {
|
|
254
|
+
return Promise.resolve().then(() => reference(params));
|
|
255
|
+
}
|
|
256
|
+
let perRef = inflightQueries.get(reference);
|
|
257
|
+
if (perRef) {
|
|
258
|
+
const existing = perRef.get(key);
|
|
259
|
+
if (existing) return existing;
|
|
260
|
+
} else {
|
|
261
|
+
perRef = new Map();
|
|
262
|
+
inflightQueries.set(reference, perRef);
|
|
263
|
+
}
|
|
264
|
+
// Wrap in Promise.resolve so a SYNCHRONOUS throw inside `reference` (e.g. a
|
|
265
|
+
// non-primitive param rejected by `buildQueryUrl`) becomes a rejected promise
|
|
266
|
+
// on the error path, preserving the Story 9.5 "sync throw surfaces as error"
|
|
267
|
+
// behavior.
|
|
268
|
+
const promise = Promise.resolve().then(() => reference(params));
|
|
269
|
+
perRef.set(key, promise);
|
|
270
|
+
const forget = () => {
|
|
271
|
+
if (perRef.get(key) === promise) perRef.delete(key);
|
|
272
|
+
};
|
|
273
|
+
// `.then(forget, forget)` (not `.finally`) so the cleanup branch swallows a
|
|
274
|
+
// rejection rather than spawning a derived promise that could surface as an
|
|
275
|
+
// unhandled rejection. The original `promise` is what callers handle.
|
|
276
|
+
promise.then(forget, forget);
|
|
277
|
+
return promise;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Story 9.5 — React hook for reading a server query. Pass a query reference
|
|
282
|
+
* (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
|
|
283
|
+
* issues `GET /q/<jsId>` on mount (and whenever the serialized params change)
|
|
284
|
+
* and returns `{ data, loading, error }`:
|
|
285
|
+
*
|
|
286
|
+
* - `loading` is true until the first resolution;
|
|
287
|
+
* - `data` carries the JSON-decoded response on success (and the last
|
|
288
|
+
* successful value while a subsequent refetch is in flight);
|
|
289
|
+
* - `error` carries the structured `RuactActionError` (or a transport
|
|
290
|
+
* `Error`) on failure, and is reset to `null` on a successful refetch.
|
|
291
|
+
*
|
|
292
|
+
* A superseded in-flight response (params changed, or the component
|
|
293
|
+
* unmounted) is dropped — the hook never sets state for a stale request.
|
|
294
|
+
*
|
|
295
|
+
* Story 9.6 — identical CONCURRENT calls (same reference + same params,
|
|
296
|
+
* order-independent) share ONE in-flight request. Dedup is in-flight only:
|
|
297
|
+
* once the request settles the shared entry is dropped, so a fresh mount
|
|
298
|
+
* refetches — there is no TTL cache and no stale-while-revalidate.
|
|
299
|
+
*
|
|
300
|
+
* @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
|
|
301
|
+
* @param {Record<string, unknown>} [params]
|
|
302
|
+
* @returns {{ data: unknown, loading: boolean, error: unknown }}
|
|
303
|
+
*/
|
|
304
|
+
export function useQuery(reference, params) {
|
|
305
|
+
const [state, setState] = useState({ data: undefined, loading: true, error: null });
|
|
306
|
+
|
|
307
|
+
// Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
|
|
308
|
+
// is a fresh object every render, which would refetch on every render if used
|
|
309
|
+
// directly as a dependency. The canonical (sorted-key) serialization collapses
|
|
310
|
+
// equal params — including reordered keys — to one string, and is the SAME key
|
|
311
|
+
// the dedup registry uses, so the effect dependency and the shared-request
|
|
312
|
+
// identity stay in lockstep.
|
|
313
|
+
const paramsKey = canonicalParamsKey(params);
|
|
314
|
+
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
let active = true;
|
|
317
|
+
setState((prev) => ({ data: prev.data, loading: true, error: null }));
|
|
318
|
+
dedupedQuery(reference, params)
|
|
319
|
+
.then((data) => {
|
|
320
|
+
if (active) setState({ data, loading: false, error: null });
|
|
321
|
+
})
|
|
322
|
+
.catch((error) => {
|
|
323
|
+
if (active) setState((prev) => ({ data: prev.data, loading: false, error }));
|
|
324
|
+
});
|
|
325
|
+
return () => {
|
|
326
|
+
active = false;
|
|
327
|
+
};
|
|
328
|
+
// `params` is intentionally tracked via the serialized `paramsKey`.
|
|
329
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
330
|
+
}, [reference, paramsKey]);
|
|
331
|
+
|
|
332
|
+
return state;
|
|
333
|
+
}
|
|
334
|
+
|
|
152
335
|
// Story 8.2 — picks the argument the wire request should serialize from
|
|
153
336
|
// `_makeRef`'s call-args, following the rules documented in the JSDoc
|
|
154
337
|
// above. Exported through `__internals` for the vitest suite (AC10) — it
|
|
@@ -218,6 +401,17 @@ export const __internals = {
|
|
|
218
401
|
pickWirePayload,
|
|
219
402
|
interpolatePath,
|
|
220
403
|
followRedirectIfPresent,
|
|
404
|
+
buildQueryUrl,
|
|
405
|
+
buildQueryFetchInit,
|
|
406
|
+
// Story 9.6 — exposed for the dedup vitest suite. `canonicalParamsKey` is the
|
|
407
|
+
// order-independent dedup/effect key; `__resetQueryDedup` swaps a fresh
|
|
408
|
+
// in-flight registry so a test asserting mid-flight state starts from a clean
|
|
409
|
+
// slate (the registry self-empties on settle, so this only matters for tests
|
|
410
|
+
// that deliberately leave a request pending).
|
|
411
|
+
canonicalParamsKey,
|
|
412
|
+
__resetQueryDedup: () => {
|
|
413
|
+
inflightQueries = new WeakMap();
|
|
414
|
+
},
|
|
221
415
|
};
|
|
222
416
|
|
|
223
417
|
// v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
|
|
@@ -265,6 +459,88 @@ async function ruactInvoke({ method, url, args, label }) {
|
|
|
265
459
|
return parseResponse(response);
|
|
266
460
|
}
|
|
267
461
|
|
|
462
|
+
// Story 9.5 — the GET fetch core for queries. Mirrors `ruactInvoke`'s
|
|
463
|
+
// structure (loud transport error, structured `RuactActionError` on !ok,
|
|
464
|
+
// shared `parseResponse`) but for a read: GET, no body, no CSRF.
|
|
465
|
+
async function ruactQueryGet(url, label) {
|
|
466
|
+
let response;
|
|
467
|
+
try {
|
|
468
|
+
response = await fetch(url, buildQueryFetchInit());
|
|
469
|
+
} catch (err) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`ruact query ${label} request failed: ${err?.message ?? err}`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (!response.ok) {
|
|
475
|
+
const body = await safeParseBody(response);
|
|
476
|
+
throw new RuactActionError({ name: label, status: response.status, body, response });
|
|
477
|
+
}
|
|
478
|
+
return parseResponse(response);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Story 9.5 — serialize the params object into the GET query string. FR88
|
|
482
|
+
// wire format: only primitives (string / number / boolean / null) are
|
|
483
|
+
// encoded. Arrays and objects throw a `TypeError` client-side for immediate
|
|
484
|
+
// local feedback; the server-side sanitization in `query_dispatch.rb` is the
|
|
485
|
+
// authoritative gate. `null` is encoded as an empty value (`key=`).
|
|
486
|
+
function buildQueryUrl(path, params) {
|
|
487
|
+
if (params == null) return path;
|
|
488
|
+
if (typeof params !== "object" || Array.isArray(params)) {
|
|
489
|
+
throw new TypeError(
|
|
490
|
+
`ruact useQuery for ${path}: params must be a plain object of ` +
|
|
491
|
+
"string / number / boolean / null values",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const search = new URLSearchParams();
|
|
495
|
+
// `null` is sent as a BARE key (`?q`, no `=`) — Rack parses that as `nil`,
|
|
496
|
+
// whereas `?q=` parses as `""`. This keeps `null` distinguishable from the
|
|
497
|
+
// empty string at the Ruby query-method boundary (the server allowlist
|
|
498
|
+
// accepts both `nil` and `String`). Bare keys are collected separately
|
|
499
|
+
// because `URLSearchParams` always emits `key=`; their order relative to
|
|
500
|
+
// the `=`-valued params is irrelevant server-side.
|
|
501
|
+
const bareKeys = [];
|
|
502
|
+
for (const [key, value] of Object.entries(params)) {
|
|
503
|
+
if (value === undefined) continue;
|
|
504
|
+
if (value === null) {
|
|
505
|
+
bareKeys.push(encodeURIComponent(key));
|
|
506
|
+
} else if (
|
|
507
|
+
typeof value === "string" ||
|
|
508
|
+
typeof value === "number" ||
|
|
509
|
+
typeof value === "boolean"
|
|
510
|
+
) {
|
|
511
|
+
search.append(key, String(value));
|
|
512
|
+
} else {
|
|
513
|
+
throw new TypeError(
|
|
514
|
+
`ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
|
|
515
|
+
"boolean, or null — arrays and objects are rejected",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const qs = [search.toString(), ...bareKeys].filter((s) => s.length > 0).join("&");
|
|
520
|
+
return qs.length === 0 ? path : `${path}?${qs}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Story 9.5 — fetch init for a query GET. No body, no `X-CSRF-Token` (reads
|
|
524
|
+
// are CSRF-free). `defaultHeaders` still apply (e.g. an API-mode
|
|
525
|
+
// `Authorization: Bearer …`), with the gem's own `Accept` winning. Keeps
|
|
526
|
+
// `redirect: "error"` so an auth `redirect_to "/login"` surfaces as a loud
|
|
527
|
+
// failure rather than resolving with the login page HTML.
|
|
528
|
+
function buildQueryFetchInit() {
|
|
529
|
+
const RESERVED = new Set(["accept"]);
|
|
530
|
+
const extra =
|
|
531
|
+
typeof runtimeOptions.defaultHeaders === "function"
|
|
532
|
+
? runtimeOptions.defaultHeaders()
|
|
533
|
+
: runtimeOptions.defaultHeaders;
|
|
534
|
+
const headers = {};
|
|
535
|
+
if (extra && typeof extra === "object") {
|
|
536
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
537
|
+
if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
headers.Accept = "application/json";
|
|
541
|
+
return { method: "GET", credentials: "same-origin", redirect: "error", headers };
|
|
542
|
+
}
|
|
543
|
+
|
|
268
544
|
// Story 9.3 (D7) — substitute dynamic path segments (`:id`, …) from the single
|
|
269
545
|
// call argument. Values are read BY NAME (FormData.get / object property); the
|
|
270
546
|
// argument is still sent as the body. A missing required segment fails loudly
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruact-server-functions-runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2). Provides `_makeRef(name)` (
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2 + 9.5 + 9.6). Provides `_makeRef(name)` / `_makeServerFunction(descriptor)` (mutations: POST/PUT/PATCH/DELETE with CSRF + JSON / FormData), `revalidate(path?)` (Flight refetch), and `_makeQuery(descriptor)` + `useQuery(ref, params?)` (reads: GET /q/<jsId>, CSRF-free, FR88 primitive params → { data, loading, error }; identical concurrent calls share one in-flight request).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "vitest run"
|
|
18
18
|
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18"
|
|
21
|
+
},
|
|
19
22
|
"devDependencies": {
|
|
23
|
+
"@testing-library/react": "^16.1.0",
|
|
24
|
+
"jsdom": "^25.0.1",
|
|
25
|
+
"react": "^19.0.0",
|
|
26
|
+
"react-dom": "^19.0.0",
|
|
20
27
|
"vitest": "^2.1.9"
|
|
21
28
|
}
|
|
22
29
|
}
|