ruact 0.0.1
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 +7 -0
- data/.github/workflows/ci.yml +166 -0
- data/.rubocop.yml +89 -0
- data/CHANGELOG.md +32 -0
- data/README.md +35 -0
- data/RELEASING.md +203 -0
- data/Rakefile +10 -0
- data/SECURITY.md +62 -0
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +163 -0
- data/lib/generators/ruact/install/install_generator.rb +100 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +51 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +18 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +26 -0
- data/lib/ruact/client_manifest.rb +115 -0
- data/lib/ruact/component_registry.rb +31 -0
- data/lib/ruact/configuration.rb +32 -0
- data/lib/ruact/controller.rb +195 -0
- data/lib/ruact/doctor.rb +84 -0
- data/lib/ruact/erb_preprocessor.rb +120 -0
- data/lib/ruact/erb_preprocessor_hook.rb +20 -0
- data/lib/ruact/errors.rb +14 -0
- data/lib/ruact/flight/react_element.rb +40 -0
- data/lib/ruact/flight/renderer.rb +73 -0
- data/lib/ruact/flight/request.rb +54 -0
- data/lib/ruact/flight/row_emitter.rb +37 -0
- data/lib/ruact/flight/serializer.rb +215 -0
- data/lib/ruact/flight.rb +12 -0
- data/lib/ruact/html_converter.rb +159 -0
- data/lib/ruact/railtie.rb +99 -0
- data/lib/ruact/render_pipeline.rb +107 -0
- data/lib/ruact/serializable.rb +58 -0
- data/lib/ruact/version.rb +5 -0
- data/lib/ruact/view_helper.rb +23 -0
- data/lib/ruact.rb +48 -0
- data/lib/rubocop/cop/ruact/no_extend_self.rb +46 -0
- data/lib/rubocop/cop/ruact/no_io_in_flight.rb +72 -0
- data/lib/rubocop/cop/ruact/no_shared_state.rb +49 -0
- data/lib/rubocop/cop/ruact.rb +5 -0
- data/lib/tasks/benchmark.rake +70 -0
- data/lib/tasks/rsc.rake +9 -0
- data/sig/ruact.rbs +4 -0
- data/spec/benchmarks/baseline.json +1 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +92 -0
- data/spec/fixtures/flight/README.md +88 -0
- data/spec/fixtures/flight/array.txt +1 -0
- data/spec/fixtures/flight/as_json_object.txt +2 -0
- data/spec/fixtures/flight/boolean_false.txt +1 -0
- data/spec/fixtures/flight/boolean_true.txt +1 -0
- data/spec/fixtures/flight/client_component_with_props.txt +2 -0
- data/spec/fixtures/flight/client_reference.txt +2 -0
- data/spec/fixtures/flight/hash.txt +1 -0
- data/spec/fixtures/flight/nil.txt +1 -0
- data/spec/fixtures/flight/number_float.txt +1 -0
- data/spec/fixtures/flight/number_integer.txt +1 -0
- data/spec/fixtures/flight/react_element_no_props.txt +1 -0
- data/spec/fixtures/flight/redirect_row.txt +1 -0
- data/spec/fixtures/flight/serializable_object.txt +2 -0
- data/spec/fixtures/flight/string_basic.txt +1 -0
- data/spec/fixtures/flight/string_dollar_escape.txt +1 -0
- data/spec/ruact/client_manifest_spec.rb +126 -0
- data/spec/ruact/controller_spec.rb +213 -0
- data/spec/ruact/doctor_spec.rb +234 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +52 -0
- data/spec/ruact/erb_preprocessor_spec.rb +89 -0
- data/spec/ruact/errors_spec.rb +43 -0
- data/spec/ruact/flight/renderer_spec.rb +122 -0
- data/spec/ruact/flight/serializer_spec.rb +453 -0
- data/spec/ruact/html_converter_spec.rb +147 -0
- data/spec/ruact/install_generator_spec.rb +212 -0
- data/spec/ruact/railtie_spec.rb +156 -0
- data/spec/ruact/render_pipeline_spec.rb +474 -0
- data/spec/ruact/serializable_spec.rb +53 -0
- data/spec/ruact/view_helper_spec.rb +46 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +25 -0
- data/spec/support/rails_stub.rb +45 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +163 -0
- metadata +136 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module Flight
|
|
8
|
+
# Converts a Ruby value/element tree into Flight wire format rows.
|
|
9
|
+
# All methods return the *inline* representation of the value
|
|
10
|
+
# (what goes inside a parent row). Side effects go into request queues.
|
|
11
|
+
class Serializer
|
|
12
|
+
# Strings larger than this are outlined into their own T row.
|
|
13
|
+
LARGE_TEXT_THRESHOLD = 1024
|
|
14
|
+
|
|
15
|
+
def initialize(request)
|
|
16
|
+
@request = request
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Entry point. Returns a value safe to pass to JSON.generate.
|
|
20
|
+
def serialize_model(value)
|
|
21
|
+
case value
|
|
22
|
+
when NilClass
|
|
23
|
+
nil
|
|
24
|
+
when TrueClass, FalseClass
|
|
25
|
+
value
|
|
26
|
+
when Integer
|
|
27
|
+
serialize_integer(value)
|
|
28
|
+
when Float
|
|
29
|
+
serialize_float(value)
|
|
30
|
+
when String
|
|
31
|
+
serialize_string(value)
|
|
32
|
+
when Symbol
|
|
33
|
+
serialize_symbol(value)
|
|
34
|
+
when Time, DateTime
|
|
35
|
+
serialize_date(value)
|
|
36
|
+
when ClientReference
|
|
37
|
+
serialize_client_reference(value)
|
|
38
|
+
when SuspenseElement
|
|
39
|
+
serialize_suspense(value)
|
|
40
|
+
when ReactElement
|
|
41
|
+
serialize_element(value)
|
|
42
|
+
when Array
|
|
43
|
+
serialize_array(value)
|
|
44
|
+
when Hash
|
|
45
|
+
serialize_hash(value)
|
|
46
|
+
else
|
|
47
|
+
serialize_unknown(value)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# --- Primitives ---
|
|
54
|
+
|
|
55
|
+
def serialize_string(value)
|
|
56
|
+
# Large strings get their own T row
|
|
57
|
+
if value.bytesize >= LARGE_TEXT_THRESHOLD
|
|
58
|
+
id = @request.allocate_id
|
|
59
|
+
@request.increment_pending
|
|
60
|
+
row = RowEmitter.text(id, value)
|
|
61
|
+
@request.completed_regular_chunks << row
|
|
62
|
+
return "$T#{id.to_s(16)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Escape leading $ so the client doesn't misinterpret it.
|
|
66
|
+
# "$danger" → "$$danger" (prepend one extra $, not two)
|
|
67
|
+
value.start_with?("$") ? "$#{value}" : value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def serialize_integer(value)
|
|
71
|
+
# BigInt range check (JS safe integer: ±2^53 - 1)
|
|
72
|
+
if value.abs > 9_007_199_254_740_991
|
|
73
|
+
"$n#{value}"
|
|
74
|
+
else
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def serialize_float(value)
|
|
80
|
+
return "$NaN" if value.nan?
|
|
81
|
+
return "$Infinity" if value.infinite? == 1
|
|
82
|
+
return "$-Infinity" if value.infinite? == -1
|
|
83
|
+
return "$-0" if value.zero? && (1.0 / value).infinite? == -1
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def serialize_symbol(value)
|
|
89
|
+
# Only :undefined is special for now
|
|
90
|
+
return "$undefined" if value == :undefined
|
|
91
|
+
|
|
92
|
+
# Unknown symbols: just use the name as a string
|
|
93
|
+
value.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def serialize_date(value)
|
|
97
|
+
"$D#{value.iso8601(3)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# --- Collections ---
|
|
101
|
+
|
|
102
|
+
def serialize_array(value)
|
|
103
|
+
value.map { |v| serialize_model(v) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def serialize_hash(value)
|
|
107
|
+
value.transform_keys(&:to_s).transform_values { |v| serialize_model(v) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- React Element ---
|
|
111
|
+
|
|
112
|
+
def serialize_element(element)
|
|
113
|
+
type = element.type
|
|
114
|
+
|
|
115
|
+
resolved_type = case type
|
|
116
|
+
when String
|
|
117
|
+
# DOM element ("div", "span", etc.) — pass through as-is
|
|
118
|
+
type
|
|
119
|
+
when ClientReference
|
|
120
|
+
serialize_client_reference(type)
|
|
121
|
+
else
|
|
122
|
+
raise TypeError, "Unsupported element type: #{type.inspect}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
key = element.key
|
|
126
|
+
props = serialize_hash(element.props)
|
|
127
|
+
|
|
128
|
+
["$", resolved_type, key, props]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Suspense Boundary ---
|
|
132
|
+
|
|
133
|
+
def serialize_suspense(element)
|
|
134
|
+
# Allocate ID for the deferred content row (emitted after root row + delay)
|
|
135
|
+
deferred_id = @request.allocate_id
|
|
136
|
+
@request.deferred_chunks << { id: deferred_id, element: element.children, delay: element.delay }
|
|
137
|
+
|
|
138
|
+
fallback_value = element.fallback ? serialize_model(element.fallback) : nil
|
|
139
|
+
|
|
140
|
+
# Children is an element tuple using the lazy ref as its type
|
|
141
|
+
lazy_ref = "$L#{deferred_id.to_s(16)}"
|
|
142
|
+
children_el = ["$", lazy_ref, nil, {}]
|
|
143
|
+
|
|
144
|
+
["$", "$SS", nil, { "fallback" => fallback_value, "children" => children_el }]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- Unknown type fallback ---
|
|
148
|
+
|
|
149
|
+
def serialize_unknown(value)
|
|
150
|
+
return serialize_serializable(value) if value.is_a?(Ruact::Serializable)
|
|
151
|
+
|
|
152
|
+
if value.respond_to?(:as_json)
|
|
153
|
+
if @request.strict_serialization
|
|
154
|
+
raise Ruact::SerializationError,
|
|
155
|
+
"Cannot serialize #{value.class.name} — " \
|
|
156
|
+
"include Ruact::Serializable or set strict_serialization: false"
|
|
157
|
+
else
|
|
158
|
+
serialize_as_json(value)
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
raise Ruact::SerializationError,
|
|
162
|
+
"Cannot serialize #{value.class.name} — include Ruact::Serializable"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --- Serializable (explicit opt-in, no warning) ---
|
|
167
|
+
|
|
168
|
+
def serialize_serializable(value)
|
|
169
|
+
serialize_model(value.rsc_serialize)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# --- as_json fallback (strict_serialization: false only) ---
|
|
173
|
+
|
|
174
|
+
def serialize_as_json(value)
|
|
175
|
+
data = begin
|
|
176
|
+
value.as_json
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
raise Ruact::SerializationError,
|
|
179
|
+
"#{value.class.name}#as_json raised #{e.class}: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if data.equal?(value)
|
|
183
|
+
raise Ruact::SerializationError,
|
|
184
|
+
"#{value.class.name}#as_json returned self — would cause infinite recursion. " \
|
|
185
|
+
"Include Ruact::Serializable and declare rsc_props instead"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
attr_names = data.is_a?(Hash) ? data.keys.join(", ") : ""
|
|
189
|
+
@request.on_as_json_warning&.call(value.class.name, attr_names)
|
|
190
|
+
serialize_model(data)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# --- Client Reference ---
|
|
194
|
+
|
|
195
|
+
def serialize_client_reference(ref)
|
|
196
|
+
# Deduplication: same object → same $L reference
|
|
197
|
+
existing = @request.written_objects[ref]
|
|
198
|
+
return existing if existing
|
|
199
|
+
|
|
200
|
+
metadata = @request.bundler_config.resolve(ref.module_id, ref.export_name)
|
|
201
|
+
|
|
202
|
+
id = @request.allocate_id
|
|
203
|
+
@request.increment_pending
|
|
204
|
+
|
|
205
|
+
json = JSON.generate(metadata)
|
|
206
|
+
row = RowEmitter.import(id, json)
|
|
207
|
+
@request.completed_import_chunks << row
|
|
208
|
+
|
|
209
|
+
lazy_ref = "$L#{id.to_s(16)}"
|
|
210
|
+
@request.written_objects[ref] = lazy_ref
|
|
211
|
+
lazy_ref
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
data/lib/ruact/flight.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "flight/react_element"
|
|
4
|
+
require_relative "flight/request"
|
|
5
|
+
require_relative "flight/row_emitter"
|
|
6
|
+
require_relative "flight/serializer"
|
|
7
|
+
require_relative "flight/renderer"
|
|
8
|
+
|
|
9
|
+
module Ruact
|
|
10
|
+
module Flight
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Converts an HTML string (ERB output) into a ReactElement tree.
|
|
7
|
+
#
|
|
8
|
+
# Rules:
|
|
9
|
+
# - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
|
|
10
|
+
# - data-react-key="x" → becomes the React key on the element
|
|
11
|
+
# - HTML comments matching __RSC_N__ tokens → replaced by client component refs
|
|
12
|
+
# - Text nodes → plain Ruby strings
|
|
13
|
+
# - Multiple root nodes → wrapped in a Fragment (array)
|
|
14
|
+
class HtmlConverter
|
|
15
|
+
# HTML attribute → React prop name mapping
|
|
16
|
+
HTML_TO_REACT = {
|
|
17
|
+
"class" => "className",
|
|
18
|
+
"for" => "htmlFor",
|
|
19
|
+
"tabindex" => "tabIndex",
|
|
20
|
+
"readonly" => "readOnly",
|
|
21
|
+
"maxlength" => "maxLength",
|
|
22
|
+
"cellpadding" => "cellPadding",
|
|
23
|
+
"cellspacing" => "cellSpacing",
|
|
24
|
+
"rowspan" => "rowSpan",
|
|
25
|
+
"colspan" => "colSpan",
|
|
26
|
+
"crossorigin" => "crossOrigin",
|
|
27
|
+
"autocomplete" => "autoComplete",
|
|
28
|
+
"autofocus" => "autoFocus",
|
|
29
|
+
"accesskey" => "accessKey",
|
|
30
|
+
"contenteditable" => "contentEditable",
|
|
31
|
+
"enctype" => "encType",
|
|
32
|
+
"formaction" => "formAction",
|
|
33
|
+
"novalidate" => "noValidate",
|
|
34
|
+
"spellcheck" => "spellCheck"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Convert an HTML string into a ReactElement tree.
|
|
38
|
+
# component_registry is an array of { token:, name:, ref: ClientReference, props: Hash }
|
|
39
|
+
def self.convert(html, component_registry = [])
|
|
40
|
+
new(component_registry).convert(html)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(component_registry)
|
|
44
|
+
@registry = component_registry
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def convert(html)
|
|
48
|
+
# Wrap in a fragment container so Nokogiri gives us a consistent root.
|
|
49
|
+
# Use HTML4 fragment parser (universally available, no libgumbo needed).
|
|
50
|
+
doc = Nokogiri::HTML::DocumentFragment.parse(html)
|
|
51
|
+
children = doc.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
|
|
52
|
+
|
|
53
|
+
case children.length
|
|
54
|
+
when 0 then nil
|
|
55
|
+
when 1 then children.first
|
|
56
|
+
else children # Fragment: array of elements
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def ignorable?(node)
|
|
63
|
+
node.text? && node.text.strip.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def convert_node(node)
|
|
67
|
+
case node.type
|
|
68
|
+
when Nokogiri::XML::Node::TEXT_NODE
|
|
69
|
+
text = node.text
|
|
70
|
+
text.strip.empty? ? nil : text
|
|
71
|
+
|
|
72
|
+
when Nokogiri::XML::Node::COMMENT_NODE
|
|
73
|
+
# Check if this is an RSC component placeholder
|
|
74
|
+
token = node.content.strip
|
|
75
|
+
entry = @registry.find { |c| c[:token] == token }
|
|
76
|
+
return nil unless entry
|
|
77
|
+
|
|
78
|
+
Flight::ReactElement.new(
|
|
79
|
+
type: entry[:ref],
|
|
80
|
+
key: nil,
|
|
81
|
+
props: entry[:props]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
when Nokogiri::XML::Node::ELEMENT_NODE
|
|
85
|
+
convert_element(node)
|
|
86
|
+
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def convert_element(node)
|
|
91
|
+
tag = node.name.downcase
|
|
92
|
+
|
|
93
|
+
# Special element: <rsc-suspense> → SuspenseElement (Suspense boundary)
|
|
94
|
+
if tag == "rsc-suspense"
|
|
95
|
+
fallback_text = node["data-rsc-fallback"] || ""
|
|
96
|
+
fallback = if fallback_text.empty?
|
|
97
|
+
nil
|
|
98
|
+
else
|
|
99
|
+
Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
child_nodes = node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
|
|
103
|
+
children = child_nodes.length == 1 ? child_nodes.first : child_nodes
|
|
104
|
+
|
|
105
|
+
return Flight::SuspenseElement.new(fallback: fallback, children: children)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
key = node["data-react-key"]
|
|
109
|
+
props = {}
|
|
110
|
+
|
|
111
|
+
node.attributes.each do |attr_name, attr_node|
|
|
112
|
+
next if attr_name == "data-react-key"
|
|
113
|
+
|
|
114
|
+
react_name = if attr_name == "value" && %w[textarea select].include?(tag)
|
|
115
|
+
"defaultValue"
|
|
116
|
+
elsif attr_name == "value" && tag == "input"
|
|
117
|
+
input_type = node["type"]&.downcase
|
|
118
|
+
%w[submit reset button image].include?(input_type) ? "value" : "defaultValue"
|
|
119
|
+
elsif attr_name == "checked" && tag == "input"
|
|
120
|
+
"defaultChecked"
|
|
121
|
+
else
|
|
122
|
+
HTML_TO_REACT[attr_name] || camel_case_data(attr_name)
|
|
123
|
+
end
|
|
124
|
+
props[react_name] = attr_name == "style" ? parse_style(attr_node.value) : attr_node.value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
children = node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
|
|
128
|
+
|
|
129
|
+
unless children.empty?
|
|
130
|
+
props["children"] = children.length == 1 ? children.first : children
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Flight::ReactElement.new(type: tag, key: key, props: props)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Converts a CSS inline style string into a React-compatible hash with camelCase keys.
|
|
137
|
+
# e.g. "font-size:16px;color:red" → {"fontSize" => "16px", "color" => "red"}
|
|
138
|
+
def parse_style(css_string)
|
|
139
|
+
css_string.split(";").each_with_object({}) do |decl, hash|
|
|
140
|
+
prop, _, value = decl.partition(":")
|
|
141
|
+
prop = prop.strip
|
|
142
|
+
value = value.strip
|
|
143
|
+
next if prop.empty? || value.empty?
|
|
144
|
+
|
|
145
|
+
camel = prop.split("-").each_with_index.map { |part, i| i.zero? ? part : part.capitalize }.join
|
|
146
|
+
hash[camel] = value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# data-foo-bar → "data-foo-bar" (kept as-is; React accepts kebab data attrs)
|
|
151
|
+
# other kebab attrs not in the map → camelCase
|
|
152
|
+
def camel_case_data(name)
|
|
153
|
+
return name if name.start_with?("data-", "aria-")
|
|
154
|
+
|
|
155
|
+
parts = name.split("-")
|
|
156
|
+
parts.first + parts[1..].map(&:capitalize).join
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "ruact.load_controller" do
|
|
8
|
+
require_relative "controller"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
rake_tasks do
|
|
12
|
+
load File.expand_path("../tasks/rsc.rake", __dir__)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Load the client manifest at boot (and on each code reload in development).
|
|
16
|
+
# config.to_prepare runs once in production and before every code reload in
|
|
17
|
+
# development, ensuring the manifest is always current without file I/O per
|
|
18
|
+
# request.
|
|
19
|
+
#
|
|
20
|
+
# Missing manifest behaviour (AC#5, #6):
|
|
21
|
+
# - development: logs a [ruact] warning; app starts normally
|
|
22
|
+
# - production: raises ManifestError; app does not start
|
|
23
|
+
#
|
|
24
|
+
# Also registers ActionView integration:
|
|
25
|
+
# - ViewHelper provides __rsc_component__ in every view context
|
|
26
|
+
# - ErbPreprocessorHook applies the RSC preprocessor to all ERB templates
|
|
27
|
+
# (layouts, views, partials) transparently via prepend.
|
|
28
|
+
config.to_prepare do
|
|
29
|
+
manifest_path = Ruact.config.manifest_path ||
|
|
30
|
+
Rails.root.join("public", "react-client-manifest.json")
|
|
31
|
+
manifest_path = Pathname.new(manifest_path) unless manifest_path.respond_to?(:exist?)
|
|
32
|
+
|
|
33
|
+
if manifest_path.exist?
|
|
34
|
+
Ruact.manifest = Ruact::ClientManifest.load(manifest_path)
|
|
35
|
+
else
|
|
36
|
+
Ruact::Railtie.check_manifest!(manifest_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
require_relative "view_helper"
|
|
40
|
+
require_relative "erb_preprocessor_hook"
|
|
41
|
+
ActionView::Base.include(Ruact::ViewHelper)
|
|
42
|
+
ActionView::Template::Handlers::ERB.prepend(Ruact::ErbPreprocessorHook)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Detect streaming capability at boot and log the active mode (AC#1–3).
|
|
46
|
+
# Also warns in development if the Vite dev server is not running (AC#4, #7).
|
|
47
|
+
config.after_initialize do
|
|
48
|
+
Ruact::Railtie.detect_streaming_mode!
|
|
49
|
+
next unless Rails.env.development?
|
|
50
|
+
|
|
51
|
+
Ruact::Railtie.check_vite!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Detects the web server at boot, stores the streaming mode, and logs the result (AC#1–3).
|
|
55
|
+
# Detection is constant-based (zero I/O): Puma → enabled, Unicorn/Passenger → buffered,
|
|
56
|
+
# unknown → buffered (safe mode).
|
|
57
|
+
def self.detect_streaming_mode!
|
|
58
|
+
mode, label = if defined?(::Puma::Server)
|
|
59
|
+
[:enabled, "Puma detected"]
|
|
60
|
+
elsif defined?(::Falcon::Server)
|
|
61
|
+
[:enabled, "Falcon detected"]
|
|
62
|
+
elsif defined?(::Unicorn)
|
|
63
|
+
[:buffered, "Unicorn detected"]
|
|
64
|
+
elsif defined?(::PhusionPassenger)
|
|
65
|
+
[:buffered, "Passenger detected"]
|
|
66
|
+
else
|
|
67
|
+
[:buffered, "server unknown — defaulting to safe mode"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Ruact.streaming_mode = mode
|
|
71
|
+
verb = mode == :enabled ? "enabled" : "buffered"
|
|
72
|
+
Rails.logger.info "[ruact] streaming: #{verb} (#{label})"
|
|
73
|
+
mode
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Checks whether the Vite dev server is accessible and warns if not (AC#4).
|
|
77
|
+
# Extracted as a class method for direct testability without a full Rails app.
|
|
78
|
+
def self.check_vite!
|
|
79
|
+
require "socket"
|
|
80
|
+
TCPSocket.new("localhost", 5173).close
|
|
81
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
82
|
+
Rails.logger.warn "[ruact] Vite dev server not detected at localhost:5173 " \
|
|
83
|
+
"— run npm run dev for HMR"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Checks whether the manifest exists and either warns (dev) or raises (prod).
|
|
87
|
+
# Extracted as a class method for direct testability without a full Rails app.
|
|
88
|
+
def self.check_manifest!(manifest_path)
|
|
89
|
+
if Rails.env.production?
|
|
90
|
+
raise ManifestError,
|
|
91
|
+
"react-client-manifest.json not found — run vite build before deploying"
|
|
92
|
+
else
|
|
93
|
+
Rails.logger.warn "[ruact] react-client-manifest.json not found at " \
|
|
94
|
+
"#{manifest_path} — RSC rendering will be unavailable. " \
|
|
95
|
+
"Run 'npm run build' or start the Vite dev server."
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Orchestrates the full server component render:
|
|
7
|
+
# ERB source → (preprocessor) → evaluated HTML → (HtmlConverter) → ReactElement tree
|
|
8
|
+
# → (Flight::Renderer) → wire bytes
|
|
9
|
+
#
|
|
10
|
+
# Two entry points:
|
|
11
|
+
# call/stream — full pipeline from ERB source (used in unit tests and legacy path)
|
|
12
|
+
# from_html — takes pre-rendered HTML from ActionView (used by Controller#rsc_render)
|
|
13
|
+
class RenderPipeline
|
|
14
|
+
def initialize(manifest, controller_path: nil, logger: nil)
|
|
15
|
+
@manifest = manifest
|
|
16
|
+
@controller_path = controller_path
|
|
17
|
+
@logger = logger
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Render ERB source within a given binding, return Flight wire format string.
|
|
21
|
+
# Deferred chunk delays are skipped — suitable for buffered responses (HTML shell).
|
|
22
|
+
def call(erb_source, binding_context)
|
|
23
|
+
_stream(erb_source, binding_context, streaming: false).to_a.join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Render ERB source and return an Enumerator that yields Flight rows one at a time.
|
|
27
|
+
# Deferred chunk delays ARE applied — suitable for ActionController::Live streaming.
|
|
28
|
+
def stream(erb_source, binding_context)
|
|
29
|
+
_stream(erb_source, binding_context, streaming: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Convert pre-rendered HTML (from ActionView) to Flight wire rows.
|
|
33
|
+
#
|
|
34
|
+
# IMPORTANT — Eager registry capture: ComponentRegistry.components is read
|
|
35
|
+
# immediately when this method is called, before the Enumerator is returned.
|
|
36
|
+
# This allows the caller to call ComponentRegistry.reset right after from_html
|
|
37
|
+
# returns (inside an ensure block) without affecting the captured registry.
|
|
38
|
+
#
|
|
39
|
+
# The returned Enumerator does NOT reference ComponentRegistry at all —
|
|
40
|
+
# only the eagerly-captured +registry+ local variable.
|
|
41
|
+
def from_html(html, streaming: false)
|
|
42
|
+
registry = ComponentRegistry.components.map do |entry|
|
|
43
|
+
ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
|
|
44
|
+
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
45
|
+
end
|
|
46
|
+
strict = Ruact.config.strict_serialization
|
|
47
|
+
warning_cb = as_json_warning_callback
|
|
48
|
+
|
|
49
|
+
Enumerator.new do |y|
|
|
50
|
+
root_element = HtmlConverter.convert(html, registry)
|
|
51
|
+
Flight::Renderer.each(root_element, @manifest,
|
|
52
|
+
strict_serialization: strict,
|
|
53
|
+
on_as_json_warning: warning_cb,
|
|
54
|
+
streaming: streaming) { |row| y << row }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def _stream(erb_source, binding_context, streaming: false)
|
|
61
|
+
Enumerator.new do |y|
|
|
62
|
+
ComponentRegistry.start
|
|
63
|
+
begin
|
|
64
|
+
transformed = ErbPreprocessor.transform(erb_source)
|
|
65
|
+
inject_helper!(binding_context)
|
|
66
|
+
html = ERB.new(transformed).result(binding_context)
|
|
67
|
+
|
|
68
|
+
registry = ComponentRegistry.components.map do |entry|
|
|
69
|
+
ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
|
|
70
|
+
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
root_element = HtmlConverter.convert(html, registry)
|
|
74
|
+
|
|
75
|
+
Flight::Renderer.each(root_element, @manifest,
|
|
76
|
+
strict_serialization: Ruact.config.strict_serialization,
|
|
77
|
+
on_as_json_warning: as_json_warning_callback,
|
|
78
|
+
streaming: streaming) { |row| y << row }
|
|
79
|
+
ensure
|
|
80
|
+
ComponentRegistry.reset
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def as_json_warning_callback
|
|
86
|
+
return nil if @logger.nil?
|
|
87
|
+
|
|
88
|
+
lambda do |class_name, attrs|
|
|
89
|
+
@logger.warn(
|
|
90
|
+
"[ruact] WARNING: #{class_name} serialized via as_json — " \
|
|
91
|
+
"ALL attributes exposed to client: #{attrs}. " \
|
|
92
|
+
"Use `include Ruact::Serializable` with `rsc_props` for explicit control"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Define __rsc_component__ in the ERB binding so it can be called.
|
|
98
|
+
def inject_helper!(binding_context)
|
|
99
|
+
binding_context.eval(<<~RUBY)
|
|
100
|
+
def __rsc_component__(name, props = {})
|
|
101
|
+
token = ::Ruact::ComponentRegistry.register(name, props)
|
|
102
|
+
"<!-- \#{token} -->"
|
|
103
|
+
end
|
|
104
|
+
RUBY
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Include this module in any Ruby object you want to pass as a prop to a
|
|
5
|
+
# client component. Declare which attributes are safe to serialize with
|
|
6
|
+
# +rsc_props+; only those attributes will be included in the wire payload.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# class Post
|
|
10
|
+
# include Ruact::Serializable
|
|
11
|
+
# attr_reader :id, :title, :secret
|
|
12
|
+
# rsc_props :id, :title # :secret is never sent to the client
|
|
13
|
+
# end
|
|
14
|
+
module Serializable
|
|
15
|
+
def self.included(base)
|
|
16
|
+
base.extend(ClassMethods)
|
|
17
|
+
base.instance_variable_set(:@rsc_props, [])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module ClassMethods
|
|
21
|
+
# Declare which instance methods should be included in the serialized
|
|
22
|
+
# payload. Raises +ArgumentError+ at class-load time if any name has no
|
|
23
|
+
# corresponding method defined on the class.
|
|
24
|
+
#
|
|
25
|
+
# @param attrs [Array<Symbol>]
|
|
26
|
+
def rsc_props(*attrs)
|
|
27
|
+
attrs.each do |attr|
|
|
28
|
+
unless method_defined?(attr)
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"rsc_props: method `#{attr}` is not defined on #{self}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
@rsc_props = attrs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the list of declared prop names as symbols.
|
|
37
|
+
# Walks the ancestor chain so subclasses inherit parent declarations.
|
|
38
|
+
#
|
|
39
|
+
# @return [Array<Symbol>]
|
|
40
|
+
def rsc_props_list
|
|
41
|
+
klass = self
|
|
42
|
+
while klass
|
|
43
|
+
return klass.instance_variable_get(:@rsc_props) if klass.instance_variable_defined?(:@rsc_props)
|
|
44
|
+
|
|
45
|
+
klass = klass.superclass
|
|
46
|
+
end
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Serialize only the attributes declared with +rsc_props+.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash{String => Object}]
|
|
54
|
+
def rsc_serialize
|
|
55
|
+
self.class.rsc_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# ActionView helper module included in ActionView::Base via Railtie.
|
|
5
|
+
# Provides the +__rsc_component__+ method that ERB templates call after the
|
|
6
|
+
# preprocessor transforms PascalCase tags into +<%= __rsc_component__(...) %>+.
|
|
7
|
+
#
|
|
8
|
+
# Thread-safe: ActionView creates a fresh view context per request, so there
|
|
9
|
+
# is no shared state between concurrent requests.
|
|
10
|
+
module ViewHelper
|
|
11
|
+
# Registers +name+ with +props+ in the per-request ComponentRegistry and
|
|
12
|
+
# returns an HTML comment placeholder that HtmlConverter later replaces with
|
|
13
|
+
# a ReactElement node.
|
|
14
|
+
#
|
|
15
|
+
# The returned string MUST be html_safe so ActionView does not escape the
|
|
16
|
+
# angle brackets — if it were escaped, HtmlConverter would not find the
|
|
17
|
+
# placeholder in the HTML output.
|
|
18
|
+
def __rsc_component__(name, props = {})
|
|
19
|
+
token = ComponentRegistry.register(name, props)
|
|
20
|
+
"<!-- #{token} -->".html_safe
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|