ruact 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module ServerFunctions
|
|
7
|
+
# Story 8.3 — execution path for STANDALONE server actions (host
|
|
8
|
+
# modules that `extend Ruact::ServerAction`). Invoked by
|
|
9
|
+
# {Ruact::ServerFunctions::EndpointController#dispatch_action} when
|
|
10
|
+
# the resolved registry entry's host is a Module rather than an
|
|
11
|
+
# `ActionController::Base` subclass.
|
|
12
|
+
#
|
|
13
|
+
# Differences from the controller-hosted path (Story 8.1):
|
|
14
|
+
# - No `host_class.dispatch` — no Rails `process_action` callback
|
|
15
|
+
# chain, no `before_action` filters, no `rescue_from` on the host.
|
|
16
|
+
# The dispatcher is in charge of the entire response cycle.
|
|
17
|
+
# - The block runs via `instance_exec` on a fresh
|
|
18
|
+
# {Ruact::ServerFunctions::StandaloneContext}, not on a controller
|
|
19
|
+
# instance. The context exposes `params` / `session` /
|
|
20
|
+
# `current_user` / `request` / `cookies` / `headers`; it does NOT
|
|
21
|
+
# expose `render` / `redirect_to` / `head` (the block's return
|
|
22
|
+
# value IS the response).
|
|
23
|
+
#
|
|
24
|
+
# Same contract for response shape (parity with Story 8.1):
|
|
25
|
+
# - `nil` block return → 204 No Content
|
|
26
|
+
# - Hash / Array / scalar block return → 200 + JSON body
|
|
27
|
+
# - `raise Ruact::ActionError.new(status:, body:)` → that status + JSON body
|
|
28
|
+
class StandaloneDispatcher
|
|
29
|
+
# Plain value object returned by {.dispatch}. The caller — typically
|
|
30
|
+
# {EndpointController#dispatch_action} when running inside the Rails
|
|
31
|
+
# request cycle — applies these directives via `render` / `head` so
|
|
32
|
+
# Rails' `ImplicitRender` does not overwrite the response. In test
|
|
33
|
+
# / benchmark contexts the value can be applied to a bare response
|
|
34
|
+
# via {.apply_to_response}.
|
|
35
|
+
Result = Struct.new(:status, :body, :content_type)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @param entry [Ruact::ServerFunctions::RegistryEntry]
|
|
39
|
+
# @param request [ActionDispatch::Request]
|
|
40
|
+
# @param response [ActionDispatch::Response, nil] when non-nil, the
|
|
41
|
+
# dispatcher writes directives onto the response and marks it
|
|
42
|
+
# committed. Tests typically pass nil and apply the Result manually.
|
|
43
|
+
# @return [Result] render directive describing the response.
|
|
44
|
+
def dispatch(entry, request, response = nil)
|
|
45
|
+
begin
|
|
46
|
+
raw_args = extract_args(request)
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
# Story 8.3 review R3 — mirror the controller-DSL path's
|
|
49
|
+
# structured 400 contract (see `Ruact::Controller#ruact_action`,
|
|
50
|
+
# Re-run-4 2026-05-15). A malformed `application/json` body
|
|
51
|
+
# is a client bug; surface it as JSON {error} + 400 so the
|
|
52
|
+
# runtime's RuactActionError surface reports it cleanly.
|
|
53
|
+
result = build_malformed_json_result(entry, e)
|
|
54
|
+
apply_to_response(result, response) if response
|
|
55
|
+
return result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
params = ActionController::Parameters.new(raw_args)
|
|
59
|
+
context = StandaloneContext.new(params: params, request: request)
|
|
60
|
+
|
|
61
|
+
result =
|
|
62
|
+
begin
|
|
63
|
+
raw = context.instance_exec(params, &entry.block)
|
|
64
|
+
build_success_result(raw)
|
|
65
|
+
rescue Ruact::ActionError => e
|
|
66
|
+
build_error_result(e)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
maybe_warn_unread_current_user(entry, context)
|
|
70
|
+
apply_to_response(result, response) if response
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Writes a {Result} onto an `ActionDispatch::Response`. Used by
|
|
75
|
+
# tests/benches that drive the dispatcher directly (the
|
|
76
|
+
# request-cycle path goes through `EndpointController#dispatch_action`
|
|
77
|
+
# which calls `render` / `head` so Rails' `ImplicitRender` does
|
|
78
|
+
# not interfere).
|
|
79
|
+
def apply_to_response(result, response)
|
|
80
|
+
response.status = result.status
|
|
81
|
+
if result.body.nil? || result.body.empty?
|
|
82
|
+
response.headers.delete("Content-Type")
|
|
83
|
+
response.body = ""
|
|
84
|
+
else
|
|
85
|
+
response.headers["Content-Type"] = result.content_type if result.content_type
|
|
86
|
+
response.body = result.body
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Mirrors {Ruact::Controller#ruact_action_raw_args}'s content-type
|
|
93
|
+
# routing so the block's `params` shadow looks identical regardless
|
|
94
|
+
# of host shape.
|
|
95
|
+
def extract_args(request)
|
|
96
|
+
content_type = request.content_mime_type&.to_s ||
|
|
97
|
+
request.headers["Content-Type"]&.to_s&.split(";")&.first
|
|
98
|
+
case content_type
|
|
99
|
+
when "application/json"
|
|
100
|
+
body = request.raw_post
|
|
101
|
+
return {} if body.nil? || body.empty?
|
|
102
|
+
|
|
103
|
+
parsed = JSON.parse(body)
|
|
104
|
+
parsed.is_a?(Hash) ? parsed : { "_value" => parsed }
|
|
105
|
+
when "multipart/form-data", "application/x-www-form-urlencoded"
|
|
106
|
+
request.request_parameters
|
|
107
|
+
else
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Story 8.3 review R3 — structured 400 for malformed JSON bodies,
|
|
113
|
+
# parity with the controller-DSL path (controller.rb:301-313).
|
|
114
|
+
def build_malformed_json_result(entry, parse_error)
|
|
115
|
+
Result.new(
|
|
116
|
+
status: 400,
|
|
117
|
+
body: JSON.generate(
|
|
118
|
+
error: "ruact action :#{entry.ruby_symbol} received malformed JSON body: #{parse_error.message}"
|
|
119
|
+
),
|
|
120
|
+
content_type: "application/json; charset=utf-8"
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_success_result(raw)
|
|
125
|
+
if raw.nil?
|
|
126
|
+
Result.new(status: 204, body: nil, content_type: nil)
|
|
127
|
+
else
|
|
128
|
+
Result.new(
|
|
129
|
+
status: 200,
|
|
130
|
+
body: JSON.generate(raw),
|
|
131
|
+
content_type: "application/json; charset=utf-8"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_error_result(action_error)
|
|
137
|
+
status = action_error.status.is_a?(Symbol) ? status_code_for(action_error.status) : action_error.status
|
|
138
|
+
body = action_error.body
|
|
139
|
+
if body.nil?
|
|
140
|
+
Result.new(status: status, body: nil, content_type: nil)
|
|
141
|
+
else
|
|
142
|
+
Result.new(
|
|
143
|
+
status: status,
|
|
144
|
+
body: JSON.generate(body),
|
|
145
|
+
content_type: "application/json; charset=utf-8"
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def status_code_for(symbol)
|
|
151
|
+
if defined?(Rack::Utils::SYMBOL_TO_STATUS_CODE)
|
|
152
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE.fetch(symbol)
|
|
153
|
+
else
|
|
154
|
+
symbol
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Pitfall #4 — dev-only warning when a standalone action never
|
|
159
|
+
# reads `current_user` even though a resolver IS configured. The
|
|
160
|
+
# warning is gated on `Rails.env.development?` so production hosts
|
|
161
|
+
# that deliberately expose unauthenticated actions don't see spam.
|
|
162
|
+
def maybe_warn_unread_current_user(entry, context)
|
|
163
|
+
return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?)
|
|
164
|
+
return unless Rails.env.development?
|
|
165
|
+
return if Ruact.config.current_user_resolver.nil?
|
|
166
|
+
return if context.__ruact_current_user_read?
|
|
167
|
+
|
|
168
|
+
host_name = entry.controller.respond_to?(:name) ? entry.controller.name : entry.controller.inspect
|
|
169
|
+
Rails.logger&.warn(
|
|
170
|
+
"[ruact] WARNING — standalone action :#{entry.ruby_symbol} on #{host_name} returned " \
|
|
171
|
+
"without ever reading `current_user`. Standalone actions have NO implicit authorization; " \
|
|
172
|
+
"ensure the block calls `current_user` (or equivalent) before exposing protected data."
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Namespace for the server-functions subsystem (Story 8.0a — codegen surface that
|
|
4
|
+
# emits app/javascript/.ruact/server-functions.ts from the gem-side registries).
|
|
5
|
+
#
|
|
6
|
+
# - {Ruact::ServerFunctions::NameBridge} — Ruby symbol → JS identifier translation
|
|
7
|
+
# (single source of truth; the Vite plugin reads the already-translated identifier
|
|
8
|
+
# from the JSON snapshot and emits it as-is).
|
|
9
|
+
# - {Ruact::ServerFunctions::RegistryEntry} — immutable record for a single
|
|
10
|
+
# registered server function.
|
|
11
|
+
# - {Ruact::ServerFunctions::Registry} — storage + register/clear + collision
|
|
12
|
+
# detection. Populated by Story 8.1 (`ruact_action`) and Story 9.1 (`ruact_query`).
|
|
13
|
+
# - {Ruact::ServerFunctions::Snapshot} — pure function: registries → JSON-shaped Hash.
|
|
14
|
+
# - {Ruact::ServerFunctions::SnapshotWriter} — atomic, write-if-changed file I/O.
|
|
15
|
+
# - {Ruact::ServerFunctions::Codegen} — snapshot Hash → TypeScript module string.
|
|
16
|
+
#
|
|
17
|
+
# Empty registries are valid (Story 8.0a ships them empty; 8.1 and 9.1 populate).
|
|
18
|
+
require "json"
|
|
19
|
+
|
|
20
|
+
module Ruact
|
|
21
|
+
module ServerFunctions
|
|
22
|
+
autoload :NameBridge, "ruact/server_functions/name_bridge"
|
|
23
|
+
autoload :RegistryEntry, "ruact/server_functions/registry_entry"
|
|
24
|
+
autoload :Registry, "ruact/server_functions/registry"
|
|
25
|
+
autoload :Snapshot, "ruact/server_functions/snapshot"
|
|
26
|
+
autoload :SnapshotWriter, "ruact/server_functions/snapshot_writer"
|
|
27
|
+
autoload :Codegen, "ruact/server_functions/codegen"
|
|
28
|
+
autoload :RouteSource, "ruact/server_functions/route_source"
|
|
29
|
+
autoload :ErrorRendering, "ruact/server_functions/error_rendering"
|
|
30
|
+
autoload :EndpointController, "ruact/server_functions/endpoint_controller"
|
|
31
|
+
autoload :StandaloneContext, "ruact/server_functions/standalone_context"
|
|
32
|
+
autoload :StandaloneDispatcher, "ruact/server_functions/standalone_dispatcher"
|
|
33
|
+
autoload :QueryContext, "ruact/server_functions/query_context"
|
|
34
|
+
autoload :QueryDispatch, "ruact/server_functions/query_dispatch"
|
|
35
|
+
|
|
36
|
+
# Story 9.3 — orchestrates the route-driven (v2) codegen target. Reads the
|
|
37
|
+
# route table via {RouteSource}, writes the version-2 bridge to the PARALLEL
|
|
38
|
+
# `.next` path (write-if-changed), and renders the inspection TS via the
|
|
39
|
+
# Ruby {Codegen} (Vite does not watch `.next`). Per AC5 the `.next` target is
|
|
40
|
+
# for parity tests + inspection only — never imported by application code —
|
|
41
|
+
# so the real `server-functions.ts` (v1, rendered by Vite) is untouched
|
|
42
|
+
# (AC6). The Decision-#6 ownership flip (zero v1 declarations → v2 owns the
|
|
43
|
+
# real file) is Story 9.8's job.
|
|
44
|
+
#
|
|
45
|
+
# AC2 — transparency over silence: the exposed names are ALWAYS logged so a
|
|
46
|
+
# routed non-GET action never becomes a callable server function silently.
|
|
47
|
+
#
|
|
48
|
+
# @param route_set [#routes] the Rails route set.
|
|
49
|
+
# @param root [Pathname] the app root (for `tmp/cache` + `app/javascript`).
|
|
50
|
+
# @param logger [#info, nil] logger for the exposure line; defaults to
|
|
51
|
+
# `Rails.logger` when Rails is loaded, else nil.
|
|
52
|
+
# @return [Array<Hash>] the exposed v2 entries.
|
|
53
|
+
def self.write_v2_snapshot!(route_set:, root:, logger: default_logger)
|
|
54
|
+
entries = RouteSource.collect(route_set)
|
|
55
|
+
|
|
56
|
+
json_path = root.join("tmp/cache/ruact/server-functions.next.json")
|
|
57
|
+
ts_path = root.join("app/javascript/.ruact/server-functions.next.ts")
|
|
58
|
+
|
|
59
|
+
# Read back from the on-disk bridge (not a fresh dump) so a stable route
|
|
60
|
+
# table never churns the timestamp baked into the rendered TS header.
|
|
61
|
+
Snapshot.generate_v2!(entries: entries, path: json_path)
|
|
62
|
+
Codegen.generate_ts!(snapshot: JSON.parse(File.read(json_path)), output_path: ts_path)
|
|
63
|
+
|
|
64
|
+
# AC2 — ALWAYS log what is exposed (even "(none)"), so a routed non-GET
|
|
65
|
+
# action never becomes a callable server function silently.
|
|
66
|
+
names = entries.empty? ? "(none)" : entries.map { |e| e["js_identifier"] }.join(", ")
|
|
67
|
+
logger&.info "[ruact] codegen: exposing #{names}"
|
|
68
|
+
entries
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.default_logger
|
|
72
|
+
defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -2,21 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
module Ruact
|
|
4
4
|
# ActionView helper module included in ActionView::Base via Railtie.
|
|
5
|
-
# Provides the +
|
|
6
|
-
# preprocessor transforms PascalCase tags into +<%=
|
|
5
|
+
# Provides the +__ruact_component__+ method that ERB templates call after the
|
|
6
|
+
# preprocessor transforms PascalCase tags into +<%= __ruact_component__(...) %>+.
|
|
7
7
|
#
|
|
8
|
-
# Thread-safe: ActionView creates a fresh view context per request, so
|
|
9
|
-
#
|
|
8
|
+
# Thread-safe: ActionView creates a fresh view context per request, so the
|
|
9
|
+
# render context (set by Ruact::Controller#ruact_render on the controller as
|
|
10
|
+
# +@ruact_render_context+ and copied to the view by Rails's view_assigns
|
|
11
|
+
# plumbing — see Story 7.9 / Bug 7.8-B) is per-request — no shared state.
|
|
10
12
|
module ViewHelper
|
|
11
|
-
# Registers +name+ with +props+ in the per-
|
|
12
|
-
#
|
|
13
|
-
#
|
|
13
|
+
# Registers +name+ with +props+ in the per-render RenderContext (set by
|
|
14
|
+
# Ruact::Controller#ruact_render on the controller as +@ruact_render_context+;
|
|
15
|
+
# Rails copies it to the view via +_assigns_for_view_context+ because the
|
|
16
|
+
# name does not match +DEFAULT_PROTECTED_INSTANCE_VARIABLES+'s +/\A@_/+
|
|
17
|
+
# filter) and returns an HTML comment placeholder that HtmlConverter later
|
|
18
|
+
# replaces with a ReactElement node.
|
|
14
19
|
#
|
|
15
20
|
# The returned string MUST be html_safe so ActionView does not escape the
|
|
16
21
|
# angle brackets — if it were escaped, HtmlConverter would not find the
|
|
17
22
|
# placeholder in the HTML output.
|
|
18
|
-
def
|
|
19
|
-
|
|
23
|
+
def __ruact_component__(name, props = {})
|
|
24
|
+
ctx = @ruact_render_context
|
|
25
|
+
raise Ruact::Error, "ruact: __ruact_component__ called outside a ruact_render flow" if ctx.nil?
|
|
26
|
+
|
|
27
|
+
token = ctx.register(name, props)
|
|
20
28
|
"<!-- #{token} -->".html_safe
|
|
21
29
|
end
|
|
22
30
|
end
|
data/lib/ruact.rb
CHANGED
|
@@ -6,12 +6,15 @@ require_relative "ruact/configuration"
|
|
|
6
6
|
require_relative "ruact/serializable"
|
|
7
7
|
require_relative "ruact/flight"
|
|
8
8
|
require_relative "ruact/erb_preprocessor"
|
|
9
|
-
require_relative "ruact/
|
|
9
|
+
require_relative "ruact/render_context"
|
|
10
10
|
require_relative "ruact/html_converter"
|
|
11
11
|
require_relative "ruact/client_manifest"
|
|
12
12
|
require_relative "ruact/render_pipeline"
|
|
13
13
|
require_relative "ruact/view_helper"
|
|
14
14
|
require_relative "ruact/erb_preprocessor_hook"
|
|
15
|
+
require_relative "ruact/server_functions"
|
|
16
|
+
require_relative "ruact/server_action"
|
|
17
|
+
require_relative "ruact/query"
|
|
15
18
|
# Railtie loads ruact/controller when inside a Rails app
|
|
16
19
|
require_relative "ruact/railtie" if defined?(Rails)
|
|
17
20
|
|
|
@@ -19,6 +22,35 @@ module Ruact
|
|
|
19
22
|
class << self
|
|
20
23
|
attr_accessor :manifest, :streaming_mode
|
|
21
24
|
|
|
25
|
+
# Registry of `ruact_action` declarations. Populated by Story 8.1's
|
|
26
|
+
# controller macro; consumed by {Ruact::ServerFunctions::Snapshot} when
|
|
27
|
+
# writing the Rails↔Vite bridge JSON.
|
|
28
|
+
#
|
|
29
|
+
# @return [Ruact::ServerFunctions::Registry] lazy-initialized singleton.
|
|
30
|
+
def action_registry
|
|
31
|
+
@action_registry ||= ServerFunctions::Registry.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Registry of `ruact_query` declarations. Populated by Story 9.1's
|
|
35
|
+
# controller macro; consumed by {Ruact::ServerFunctions::Snapshot} when
|
|
36
|
+
# writing the Rails↔Vite bridge JSON.
|
|
37
|
+
#
|
|
38
|
+
# @return [Ruact::ServerFunctions::Registry] lazy-initialized singleton.
|
|
39
|
+
def query_registry
|
|
40
|
+
@query_registry ||= ServerFunctions::Registry.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Story 8.4 — Absolute path to the gem's `lib/` root. Used by
|
|
44
|
+
# {Ruact::ServerFunctions::BacktraceCleaner} to classify backtrace frames as
|
|
45
|
+
# APP or GEM. Memoised at first call so the per-frame `start_with?` check
|
|
46
|
+
# stays constant-time. Anchors on this file's directory: `lib/ruact.rb`
|
|
47
|
+
# resolves to `lib/` after `expand_path("..", __dir__)`.
|
|
48
|
+
#
|
|
49
|
+
# @return [String] absolute path to the gem's `lib/` directory
|
|
50
|
+
def gem_path
|
|
51
|
+
@gem_path ||= File.expand_path("..", __dir__)
|
|
52
|
+
end
|
|
53
|
+
|
|
22
54
|
# Returns the absolute path to the Vite plugin bundled inside this gem.
|
|
23
55
|
# Use this in vite.config.js: import ruact from '<%= Ruact.vite_plugin_path %>'
|
|
24
56
|
# Re-run `rails generate ruact:install` after gem upgrades to refresh the path.
|
|
@@ -28,21 +60,68 @@ module Ruact
|
|
|
28
60
|
File.expand_path("../vendor/javascript/vite-plugin-ruact/index.js", __dir__)
|
|
29
61
|
end
|
|
30
62
|
|
|
31
|
-
# Yields
|
|
63
|
+
# Yields a mutable Configuration draft for block-style setup. The draft is
|
|
64
|
+
# frozen and atomically swapped into `Ruact.config` when the block returns.
|
|
65
|
+
# Mutating `Ruact.config` outside this block raises
|
|
66
|
+
# `Ruact::ConfigurationError` (Story 7.3).
|
|
67
|
+
#
|
|
68
|
+
# When called a second time after boot, this method emits a `[ruact]`
|
|
69
|
+
# warning advising that runtime re-configuration is unusual.
|
|
32
70
|
#
|
|
33
71
|
# @example
|
|
34
72
|
# Ruact.configure do |config|
|
|
35
73
|
# config.strict_serialization = true
|
|
36
74
|
# end
|
|
75
|
+
# @yieldparam [Ruact::Configuration] mutable draft cloned from the current
|
|
76
|
+
# configuration (or built from defaults on first call)
|
|
37
77
|
def configure
|
|
38
|
-
|
|
78
|
+
draft = if defined?(@config) && @config
|
|
79
|
+
Configuration.new(template: @config)
|
|
80
|
+
else
|
|
81
|
+
Configuration.new
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
yield draft
|
|
85
|
+
|
|
86
|
+
warn_if_re_configuration!
|
|
87
|
+
@config = draft.__send__(:seal!)
|
|
39
88
|
end
|
|
40
89
|
|
|
41
|
-
# Returns the singleton configuration instance
|
|
90
|
+
# Returns the singleton configuration instance, frozen on first access so
|
|
91
|
+
# that mutation outside `Ruact.configure` always raises (Story 7.3).
|
|
92
|
+
# First-access publication counts as the boot configuration, so a later
|
|
93
|
+
# `Ruact.configure` call after default reads triggers the AC3 warning
|
|
94
|
+
# (otherwise the warning would be silently bypassed in apps that never
|
|
95
|
+
# call `Ruact.configure` at boot but reconfigure later).
|
|
42
96
|
#
|
|
43
|
-
# @return [Ruact::Configuration]
|
|
97
|
+
# @return [Ruact::Configuration] frozen
|
|
44
98
|
def config
|
|
45
|
-
@config
|
|
99
|
+
return @config if defined?(@config) && @config
|
|
100
|
+
|
|
101
|
+
@config = Configuration.new.__send__(:seal!)
|
|
102
|
+
@configured_at_least_once = true
|
|
103
|
+
@config
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def warn_if_re_configuration!
|
|
109
|
+
return unless @configured_at_least_once
|
|
110
|
+
|
|
111
|
+
caller_loc = caller_locations(2, 1).first
|
|
112
|
+
message = "[ruact] Ruact.configure called after boot at #{caller_loc.path}:#{caller_loc.lineno}. " \
|
|
113
|
+
"Re-configuration at runtime is unusual and may indicate that configuration is being " \
|
|
114
|
+
"driven by request state, environment, or feature flags rather than initializer-time invariants. " \
|
|
115
|
+
"If this is intentional (e.g. test setup), ignore this warning; otherwise, consolidate " \
|
|
116
|
+
"configuration into config/initializers/ruact.rb."
|
|
117
|
+
|
|
118
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
119
|
+
Rails.logger.warn(message)
|
|
120
|
+
else
|
|
121
|
+
warn(message)
|
|
122
|
+
end
|
|
123
|
+
ensure
|
|
124
|
+
@configured_at_least_once = true
|
|
46
125
|
end
|
|
47
126
|
end
|
|
48
127
|
end
|
data/lib/tasks/benchmark.rake
CHANGED
|
@@ -7,16 +7,16 @@ namespace :benchmark do
|
|
|
7
7
|
desc "Run speed benchmark with benchmark-ips (development reporting)"
|
|
8
8
|
task :speed do
|
|
9
9
|
require "benchmark/ips"
|
|
10
|
-
require "
|
|
10
|
+
require "ruact"
|
|
11
11
|
|
|
12
|
-
manifest =
|
|
12
|
+
manifest = Ruact::ClientManifest.from_hash(
|
|
13
13
|
(1..20).to_h do |i|
|
|
14
14
|
["Component#{i}", { "id" => "/assets/Component#{i}.js",
|
|
15
15
|
"name" => "Component#{i}",
|
|
16
16
|
"chunks" => ["/assets/Component#{i}.js"] }]
|
|
17
17
|
end
|
|
18
18
|
)
|
|
19
|
-
pipeline =
|
|
19
|
+
pipeline = Ruact::RenderPipeline.new(manifest)
|
|
20
20
|
erb_typical = "<div>\n#{(1..20).map { |i| "<Component#{i} index={#{i}} />" }.join("\n")}\n</div>"
|
|
21
21
|
|
|
22
22
|
ctx = Object.new
|
|
@@ -24,7 +24,9 @@ namespace :benchmark do
|
|
|
24
24
|
|
|
25
25
|
Benchmark.ips do |x|
|
|
26
26
|
x.config(time: 5, warmup: 2)
|
|
27
|
-
x.report("render 20 components")
|
|
27
|
+
x.report("render 20 components") do
|
|
28
|
+
pipeline.render({ erb: erb_typical, binding: binding_ctx }, mode: :string)
|
|
29
|
+
end
|
|
28
30
|
x.compare!
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -32,22 +34,24 @@ namespace :benchmark do
|
|
|
32
34
|
desc "Run memory allocation benchmark; exits 1 if allocations exceed baseline × 1.20"
|
|
33
35
|
task :memory do
|
|
34
36
|
require "memory_profiler"
|
|
35
|
-
require "
|
|
37
|
+
require "ruact"
|
|
36
38
|
|
|
37
|
-
manifest =
|
|
39
|
+
manifest = Ruact::ClientManifest.from_hash(
|
|
38
40
|
(1..20).to_h do |i|
|
|
39
41
|
["Component#{i}", { "id" => "/assets/Component#{i}.js",
|
|
40
42
|
"name" => "Component#{i}",
|
|
41
43
|
"chunks" => ["/assets/Component#{i}.js"] }]
|
|
42
44
|
end
|
|
43
45
|
)
|
|
44
|
-
pipeline =
|
|
46
|
+
pipeline = Ruact::RenderPipeline.new(manifest)
|
|
45
47
|
erb_typical = "<div>\n#{(1..20).map { |i| "<Component#{i} index={#{i}} />" }.join("\n")}\n</div>"
|
|
46
48
|
ctx = Object.new
|
|
47
49
|
binding_ctx = ctx.instance_eval { binding }
|
|
48
50
|
|
|
49
51
|
baseline_path = File.expand_path("../../spec/benchmarks/baseline.json", __dir__)
|
|
50
|
-
report = MemoryProfiler.report
|
|
52
|
+
report = MemoryProfiler.report do
|
|
53
|
+
pipeline.render({ erb: erb_typical, binding: binding_ctx }, mode: :string)
|
|
54
|
+
end
|
|
51
55
|
current = report.total_allocated
|
|
52
56
|
|
|
53
57
|
if File.exist?(baseline_path)
|
|
@@ -56,15 +60,15 @@ namespace :benchmark do
|
|
|
56
60
|
puts "Memory allocations: #{current} (baseline: #{baseline['typical_allocations']}, limit: #{limit})"
|
|
57
61
|
|
|
58
62
|
if current > limit
|
|
59
|
-
warn "[
|
|
63
|
+
warn "[ruact] FAIL: allocations #{current} exceed baseline limit #{limit}"
|
|
60
64
|
exit 1
|
|
61
65
|
else
|
|
62
|
-
puts "[
|
|
66
|
+
puts "[ruact] PASS: allocations within 120% of baseline"
|
|
63
67
|
end
|
|
64
68
|
else
|
|
65
69
|
baseline_data = { "typical_allocations" => current, "heavy_allocations" => nil }
|
|
66
70
|
File.write(baseline_path, JSON.generate(baseline_data))
|
|
67
|
-
puts "[
|
|
71
|
+
puts "[ruact] Baseline established: #{current} allocations. Re-run to compare."
|
|
68
72
|
end
|
|
69
73
|
end
|
|
70
74
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ruact do
|
|
4
|
+
desc "Check ruact installation and configuration (FR27)"
|
|
5
|
+
task doctor: :environment do
|
|
6
|
+
require "ruact/doctor"
|
|
7
|
+
exit 1 unless Ruact::Doctor.run
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
namespace :server_functions do
|
|
11
|
+
# Story 8.0a — manual / CI / production codegen entry point. Mirrors the
|
|
12
|
+
# Railtie hook (config.to_prepare) for environments where the dev server
|
|
13
|
+
# is not running (CI, deploy pipelines, container build steps).
|
|
14
|
+
#
|
|
15
|
+
# Pipeline:
|
|
16
|
+
# 1. `Snapshot.generate!` writes the JSON bridge IF its registry
|
|
17
|
+
# payload differs from the on-disk version (write-if-changed by
|
|
18
|
+
# payload, not by timestamp).
|
|
19
|
+
# 2. `Snapshot.read_for_codegen` re-loads the persisted JSON — this is
|
|
20
|
+
# the on-disk source of truth that the Vite plugin consumes, and
|
|
21
|
+
# reusing it (instead of freshly dumping with a new timestamp)
|
|
22
|
+
# keeps the TS module byte-stable on unchanged registries.
|
|
23
|
+
# 3. `Codegen.generate_ts!` writes the TS module via the same write-
|
|
24
|
+
# if-changed guard.
|
|
25
|
+
#
|
|
26
|
+
# Exit codes: 0 on success or no-op rewrites; 1 on
|
|
27
|
+
# `Ruact::ConfigurationError` (invalid symbol shape per AC7, cross-
|
|
28
|
+
# registry collision, invalid kind, or a corrupted snapshot rejected by
|
|
29
|
+
# the codegen's identifier guard).
|
|
30
|
+
desc "Regenerate app/javascript/.ruact/server-functions.ts from the gem registries (Story 8.0a)"
|
|
31
|
+
task generate: :environment do
|
|
32
|
+
require "ruact/server_functions"
|
|
33
|
+
require "json"
|
|
34
|
+
|
|
35
|
+
json_path = Rails.root.join("tmp/cache/ruact/server-functions.json")
|
|
36
|
+
ts_path = Rails.root.join("app/javascript/.ruact/server-functions.ts")
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
Ruact::ServerFunctions::Snapshot.generate!(
|
|
40
|
+
action_registry: Ruact.action_registry,
|
|
41
|
+
query_registry: Ruact.query_registry,
|
|
42
|
+
path: json_path
|
|
43
|
+
)
|
|
44
|
+
# Pass-2 patch 2026-05-14 — the JSON can disappear OR be partially
|
|
45
|
+
# written between `generate!` and `File.read` (a concurrent rake
|
|
46
|
+
# invocation flushing mid-write, a tmpdir wipe by Spring, an
|
|
47
|
+
# externally-managed `tmp/cache` cleaner). Both `Errno::ENOENT` AND
|
|
48
|
+
# `JSON::ParserError` indicate the same TOCTOU window — re-invoke
|
|
49
|
+
# `generate!` once with the same registries; if the second read
|
|
50
|
+
# still fails we surface the error with a clear "[ruact] error"
|
|
51
|
+
# envelope so the caller sees a real failure rather than an
|
|
52
|
+
# unwrapped Errno / parser backtrace.
|
|
53
|
+
snapshot = begin
|
|
54
|
+
JSON.parse(File.read(json_path)).transform_keys(&:to_sym)
|
|
55
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
56
|
+
Ruact::ServerFunctions::Snapshot.generate!(
|
|
57
|
+
action_registry: Ruact.action_registry,
|
|
58
|
+
query_registry: Ruact.query_registry,
|
|
59
|
+
path: json_path
|
|
60
|
+
)
|
|
61
|
+
JSON.parse(File.read(json_path)).transform_keys(&:to_sym)
|
|
62
|
+
end
|
|
63
|
+
Ruact::ServerFunctions::Codegen.generate_ts!(
|
|
64
|
+
snapshot: snapshot,
|
|
65
|
+
output_path: ts_path
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Story 9.3 — also emit the route-driven (v2) parallel target
|
|
69
|
+
# (`server-functions.next.{json,ts}`). Vite does not render the `.next`
|
|
70
|
+
# bridge, so the rake is the production/CI path that materializes it for
|
|
71
|
+
# parity + inspection. The real `server-functions.ts` above stays v1.
|
|
72
|
+
Ruact::ServerFunctions.write_v2_snapshot!(
|
|
73
|
+
route_set: Rails.application.routes, root: Rails.root, logger: Rails.logger
|
|
74
|
+
)
|
|
75
|
+
rescue Ruact::ConfigurationError, Errno::ENOENT, JSON::ParserError => e
|
|
76
|
+
warn "[ruact] error: #{e.message}"
|
|
77
|
+
exit 1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -32,7 +32,7 @@ RSpec.describe "RenderPipeline benchmark" do
|
|
|
32
32
|
|
|
33
33
|
def render_erb(erb_source, active_pipeline = pipeline)
|
|
34
34
|
ctx = Object.new
|
|
35
|
-
active_pipeline.
|
|
35
|
+
active_pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
describe "typical view (20 components)" do
|