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,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Ruact
|
|
8
|
+
# Include in ApplicationController to enable RSC rendering.
|
|
9
|
+
#
|
|
10
|
+
# class ApplicationController < ActionController::Base
|
|
11
|
+
# include Ruact::Controller
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# After that, any action whose view is a .html.erb file will automatically:
|
|
15
|
+
# - Respond to text/x-component requests with a raw Flight payload
|
|
16
|
+
# - Respond to text/html requests with an HTML shell + inline Flight payload
|
|
17
|
+
module Controller
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
|
|
23
|
+
# No per-request file I/O (AC#6).
|
|
24
|
+
def rsc_manifest
|
|
25
|
+
Ruact.manifest
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Only activate RSC rendering for HTML-like requests (AC FR26).
|
|
29
|
+
# JSON, XML, and other formats bypass RSC entirely so respond_to blocks
|
|
30
|
+
# and explicit render calls work without interference.
|
|
31
|
+
def default_render
|
|
32
|
+
if rsc_template_exists? && (request.format.html? || rsc_request?)
|
|
33
|
+
rsc_render
|
|
34
|
+
else
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Render the RSC view for the current action using ActionView's full pipeline.
|
|
40
|
+
# ActionView handles layouts, partials, and helpers — the ErbPreprocessorHook
|
|
41
|
+
# ensures all PascalCase tags are transformed before template compilation.
|
|
42
|
+
#
|
|
43
|
+
# Called automatically when no explicit render is performed and a matching
|
|
44
|
+
# .html.erb template exists. Can also be called explicitly with options.
|
|
45
|
+
#
|
|
46
|
+
# +template+: logical template name (e.g. "posts/custom"), or nil to use
|
|
47
|
+
# the current action's default template.
|
|
48
|
+
# +locals+: hash of local variables to pass to the template.
|
|
49
|
+
def rsc_render(template: nil, locals: {})
|
|
50
|
+
pipeline = RenderPipeline.new(rsc_manifest, controller_path: controller_path, logger: logger)
|
|
51
|
+
streaming = rsc_request? && self.class.ancestors.include?(ActionController::Live)
|
|
52
|
+
|
|
53
|
+
# ComponentRegistry is started before ActionView renders the template.
|
|
54
|
+
# ViewHelper's __rsc_component__ registers components during rendering.
|
|
55
|
+
# from_html eagerly captures the registry before the ensure block resets it.
|
|
56
|
+
ComponentRegistry.start
|
|
57
|
+
enumerator = begin
|
|
58
|
+
opts = template ? { template: template } : { action: action_name }
|
|
59
|
+
html = render_to_string(opts.merge(layout: false, locals: locals))
|
|
60
|
+
pipeline.from_html(html, streaming: streaming)
|
|
61
|
+
ensure
|
|
62
|
+
ComponentRegistry.reset
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if rsc_request?
|
|
66
|
+
if streaming
|
|
67
|
+
response.headers["Content-Type"] = "text/x-component; charset=utf-8"
|
|
68
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
69
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
70
|
+
begin
|
|
71
|
+
enumerator.each { |row| response.stream.write(row) }
|
|
72
|
+
ensure
|
|
73
|
+
response.stream.close
|
|
74
|
+
end
|
|
75
|
+
else
|
|
76
|
+
render plain: enumerator.to_a.join, content_type: "text/x-component"
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
render html: rsc_html_shell(enumerator.to_a.join).html_safe, layout: false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Overrides Rails redirect_to for RSC requests: emits a Flight redirect row
|
|
84
|
+
# (`0:{"redirectUrl":"...","redirectType":"push"}`) instead of a 302 response.
|
|
85
|
+
# This allows the client-side router to handle the navigation without an extra
|
|
86
|
+
# HTTP round-trip. Non-RSC requests and external-origin redirects fall through
|
|
87
|
+
# to the standard Rails implementation.
|
|
88
|
+
def redirect_to(options = {}, response_options = {})
|
|
89
|
+
return super unless rsc_request?
|
|
90
|
+
|
|
91
|
+
url = url_for(options)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
uri = ::URI.parse(url)
|
|
95
|
+
# External origin: fall back to standard 302 so the browser follows it normally.
|
|
96
|
+
# Compare host, port, and scheme to avoid treating same-host-different-port as same-origin.
|
|
97
|
+
if uri.host
|
|
98
|
+
return super if uri.host != request.host
|
|
99
|
+
return super if uri.port && uri.port != request.port
|
|
100
|
+
return super if uri.scheme && uri.scheme != request.scheme
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
redirect_url = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
104
|
+
redirect_url += "?#{uri.query}" if uri.query
|
|
105
|
+
redirect_url += "##{uri.fragment}" if uri.fragment
|
|
106
|
+
rescue ::URI::InvalidURIError
|
|
107
|
+
return super
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
|
|
111
|
+
content_type: "text/x-component"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def rsc_request?
|
|
115
|
+
request.headers["Accept"]&.include?("text/x-component") ||
|
|
116
|
+
request.headers["RSC-Request"] == "1"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def rsc_template_exists?
|
|
120
|
+
File.exist?(default_template_path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def default_template_path
|
|
124
|
+
action = action_name
|
|
125
|
+
controller = self.class.name.underscore.sub("_controller", "")
|
|
126
|
+
Rails.root.join("app", "views", controller, "#{action}.html.erb")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rsc_html_shell(flight_payload)
|
|
130
|
+
escaped_payload = flight_payload.gsub("</script>", '<\/script>')
|
|
131
|
+
<<~HTML
|
|
132
|
+
<!DOCTYPE html>
|
|
133
|
+
<html lang="en">
|
|
134
|
+
<head>
|
|
135
|
+
<meta charset="UTF-8" />
|
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
137
|
+
<title>Rails RSC</title>
|
|
138
|
+
#{vite_tags}
|
|
139
|
+
</head>
|
|
140
|
+
<body>
|
|
141
|
+
<div id="root"></div>
|
|
142
|
+
<script>
|
|
143
|
+
(function() {
|
|
144
|
+
var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
|
|
145
|
+
d.push(#{escaped_payload.inspect});
|
|
146
|
+
})();
|
|
147
|
+
</script>
|
|
148
|
+
</body>
|
|
149
|
+
</html>
|
|
150
|
+
HTML
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def vite_tags
|
|
154
|
+
if Rails.env.development? && vite_dev_running?
|
|
155
|
+
# @vitejs/plugin-react normally injects this preamble by processing index.html.
|
|
156
|
+
# Since our HTML is generated by Rails (not Vite), we inject it manually.
|
|
157
|
+
# Without it, every JSX file throws "can't detect preamble" at runtime.
|
|
158
|
+
react_preamble = <<~JS
|
|
159
|
+
<script type="module">
|
|
160
|
+
import RefreshRuntime from 'http://localhost:5173/@react-refresh';
|
|
161
|
+
RefreshRuntime.injectIntoGlobalHook(window);
|
|
162
|
+
window.$RefreshReg$ = () => {};
|
|
163
|
+
window.$RefreshSig$ = () => (type) => type;
|
|
164
|
+
window.__vite_plugin_react_preamble_installed__ = true;
|
|
165
|
+
</script>
|
|
166
|
+
JS
|
|
167
|
+
|
|
168
|
+
react_preamble + <<~HTML
|
|
169
|
+
<script type="module" src="http://localhost:5173/@vite/client"></script>
|
|
170
|
+
<script type="module" src="http://localhost:5173/app/javascript/application.jsx"></script>
|
|
171
|
+
HTML
|
|
172
|
+
else
|
|
173
|
+
# Production: read hashed URL from Vite manifest
|
|
174
|
+
entry = vite_manifest_entry("app/javascript/application.jsx")
|
|
175
|
+
src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
|
|
176
|
+
%(<script type="module" src="#{src}"></script>)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def vite_dev_running?
|
|
181
|
+
require "socket"
|
|
182
|
+
Socket.tcp("localhost", 5173, connect_timeout: 1).close
|
|
183
|
+
true
|
|
184
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def vite_manifest_entry(src_path)
|
|
189
|
+
manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
|
|
190
|
+
return nil unless File.exist?(manifest_path)
|
|
191
|
+
|
|
192
|
+
JSON.parse(File.read(manifest_path))[src_path]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/ruact/doctor.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
# Runs a suite of installation health checks and prints ✓/✗ per check.
|
|
8
|
+
# Extracted from the rsc:doctor Rake task for direct testability (FR27).
|
|
9
|
+
class Doctor
|
|
10
|
+
CHECKS = %i[manifest vite controller layout streaming].freeze
|
|
11
|
+
|
|
12
|
+
# Runs all checks, prints results, returns true if all pass.
|
|
13
|
+
def self.run
|
|
14
|
+
new.run
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
results = CHECKS.map { |check| send(:"check_#{check}") }
|
|
19
|
+
results.each { |status, message| puts format_result(status, message) }
|
|
20
|
+
passed = results.all? { |status, _| status == :pass }
|
|
21
|
+
puts "Run rails generate ruact:install to fix configuration issues" unless passed
|
|
22
|
+
passed
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def check_manifest
|
|
28
|
+
path = manifest_path
|
|
29
|
+
if Pathname(path).exist?
|
|
30
|
+
[:pass, "Manifest found at #{path}"]
|
|
31
|
+
else
|
|
32
|
+
[:fail, "Manifest not found — run vite build"]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def check_vite
|
|
37
|
+
TCPSocket.new("localhost", 5173).close
|
|
38
|
+
[:pass, "Vite accessible at localhost:5173"]
|
|
39
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
40
|
+
[:fail, "Vite not accessible at localhost:5173 — run npm run dev"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_controller
|
|
44
|
+
path = Rails.root.join("app", "controllers", "application_controller.rb")
|
|
45
|
+
if File.exist?(path) && File.read(path).include?("Ruact::Controller")
|
|
46
|
+
[:pass, "Ruact::Controller included in ApplicationController"]
|
|
47
|
+
else
|
|
48
|
+
[:fail, "Ruact::Controller not included in ApplicationController"]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_layout
|
|
53
|
+
path = Rails.root.join("app", "views", "layouts", "application.html.erb")
|
|
54
|
+
if File.exist?(path) && File.read(path).include?("ruact: root")
|
|
55
|
+
[:pass, "React shell present in application.html.erb"]
|
|
56
|
+
else
|
|
57
|
+
[:fail, "React shell missing from application.html.erb"]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_streaming
|
|
62
|
+
mode = Ruact.streaming_mode || :buffered
|
|
63
|
+
label = mode == :enabled ? "enabled" : "buffered"
|
|
64
|
+
[:pass, "streaming: #{label} (#{streaming_server_hint})"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def streaming_server_hint
|
|
68
|
+
return "Puma" if defined?(::Puma)
|
|
69
|
+
return "Unicorn" if defined?(::Unicorn)
|
|
70
|
+
return "Passenger" if defined?(::PhusionPassenger)
|
|
71
|
+
|
|
72
|
+
"unknown"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def manifest_path
|
|
76
|
+
Ruact.config.manifest_path ||
|
|
77
|
+
Rails.root.join("public", "react-client-manifest.json")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_result(status, message)
|
|
81
|
+
status == :pass ? "✓ #{message}" : "✗ #{message}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Transforms ERB source before Ruby evaluation.
|
|
5
|
+
#
|
|
6
|
+
# It handles one thing: PascalCase component tags with +{expr}+ props.
|
|
7
|
+
#
|
|
8
|
+
# <LikeButton postId={@post.id} initialCount={5} />
|
|
9
|
+
#
|
|
10
|
+
# becomes a placeholder that evaluates the props as Ruby:
|
|
11
|
+
#
|
|
12
|
+
# <%= __rsc_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
|
|
13
|
+
#
|
|
14
|
+
# The placeholder is replaced by an HTML comment with a unique token:
|
|
15
|
+
# <!-- __RSC_COMPONENT_0__ -->
|
|
16
|
+
#
|
|
17
|
+
# The actual ClientReference + props are registered in the binding and
|
|
18
|
+
# collected by HtmlConverter after the ERB renders.
|
|
19
|
+
class ErbPreprocessor
|
|
20
|
+
# Matches a PascalCase opening tag with optional attributes and optional self-closing.
|
|
21
|
+
# Examples:
|
|
22
|
+
# <Button />
|
|
23
|
+
# <LikeButton postId={@post.id} initialCount={5} />
|
|
24
|
+
# <Dialog open={true}>
|
|
25
|
+
COMPONENT_TAG_RE = %r{<([A-Z][A-Za-z0-9]*)(\s[^>]*)?\s*/?>}
|
|
26
|
+
|
|
27
|
+
# Matches <Suspense ...> opening tags (handled before general PascalCase processing).
|
|
28
|
+
SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
|
|
29
|
+
SUSPENSE_CLOSE_RE = %r{</Suspense>}
|
|
30
|
+
|
|
31
|
+
# Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
|
|
32
|
+
# We use a simple bracket-depth counter approach during scanning instead of regex
|
|
33
|
+
# because expressions can contain nested braces: {foo.bar({ a: 1 })}.
|
|
34
|
+
PROP_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/
|
|
35
|
+
|
|
36
|
+
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
37
|
+
# Returns the transformed source string.
|
|
38
|
+
def self.transform(source)
|
|
39
|
+
new.transform(source)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def transform(source)
|
|
43
|
+
# Step 1: transform <Suspense> paired tags into <rsc-suspense> HTML elements.
|
|
44
|
+
# This runs before the general component regex so Suspense isn't treated as a component.
|
|
45
|
+
result = source
|
|
46
|
+
.gsub(SUSPENSE_OPEN_RE) do
|
|
47
|
+
attrs = ::Regexp.last_match(1)
|
|
48
|
+
fallback = extract_string_attr(attrs, "fallback") || ""
|
|
49
|
+
escaped = fallback.gsub('"', """)
|
|
50
|
+
%(<rsc-suspense data-rsc-fallback="#{escaped}">)
|
|
51
|
+
end
|
|
52
|
+
.gsub(SUSPENSE_CLOSE_RE, "</rsc-suspense>")
|
|
53
|
+
|
|
54
|
+
# Step 2: transform remaining PascalCase self-closing / opening component tags.
|
|
55
|
+
result.gsub(COMPONENT_TAG_RE) do |match|
|
|
56
|
+
component_name = ::Regexp.last_match(1)
|
|
57
|
+
attrs_string = ::Regexp.last_match(2).to_s.strip
|
|
58
|
+
match_start = ::Regexp.last_match.begin(0)
|
|
59
|
+
line = result[0...match_start].count("\n") + 1
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
props_ruby = parse_props(attrs_string)
|
|
63
|
+
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
64
|
+
%(<%= __rsc_component__(#{component_name.inspect}, #{props_hash}) %>)
|
|
65
|
+
rescue PreprocessorError => e
|
|
66
|
+
raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Extract a string attribute value (double or single quoted) from an attrs string.
|
|
74
|
+
def extract_string_attr(attrs, name)
|
|
75
|
+
m = attrs.match(/\b#{Regexp.escape(name)}\s*=\s*"([^"]*)"/) ||
|
|
76
|
+
attrs.match(/\b#{Regexp.escape(name)}\s*=\s*'([^']*)'/)
|
|
77
|
+
m&.[](1)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Parses the attributes string of a component tag and returns a Ruby
|
|
81
|
+
# fragment representing a Hash literal, e.g.:
|
|
82
|
+
# "postId" => @post.id, "initialCount" => 5
|
|
83
|
+
def parse_props(attrs_string)
|
|
84
|
+
return "" if attrs_string.empty?
|
|
85
|
+
|
|
86
|
+
pairs = []
|
|
87
|
+
remaining = attrs_string.dup
|
|
88
|
+
|
|
89
|
+
while (m = PROP_RE.match(remaining))
|
|
90
|
+
prop_name = m[1]
|
|
91
|
+
# Find the matching closing brace, respecting nesting
|
|
92
|
+
value_start = m.end(0)
|
|
93
|
+
value_expr = extract_braced_expr(remaining, value_start)
|
|
94
|
+
pairs << "#{prop_name.inspect} => #{value_expr}"
|
|
95
|
+
# Advance past this prop
|
|
96
|
+
remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
|
|
97
|
+
break if remaining.nil?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
pairs.join(", ")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Given a string and a start position (just after the opening '{'),
|
|
104
|
+
# returns the content up to the matching '}'.
|
|
105
|
+
def extract_braced_expr(str, start)
|
|
106
|
+
depth = 1
|
|
107
|
+
i = start
|
|
108
|
+
while i < str.length && depth.positive?
|
|
109
|
+
case str[i]
|
|
110
|
+
when "{" then depth += 1
|
|
111
|
+
when "}" then depth -= 1
|
|
112
|
+
end
|
|
113
|
+
i += 1
|
|
114
|
+
end
|
|
115
|
+
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
116
|
+
|
|
117
|
+
str[start...(i - 1)]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Module prepended into ActionView::Template::Handlers::ERB via Railtie.
|
|
5
|
+
# Applies the RSC preprocessor to every ERB template source before
|
|
6
|
+
# ActionView compiles it — transparent to views, layouts, and partials.
|
|
7
|
+
#
|
|
8
|
+
# ErbPreprocessor.transform has a fast-path O(1) return when the source
|
|
9
|
+
# contains no PascalCase tags, so non-RSC templates pay essentially no cost.
|
|
10
|
+
#
|
|
11
|
+
# Idempotent: prepend is a no-op if this module is already in the ancestor
|
|
12
|
+
# chain, so reloads in development mode are safe.
|
|
13
|
+
module ErbPreprocessorHook
|
|
14
|
+
# Called by ActionView for every ERB template. +source+ is the raw ERB
|
|
15
|
+
# text; the return value is Ruby code that ActionView will eval.
|
|
16
|
+
def call(template, source)
|
|
17
|
+
super(template, ErbPreprocessor.transform(source))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/ruact/errors.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when react-client-manifest.json is absent or a component is not found in it.
|
|
7
|
+
class ManifestError < Error; end
|
|
8
|
+
|
|
9
|
+
# Raised when a Ruby value cannot be serialized as a React prop.
|
|
10
|
+
class SerializationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when the ERB preprocessor encounters a malformed component tag.
|
|
13
|
+
class PreprocessorError < Error; end
|
|
14
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# Represents a React element: <div>, <Component>, etc.
|
|
6
|
+
# Wire format: ["$", type, key, props]
|
|
7
|
+
class ReactElement
|
|
8
|
+
attr_reader :type, :key, :props
|
|
9
|
+
|
|
10
|
+
def initialize(type:, key: nil, props: {})
|
|
11
|
+
@type = type
|
|
12
|
+
@key = key
|
|
13
|
+
@props = props
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Represents a React Suspense boundary.
|
|
18
|
+
# `fallback` is a ReactElement shown while the deferred content is loading.
|
|
19
|
+
# `children` is the actual content (emitted as a deferred row after `delay` seconds).
|
|
20
|
+
class SuspenseElement
|
|
21
|
+
attr_reader :fallback, :children, :delay
|
|
22
|
+
|
|
23
|
+
def initialize(fallback:, children:, delay: 1.5)
|
|
24
|
+
@fallback = fallback
|
|
25
|
+
@children = children
|
|
26
|
+
@delay = delay
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Points to a "use client" module — will become an I row + $L<id> reference.
|
|
31
|
+
class ClientReference
|
|
32
|
+
attr_reader :module_id, :export_name
|
|
33
|
+
|
|
34
|
+
def initialize(module_id:, export_name: "default")
|
|
35
|
+
@module_id = module_id
|
|
36
|
+
@export_name = export_name
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module Flight
|
|
7
|
+
# Renders a React element tree to a Flight wire format string.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# output = Renderer.render(root_element, bundler_config)
|
|
11
|
+
# # => "1:I[...]\n0:[\"$\",\"div\",...]\n"
|
|
12
|
+
class Renderer
|
|
13
|
+
def self.render(model, bundler_config, **)
|
|
14
|
+
each(model, bundler_config, streaming: false, **).to_a.join
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.each(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
|
|
18
|
+
streaming: true, &)
|
|
19
|
+
new(model, bundler_config,
|
|
20
|
+
strict_serialization: strict_serialization,
|
|
21
|
+
on_as_json_warning: on_as_json_warning).each(streaming: streaming, &)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
|
|
25
|
+
@request = Request.new(model, bundler_config,
|
|
26
|
+
strict_serialization: strict_serialization,
|
|
27
|
+
on_as_json_warning: on_as_json_warning)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Yields Flight rows one at a time.
|
|
31
|
+
# Flush order: imports → regular → root → deferred (with optional delay) → errors.
|
|
32
|
+
# When streaming: false (initial HTML shell), deferred delays are skipped.
|
|
33
|
+
def each(streaming: true, &block)
|
|
34
|
+
return enum_for(:each, streaming: streaming) unless block_given?
|
|
35
|
+
|
|
36
|
+
root_id = @request.allocate_id # => 0
|
|
37
|
+
|
|
38
|
+
serializer = Serializer.new(@request)
|
|
39
|
+
root_value = serializer.serialize_model(@request.root_model)
|
|
40
|
+
root_json = JSON.generate(root_value)
|
|
41
|
+
root_row = RowEmitter.model(root_id, root_json)
|
|
42
|
+
|
|
43
|
+
@request.completed_import_chunks.each(&block)
|
|
44
|
+
@request.completed_regular_chunks.each(&block)
|
|
45
|
+
yield root_row
|
|
46
|
+
|
|
47
|
+
# Deferred chunks: emitted after root, optionally delayed (Suspense streaming).
|
|
48
|
+
# When the chunk delay exceeds suspense_timeout, an E-type error row is emitted instead.
|
|
49
|
+
@request.deferred_chunks.each do |deferred|
|
|
50
|
+
if streaming && deferred[:delay]&.positive?
|
|
51
|
+
timeout = Ruact.config.suspense_timeout
|
|
52
|
+
if timeout&.positive? && deferred[:delay] > timeout
|
|
53
|
+
yield RowEmitter.error(deferred[:id], JSON.generate("Suspense timeout exceeded"))
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
sleep(deferred[:delay])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Serialize deferred content — may produce new import rows
|
|
60
|
+
import_count_before = @request.completed_import_chunks.length
|
|
61
|
+
deferred_value = serializer.serialize_model(deferred[:element])
|
|
62
|
+
|
|
63
|
+
# Yield any import rows discovered during deferred serialization
|
|
64
|
+
@request.completed_import_chunks[import_count_before..].each(&block)
|
|
65
|
+
|
|
66
|
+
yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@request.completed_error_chunks.each(&block)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# Central state for a single Flight render.
|
|
6
|
+
# Owns the ID allocator, chunk queues, and dedup tracker.
|
|
7
|
+
class Request
|
|
8
|
+
# I rows — flushed first
|
|
9
|
+
attr_reader :completed_import_chunks
|
|
10
|
+
# model rows
|
|
11
|
+
attr_reader :completed_regular_chunks
|
|
12
|
+
# E rows — flushed last
|
|
13
|
+
attr_reader :completed_error_chunks
|
|
14
|
+
# { id:, element:, delay: } — emitted after root row
|
|
15
|
+
attr_reader :deferred_chunks
|
|
16
|
+
# object_id => "$L<hex>" reference (dedup)
|
|
17
|
+
attr_reader :written_objects
|
|
18
|
+
attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
|
|
19
|
+
:strict_serialization, :on_as_json_warning
|
|
20
|
+
|
|
21
|
+
def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
|
|
22
|
+
@strict_serialization = strict_serialization
|
|
23
|
+
@on_as_json_warning = on_as_json_warning
|
|
24
|
+
@next_chunk_id = 0
|
|
25
|
+
@pending_chunks = 0
|
|
26
|
+
@bundler_config = bundler_config
|
|
27
|
+
|
|
28
|
+
@completed_import_chunks = []
|
|
29
|
+
@completed_regular_chunks = []
|
|
30
|
+
@completed_error_chunks = []
|
|
31
|
+
@deferred_chunks = []
|
|
32
|
+
|
|
33
|
+
@written_objects = {}.compare_by_identity
|
|
34
|
+
|
|
35
|
+
# Root task is always ID 0
|
|
36
|
+
@root_model = model
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def allocate_id
|
|
40
|
+
id = @next_chunk_id
|
|
41
|
+
@next_chunk_id += 1
|
|
42
|
+
id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def increment_pending
|
|
46
|
+
@pending_chunks += 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def decrement_pending
|
|
50
|
+
@pending_chunks -= 1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# Formats Flight wire format rows.
|
|
6
|
+
#
|
|
7
|
+
# Text rows: <hex_id>:<tag><json_payload>\n
|
|
8
|
+
# Binary rows: <hex_id>:<tag><hex_byte_length>,<binary_data> (no newline)
|
|
9
|
+
module RowEmitter
|
|
10
|
+
# A plain model row (most elements, objects, arrays)
|
|
11
|
+
def self.model(id, json)
|
|
12
|
+
"#{id.to_s(16)}:#{json}\n"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# An import row — tells the client where to load a "use client" module
|
|
16
|
+
def self.import(id, metadata_json)
|
|
17
|
+
"#{id.to_s(16)}:I#{metadata_json}\n"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# An error row
|
|
21
|
+
def self.error(id, error_json)
|
|
22
|
+
"#{id.to_s(16)}:E#{error_json}\n"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A large text row (binary framing, no trailing newline)
|
|
26
|
+
def self.text(id, text)
|
|
27
|
+
byte_length = text.bytesize
|
|
28
|
+
"#{id.to_s(16)}:T#{byte_length.to_s(16)},#{text}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A hint row — no ID, fire-and-forget preload signal
|
|
32
|
+
def self.hint(code, model_json)
|
|
33
|
+
":H#{code}#{model_json}\n"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|