ruact 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.3"
5
5
  end
@@ -2,21 +2,29 @@
2
2
 
3
3
  module Ruact
4
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__(...) %>+.
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 there
9
- # is no shared state between concurrent requests.
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-request ComponentRegistry and
12
- # returns an HTML comment placeholder that HtmlConverter later replaces with
13
- # a ReactElement node.
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 __rsc_component__(name, props = {})
19
- token = ComponentRegistry.register(name, props)
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/component_registry"
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 the configuration object for block-style setup.
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
- yield config
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 ||= Configuration.new
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
@@ -10,7 +10,7 @@ module RuboCop
10
10
  # @example
11
11
  # # bad
12
12
  # @@manifest = nil
13
- # Thread.current[:rsc_request] = req
13
+ # Thread.current[:ruact_request] = req
14
14
  #
15
15
  # # good
16
16
  # def serialize(value, request:)
@@ -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 "rails_rsc"
10
+ require "ruact"
11
11
 
12
- manifest = RailsRsc::ClientManifest.from_hash(
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 = RailsRsc::RenderPipeline.new(manifest)
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") { pipeline.call(erb_typical, binding_ctx) }
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 "rails_rsc"
37
+ require "ruact"
36
38
 
37
- manifest = RailsRsc::ClientManifest.from_hash(
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 = RailsRsc::RenderPipeline.new(manifest)
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 { pipeline.call(erb_typical, binding_ctx) }
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 "[rails_rsc] FAIL: allocations #{current} exceed baseline limit #{limit}"
63
+ warn "[ruact] FAIL: allocations #{current} exceed baseline limit #{limit}"
60
64
  exit 1
61
65
  else
62
- puts "[rails_rsc] PASS: allocations within 120% of baseline"
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 "[rails_rsc] Baseline established: #{current} allocations. Re-run to compare."
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.call(erb_source, ctx.instance_eval { binding })
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