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.
- 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 +86 -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 +1680 -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 +89 -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 +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -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 +75 -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 +446 -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 +429 -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 +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -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 +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- 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 +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
data/spec/support/rails_stub.rb
CHANGED
|
@@ -1,11 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
+
}
|