ruact 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +9 -0
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +14 -2
|
@@ -28,6 +28,29 @@ module Ruact
|
|
|
28
28
|
SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
|
|
29
29
|
SUSPENSE_CLOSE_RE = %r{</Suspense>}
|
|
30
30
|
|
|
31
|
+
# Story 15.2 (FR106) — matches ANY PascalCase component tag: opening
|
|
32
|
+
# (`<Card>`), self-closing (`<Card />`), or closing (`</Card>`). Capture 1 is
|
|
33
|
+
# the leading slash (present only on a closing tag); capture 2 is the name.
|
|
34
|
+
# The loud-children detection scans these left-to-right with a stack: an
|
|
35
|
+
# opening pushes, a self-closing (`/>`) is ignored, and a closing that pops a
|
|
36
|
+
# matching opening means that opening had children (paired usage) → loud
|
|
37
|
+
# error. A single linear pass (no backreference/lazy backtracking) keeps the
|
|
38
|
+
# component-dense fast path linear regardless of how many tags go unclosed.
|
|
39
|
+
COMPONENT_ANY_TAG_RE = %r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>}
|
|
40
|
+
|
|
41
|
+
# Cheap allocation-free probe (`String#match?`) for "is there ANY PascalCase
|
|
42
|
+
# closing tag at all?". The loud-children scan only matters when one exists,
|
|
43
|
+
# so this gates the (copy-heavy) mask/scan work off the common all-self-
|
|
44
|
+
# closing fast path. `</Suspense>` matches too — harmless, it gets masked.
|
|
45
|
+
CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*>}
|
|
46
|
+
|
|
47
|
+
# Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
|
|
48
|
+
# The loud-children scan blanks these first so a `</Card>` that lives inside
|
|
49
|
+
# Ruby/ERB string or comment text can never be mistaken for a real component
|
|
50
|
+
# closing tag (a valid bare `<Dialog>` opening must not error because some
|
|
51
|
+
# unrelated `<% "</Dialog>" %>` appears later).
|
|
52
|
+
ERB_ISLAND_RE = /<%.*?%>/m
|
|
53
|
+
|
|
31
54
|
# Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
|
|
32
55
|
# We use a simple bracket-depth counter approach during scanning instead of regex
|
|
33
56
|
# because expressions can contain nested braces: {foo.bar({ a: 1 })}.
|
|
@@ -66,6 +89,16 @@ module Ruact
|
|
|
66
89
|
end
|
|
67
90
|
.gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
|
|
68
91
|
|
|
92
|
+
# Step 1.5 (Story 15.2 / FR106): before the general component pass, fail
|
|
93
|
+
# loudly if any PascalCase component tag is used with children (a matching
|
|
94
|
+
# closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
|
|
95
|
+
# predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
|
|
96
|
+
# PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
|
|
97
|
+
# file:line is exact) and masks Suspense (the one legitimate paired
|
|
98
|
+
# PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
|
|
99
|
+
# never trip and every reported line matches the template verbatim.
|
|
100
|
+
detect_children!(source, identifier)
|
|
101
|
+
|
|
69
102
|
# Step 2: transform remaining PascalCase self-closing / opening component tags.
|
|
70
103
|
result.gsub(COMPONENT_TAG_RE) do |match|
|
|
71
104
|
component_name = ::Regexp.last_match(1)
|
|
@@ -99,6 +132,86 @@ module Ruact
|
|
|
99
132
|
|
|
100
133
|
private
|
|
101
134
|
|
|
135
|
+
# Story 15.2 (FR106) — raise a loud, self-contained {ChildrenNotSupportedError}
|
|
136
|
+
# on the FIRST PascalCase component tag used with children (a matching closing
|
|
137
|
+
# tag). +source+ is the ORIGINAL template text; +identifier+ is the template
|
|
138
|
+
# path (from {ErbPreprocessorHook}). Suspense — the one legitimate paired
|
|
139
|
+
# PascalCase tag — is masked newline-for-newline first, so it never trips AND
|
|
140
|
+
# every byte position (hence every reported line) still lines up with the raw
|
|
141
|
+
# template. The line uses the same idiom as Step 2 (`count("\n") + 1`) on the
|
|
142
|
+
# OPENING tag's offset. Message mirrors {ComponentContract.raise_error} shape
|
|
143
|
+
# so both loud preprocess errors read identically. A no-op when no pair is
|
|
144
|
+
# present — the fast path stays byte-identical.
|
|
145
|
+
def detect_children!(source, identifier)
|
|
146
|
+
# Fast path: a children pair REQUIRES a literal PascalCase closing tag, so
|
|
147
|
+
# a source without one (the common all-self-closing case) can never trip —
|
|
148
|
+
# bail before allocating anything. `match?` builds no MatchData, and this
|
|
149
|
+
# skips the mask/scan copies entirely, keeping the hot render/preprocess
|
|
150
|
+
# path's allocation profile flat (the benchmark renders only self-closing
|
|
151
|
+
# components, so it must stay at baseline).
|
|
152
|
+
return unless source.match?(CLOSING_TAG_PROBE_RE)
|
|
153
|
+
|
|
154
|
+
# ERB islands first, then Suspense — both blank their text newline-for-
|
|
155
|
+
# newline so byte offsets (hence reported lines) still match the raw
|
|
156
|
+
# template, while neither ERB string text nor the legitimate Suspense pair
|
|
157
|
+
# can be seen by the tag scan.
|
|
158
|
+
scan = mask_suspense(mask_erb(source))
|
|
159
|
+
# PER-NAME open stacks (name → [offsets]) so a closing tag checks for a
|
|
160
|
+
# matching open in O(1) via `open_ats[name].last`, keeping the whole scan
|
|
161
|
+
# linear even under thousands of stray/unmatched PascalCase closing tags
|
|
162
|
+
# (a global stack + `rindex` was quadratic — Codex Round 3).
|
|
163
|
+
open_ats = Hash.new { |h, k| h[k] = [] }
|
|
164
|
+
|
|
165
|
+
scan.scan(COMPONENT_ANY_TAG_RE) do
|
|
166
|
+
m = ::Regexp.last_match
|
|
167
|
+
name = m[2]
|
|
168
|
+
|
|
169
|
+
if m[1] # a closing tag `</Name>`
|
|
170
|
+
at = open_ats[name].last
|
|
171
|
+
next unless at # stray close with no open → literal text, ignore
|
|
172
|
+
|
|
173
|
+
raise_children_error(name, identifier, scan, at)
|
|
174
|
+
elsif m[0].end_with?("/>") # self-closing → carries no children
|
|
175
|
+
next
|
|
176
|
+
else # an opening tag `<Name ...>` — record the NEAREST open of this name
|
|
177
|
+
open_ats[name] << m.begin(0)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Raise the self-contained {ChildrenNotSupportedError}. +at+ is the OPENING
|
|
185
|
+
# tag's byte offset in +scan+ (position-faithful to the raw source), so the
|
|
186
|
+
# line uses the same idiom as Step 2. Message mirrors
|
|
187
|
+
# {ComponentContract.raise_error} so both loud preprocess errors read alike.
|
|
188
|
+
def raise_children_error(component, identifier, scan, at)
|
|
189
|
+
line = scan[0...at].count("\n") + 1
|
|
190
|
+
location = [identifier, line].compact.join(":")
|
|
191
|
+
location = "(unknown location)" if location.empty?
|
|
192
|
+
raise ChildrenNotSupportedError,
|
|
193
|
+
"ruact: <#{component}> at #{location} children are not supported " \
|
|
194
|
+
"— pass content as a prop, e.g. `<#{component} content={...} />`."
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Blank out Suspense open/close tags (the one legitimate paired PascalCase
|
|
198
|
+
# tag) while preserving EVERY newline and byte offset, so the loud-children
|
|
199
|
+
# scan neither trips on `<Suspense>...</Suspense>` nor mis-reports a line when
|
|
200
|
+
# a multi-line Suspense opening precedes the offending tag. Non-newline chars
|
|
201
|
+
# → spaces (same length); newlines kept verbatim.
|
|
202
|
+
def mask_suspense(source)
|
|
203
|
+
source
|
|
204
|
+
.gsub(SUSPENSE_OPEN_RE) { |m| m.gsub(/[^\n]/, " ") }
|
|
205
|
+
.gsub(SUSPENSE_CLOSE_RE) { |m| m.gsub(/[^\n]/, " ") }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Blank ERB islands (position-faithful, see {mask_suspense}) so component
|
|
209
|
+
# tags that appear only inside Ruby/ERB string or comment text are invisible
|
|
210
|
+
# to the loud-children tag scan.
|
|
211
|
+
def mask_erb(source)
|
|
212
|
+
source.gsub(ERB_ISLAND_RE) { |m| m.gsub(/[^\n]/, " ") }
|
|
213
|
+
end
|
|
214
|
+
|
|
102
215
|
# Extract a string attribute value (double or single quoted) from an attrs string.
|
|
103
216
|
def extract_string_attr(attrs, name)
|
|
104
217
|
m = attrs.match(/\b#{Regexp.escape(name)}\s*=\s*"([^"]*)"/) ||
|
data/lib/ruact/errors.rb
CHANGED
|
@@ -22,6 +22,22 @@ module Ruact
|
|
|
22
22
|
# re-wrapping it with the generic "at line N: snippet" tail.
|
|
23
23
|
class ComponentContractError < PreprocessorError; end
|
|
24
24
|
|
|
25
|
+
# Story 15.2 (FR106) — raised by {Ruact::ErbPreprocessor} at preprocess time
|
|
26
|
+
# when a PascalCase component tag is used with children (a matching closing
|
|
27
|
+
# tag, e.g. `<Card>Hello</Card>`). ruact component tags are self-closing only:
|
|
28
|
+
# a component receives a props Hash, never a children element tree. This is the
|
|
29
|
+
# #1 predictable JSX-habit mistake, and it used to degrade silently (the
|
|
30
|
+
# children leaked into the surrounding HTML while the component rendered with
|
|
31
|
+
# none). Subclasses {PreprocessorError} so it flows through the same dev error
|
|
32
|
+
# overlay (NFR30 lineage) and the hook treats it uniformly — but the distinct
|
|
33
|
+
# class (mirroring {ComponentContractError}) lets the preprocessor re-raise it
|
|
34
|
+
# AS-IS (its message already carries the component name + file:line + the exact
|
|
35
|
+
# fix) and lets the 15.7 capstone assert this trap fails loudly by class. The
|
|
36
|
+
# sole legitimate paired PascalCase tag, `<Suspense>...</Suspense>`, is
|
|
37
|
+
# normalized to `<ruact-suspense>` in Step 1 before this detection runs, so it
|
|
38
|
+
# never trips.
|
|
39
|
+
class ChildrenNotSupportedError < PreprocessorError; end
|
|
40
|
+
|
|
25
41
|
# Raised when application code attempts to mutate Ruact::Configuration outside
|
|
26
42
|
# of a Ruact.configure block. The configuration is frozen after initialization
|
|
27
43
|
# to prevent runtime drift; see Story 7.3 for the rationale and the decision
|
|
@@ -79,8 +79,8 @@ module Ruact
|
|
|
79
79
|
return nil if soft
|
|
80
80
|
|
|
81
81
|
raise ManifestError, <<~MSG.strip
|
|
82
|
-
[ruact] Vite dev server
|
|
83
|
-
react-client-manifest.json
|
|
82
|
+
[ruact] Vite dev server unreachable at #{base_url} and no \
|
|
83
|
+
react-client-manifest.json found at #{file_path} — run `bin/dev`.
|
|
84
84
|
MSG
|
|
85
85
|
end
|
|
86
86
|
|
data/lib/ruact/serializable.rb
CHANGED
|
@@ -5,32 +5,71 @@ module Ruact
|
|
|
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
|
+
# Works on POROs and on ActiveRecord models alike. For a PORO the loud check
|
|
9
|
+
# fires at class-load; for an ActiveRecord model (whose attribute readers are
|
|
10
|
+
# defined lazily) the same loud check is deferred to the first
|
|
11
|
+
# +ruact_serialize+ — see {ClassMethods#ruact_props}.
|
|
12
|
+
#
|
|
13
|
+
# @example PORO
|
|
9
14
|
# class Post
|
|
10
15
|
# include Ruact::Serializable
|
|
11
16
|
# attr_reader :id, :title, :secret
|
|
12
17
|
# ruact_props :id, :title # :secret is never sent to the client
|
|
13
18
|
# end
|
|
19
|
+
#
|
|
20
|
+
# @example ActiveRecord model
|
|
21
|
+
# class Post < ApplicationRecord
|
|
22
|
+
# include Ruact::Serializable
|
|
23
|
+
# ruact_props :id, :title # other columns never cross to the client
|
|
24
|
+
# end
|
|
14
25
|
module Serializable
|
|
15
26
|
def self.included(base)
|
|
16
27
|
base.extend(ClassMethods)
|
|
17
28
|
base.instance_variable_set(:@ruact_props, [])
|
|
29
|
+
base.instance_variable_set(:@ruact_deferred_props, [])
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
module ClassMethods
|
|
21
33
|
# Declare which instance methods should be included in the serialized
|
|
22
|
-
# payload.
|
|
23
|
-
#
|
|
34
|
+
# payload.
|
|
35
|
+
#
|
|
36
|
+
# The loud-omission guarantee is preserved: a name with no corresponding
|
|
37
|
+
# method still raises a clean +ArgumentError+. Only the *timing* of that
|
|
38
|
+
# check depends on the class:
|
|
39
|
+
#
|
|
40
|
+
# * **PORO** — checked eagerly at class-load (macro-invocation) time, as
|
|
41
|
+
# before. A typo'd/absent prop raises immediately.
|
|
42
|
+
# * **ActiveRecord model** — ActiveRecord defines its attribute reader
|
|
43
|
+
# methods lazily (on first instance access), so at macro-invocation time
|
|
44
|
+
# +method_defined?(:title)+ is +false+ even for a real column. Checking
|
|
45
|
+
# eagerly would either reject a valid model at boot or require a live DB
|
|
46
|
+
# connection at class-load (a Rails anti-pattern). So for a lazy-attribute
|
|
47
|
+
# class the not-yet-defined names are recorded and their loud check is
|
|
48
|
+
# deferred to the first +ruact_serialize+ (via +respond_to?+ on the
|
|
49
|
+
# instance), where the DB is up. A genuine typo still raises the same
|
|
50
|
+
# clean +ArgumentError+ — just at first render of that model, not at boot.
|
|
24
51
|
#
|
|
25
52
|
# @param attrs [Array<Symbol>]
|
|
53
|
+
# @raise [ArgumentError] immediately for an undefined prop on a PORO; at
|
|
54
|
+
# first +ruact_serialize+ for an undefined prop on an ActiveRecord model.
|
|
26
55
|
def ruact_props(*attrs)
|
|
56
|
+
deferred = []
|
|
27
57
|
attrs.each do |attr|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
next if method_defined?(attr)
|
|
59
|
+
|
|
60
|
+
# Lazy-attribute (ActiveRecord) class: the reader may still appear on
|
|
61
|
+
# first instance access. Record it and check loudly on first serialize
|
|
62
|
+
# instead of failing a valid model at boot.
|
|
63
|
+
if ruact_lazy_attribute_class?
|
|
64
|
+
deferred << attr
|
|
65
|
+
next
|
|
31
66
|
end
|
|
67
|
+
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"ruact_props: method `#{attr}` is not defined on #{self}"
|
|
32
70
|
end
|
|
33
71
|
@ruact_props = attrs
|
|
72
|
+
@ruact_deferred_props = deferred
|
|
34
73
|
end
|
|
35
74
|
|
|
36
75
|
# Returns the list of declared prop names as symbols.
|
|
@@ -46,12 +85,65 @@ module Ruact
|
|
|
46
85
|
end
|
|
47
86
|
[]
|
|
48
87
|
end
|
|
88
|
+
|
|
89
|
+
# Names whose eager loud check was deferred to first serialize (lazy
|
|
90
|
+
# ActiveRecord attributes). Read from the same class that declared the
|
|
91
|
+
# props, so subclasses share the parent declaration.
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Symbol>]
|
|
94
|
+
def ruact_deferred_props_list
|
|
95
|
+
klass = self
|
|
96
|
+
while klass
|
|
97
|
+
if klass.instance_variable_defined?(:@ruact_props)
|
|
98
|
+
return klass.instance_variable_get(:@ruact_deferred_props) || []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
klass = klass.superclass
|
|
102
|
+
end
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run the deferred loud check once, on first +ruact_serialize+. A recorded
|
|
107
|
+
# name that the instance does not +respond_to?+ (a typo, or a genuinely
|
|
108
|
+
# absent column) raises the same clean +ArgumentError+ the eager path would.
|
|
109
|
+
#
|
|
110
|
+
# Memoization keys on the *validated deferred list itself* (not a bare
|
|
111
|
+
# boolean), so any change to the effective declaration — a re-declaration
|
|
112
|
+
# on this class OR on an ancestor whose props a subclass inherits — is
|
|
113
|
+
# detected and re-validated loudly on the next serialize. Once a given
|
|
114
|
+
# list has been validated it costs one array comparison per serialize.
|
|
115
|
+
#
|
|
116
|
+
# @param instance [Object]
|
|
117
|
+
# @raise [ArgumentError]
|
|
118
|
+
def ruact_validate_deferred_props!(instance)
|
|
119
|
+
deferred = ruact_deferred_props_list
|
|
120
|
+
return if @ruact_deferred_props_validated == deferred
|
|
121
|
+
|
|
122
|
+
deferred.each do |attr|
|
|
123
|
+
next if instance.respond_to?(attr)
|
|
124
|
+
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"ruact_props: method `#{attr}` is not defined on #{self}"
|
|
127
|
+
end
|
|
128
|
+
@ruact_deferred_props_validated = deferred
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# True when this class defines its attribute reader methods lazily, i.e.
|
|
132
|
+
# an ActiveRecord model. Detected WITHOUT a hard ActiveRecord dependency
|
|
133
|
+
# (the gem stays single-dep +nokogiri+): the constant is only referenced
|
|
134
|
+
# when it is already defined in the host.
|
|
135
|
+
#
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def ruact_lazy_attribute_class?
|
|
138
|
+
!!(defined?(ActiveRecord::Base) && self < ActiveRecord::Base)
|
|
139
|
+
end
|
|
49
140
|
end
|
|
50
141
|
|
|
51
142
|
# Serialize only the attributes declared with +ruact_props+.
|
|
52
143
|
#
|
|
53
144
|
# @return [Hash{String => Object}]
|
|
54
145
|
def ruact_serialize
|
|
146
|
+
self.class.ruact_validate_deferred_props!(self)
|
|
55
147
|
self.class.ruact_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
|
|
56
148
|
end
|
|
57
149
|
end
|
data/lib/ruact/server.rb
CHANGED
|
@@ -104,6 +104,28 @@ module Ruact
|
|
|
104
104
|
before_action :__ruact_set_vary_on_accept!
|
|
105
105
|
after_action :__ruact_set_vary_on_accept!
|
|
106
106
|
|
|
107
|
+
# Story 15.0 (F6) — dev-only legibility warning: an action that registers
|
|
108
|
+
# validation errors (`ruact_errors(record)`) and then performs an EXPLICIT
|
|
109
|
+
# render on a function-call request bypasses `default_render`, so the
|
|
110
|
+
# registered errors silently vanish from the JSON body. The warning names
|
|
111
|
+
# the opt-out; it never fires on the documented-correct patterns (Bucket-1
|
|
112
|
+
# page render, redirect on either bucket, the fall-through itself, or an
|
|
113
|
+
# action that never touches the collector). Log-only — bodies and statuses
|
|
114
|
+
# are byte-identical in every environment.
|
|
115
|
+
after_action :__ruact_warn_errors_injection_opt_out!
|
|
116
|
+
|
|
117
|
+
# Story 15.5 (FR109) — dev-only response-shape legibility log: one
|
|
118
|
+
# `[ruact]` line per ruact-NEGOTIATED request naming the chosen bucket +
|
|
119
|
+
# the deciding signal (e.g. `Accept: application/json + POST →
|
|
120
|
+
# function-call JSON`), so the dual-bucket negotiation is observable at
|
|
121
|
+
# runtime without a debugger. Adjacent to the F6 warning above so the two
|
|
122
|
+
# dev-only legibility hooks read as a pair. Same dev gate
|
|
123
|
+
# (`__ruact_development?`), same log-only invariant — bodies, statuses,
|
|
124
|
+
# and headers are byte-identical in every environment. SILENT outside
|
|
125
|
+
# development and on a plain Rails action on the same controller (a
|
|
126
|
+
# response ruact did not negotiate).
|
|
127
|
+
after_action :__ruact_log_response_shape!
|
|
128
|
+
|
|
107
129
|
# Story 8.4 salvage — same registration order as v1 (Pitfall #1): the
|
|
108
130
|
# generic StandardError entry first, the explicit
|
|
109
131
|
# InvalidAuthenticityToken entry second so it wins Rails'
|
|
@@ -201,6 +223,12 @@ module Ruact
|
|
|
201
223
|
# one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
|
|
202
224
|
assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
|
|
203
225
|
|
|
226
|
+
# Story 15.0 (F6) — reaching this branch means ruact itself is producing
|
|
227
|
+
# the function-call response (fall-through: injection, plain JSON, or
|
|
228
|
+
# 204), so the opt-out warning must stay silent. Set AFTER `view_assigns`
|
|
229
|
+
# is captured above so the flag ivar never leaks into the serialized body.
|
|
230
|
+
@__ruact_function_response_owned = true
|
|
231
|
+
|
|
204
232
|
# Story 13.3 (FR98, AC3) — when the host opted into the validation-error
|
|
205
233
|
# round-trip (`ruact_errors(@record)` was called this request), inject the
|
|
206
234
|
# collected errors under the reserved JSON key `"errors"` alongside the
|
|
@@ -249,6 +277,11 @@ module Ruact
|
|
|
249
277
|
_ensure_url_is_http_header_safe(location)
|
|
250
278
|
location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
|
|
251
279
|
|
|
280
|
+
# Story 15.0 (F6) — a Bucket-2 `redirect_to` is a ruact-owned response
|
|
281
|
+
# (`$redirect`; registered errors ride flash), not an injection opt-out.
|
|
282
|
+
@__ruact_function_response_owned = true
|
|
283
|
+
# Story 15.5 (FR109) — mark the `$redirect` sub-shape for the dev log.
|
|
284
|
+
@__ruact_function_redirect = true
|
|
252
285
|
render json: { "$redirect" => __ruact_redirect_path(location) }
|
|
253
286
|
end
|
|
254
287
|
|
|
@@ -290,6 +323,136 @@ module Ruact
|
|
|
290
323
|
url
|
|
291
324
|
end
|
|
292
325
|
|
|
326
|
+
# Story 15.0 (F6) — the dev-only opt-out warning. Fires when ALL hold:
|
|
327
|
+
# development environment, the request is a function call
|
|
328
|
+
# ({#__ruact_function_call?}), the collector was touched
|
|
329
|
+
# (`ruact_errors(record)` ran this request), and the response was produced
|
|
330
|
+
# by an EXPLICIT host render — i.e. neither {#default_render}'s
|
|
331
|
+
# function-call branch nor the Bucket-2 {#redirect_to} set
|
|
332
|
+
# `@__ruact_function_response_owned`. In that shape the registered errors
|
|
333
|
+
# were silently dropped from the JSON body (the F6 finding).
|
|
334
|
+
#
|
|
335
|
+
# The four MUST-NOT-warn guardrails fall out of the predicate:
|
|
336
|
+
# (a) Bucket-1 `render :new` — not a function call;
|
|
337
|
+
# (b) `redirect_to` on either bucket — Bucket-2 sets the owned flag,
|
|
338
|
+
# Bucket-1 is not a function call;
|
|
339
|
+
# (c) the fall-through — `default_render` sets the owned flag;
|
|
340
|
+
# (d) an untouched collector — `__ruact_errors_touched?` is false.
|
|
341
|
+
# The exception path never reaches here either: a raise aborts the
|
|
342
|
+
# after_action chain before this hook runs (`rescue_from` renders outside
|
|
343
|
+
# it), so a structured error payload is not misread as an opt-out.
|
|
344
|
+
def __ruact_warn_errors_injection_opt_out!
|
|
345
|
+
return unless __ruact_development?
|
|
346
|
+
return unless __ruact_function_call?
|
|
347
|
+
return unless __ruact_errors_touched?
|
|
348
|
+
return if @__ruact_function_response_owned
|
|
349
|
+
|
|
350
|
+
Rails.logger&.warn(
|
|
351
|
+
"[ruact] #{self.class.name}##{action_name} called `ruact_errors` and then rendered " \
|
|
352
|
+
"explicitly on a function-call request — the registered validation errors were NOT " \
|
|
353
|
+
"injected into the JSON response. Remove the explicit render and let the action fall " \
|
|
354
|
+
"through (ruact injects `errors` into the body), or surface them on a page render with " \
|
|
355
|
+
"`errors={ruact_errors}` / carry them through a `redirect_to` (flash)."
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Story 15.5 (FR109) — the dev-only response-shape log. Emits exactly one
|
|
360
|
+
# `[ruact]` line per ruact-negotiated request naming the chosen bucket and
|
|
361
|
+
# the deciding signal (Accept + verb), mirroring the vocabulary of the
|
|
362
|
+
# canonical caller→shape table in `server-actions.md`. Log-only: the
|
|
363
|
+
# response body, status, and headers are byte-identical to before in every
|
|
364
|
+
# environment.
|
|
365
|
+
#
|
|
366
|
+
# Coverage (D1, `Ruact::Server`-only for 15.5): the function-call-JSON
|
|
367
|
+
# bucket (incl. the `204` and `$redirect` sub-shapes) plus, for a page
|
|
368
|
+
# render on a controller that also includes {Ruact::Controller}, the
|
|
369
|
+
# Flight-page and HTML-shell shapes. A GET query (separate dispatch
|
|
370
|
+
# controller) and a page-only controller (includes `Ruact::Controller` but
|
|
371
|
+
# not `Ruact::Server`) are NOT covered — the table documents those shapes;
|
|
372
|
+
# extending the log to them is deferred (see deferred-work.md).
|
|
373
|
+
#
|
|
374
|
+
# SILENT (returns nil message) outside development and on a plain Rails
|
|
375
|
+
# action on the same controller — a response ruact did not negotiate
|
|
376
|
+
# (no function-call Accept and no ruact page render). A raise / before_action
|
|
377
|
+
# halt that skips the after_action chain is an accepted, documented log gap
|
|
378
|
+
# (identical to the F6 warning).
|
|
379
|
+
def __ruact_log_response_shape!
|
|
380
|
+
return unless __ruact_development?
|
|
381
|
+
|
|
382
|
+
message = __ruact_response_shape_log_message
|
|
383
|
+
return unless message
|
|
384
|
+
|
|
385
|
+
Rails.logger&.info(message)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Classify the negotiated response shape from the request signals
|
|
389
|
+
# (`__ruact_function_call?`, Accept, verb) and the ruact-owned outcome flags
|
|
390
|
+
# (`@__ruact_function_redirect` set by the Bucket-2 {#redirect_to};
|
|
391
|
+
# `@__ruact_negotiated_page` set by {Ruact::Controller#emit_ruact_response} /
|
|
392
|
+
# its Flight `redirect_to`). Returns nil when ruact did not negotiate the
|
|
393
|
+
# response, so the log stays silent on plain Rails actions.
|
|
394
|
+
#
|
|
395
|
+
# @return [String, nil] the `[ruact] Ctrl#action — <signal> → <bucket>`
|
|
396
|
+
# line, or nil for a non-negotiated response.
|
|
397
|
+
def __ruact_response_shape_log_message
|
|
398
|
+
shape, signal = __ruact_negotiated_shape_and_signal
|
|
399
|
+
return unless shape
|
|
400
|
+
|
|
401
|
+
"[ruact] #{self.class.name}##{action_name} — #{signal} → #{shape}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# @return [Array(String, String), nil] `[bucket, deciding_signal]` for a
|
|
405
|
+
# ruact-negotiated response, or nil for one ruact did not own.
|
|
406
|
+
def __ruact_negotiated_shape_and_signal
|
|
407
|
+
if __ruact_function_call?
|
|
408
|
+
[__ruact_function_call_shape, "Accept: application/json + #{request.request_method}"]
|
|
409
|
+
else
|
|
410
|
+
__ruact_page_shape_and_signal
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# The function-call (Bucket-2) sub-shape, distinguished by the ruact-owned
|
|
415
|
+
# outcome: a `$redirect` directive, a `204 No Content` (no exposed ivars),
|
|
416
|
+
# or the ordinary serialized-ivars JSON.
|
|
417
|
+
def __ruact_function_call_shape
|
|
418
|
+
return "function-call $redirect" if @__ruact_function_redirect
|
|
419
|
+
return "function-call JSON (204, no ivars)" if response.status == 204
|
|
420
|
+
|
|
421
|
+
"function-call JSON"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# The page-render (Bucket-1 / HTML-shell) shape, keyed on the flag
|
|
425
|
+
# {Ruact::Controller#emit_ruact_response} set when ruact rendered the page.
|
|
426
|
+
# nil for a plain Rails render (the flag was never set) → the log stays
|
|
427
|
+
# silent on a non-negotiated response.
|
|
428
|
+
def __ruact_page_shape_and_signal
|
|
429
|
+
case @__ruact_negotiated_page
|
|
430
|
+
when :flight
|
|
431
|
+
["Flight page", __ruact_flight_signal]
|
|
432
|
+
when :flight_redirect
|
|
433
|
+
["Flight redirect", __ruact_flight_signal]
|
|
434
|
+
when :html_shell
|
|
435
|
+
["HTML shell", "HTML-acceptable Accept, no Flight header"]
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# The Flight deciding signal, faithful to how the request announced itself:
|
|
440
|
+
# the `Accept: text/x-component` header, or the `Ruact-Request: 1` header.
|
|
441
|
+
def __ruact_flight_signal
|
|
442
|
+
if request.headers["Accept"].to_s.include?("text/x-component")
|
|
443
|
+
"Accept: text/x-component"
|
|
444
|
+
else
|
|
445
|
+
"Ruact-Request: 1"
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# @return [Boolean] true only in a real Rails development environment
|
|
450
|
+
# (mirrors {Ruact::ManifestResolver.development?}).
|
|
451
|
+
def __ruact_development?
|
|
452
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
|
|
453
|
+
Rails.env.development?
|
|
454
|
+
end
|
|
455
|
+
|
|
293
456
|
# Raw discriminator — does the request's `Accept` header equal
|
|
294
457
|
# `application/json`? This is exactly what the 8.1 runtime sends on every
|
|
295
458
|
# `_makeRef` fetch (Bucket 2 — imperative `await createPost(...)`).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# Story 15.3 (FR107) — the machine-readable `ruact:routes --json` document:
|
|
6
|
+
# a read-only, side-effect-free view of the accessor/route table for headless
|
|
7
|
+
# agents and CI gates. Reuses the SAME collectors codegen consumes (via
|
|
8
|
+
# {Ruact::ServerFunctions.introspect}), so the introspection can never fork a
|
|
9
|
+
# parallel derivation and never writes the codegen bridge / TS module.
|
|
10
|
+
#
|
|
11
|
+
# ## Shape
|
|
12
|
+
#
|
|
13
|
+
# { "schema_version" => Integer,
|
|
14
|
+
# "accessors" => [
|
|
15
|
+
# { "accessor" => String, # the JS identifier (js_identifier)
|
|
16
|
+
# "kind" => String, # "action" | "query"
|
|
17
|
+
# "verb" => String, # HTTP method
|
|
18
|
+
# "path" => String, # cleaned route path
|
|
19
|
+
# "segments" => Array<String>,
|
|
20
|
+
# "params" => [{ "name" => String, "required" => Boolean }] } ] }
|
|
21
|
+
#
|
|
22
|
+
# Queries carry their declared kwargs as `params` (Story 13.4); actions carry
|
|
23
|
+
# their required path segments, uniformly shaped as `{ name, required: true }`.
|
|
24
|
+
# Per Story 15.3 decision D3 there is NO per-accessor component-contract link
|
|
25
|
+
# (accessors are server functions, not components) — the table reports
|
|
26
|
+
# accessors faithfully and omits the "target component contract" clause.
|
|
27
|
+
module Introspection
|
|
28
|
+
# Version of the `ruact:routes --json` document. **EXPERIMENTAL / UNSTABLE**:
|
|
29
|
+
# `0` signals the shape may change without a major version bump while the
|
|
30
|
+
# agent-facing surface is iterated — gate any parser on it. Distinct from
|
|
31
|
+
# {Ruact::Doctor::SCHEMA_VERSION} (a separate, independently-versioned
|
|
32
|
+
# document) and from {Snapshot::VERSION_V2} (the internal codegen bridge).
|
|
33
|
+
SCHEMA_VERSION = 0
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Builds the `ruact:routes --json` document from +route_set+. The resolver
|
|
37
|
+
# callables default to real constant resolution; specs inject lambdas.
|
|
38
|
+
#
|
|
39
|
+
# @param route_set [#routes] the Rails route set.
|
|
40
|
+
# @param host_predicate [#call, nil] forwarded to {ServerFunctions.introspect}.
|
|
41
|
+
# @param overrides_for [#call, nil] forwarded to {ServerFunctions.introspect}.
|
|
42
|
+
# @param query_class_for [#call, nil] forwarded to {ServerFunctions.introspect}.
|
|
43
|
+
# @return [Hash] the schema-versioned introspection document.
|
|
44
|
+
# @raise [Ruact::ConfigurationError] on any naming collision.
|
|
45
|
+
def as_json(route_set:, host_predicate: nil, overrides_for: nil, query_class_for: nil)
|
|
46
|
+
entries = ServerFunctions.introspect(
|
|
47
|
+
route_set: route_set,
|
|
48
|
+
host_predicate: host_predicate,
|
|
49
|
+
overrides_for: overrides_for,
|
|
50
|
+
query_class_for: query_class_for
|
|
51
|
+
)
|
|
52
|
+
{
|
|
53
|
+
"schema_version" => SCHEMA_VERSION,
|
|
54
|
+
"accessors" => entries.map { |entry| shape(entry) }
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def shape(entry)
|
|
61
|
+
{
|
|
62
|
+
"accessor" => entry["js_identifier"],
|
|
63
|
+
"kind" => entry["kind"],
|
|
64
|
+
"verb" => entry["http_method"],
|
|
65
|
+
"path" => entry["path"],
|
|
66
|
+
"segments" => entry["segments"],
|
|
67
|
+
"params" => params_for(entry)
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Queries declare their params as kwargs (Story 13.4 → `params`); actions
|
|
72
|
+
# derive them from required path segments. Uniform `{ name, required }`.
|
|
73
|
+
def params_for(entry)
|
|
74
|
+
return entry["params"] if entry["kind"] == "query"
|
|
75
|
+
|
|
76
|
+
entry["segments"].map { |segment| { "name" => segment, "required" => true } }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -29,6 +29,7 @@ module Ruact
|
|
|
29
29
|
autoload :ValidationErrors, "ruact/server_functions/validation_errors"
|
|
30
30
|
autoload :QueryContext, "ruact/server_functions/query_context"
|
|
31
31
|
autoload :QueryDispatch, "ruact/server_functions/query_dispatch"
|
|
32
|
+
autoload :Introspection, "ruact/server_functions/introspection"
|
|
32
33
|
|
|
33
34
|
# Story 9.3 / 9.9 — orchestrates the route-driven (v2) codegen. Reads the
|
|
34
35
|
# route table via {RouteSource} + {QuerySource}, writes the version-2 bridge
|
|
@@ -55,10 +56,7 @@ module Ruact
|
|
|
55
56
|
# `Rails.logger` when Rails is loaded, else nil.
|
|
56
57
|
# @return [Array<Hash>] the exposed v2 entries (actions + queries).
|
|
57
58
|
def self.write_v2_snapshot!(route_set:, root:, logger: default_logger)
|
|
58
|
-
|
|
59
|
-
queries = QuerySource.collect(route_set)
|
|
60
|
-
entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
|
|
61
|
-
detect_merged_namespace_collisions!(entries)
|
|
59
|
+
entries = introspect(route_set: route_set)
|
|
62
60
|
|
|
63
61
|
json_path = root.join("tmp/cache/ruact/server-functions.json")
|
|
64
62
|
ts_path = root.join("app/javascript/.ruact/server-functions.ts")
|
|
@@ -75,6 +73,30 @@ module Ruact
|
|
|
75
73
|
entries
|
|
76
74
|
end
|
|
77
75
|
|
|
76
|
+
# Story 15.3 (FR107) — the read-only, side-effect-free combine point:
|
|
77
|
+
# collect mutation actions ({RouteSource}) + read queries ({QuerySource}),
|
|
78
|
+
# sort by `js_identifier`, and run the merged-namespace collision check —
|
|
79
|
+
# WITHOUT writing the bridge/TS. Both {.write_v2_snapshot!} (codegen) and
|
|
80
|
+
# {Ruact::ServerFunctions::Introspection} (`ruact:routes --json`) call THIS,
|
|
81
|
+
# so what an agent verifies against can never drift from what codegen emits
|
|
82
|
+
# (single source of truth), and a CI gate can introspect without mutating the
|
|
83
|
+
# tree. The resolver callables default to real constant resolution; specs
|
|
84
|
+
# inject lambdas to exercise the derivation without booting controllers.
|
|
85
|
+
#
|
|
86
|
+
# @param route_set [#routes] the Rails route set.
|
|
87
|
+
# @param host_predicate [#call, nil] forwarded to {RouteSource.collect}.
|
|
88
|
+
# @param overrides_for [#call, nil] forwarded to {RouteSource.collect}.
|
|
89
|
+
# @param query_class_for [#call, nil] forwarded to {QuerySource.collect}.
|
|
90
|
+
# @return [Array<Hash>] merged action + query entries (string keys), sorted.
|
|
91
|
+
# @raise [Ruact::ConfigurationError] on any naming collision.
|
|
92
|
+
def self.introspect(route_set:, host_predicate: nil, overrides_for: nil, query_class_for: nil)
|
|
93
|
+
actions = RouteSource.collect(route_set, host_predicate: host_predicate, overrides_for: overrides_for)
|
|
94
|
+
queries = QuerySource.collect(route_set, query_class_for: query_class_for)
|
|
95
|
+
entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
|
|
96
|
+
detect_merged_namespace_collisions!(entries)
|
|
97
|
+
entries
|
|
98
|
+
end
|
|
99
|
+
|
|
78
100
|
def self.default_logger
|
|
79
101
|
defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
80
102
|
end
|