ruact 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -1,11 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Minimal Rails stub for specs that need Rails without it being in the bundle.
4
- # Loaded automatically by spec_helper. Does nothing if Rails is already defined.
5
- return if defined?(Rails)
3
+ # Spec-only Rails bootstrap for tests that need a `Rails` constant. Two modes:
4
+ #
5
+ # 1. **Augment** (default since Story 7.9): when the `rails` gem is in the
6
+ # bundle, load its core (`rails.rb` — just `Rails::VERSION`,
7
+ # `Rails::Railtie`, `ActiveSupport::StringInquirer`, etc., *not*
8
+ # `action_controller` / `action_view`) and add test-only writers
9
+ # (`Rails.root=`, `Rails.env=`, `Rails.logger=`) on top. Specs that need
10
+ # the full Rails request cycle (e.g. controller_request_spec.rb) require
11
+ # `action_controller/railtie` and `action_view/railtie` themselves — the
12
+ # rest of the suite never pays that cost.
13
+ #
14
+ # 2. **Full stub** (fallback): when `rails` is not in the bundle (e.g. a
15
+ # matrix run that pruned it), provide a minimal `Rails` module + a
16
+ # `Rails::Railtie` class with no-op class methods so `gem/lib/ruact/railtie.rb`
17
+ # can `class Railtie < Rails::Railtie` without crashing. `$LOADED_FEATURES`
18
+ # is patched so `require "rails"` inside loaded files no-ops.
19
+ #
20
+ # Loaded automatically by spec_helper.
21
+ #
22
+ # Augmentation is idempotent (each `Rails.define_singleton_method` is guarded
23
+ # by `unless Rails.singleton_class.method_defined?(...)`), so we run through
24
+ # this file every time it loads — even when Rails was already required by
25
+ # another spec file (e.g. controller_request_spec.rb pre-loads
26
+ # `action_controller/railtie`). Skipping with `return if defined?(Rails)`
27
+ # would leave doctor_spec / railtie_spec without the test-only writers when
28
+ # the request spec runs first.
6
29
 
7
- # Prevent `require "rails"` inside loaded files from failing.
8
- $LOADED_FEATURES << "rails.rb" unless $LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }
30
+ unless defined?(Rails)
31
+ begin
32
+ # Loads Rails core only — not the request-cycle subsystem. Cheap.
33
+ require "rails"
34
+ rescue LoadError
35
+ # Rails not available; fall through to the full stub below.
36
+ end
37
+ end
38
+
39
+ if defined?(Rails) && Rails.respond_to?(:application)
40
+ # Augment-mode: real Rails is present. Add test-only writers that the
41
+ # existing suite (doctor_spec, railtie_spec) relies on, without clobbering
42
+ # real Rails's behaviour outside the test override.
43
+ unless Rails.singleton_class.method_defined?(:root=)
44
+ original_root = Rails.method(:root) if Rails.respond_to?(:root)
45
+
46
+ Rails.define_singleton_method(:root=) { |v| @_test_root = v }
47
+ Rails.define_singleton_method(:root) do
48
+ return @_test_root if defined?(@_test_root) && @_test_root
49
+
50
+ original_root&.call
51
+ end
52
+ end
53
+
54
+ unless Rails.singleton_class.method_defined?(:env=)
55
+ Rails.define_singleton_method(:env=) { |v| @_test_env = v }
56
+ original_env = Rails.method(:env) if Rails.respond_to?(:env)
57
+ Rails.define_singleton_method(:env) do
58
+ return @_test_env if defined?(@_test_env) && @_test_env
59
+
60
+ original_env ? original_env.call : ActiveSupport::StringInquirer.new("test")
61
+ end
62
+ end
63
+
64
+ unless Rails.singleton_class.method_defined?(:logger=)
65
+ Rails.define_singleton_method(:logger=) { |v| @_test_logger = v }
66
+ original_logger = Rails.method(:logger) if Rails.respond_to?(:logger)
67
+ Rails.define_singleton_method(:logger) do
68
+ return @_test_logger if defined?(@_test_logger) && @_test_logger
69
+
70
+ original_logger&.call
71
+ end
72
+ end
73
+
74
+ return
75
+ end
76
+
77
+ # Full-stub fallback: rails gem is not in the bundle.
78
+ $LOADED_FEATURES << "rails.rb"
9
79
 
