ruact 0.0.2 → 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 +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +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 +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
data/lib/ruact/serializable.rb
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
module Ruact
|
|
4
4
|
# Include this module in any Ruby object you want to pass as a prop to a
|
|
5
5
|
# client component. Declare which attributes are safe to serialize with
|
|
6
|
-
# +
|
|
6
|
+
# +ruact_props+; only those attributes will be included in the wire payload.
|
|
7
7
|
#
|
|
8
8
|
# @example
|
|
9
9
|
# class Post
|
|
10
10
|
# include Ruact::Serializable
|
|
11
11
|
# attr_reader :id, :title, :secret
|
|
12
|
-
#
|
|
12
|
+
# ruact_props :id, :title # :secret is never sent to the client
|
|
13
13
|
# end
|
|
14
14
|
module Serializable
|
|
15
15
|
def self.included(base)
|
|
16
16
|
base.extend(ClassMethods)
|
|
17
|
-
base.instance_variable_set(:@
|
|
17
|
+
base.instance_variable_set(:@ruact_props, [])
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
module ClassMethods
|
|
@@ -23,24 +23,24 @@ module Ruact
|
|
|
23
23
|
# corresponding method defined on the class.
|
|
24
24
|
#
|
|
25
25
|
# @param attrs [Array<Symbol>]
|
|
26
|
-
def
|
|
26
|
+
def ruact_props(*attrs)
|
|
27
27
|
attrs.each do |attr|
|
|
28
28
|
unless method_defined?(attr)
|
|
29
29
|
raise ArgumentError,
|
|
30
|
-
"
|
|
30
|
+
"ruact_props: method `#{attr}` is not defined on #{self}"
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
|
-
@
|
|
33
|
+
@ruact_props = attrs
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# Returns the list of declared prop names as symbols.
|
|
37
37
|
# Walks the ancestor chain so subclasses inherit parent declarations.
|
|
38
38
|
#
|
|
39
39
|
# @return [Array<Symbol>]
|
|
40
|
-
def
|
|
40
|
+
def ruact_props_list
|
|
41
41
|
klass = self
|
|
42
42
|
while klass
|
|
43
|
-
return klass.instance_variable_get(:@
|
|
43
|
+
return klass.instance_variable_get(:@ruact_props) if klass.instance_variable_defined?(:@ruact_props)
|
|
44
44
|
|
|
45
45
|
klass = klass.superclass
|
|
46
46
|
end
|
|
@@ -48,11 +48,11 @@ module Ruact
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# Serialize only the attributes declared with +
|
|
51
|
+
# Serialize only the attributes declared with +ruact_props+.
|
|
52
52
|
#
|
|
53
53
|
# @return [Hash{String => Object}]
|
|
54
|
-
def
|
|
55
|
-
self.class.
|
|
54
|
+
def ruact_serialize
|
|
55
|
+
self.class.ruact_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|
data/lib/ruact/server.rb
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
# Review patch (2026-06-07) — a direct `require "ruact/server"` (without the
|
|
6
|
+
# host having required "ruact" first) must still resolve everything the
|
|
7
|
+
# salvaged chains touch at request time: `Ruact.config` (defined in ruact.rb,
|
|
8
|
+
# not configuration.rb), `Ruact::UploadTooLargeError`, the ErrorPayload
|
|
9
|
+
# pipeline. The gem root never requires this file back (the bare
|
|
10
|
+
# `require "ruact"` path stays ActionController-free; the Railtie loads the
|
|
11
|
+
# concern), so this is acyclic by construction.
|
|
12
|
+
require "uri"
|
|
13
|
+
require_relative "../ruact"
|
|
14
|
+
require_relative "server_functions/error_rendering"
|
|
15
|
+
require_relative "server_functions/bucket_two_payload"
|
|
16
|
+
require_relative "server_functions/name_bridge"
|
|
17
|
+
|
|
18
|
+
module Ruact
|
|
19
|
+
# Story 9.1 (route-driven redesign, Phase A) — the v2 server-functions
|
|
20
|
+
# marker concern.
|
|
21
|
+
#
|
|
22
|
+
# class PostsController < ApplicationController
|
|
23
|
+
# include Ruact::Server # the ONLY marker — no per-action DSL
|
|
24
|
+
#
|
|
25
|
+
# def create # non-GET routed action → callable server function
|
|
26
|
+
# @post = Post.create!(title: params[:title])
|
|
27
|
+
# redirect_to @post
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# Per the 2026-06-02 ADR addendum (Story 9-0,
|
|
32
|
+
# `docs/internal/decisions/server-functions-api.md`), exposure is decided by
|
|
33
|
+
# `routes.rb` — the Story 9.3 codegen reads `Rails.application.routes`
|
|
34
|
+
# filtered to non-GET routes on controllers that include this concern. The
|
|
35
|
+
# concern itself registers NOTHING and emits NOTHING; at this story it is a
|
|
36
|
+
# pure marker plus the new home of the two salvaged Epic-8 subsystems,
|
|
37
|
+
# running on the host controller's own callback chain:
|
|
38
|
+
#
|
|
39
|
+
# - **Story 8.4 structured-error chain** — `rescue_from StandardError` (+ an
|
|
40
|
+
# explicit `ActionController::InvalidAuthenticityToken` registration that
|
|
41
|
+
# preempts Rails' default `handle_unverified_request`, Pitfall #1). On
|
|
42
|
+
# function-call requests ({#__ruact_function_call?}) an uncaught exception
|
|
43
|
+
# renders the structured JSON payload (discriminator
|
|
44
|
+
# `_ruact_server_action_error: true`, four baseline fields, dev/prod split
|
|
45
|
+
# via `Ruact.config.dev_error_payload_enabled`, status mapping 422/403/
|
|
46
|
+
# 413/500). On every other request shape — including GET/HEAD requests
|
|
47
|
+
# regardless of their Accept header — the handler re-raises, so GET
|
|
48
|
+
# pages, `default_render`, and Phase-1 behavior stay byte-for-byte
|
|
49
|
+
# untouched. Host `rescue_from` declarations — whether inherited from a
|
|
50
|
+
# parent class or declared in the host's own body — keep precedence:
|
|
51
|
+
# the chain only catches what the host did not.
|
|
52
|
+
# - **Story 8.5 upload guard** — `prepend_before_action` enforcing
|
|
53
|
+
# `Ruact.config.max_upload_bytes` against the wire `Content-Length`
|
|
54
|
+
# BEFORE Rack's multipart parser. The three carve-outs are preserved:
|
|
55
|
+
# nil limit, non-multipart/urlencoded content type, absent
|
|
56
|
+
# Content-Length. New here (D2): GET/HEAD requests skip the guard
|
|
57
|
+
# entirely. The 413 renders structured for ALL request shapes (D1) —
|
|
58
|
+
# a meaningful 413 beats a re-raised 500 for native form submits too.
|
|
59
|
+
# Contract simplification: the concern assumes the host includes it after
|
|
60
|
+
# `protect_from_forgery`; no runtime callback-order verifier runs here.
|
|
61
|
+
#
|
|
62
|
+
# Both bodies live in {Ruact::ServerFunctions::ErrorRendering}, shared with
|
|
63
|
+
# the v1 {Ruact::ServerFunctions::EndpointController} during the
|
|
64
|
+
# strangler-fig transition so the wire contract is identical by
|
|
65
|
+
# construction. Dual-bucket response negotiation (ivar serialization,
|
|
66
|
+
# `$redirect`, 204, `Vary: Accept`) is Story 9.2; this concern only
|
|
67
|
+
# contributes the discrimination predicate 9.2 will reuse.
|
|
68
|
+
module Server
|
|
69
|
+
extend ActiveSupport::Concern
|
|
70
|
+
|
|
71
|
+
include Ruact::ServerFunctions::ErrorRendering
|
|
72
|
+
|
|
73
|
+
included do
|
|
74
|
+
# Story 8.5 salvage — prepended so the size check wins the race against
|
|
75
|
+
# every other callback, including `verify_authenticity_token`.
|
|
76
|
+
prepend_before_action :__ruact_enforce_upload_limit!
|
|
77
|
+
|
|
78
|
+
# Story 9.2 AC6 — `Vary: Accept` on every non-GET SUCCESS shape. The same
|
|
79
|
+
# URL + verb serves two bodies discriminated solely on `Accept`, so a
|
|
80
|
+
# cache MUST vary on it (never serve Flight to a JSON caller or vice-versa).
|
|
81
|
+
# Set in BOTH a `before_action` and an `after_action` (review rounds 1–2),
|
|
82
|
+
# because either callback alone has a gap:
|
|
83
|
+
# - the `after_action` APPENDS to a host-set `Vary` (preserving it), but
|
|
84
|
+
# is skipped when a `before_action` performs the response (e.g. an auth
|
|
85
|
+
# `before_action` that `redirect_to`s a Bucket-2 call);
|
|
86
|
+
# - the `before_action` covers that halt case (it runs ahead of the
|
|
87
|
+
# host's own before-callbacks), but a later `response.headers["Vary"] =`
|
|
88
|
+
# in the action would clobber it.
|
|
89
|
+
# Together they cover the 200 ivar-JSON / 204 / `$redirect` / Bucket-1
|
|
90
|
+
# Flight shapes (incl. before-callback redirects). The method is
|
|
91
|
+
# idempotent. Error responses (413/403/500) may still omit it — they are
|
|
92
|
+
# non-cacheable, not dual representations.
|
|
93
|
+
#
|
|
94
|
+
# Documented limitation (accepted 2026-06-09): a host `before_action` that
|
|
95
|
+
# BOTH overwrites `Vary` AND performs the response (e.g.
|
|
96
|
+
# `response.headers["Vary"] = "Cookie"; redirect_to "/login"` in one
|
|
97
|
+
# before-callback) leaves the final response without `Accept` — the Ruact
|
|
98
|
+
# before-action set it first, the host clobbered it, and Rails skips the
|
|
99
|
+
# after-action on the before-halt. This combination is contrived (real
|
|
100
|
+
# auth callbacks don't reassign `Vary` while redirecting); a callback can't
|
|
101
|
+
# guarantee the final header unconditionally, and a Rack-level mechanism
|
|
102
|
+
# was judged not worth the complexity for this edge.
|
|
103
|
+
before_action :__ruact_set_vary_on_accept!
|
|
104
|
+
after_action :__ruact_set_vary_on_accept!
|
|
105
|
+
|
|
106
|
+
# Story 8.4 salvage — same registration order as v1 (Pitfall #1): the
|
|
107
|
+
# generic StandardError entry first, the explicit
|
|
108
|
+
# InvalidAuthenticityToken entry second so it wins Rails'
|
|
109
|
+
# most-recently-registered handler walk for CSRF failures.
|
|
110
|
+
#
|
|
111
|
+
# Review patch (2026-06-07) — handlers the host INHERITED from a parent
|
|
112
|
+
# class must keep precedence too, not just ones declared after the
|
|
113
|
+
# include: Rails walks `rescue_handlers` most-recently-registered
|
|
114
|
+
# first, and a plain `rescue_from` here would land the concern's
|
|
115
|
+
# entries AFTER the inherited ones. The concern's entries are therefore
|
|
116
|
+
# moved to the FRONT of the array, so every host handler — inherited or
|
|
117
|
+
# declared later in the class body — stays more recent and wins.
|
|
118
|
+
inherited_handlers = rescue_handlers
|
|
119
|
+
rescue_from StandardError, with: :__ruact_render_action_error
|
|
120
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :__ruact_render_action_error
|
|
121
|
+
self.rescue_handlers = (rescue_handlers - inherited_handlers) + inherited_handlers
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Story 9.3 — the JS-identifier rename escape hatch (AC4). Naming is
|
|
125
|
+
# derived from the route table by default ({Ruact::ServerFunctions::RouteSource});
|
|
126
|
+
# when two routes collide in the merged JS namespace the codegen fails loudly
|
|
127
|
+
# at boot, and this macro is how a host breaks the tie (or simply prefers a
|
|
128
|
+
# different name):
|
|
129
|
+
#
|
|
130
|
+
# class PostsController < ApplicationController
|
|
131
|
+
# include Ruact::Server
|
|
132
|
+
# ruact_function_name :publish_all, as: "publishEverything"
|
|
133
|
+
# end
|
|
134
|
+
#
|
|
135
|
+
# The override is keyed by ACTION name (string) and read by the codegen via
|
|
136
|
+
# {#__ruact_function_name_overrides}. The target identifier is validated at
|
|
137
|
+
# class-load time against the same JS-identifier shape + reserved-word rules
|
|
138
|
+
# the codegen enforces, so a bad override fails at boot, never at codegen.
|
|
139
|
+
module ClassMethods
|
|
140
|
+
# JS identifier shape — mirror of {Ruact::ServerFunctions::Codegen::VALID_JS_IDENTIFIER}
|
|
141
|
+
# (kept local so the concern does not depend on the codegen module).
|
|
142
|
+
RUACT_VALID_JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
|
|
143
|
+
|
|
144
|
+
# @param action [Symbol, String] the controller action whose generated
|
|
145
|
+
# server-function name is being overridden.
|
|
146
|
+
# @param as [Symbol, String] the JS identifier to emit instead of the
|
|
147
|
+
# derived one.
|
|
148
|
+
# @raise [Ruact::ConfigurationError] when +as+ is not a valid JS identifier
|
|
149
|
+
# or collides with a reserved word / a name the runtime already binds.
|
|
150
|
+
def ruact_function_name(action, as:)
|
|
151
|
+
js = as.to_s
|
|
152
|
+
unless js.match?(RUACT_VALID_JS_IDENTIFIER)
|
|
153
|
+
raise Ruact::ConfigurationError,
|
|
154
|
+
"ruact_function_name :#{action}, as: #{as.inspect} — " \
|
|
155
|
+
"\"#{js}\" is not a valid JS identifier (must match #{RUACT_VALID_JS_IDENTIFIER.inspect})"
|
|
156
|
+
end
|
|
157
|
+
if Ruact::ServerFunctions::NameBridge::RESERVED_JS_IDENTIFIERS.include?(js) ||
|
|
158
|
+
Ruact::ServerFunctions::NameBridge::RESERVED_BY_RUACT.include?(js)
|
|
159
|
+
raise Ruact::ConfigurationError,
|
|
160
|
+
"ruact_function_name :#{action}, as: #{as.inspect} — " \
|
|
161
|
+
"\"#{js}\" is a reserved JS word or is already bound by the ruact runtime " \
|
|
162
|
+
"(`_makeRef` / `_makeServerFunction` / `revalidate`); pick another name"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
__ruact_function_name_overrides[action.to_s] = js
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The action-name → js-identifier override map for this controller,
|
|
169
|
+
# consulted by {Ruact::ServerFunctions::RouteSource}. Per-controller (not
|
|
170
|
+
# inherited) — overrides describe the host's own actions.
|
|
171
|
+
#
|
|
172
|
+
# @return [Hash{String=>String}]
|
|
173
|
+
def __ruact_function_name_overrides
|
|
174
|
+
@__ruact_function_name_overrides ||= {}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the
|
|
179
|
+
# action finished without an explicit render on a function-call request
|
|
180
|
+
# ({#__ruact_function_call?} — `Accept: application/json`, non-GET), serialize
|
|
181
|
+
# the action's exposed instance variables (Rails `view_assigns`, verbatim —
|
|
182
|
+
# the same set a view would see) as a JSON object keyed by ivar name, or
|
|
183
|
+
# `204 No Content` when none were set. Any other request shape falls through
|
|
184
|
+
# to `super` so Bucket-1 rendering — the host's `Ruact::Controller` Flight
|
|
185
|
+
# re-render, then Rails — is byte-for-byte unchanged (AC1).
|
|
186
|
+
#
|
|
187
|
+
# The exposed-ivar set is Rails' own `view_assigns` with no custom filtering:
|
|
188
|
+
# Rails already excludes its protected `@_`-prefixed internals (including the
|
|
189
|
+
# CSRF `@_marked_for_same_origin_verification` flag), so what remains is
|
|
190
|
+
# exactly what the action assigned. Each value is serialized through the
|
|
191
|
+
# `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules
|
|
192
|
+
# ({Ruact::ServerFunctions::BucketTwoPayload}); a single ivar stays keyed
|
|
193
|
+
# (no magic unwrap).
|
|
194
|
+
def default_render(*)
|
|
195
|
+
return super unless __ruact_function_call?
|
|
196
|
+
|
|
197
|
+
assigns = view_assigns
|
|
198
|
+
return head(:no_content) if assigns.empty?
|
|
199
|
+
|
|
200
|
+
render json: ServerFunctions::BucketTwoPayload.build(
|
|
201
|
+
assigns, strict: Ruact.config.strict_serialization
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Story 9.2 AC3 (D2) — on a function-call request, `redirect_to` surfaces as
|
|
206
|
+
# a JSON redirect directive — body `"$redirect" => "<path>"` (the runtime
|
|
207
|
+
# follows it client-side; re-targeting/following is Story 9.3) instead of a
|
|
208
|
+
# 302 or a Flight redirect row. Any other request shape falls through to
|
|
209
|
+
# `super` so the Bucket-1 Flight redirect row / Rails 302 is unchanged (AC1).
|
|
210
|
+
#
|
|
211
|
+
# Review round 1 — reuses Rails' OWN redirect machinery
|
|
212
|
+
# (`_compute_redirect_to_location`, `_ensure_url_is_http_header_safe`,
|
|
213
|
+
# `_enforce_open_redirect_protection`) so the nil-check, header-safety, and
|
|
214
|
+
# open-redirect protection (`allow_other_host` / `raise_on_open_redirects`)
|
|
215
|
+
# match Bucket 1 / stock Rails exactly — a cross-host `redirect_to` raises
|
|
216
|
+
# `UnsafeRedirectError` instead of leaking an external `$redirect`. Same-
|
|
217
|
+
# origin targets collapse to a path; an allowed external origin keeps the
|
|
218
|
+
# absolute URL.
|
|
219
|
+
def redirect_to(options = {}, response_options = {})
|
|
220
|
+
return super unless __ruact_function_call?
|
|
221
|
+
|
|
222
|
+
raise ActionController::ActionControllerError, "Cannot redirect to nil!" unless options
|
|
223
|
+
raise AbstractController::DoubleRenderError if response_body
|
|
224
|
+
|
|
225
|
+
allow_other_host = response_options.delete(:allow_other_host)
|
|
226
|
+
location = _compute_redirect_to_location(request, options)
|
|
227
|
+
_ensure_url_is_http_header_safe(location)
|
|
228
|
+
location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
|
|
229
|
+
|
|
230
|
+
render json: { "$redirect" => __ruact_redirect_path(location) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
# AC6 — append `Accept` to the `Vary` response header for non-GET requests
|
|
236
|
+
# (idempotent, preserves any host-set `Vary`). A host `Vary: *` wildcard is
|
|
237
|
+
# left as-is (review round 2): per HTTP, `Vary` is either `*` (varies on
|
|
238
|
+
# everything) OR a field-name list — `*, Accept` is invalid/weaker.
|
|
239
|
+
def __ruact_set_vary_on_accept!
|
|
240
|
+
return if request.get? || request.head?
|
|
241
|
+
|
|
242
|
+
values = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
243
|
+
return if values.include?("*")
|
|
244
|
+
|
|
245
|
+
values << "Accept" unless values.any? { |value| value.casecmp?("Accept") }
|
|
246
|
+
response.headers["Vary"] = values.join(", ")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Collapse a (already open-redirect-validated) location to a path for
|
|
250
|
+
# same-origin URLs (the common `redirect_to @record` case); keep the
|
|
251
|
+
# absolute URL for an allowed external origin so a cross-origin redirect
|
|
252
|
+
# (e.g. a payment provider, with `allow_other_host: true`) survives. Mirrors
|
|
253
|
+
# the same-origin handling in {Ruact::Controller#redirect_to}.
|
|
254
|
+
def __ruact_redirect_path(url)
|
|
255
|
+
uri = ::URI.parse(url)
|
|
256
|
+
if uri.host &&
|
|
257
|
+
(uri.host != request.host ||
|
|
258
|
+
(uri.port && uri.port != request.port) ||
|
|
259
|
+
(uri.scheme && uri.scheme != request.scheme))
|
|
260
|
+
return url
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
264
|
+
path += "?#{uri.query}" if uri.query
|
|
265
|
+
path += "##{uri.fragment}" if uri.fragment
|
|
266
|
+
path
|
|
267
|
+
rescue ::URI::InvalidURIError
|
|
268
|
+
url
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Raw discriminator — does the request's `Accept` header equal
|
|
272
|
+
# `application/json`? This is exactly what the 8.1 runtime sends on every
|
|
273
|
+
# `_makeRef` fetch (Bucket 2 — imperative `await createPost(...)`).
|
|
274
|
+
# Browser navigation and `<form>` submits never use that exact header, and
|
|
275
|
+
# Flight requests send `text/x-component`.
|
|
276
|
+
#
|
|
277
|
+
# Deliberately NOT `request.format`: the Rails format negotiation is
|
|
278
|
+
# influenced by path extensions (`/posts.json`) and `params[:format]`,
|
|
279
|
+
# neither of which may flip the bucket. This is the verb-AGNOSTIC header
|
|
280
|
+
# check; the semantic predicate {#__ruact_function_call?} layers the verb
|
|
281
|
+
# rule on top.
|
|
282
|
+
def __ruact_json_accept?
|
|
283
|
+
request.headers["Accept"] == "application/json"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Story 9.1 AC2 — THE discrimination point: "is this request a function
|
|
287
|
+
# call?". A function call is a JSON-Accept request that is ALSO non-GET/
|
|
288
|
+
# HEAD: function calls are non-GET by the verb rule (epic contract
|
|
289
|
+
# decision #1), so a GET/HEAD carrying `Accept: application/json` (a
|
|
290
|
+
# `fetch()` against a page action, an API probe) is NOT one.
|
|
291
|
+
#
|
|
292
|
+
# Review patch (2026-06-08) — the verb gate that used to live only in
|
|
293
|
+
# {#__ruact_render_structured_error?} now lives HERE, in the predicate
|
|
294
|
+
# itself, so Story 9.2 reuses the CORRECT contract verbatim as the
|
|
295
|
+
# dual-bucket discriminator (the raw header check is {#__ruact_json_accept?}).
|
|
296
|
+
# The predicate lives in one place only.
|
|
297
|
+
def __ruact_function_call?
|
|
298
|
+
__ruact_json_accept? && !(request.get? || request.head?)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# D1 (amended by the 2026-06-07 / 2026-06-08 review patches) — render the
|
|
302
|
+
# structured payload only for NON-GET/HEAD requests; within those, for a
|
|
303
|
+
# function call ({#__ruact_function_call?}) or any
|
|
304
|
+
# `Ruact::UploadTooLargeError`. Everything else re-raises so
|
|
305
|
+
# non-function-call requests keep Rails' default error behavior (AC1
|
|
306
|
+
# byte-for-byte).
|
|
307
|
+
#
|
|
308
|
+
# The verb gate is the FIRST thing checked so it covers the
|
|
309
|
+
# `UploadTooLargeError` branch too (2026-06-08 patch): the guard never
|
|
310
|
+
# produces that error on a GET/HEAD (it skips those — D2), so the only way
|
|
311
|
+
# one reaches this handler on a GET is a manual `raise` inside a page
|
|
312
|
+
# action — which must keep stock Rails behavior, not be swallowed into a
|
|
313
|
+
# structured 413. For the non-GET case the documented exception still
|
|
314
|
+
# holds: a `UploadTooLargeError` from a native multipart form submit
|
|
315
|
+
# (Bucket 1, no JSON Accept) renders a meaningful 413 rather than a
|
|
316
|
+
# re-raised 500.
|
|
317
|
+
# Review patch (2026-06-08, round 3) — `Ruact::ConfigurationError` is never
|
|
318
|
+
# rendered as a structured server-action error: configuration invariants
|
|
319
|
+
# (most notably the upload-guard ordering check) must stay LOUD setup
|
|
320
|
+
# failures. Folding one into an ordinary `_ruact_server_action_error` 500
|
|
321
|
+
# on a JSON function call would disguise a deploy-blocking misconfiguration
|
|
322
|
+
# as a transient runtime error. It re-raises so Rails' default error
|
|
323
|
+
# handling surfaces it.
|
|
324
|
+
def __ruact_render_structured_error?(error)
|
|
325
|
+
return false if request.get? || request.head?
|
|
326
|
+
return false if error.is_a?(Ruact::ConfigurationError)
|
|
327
|
+
|
|
328
|
+
error.is_a?(Ruact::UploadTooLargeError) || __ruact_function_call?
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# D2 — the v1 endpoint was POST-only so the guard never saw GETs; on a
|
|
332
|
+
# host controller it must skip GET/HEAD so page actions stay
|
|
333
|
+
# byte-for-byte untouched (AC1) while every non-GET action — both
|
|
334
|
+
# buckets — is protected (AC4 says "non-GET action", not "function
|
|
335
|
+
# call": native multipart form submits are exactly where uploads come
|
|
336
|
+
# from).
|
|
337
|
+
def __ruact_upload_guard_applicable?
|
|
338
|
+
!(request.get? || request.head?)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Story 8.3 — host module for STANDALONE server actions. The Ruby
|
|
5
|
+
# equivalent of React's `"use server"` directive: a module under
|
|
6
|
+
# `app/server_actions/` that `extend`s {Ruact::ServerAction} and declares
|
|
7
|
+
# one action body per file with the `ruact_action` macro.
|
|
8
|
+
#
|
|
9
|
+
# # app/server_actions/create_post.rb
|
|
10
|
+
# module CreatePost
|
|
11
|
+
# extend Ruact::ServerAction
|
|
12
|
+
#
|
|
13
|
+
# ruact_action :create_post do |params|
|
|
14
|
+
# Post.create!(title: params[:title], body: params[:body])
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# The React side cannot tell standalone-hosted actions apart from
|
|
19
|
+
# controller-hosted ones — both export under the same accessor at
|
|
20
|
+
# `app/javascript/.ruact/server-functions.ts` (Story 8.0a codegen).
|
|
21
|
+
#
|
|
22
|
+
# Differences from {Ruact::Controller#ruact_action} (the controller-hosted
|
|
23
|
+
# path from Story 8.1):
|
|
24
|
+
#
|
|
25
|
+
# - NO instance method is defined on the host module. There is no
|
|
26
|
+
# `host.public_send(action_name)` call shape — the dispatcher
|
|
27
|
+
# ({Ruact::ServerFunctions::EndpointController}) detects the standalone
|
|
28
|
+
# host shape and routes through {Ruact::ServerFunctions::StandaloneDispatcher}
|
|
29
|
+
# instead of `host_class.dispatch`. Standalone modules are reachable
|
|
30
|
+
# ONLY through the gem's `POST /__ruact/fn/:name` endpoint; there is
|
|
31
|
+
# no public Ruby surface to invoke them, by design.
|
|
32
|
+
# - NO controller `before_action` chain runs. The dev opts out of the
|
|
33
|
+
# controller-DSL ergonomic by picking the standalone path; the
|
|
34
|
+
# trade-off is no implicit auth/scoping. The dev calls `current_user`
|
|
35
|
+
# (resolved via {Ruact::Configuration#current_user_resolver}) and
|
|
36
|
+
# Pundit / ActionPolicy directly inside the block when needed.
|
|
37
|
+
# - NO `method_added` hook, NO `Thread.current[:__ruact_dispatching]`
|
|
38
|
+
# sentinel. The standalone path has no routing surface that could
|
|
39
|
+
# reach the action body outside of the gem-managed endpoint, so the
|
|
40
|
+
# controller-only security guards are unnecessary (and would be
|
|
41
|
+
# misleading — the block is stored in the registry, not on the
|
|
42
|
+
# module as an instance method).
|
|
43
|
+
#
|
|
44
|
+
# Shared with {Ruact::Controller#ruact_action}:
|
|
45
|
+
#
|
|
46
|
+
# - {Ruact::ServerFunctions::Registry} via `Ruact.action_registry`.
|
|
47
|
+
# Symbols declared by standalone modules and by controller hosts live
|
|
48
|
+
# in the same registry instance; the existing collision detector
|
|
49
|
+
# ({Ruact::ServerFunctions::Registry#detect_collision!}) catches
|
|
50
|
+
# same-symbol-different-host collisions across both host shapes.
|
|
51
|
+
# - The naming-bridge rule ({Ruact::ServerFunctions::NameBridge}) and
|
|
52
|
+
# reserved-identifier sets (`RESERVED_JS_IDENTIFIERS`,
|
|
53
|
+
# `RESERVED_BY_RUACT`).
|
|
54
|
+
# - The block-parameter shape guard (one positional argument, no
|
|
55
|
+
# required keyword arguments) — same regex as Story 8.1 Re-run-5.
|
|
56
|
+
module ServerAction
|
|
57
|
+
# @param symbol [Symbol] the action name; same naming-bridge rule as
|
|
58
|
+
# {Ruact::Controller#ruact_action}.
|
|
59
|
+
# @yield [params] the block runs via `instance_exec` on a fresh
|
|
60
|
+
# {Ruact::ServerFunctions::StandaloneContext} at dispatch time.
|
|
61
|
+
# @return [Ruact::ServerFunctions::RegistryEntry] the entry just registered.
|
|
62
|
+
# @raise [ArgumentError] when +symbol+ is not a Symbol, the block is
|
|
63
|
+
# missing, or the block's parameter shape is rejected.
|
|
64
|
+
# @raise [Ruact::ConfigurationError] when the symbol fails the
|
|
65
|
+
# naming-bridge rule or collides with another `ruact_action` in the
|
|
66
|
+
# registry (mixed-host collisions are caught here too — see AC4).
|
|
67
|
+
def ruact_action(symbol, &block)
|
|
68
|
+
# Story 8.3 review R4 — reject Class hosts loudly. `extend Ruact::ServerAction`
|
|
69
|
+
# on a Class would work at extend time (Class < Module), but the
|
|
70
|
+
# endpoint dispatcher's `standalone_host?` predicate returns false for
|
|
71
|
+
# Class hosts (it requires Module-NOT-Class), and the controller-DSL
|
|
72
|
+
# branch would then try `host_class.dispatch(...)` against a class
|
|
73
|
+
# that has NO Rails dispatch surface — crashing at request time.
|
|
74
|
+
# Catch this at declaration time so the failure is visible at boot.
|
|
75
|
+
if is_a?(Class)
|
|
76
|
+
raise Ruact::ConfigurationError,
|
|
77
|
+
"Ruact::ServerAction is intended for standalone HOST MODULES under " \
|
|
78
|
+
"`app/server_actions/`. You extended it onto #{name || self} which " \
|
|
79
|
+
"is a Class — that's a controller-shape host. For controller-hosted " \
|
|
80
|
+
"actions use `include Ruact::Controller` and declare `ruact_action` " \
|
|
81
|
+
"inside the controller class body; for standalone actions declare a " \
|
|
82
|
+
"`module Foo; extend Ruact::ServerAction; ...; end` instead."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
unless symbol.is_a?(Symbol)
|
|
86
|
+
raise ArgumentError,
|
|
87
|
+
"ruact_action requires a Symbol argument, got " \
|
|
88
|
+
"#{symbol.inspect} (#{symbol.class}). Use " \
|
|
89
|
+
"`ruact_action :#{symbol}` not `ruact_action #{symbol.inspect}`."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless block
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"ruact_action :#{symbol} (standalone) requires a block — declare the " \
|
|
95
|
+
"implementation with `ruact_action :#{symbol} do |params| ... end`"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Mirror the block-parameter shape guard from Story 8.1 Re-run-5
|
|
99
|
+
# (controller.rb:125-137). The standalone dispatcher invokes the
|
|
100
|
+
# block with one positional argument (the params shadow); blocks
|
|
101
|
+
# with wrong arity / required kwargs would crash at dispatch time.
|
|
102
|
+
req_count = block.parameters.count { |kind, _| kind == :req }
|
|
103
|
+
opt_count = block.parameters.count { |kind, _| kind == :opt }
|
|
104
|
+
rest_count = block.parameters.count { |kind, _| kind == :rest }
|
|
105
|
+
named_positional = req_count + opt_count
|
|
106
|
+
positional_total = named_positional + rest_count
|
|
107
|
+
has_required_kwarg = block.parameters.any? { |kind, _| kind == :keyreq }
|
|
108
|
+
if positional_total.zero? || named_positional > 1 || has_required_kwarg
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"ruact_action :#{symbol} (standalone) block must accept exactly one " \
|
|
111
|
+
"positional parameter and no required keyword arguments " \
|
|
112
|
+
"(got parameters=#{block.parameters.inspect}). Use " \
|
|
113
|
+
"`ruact_action :#{symbol} do |params| ... end`."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# NOTE: no `FRAMEWORK_RESERVED_METHODS` check — standalone modules
|
|
117
|
+
# have no ActionController surface to clobber. NameBridge +
|
|
118
|
+
# RESERVED_JS_IDENTIFIERS + RESERVED_BY_RUACT still fire via the
|
|
119
|
+
# registry's `register` path below.
|
|
120
|
+
#
|
|
121
|
+
# NOTE: no `own_methods` / inherited-helper guard — there is no
|
|
122
|
+
# `define_method` step that could shadow anything; the block lives
|
|
123
|
+
# in the registry, not on the module.
|
|
124
|
+
#
|
|
125
|
+
# NOTE: no `method_added` hook — standalone modules don't define
|
|
126
|
+
# instance methods at all, so a same-name `def` cannot shadow
|
|
127
|
+
# anything (Pitfall #2 in the story spec).
|
|
128
|
+
Ruact.action_registry.register(symbol, kind: :action, controller: self, &block)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# Story 8.4 — Splits an exception backtrace into application frames and
|
|
6
|
+
# gem-internal frames so the dev overlay can hide gem frames behind a
|
|
7
|
+
# toggle by default while still capturing them for the "Copy to clipboard"
|
|
8
|
+
# affordance.
|
|
9
|
+
#
|
|
10
|
+
# Pure function, no I/O. Anchors classification on {Ruact.gem_path} which
|
|
11
|
+
# is memoised at gem load time; the per-frame `start_with?` check is
|
|
12
|
+
# therefore constant-time.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately NOT a thin wrapper over `ActiveSupport::BacktraceCleaner` —
|
|
15
|
+
# that class's silencer/filter API is heavyweight for this single-purpose
|
|
16
|
+
# need; this module is ~10 LoC with zero ActiveSupport dependency so it
|
|
17
|
+
# loads cleanly in AR-less specs (see Pitfall #4 in the story).
|
|
18
|
+
module BacktraceCleaner
|
|
19
|
+
# Split a raw backtrace into app and gem buckets.
|
|
20
|
+
#
|
|
21
|
+
# @param backtrace [Array<String>, nil] frames from `Exception#backtrace`
|
|
22
|
+
# @return [Hash{Symbol=>Array<String>}] `{ app: [...], gem: [...] }`
|
|
23
|
+
def self.split(backtrace)
|
|
24
|
+
return { app: [], gem: [] } if backtrace.nil? || backtrace.empty?
|
|
25
|
+
|
|
26
|
+
gem_prefix = Ruact.gem_path
|
|
27
|
+
gem_frames, app_frames = backtrace.partition { |frame| frame.start_with?(gem_prefix) }
|
|
28
|
+
{ app: app_frames, gem: gem_frames }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|