10
80
  module Rails
11
81
  class Railtie
@@ -0,0 +1,139 @@
1
+ // Story 8.1 — TypeScript declarations for the real server-functions runtime.
2
+ // Mirrors the JS exports in `index.js` so the generated module's
3
+ // `import { _makeRef } from "ruact/server-functions-runtime"` resolves under
4
+ // `tsc --noEmit` (AC10's import guarantee).
5
+ //
6
+ // The generated module's per-export signature is
7
+ // `(args?: Record<string, unknown>) => Promise<unknown>` per the 8.0a
8
+ // codegen contract; the runtime accepts a wider `FormData` argument too
9
+ // (Story 8.2 owns the codegen signature widening if it picks the FormData
10
+ // path). Devs writing call sites against the 8.0a-emitted module continue
11
+ // to see the conservative Record<string, unknown> signature; the FormData
12
+ // branch only fires through Story 8.2's `<form action={fn}>` wiring.
13
+
14
+ /**
15
+ * Re-run-3 (2026-05-15), refined Re-run-4 (2026-05-15) — local alias
16
+ * for the FormData INSTANCE type that does NOT require `lib: ["dom"]`
17
+ * in the consumer's tsconfig.
18
+ *
19
+ * Re-run-4 fix: pre-batch this inferred `F = typeof FormData` (the
20
+ * constructor), so DOM consumers passing `new FormData()` were typed
21
+ * against the constructor signature and `tsc` would reject the call.
22
+ * The conditional below now extracts the INSTANCE type from the
23
+ * constructor (`new (...args) => I`) when DOM lib is loaded, and
24
+ * falls back to a minimal structural shape in non-DOM targets.
25
+ */
26
+ type RuactFormData = typeof globalThis extends { FormData: new (...args: never[]) => infer Instance }
27
+ ? Instance
28
+ : { append(name: string, value: unknown): void };
29
+
30
+ /**
31
+ * Re-run-4 (2026-05-15) — same conditional-typeof pattern for the
32
+ * fetch `Response` type so the declaration compiles without DOM lib.
33
+ */
34
+ type RuactResponse = typeof globalThis extends { Response: new (...args: never[]) => infer Instance }
35
+ ? Instance
36
+ : unknown;
37
+
38
+ /**
39
+ * Returns a callable accessor for a server function registered with the
40
+ * given Ruby symbol name. The accessor, when invoked, POSTs the args to
41
+ * `/__ruact/fn/${name}`.
42
+ *
43
+ * Story 8.2 (refined 2026-05-17 per review patch R1) — the return type
44
+ * is an intersection of FOUR call signatures so the same exported
45
+ * reference is usable from every call site:
46
+ *
47
+ * 1. `()` / `(args)` / `(prevState, formData)` — direct callers and
48
+ * `useActionState`'s two-arg invocation; returns `Promise<unknown>`.
49
+ * 2. `(formData: FormData)` — assignable to React 19's `<form action>`
50
+ * prop, which is typed as `(formData: FormData) => void | Promise<void>`.
51
+ * Promise generics are invariant in TS, so `Promise<unknown>` is
52
+ * NOT assignable to `Promise<void>` even via the void-discard rule;
53
+ * the intersection lets `<form action={createPost}>` typecheck
54
+ * DIRECTLY against the emitted module without a call-site cast.
55
+ *
56
+ * Runtime behavior is unchanged — `_makeRef` always resolves with the
57
+ * JSON-decoded value. The `Promise<void>` overload is a TYPE-ONLY
58
+ * surface: when React invokes the function from a `<form action>` prop,
59
+ * the return value is discarded by React anyway.
60
+ */
61
+ export function _makeRef(
62
+ name: string,
63
+ ): ((
64
+ arg1?: Record<string, unknown> | RuactFormData,
65
+ arg2?: RuactFormData | Record<string, unknown>,
66
+ ) => Promise<unknown>) &
67
+ ((formData: RuactFormData) => Promise<void>);
68
+
69
+ /**
70
+ * Story 9.3 — the route-driven (v2) accessor. The codegen emits
71
+ * `_makeServerFunction({ method, path, segments })` for every non-GET routed
72
+ * action on a `Ruact::Server` controller. The returned callable targets the
73
+ * REAL Rails route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating
74
+ * dynamic path segments by name from the single call argument, and follows a
75
+ * Bucket-2 `{ "$redirect": "<path>" }` response client-side.
76
+ *
77
+ * Shares the same intersection call-signature contract as {@link _makeRef} so
78
+ * `<form action={createPost}>` and `useActionState` keep type-checking.
79
+ */
80
+ export function _makeServerFunction(descriptor: {
81
+ method: string;
82
+ path: string;
83
+ segments?: string[];
84
+ }): ((
85
+ arg1?: Record<string, unknown> | RuactFormData,
86
+ arg2?: RuactFormData | Record<string, unknown>,
87
+ ) => Promise<unknown>) &
88
+ ((formData: RuactFormData) => Promise<void>);
89
+
90
+ /**
91
+ * Story 8.2 — issues a Flight refetch of the supplied path (or the
92
+ * current URL when omitted) and swaps the React tree in place. Mirrors
93
+ * Next.js' `revalidatePath` ergonomic: call it after a server action
94
+ * settles when local React state is not enough to reflect the server
95
+ * mutation.
96
+ *
97
+ * Requires the ruact router to be installed (`setupRouter()` publishes
98
+ * `globalThis.__ruact_revalidate`). Throws a descriptive error when
99
+ * called without an installed router so the failure mode is loud rather
100
+ * than a silent no-op.
101
+ */
102
+ export function revalidate(path?: string): Promise<void>;
103
+
104
+ /** Numeric sentinel downstream tooling can read to confirm the real
105
+ * runtime is in place (the Story 8.0a placeholder exported
106
+ * `__PLACEHOLDER__: true`; that export is removed in Story 8.1). */
107
+ export const __RUNTIME_VERSION__: number;
108
+
109
+ /**
110
+ * Re-run-5 (2026-05-15) — app-wide runtime configuration. Hosts in
111
+ * API mode (no CSRF meta tag) call this once at boot to register a
112
+ * default-headers function that supplies the `Authorization: Bearer …`
113
+ * (or similar) header on every server-function call.
114
+ *
115
+ * `defaultHeaders` accepts:
116
+ * - a plain object → merged on every call
117
+ * - a `() => object` function → called on every call (for tokens
118
+ * that may refresh at runtime)
119
+ * - `null` → clears any previously-registered default
120
+ *
121
+ * The gem's own headers (`Accept`, `Content-Type`, `X-CSRF-Token`)
122
+ * win over `defaultHeaders` — CSRF cannot be silently overridden.
123
+ */
124
+ export function configureRuactRuntime(options: {
125
+ defaultHeaders?: Record<string, string> | (() => Record<string, string>) | null;
126
+ }): void;
127
+
128
+ /**
129
+ * Re-run-4 (2026-05-15) — structured error thrown for 4xx/5xx responses.
130
+ * Callers can branch on `status` and inspect `body` (already
131
+ * JSON-decoded if the server's Content-Type indicated JSON) instead
132
+ * of scraping the `message` string.
133
+ */
134
+ export class RuactActionError extends Error {
135
+ readonly actionName: string;
136
+ readonly status: number;
137
+ readonly body: unknown;
138
+ readonly response: RuactResponse;
139
+ }
@@ -0,0 +1,438 @@
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
+ const RUNTIME_VERSION = 1;
20
+
21
+ // Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
22
+ // in API mode (no session cookie / no CSRF meta tag) need a way to
23
+ // inject auth headers (`Authorization: Bearer …`) on every call.
24
+ // `_makeRef`'s signature is locked by the codegen so we don't widen
25
+ // it; instead, hosts call `configureRuactRuntime` once at app boot
26
+ // to register a headers-producing function. The function runs on
27
+ // every fetch so dynamic tokens (refreshed at runtime) are picked up.
28
+ const runtimeOptions = {
29
+ defaultHeaders: null,
30
+ };
31
+ export function configureRuactRuntime(options) {
32
+ if (options && Object.prototype.hasOwnProperty.call(options, "defaultHeaders")) {
33
+ const value = options.defaultHeaders;
34
+ if (value === null || typeof value === "function" || (typeof value === "object" && value !== null)) {
35
+ runtimeOptions.defaultHeaders = value;
36
+ } else {
37
+ throw new TypeError(
38
+ "configureRuactRuntime: defaultHeaders must be a plain object or a () => object function",
39
+ );
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Re-run-4 (2026-05-15) — structured error class for 4xx/5xx responses.
46
+ *
47
+ * Pre-batch the runtime threw a plain `Error` whose message embedded
48
+ * the status + body. Callers wanting to branch on status (`error.status
49
+ * === 422`) or parse the body had to scrape the message — fragile and
50
+ * not the AC4/AC10 contract. `RuactActionError` exposes the structured
51
+ * fields directly while still carrying a human-readable `message`.
52
+ *
53
+ * The constructor accepts the parsed body (already JSON-decoded if the
54
+ * Content-Type said so) so callers don't re-parse.
55
+ */
56
+ export class RuactActionError extends Error {
57
+ constructor({ name, status, body, response }) {
58
+ const bodyForMessage = typeof body === "string" ? body : JSON.stringify(body);
59
+ super(`ruact action :${name} failed: ${status} ${bodyForMessage}`);
60
+ this.name = "RuactActionError";
61
+ this.actionName = name;
62
+ this.status = status;
63
+ this.body = body;
64
+ this.response = response;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Returns a callable accessor for a server function registered with the
70
+ * given Ruby symbol name. The accessor, when invoked, POSTs the args to
71
+ * `/__ruact/fn/${name}` and resolves with the response (JSON-decoded for
72
+ * application/json responses, text for everything else).
73
+ *
74
+ * Story 8.2 — the returned function accepts up to TWO positional
75
+ * arguments to support React 19's `useActionState` shape:
76
+ *
77
+ * useActionState(action, initialState)
78
+ *
79
+ * calls `action(prevState, formData)` on every submit. `_makeRef` picks
80
+ * the FormData-typed candidate from the call and discards the prevState
81
+ * argument silently — prev-state is a client-only concern, never
82
+ * transmitted to the server. The single-arg shape (`fn(args)` from event
83
+ * handlers; `<form action={fn}>` passing FormData directly) is preserved.
84
+ *
85
+ * Argument shape selection rules (first match wins):
86
+ * - 0 args → JSON body, `{}`
87
+ * - 1 arg, FormData → multipart
88
+ * - 1 arg, plain object / null / undefined → JSON body
89
+ * - 2 args, FormData in either slot → multipart (FormData wins;
90
+ * the other arg is discarded)
91
+ * - 2 args, neither FormData → JSON body of the SECOND arg
92
+ * (the `useActionState` payload
93
+ * slot); first arg discarded
94
+ * as prev-state
95
+ * - 3+ args → TypeError
96
+ *
97
+ * @param {string} name
98
+ * @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
99
+ */
100
+ export function _makeRef(name) {
101
+ return function ruactServerFunctionCall(...callArgs) {
102
+ if (callArgs.length > 2) {
103
+ throw new TypeError(
104
+ `ruact action :${name} called with ${callArgs.length} arguments — ` +
105
+ "expected 0, 1, or 2 (the useActionState shape)",
106
+ );
107
+ }
108
+ return ruactPost(name, pickWirePayload(callArgs));
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Story 9.3 — the route-driven (v2) accessor. The codegen emits
114
+ * `_makeServerFunction({ method, path, segments })` for every non-GET routed
115
+ * action on a `Ruact::Server` controller (instead of v1's `_makeRef("<sym>")`).
116
+ * The returned callable targets the REAL Rails route + verb (e.g. `POST /posts`,
117
+ * `PUT /posts/:id`) rather than the v1 synthetic `POST /__ruact/fn/:name`.
118
+ *
119
+ * It shares the exact fetch core (`ruactInvoke`) with `_makeRef` — FormData
120
+ * branching, CSRF meta injection, text-first parsing, `RuactActionError`,
121
+ * `redirect: "error"` — so all the salvaged 8.1/8.2 behaviors are preserved.
122
+ * Two additions over v1:
123
+ * - **Path-param interpolation (D7):** dynamic `:id`-style segments are read
124
+ * BY NAME from the single call argument (FormData.get / object property) and
125
+ * substituted into the path; the full argument is still sent as the body
126
+ * (Rails reads `params[:id]` from the path, ignores the duplicate). The
127
+ * single-arg shape (8.2 `<form action>` / `useActionState`) is unchanged.
128
+ * - **`$redirect` follow (9.2 → 9.3):** when the Bucket-2 response is
129
+ * `{ "$redirect": "<path>" }`, the runtime navigates via the router handoff
130
+ * (`globalThis.__ruact_navigate`, `window.location.assign` fallback) and
131
+ * resolves `null`.
132
+ *
133
+ * @param {{ method: string, path: string, segments?: string[] }} descriptor
134
+ * @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
135
+ */
136
+ export function _makeServerFunction(descriptor) {
137
+ const { method, path, segments = [] } = descriptor || {};
138
+ return async function ruactServerFunctionCall(...callArgs) {
139
+ if (callArgs.length > 2) {
140
+ throw new TypeError(
141
+ `ruact server function ${method} ${path} called with ${callArgs.length} arguments — ` +
142
+ "expected 0, 1, or 2 (the useActionState shape)",
143
+ );
144
+ }
145
+ const args = pickWirePayload(callArgs);
146
+ const url = interpolatePath(path, segments, args);
147
+ const parsed = await ruactInvoke({ method, url, args, label: path });
148
+ return followRedirectIfPresent(parsed);
149
+ };
150
+ }
151
+
152
+ // Story 8.2 — picks the argument the wire request should serialize from
153
+ // `_makeRef`'s call-args, following the rules documented in the JSDoc
154
+ // above. Exported through `__internals` for the vitest suite (AC10) — it
155
+ // is intentionally NOT part of the public runtime surface.
156
+ function pickWirePayload(callArgs) {
157
+ const isFD = (v) => typeof FormData !== "undefined" && v instanceof FormData;
158
+ if (callArgs.length === 0) return undefined;
159
+ if (callArgs.length === 1) return callArgs[0];
160
+ // 2 args. Prefer a FormData candidate regardless of position.
161
+ if (isFD(callArgs[1])) return callArgs[1];
162
+ if (isFD(callArgs[0])) return callArgs[0];
163
+ // Defensive: useActionState's normal payload is in slot 1 (slot 0 is
164
+ // prev-state); echo that ordering for the non-FormData case too. The
165
+ // prevState shape is opaque (any React-state value) — never sent to
166
+ // the server.
167
+ return callArgs[1];
168
+ }
169
+
170
+ export const __RUNTIME_VERSION__ = RUNTIME_VERSION;
171
+
172
+ /**
173
+ * Story 8.2 — issues a Flight refetch of the supplied path (or the
174
+ * current URL when omitted) and swaps the React tree in place. The
175
+ * runtime side is intentionally thin: it reads a globally-published
176
+ * handle (`globalThis.__ruact_revalidate`) that `ruact-router.js`'s
177
+ * `setupRouter()` registers at app boot, and delegates the actual
178
+ * refetch to the router's existing `navigate()` machinery (push: false,
179
+ * scroll: false — semantically: refetch in place, no history entry).
180
+ *
181
+ * Why globalThis instead of a direct import: the runtime ships inside
182
+ * the gem (`gem/vendor/javascript/...`) and the router lives inside the
183
+ * host app (`app/javascript/ruact-router.js`). Direct imports would
184
+ * couple the two packages and force the router into the gem; the
185
+ * globalThis handoff keeps the runtime portable.
186
+ *
187
+ * Throws when called without an installed router so a misconfigured
188
+ * app fails LOUDLY at the first call rather than silently no-op'ing.
189
+ *
190
+ * @param {string} [path] Optional path to refetch. Defaults to the
191
+ * current `location.pathname + location.search`.
192
+ * @returns {Promise<void>}
193
+ */
194
+ export async function revalidate(path) {
195
+ const handle = typeof globalThis !== "undefined" ? globalThis.__ruact_revalidate : undefined;
196
+ if (typeof handle !== "function") {
197
+ throw new Error(
198
+ "ruact: revalidate() called but no router is installed — wire setupRouter() in your application.jsx",
199
+ );
200
+ }
201
+ const target =
202
+ path != null
203
+ ? path
204
+ : typeof location !== "undefined"
205
+ ? location.pathname + location.search
206
+ : "/";
207
+ return handle(target);
208
+ }
209
+
210
+ // Exported for tests; intentionally NOT part of the public API surface
211
+ // the codegen consumes. The vitest suite stubs `globalThis.fetch` and
212
+ // asserts the request shape — exporting these helpers keeps the tests
213
+ // honest without leaking surface to host apps.
214
+ export const __internals = {
215
+ buildFetchInit,
216
+ resolveCsrfToken,
217
+ parseResponse,
218
+ pickWirePayload,
219
+ interpolatePath,
220
+ followRedirectIfPresent,
221
+ };
222
+
223
+ // v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
224
+ // shared `ruactInvoke` core; the URL, verb, and error label are exactly what
225
+ // 8.1 used so the v1 path stays byte-behavior-identical (Story 9.3 AC6).
226
+ //
227
+ // Re-run-3 (2026-05-15) — `encodeURIComponent(name)` so a stray `/`, `?`, or
228
+ // `#` in a name (only reachable through direct/buggy `_makeRef` calls — the
229
+ // gem-side route constraint and the codegen validator both refuse
230
+ // non-identifier characters) cannot rewrite the path or hijack the
231
+ // query/fragment of the request URL.
232
+ function ruactPost(name, args) {
233
+ return ruactInvoke({
234
+ method: "POST",
235
+ url: `/__ruact/fn/${encodeURIComponent(name)}`,
236
+ args,
237
+ label: name,
238
+ });
239
+ }
240
+
241
+ // Story 9.3 — the shared fetch core for BOTH v1 (`_makeRef`) and v2
242
+ // (`_makeServerFunction`). Extracted verbatim from the original `ruactPost` so
243
+ // neither path drifts: same FormData branching, CSRF injection, `redirect:
244
+ // "error"`, text-first parsing, and structured `RuactActionError`. The only
245
+ // parameters are the verb + URL + the wire payload + a human label for errors.
246
+ async function ruactInvoke({ method, url, args, label }) {
247
+ const init = buildFetchInit(args, method);
248
+ let response;
249
+ try {
250
+ response = await fetch(url, init);
251
+ } catch (err) {
252
+ throw new Error(
253
+ `ruact action :${label} request failed: ${err?.message ?? err}`,
254
+ );
255
+ }
256
+ if (!response.ok) {
257
+ // Re-run-4 (2026-05-15) — throw a structured `RuactActionError` so
258
+ // callers can branch on `status` / `body` instead of scraping the
259
+ // message string. We parse the body the same way as a successful
260
+ // response (JSON when CT says so, otherwise text) so the shape is
261
+ // consistent across the success and failure paths.
262
+ const body = await safeParseBody(response);
263
+ throw new RuactActionError({ name: label, status: response.status, body, response });
264
+ }
265
+ return parseResponse(response);
266
+ }
267
+
268
+ // Story 9.3 (D7) — substitute dynamic path segments (`:id`, …) from the single
269
+ // call argument. Values are read BY NAME (FormData.get / object property); the
270
+ // argument is still sent as the body. A missing required segment fails loudly
271
+ // rather than silently POSTing to a malformed URL.
272
+ function interpolatePath(path, segments, args) {
273
+ let url = path;
274
+ for (const seg of segments) {
275
+ const value = readSegment(args, seg);
276
+ if (value == null || value === "") {
277
+ throw new TypeError(
278
+ `ruact server function for ${path} requires path segment ":${seg}", ` +
279
+ "but it was missing from the call argument",
280
+ );
281
+ }
282
+ // Whole-token replace so `:id` does not clobber the prefix of a longer
283
+ // segment name (e.g. `:id` within `:id_extra`).
284
+ const token = new RegExp(`:${seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_])`);
285
+ url = url.replace(token, encodeURIComponent(String(value)));
286
+ }
287
+ return url;
288
+ }
289
+
290
+ function readSegment(args, seg) {
291
+ if (typeof FormData !== "undefined" && args instanceof FormData) return args.get(seg);
292
+ if (args && typeof args === "object") return args[seg];
293
+ return undefined;
294
+ }
295
+
296
+ // Story 9.3 (AC8 / D2) — the client half of the `$redirect` contract 9.2
297
+ // deferred. A Bucket-2 mutation that `redirect_to`s returns
298
+ // `{ "$redirect": "<path>" }`; follow it via the router handoff and resolve
299
+ // `null` (consistent with the 204→null contract). Only applied on the v2 path
300
+ // — the v1 endpoint never emits `$redirect`, so `_makeRef` is unaffected (AC6).
301
+ function followRedirectIfPresent(parsed) {
302
+ if (
303
+ parsed &&
304
+ typeof parsed === "object" &&
305
+ !Array.isArray(parsed) &&
306
+ typeof parsed.$redirect === "string"
307
+ ) {
308
+ navigateTo(parsed.$redirect);
309
+ return null;
310
+ }
311
+ return parsed;
312
+ }
313
+
314
+ function navigateTo(target) {
315
+ const nav = typeof globalThis !== "undefined" ? globalThis.__ruact_navigate : undefined;
316
+ if (typeof nav === "function") {
317
+ nav(target);
318
+ return;
319
+ }
320
+ // No router installed — hard-navigate so the redirect is never silently
321
+ // dropped (mirrors `revalidate()`'s loud-by-default stance, but a redirect
322
+ // CAN fall back to a full page load where a refetch cannot).
323
+ if (typeof window !== "undefined" && window.location && typeof window.location.assign === "function") {
324
+ window.location.assign(target);
325
+ }
326
+ }
327
+
328
+ function buildFetchInit(args, method = "POST") {
329
+ // Re-run-2 (2026-05-14) — `Accept: application/json` so the host's
330
+ // `respond_to` / `before_action` / `rescue_from` logic sees a JSON
331
+ // request format. Without it, Rails' default for a POST to an HTML
332
+ // controller would select the HTML branch — surprising in actions
333
+ // expected to return structured JSON.
334
+ // Re-run-5 — start the headers map with defaultHeaders (so the gem's
335
+ // own keys, set below, OVERRIDE them) and then layer the gem's own
336
+ // keys on top. `Accept`, `Content-Type`, and `X-CSRF-Token` are the
337
+ // gem's responsibility — `configureRuactRuntime({ defaultHeaders })`
338
+ // can't silently downgrade CSRF or swap the response negotiation.
339
+ // Re-run-6 (2026-05-15) — match header names case-insensitively when
340
+ // filtering reserved keys from the caller-provided `defaultHeaders`.
341
+ // HTTP header names are case-insensitive (RFC 9110 §5.1), so a host
342
+ // passing `{ accept: "text/html" }` or `{ "content-type": "..." }`
343
+ // would otherwise survive the gem's own assignment (object keys are
344
+ // case-sensitive in JS) and either downgrade the Accept negotiation
345
+ // or — for the FormData branch — kill the multipart boundary the
346
+ // browser sets automatically.
347
+ const RESERVED = new Set(["accept", "content-type", "x-csrf-token"]);
348
+ const extra = typeof runtimeOptions.defaultHeaders === "function"
349
+ ? runtimeOptions.defaultHeaders()
350
+ : runtimeOptions.defaultHeaders;
351
+ const headers = {};
352
+ if (extra && typeof extra === "object") {
353
+ for (const [key, value] of Object.entries(extra)) {
354
+ if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
355
+ }
356
+ }
357
+ headers.Accept = "application/json";
358
+ const csrf = resolveCsrfToken();
359
+ if (csrf) headers["X-CSRF-Token"] = csrf;
360
+
361
+ let body;
362
+ if (typeof FormData !== "undefined" && args instanceof FormData) {
363
+ // Let the browser set the `Content-Type: multipart/form-data; boundary=...`
364
+ // header — don't set it manually. The case-insensitive filter above
365
+ // already stripped any `Content-Type` from defaultHeaders, so the
366
+ // browser is in control here.
367
+ body = args;
368
+ } else {
369
+ headers["Content-Type"] = "application/json";
370
+ body = JSON.stringify(args ?? {});
371
+ }
372
+
373
+ return {
374
+ method,
375
+ credentials: "same-origin",
376
+ // Re-run-5 (2026-05-15) — `redirect: "error"` so the runtime
377
+ // FAILS LOUDLY when a host `before_action` `redirect_to "/login"`
378
+ // (auth filter) issues a 302. Default fetch follows redirects
379
+ // silently and would resolve with the eventual HTML login page
380
+ // body — masking auth failures from the caller. The structured
381
+ // `RuactActionError` path is the right surface for "not allowed".
382
+ redirect: "error",
383
+ headers,
384
+ body,
385
+ };
386
+ }
387
+
388
+ function resolveCsrfToken() {
389
+ if (typeof document === "undefined") return null;
390
+ const meta = document.querySelector('meta[name="csrf-token"]');
391
+ return meta?.getAttribute("content") || null;
392
+ }
393
+
394
+ async function parseResponse(response) {
395
+ // Re-run-2 (2026-05-14) — read the response as TEXT first, then attempt
396
+ // JSON parse if the body is non-empty AND the Content-Type indicates
397
+ // JSON. This handles ALL empty-body cases (`head :no_content` (204),
398
+ // `head :ok` (200 + empty body), `head :reset_content` (205), etc.)
399
+ // uniformly: empty body → null, regardless of Content-Type. Earlier
400
+ // versions parsed JSON eagerly and failed `SyntaxError` on these.
401
+ const text = await response.text();
402
+ if (text.length === 0) return null;
403
+ // Re-run-3 (2026-05-15) — Content-Type matching is case-insensitive
404
+ // per RFC 9110 §8.3.1 (`Application/JSON` and `application/json` are
405
+ // the same media type).
406
+ // Re-run-4 (2026-05-15) — also accept structured-syntax-suffix JSON
407
+ // types per RFC 6838 §4.2.8: `application/problem+json`,
408
+ // `application/vnd.api+json`, etc. The regex matches the literal
409
+ // `application/json` and any `+json` suffix.
410
+ const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
411
+ if (/^application\/(json|.*\+json)\b/.test(contentType)) {
412
+ return JSON.parse(text);
413
+ }
414
+ return text;
415
+ }
416
+
417
+ async function safeParseBody(response) {
418
+ // Mirror `parseResponse`'s logic but never let a parse failure crash
419
+ // the error path — if the JSON parse fails we fall back to the raw
420
+ // text. The error path is already a sad path; surfacing a second
421
+ // exception inside it would hide the real failure from the caller.
422
+ let text;
423
+ try {
424
+ text = await response.text();
425
+ } catch {
426
+ return "<unreadable body>";
427
+ }
428
+ if (text.length === 0) return null;
429
+ const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
430
+ if (/^application\/(json|.*\+json)\b/.test(contentType)) {
431
+ try {
432
+ return JSON.parse(text);
433
+ } catch {
434
+ return text;
435
+ }
436
+ }
437
+ return text;
438
+ }