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.
Files changed (129) 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 +3 -2
  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 +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -61,6 +61,49 @@ module Ruact
61
61
  File.exist?(destination_root.join("app/javascript/components/.keep"))
62
62
  end
63
63
 
64
+ # Story 8.0a — scaffold the directory the codegen writes into and add the
65
+ # generated artifacts to .gitignore. The TS module is regenerated on every
66
+ # boot from the action and query registries, so it should never be
67
+ # version-controlled; same for the bridge JSON under tmp/cache/.
68
+ def create_server_functions_directory
69
+ empty_directory "app/javascript/.ruact"
70
+ create_file "app/javascript/.ruact/.gitkeep" unless
71
+ File.exist?(destination_root.join("app/javascript/.ruact/.gitkeep"))
72
+ end
73
+
74
+ def append_gitignore_entries
75
+ gitignore = destination_root.join(".gitignore")
76
+ return unless gitignore.exist?
77
+
78
+ entries = [
79
+ "app/javascript/.ruact/server-functions.ts",
80
+ # Story 9.3 — the route-driven (v2) parallel inspection target is also
81
+ # generated output, never source.
82
+ "app/javascript/.ruact/server-functions.next.ts",
83
+ "tmp/cache/ruact/"
84
+ ]
85
+ # Substring matches (`existing.include?(entry)`) were unsafe — they
86
+ # would skip "tmp/cache/ruact/" when the file already contained
87
+ # "tmp/cache/ruact/some-cache.bin", leaving the directory itself
88
+ # un-ignored. Match by exact normalized line instead.
89
+ existing_lines = File.read(gitignore).each_line.to_set { |line| line.chomp.strip }
90
+ new_entries = entries.reject { |e| existing_lines.include?(e) }
91
+ return if new_entries.empty?
92
+
93
+ append_to_file ".gitignore", "\n# ruact (Story 8.0a — auto-generated server-functions module)\n"
94
+ new_entries.each { |entry| append_to_file ".gitignore", "#{entry}\n" }
95
+ end
96
+
97
+ # Invokes `ruact:server_functions:generate` so a fresh install completes
98
+ # with the AC8-required empty-but-valid generated module on disk.
99
+ # Failures (a NameBridge violation, a collision, an unwritable
100
+ # `tmp/cache/ruact/` directory) propagate intentionally — silencing
101
+ # them via a rescue would let an install finish in a broken state, which
102
+ # is the bug the Re-run review caught.
103
+ def prime_server_functions_codegen
104
+ rake "ruact:server_functions:generate"
105
+ end
106
+
64
107
  def create_vite_config
65
108
  vite_config_file = destination_root.join("vite.config.js")
66
109
 
@@ -1,7 +1,7 @@
1
1
  import { createRoot } from 'react-dom/client';
2
2
  import { useState, useEffect } from 'react';
3
3
  import { createFromFlightPayload } from './flight-client.js';
4
- import { setupRouter, teardownRouter } from './rsc-router.js';
4
+ import { setupRouter, teardownRouter } from './ruact-router.js';
5
5
 
6
6
  // MODULE_REGISTRY maps react-client-manifest "id" values to component exports.
7
7
  // Add each "use client" component here as you create it.
@@ -5,7 +5,7 @@ Ruact.configure do |config|
5
5
  # Defaults to Rails.root.join("public/react-client-manifest.json").
6
6
  # config.manifest_path = Rails.root.join("public", "react-client-manifest.json")
7
7
 
8
- # When true, objects without explicit rsc_props declaration raise
8
+ # When true, objects without explicit ruact_props declaration raise
9
9
  # Ruact::SerializationError instead of falling back to as_json.
10
10
  # Recommended: true in production to prevent accidental attribute exposure.
11
11
  # config.strict_serialization = Rails.env.production?
@@ -43,19 +43,22 @@ module Ruact
43
43
  #
44
44
  # Returns the same object for repeated calls with the same resolved key
45
45
  # (needed for dedup by object_id in Flight::Serializer).
46
- # Raises if the resolved name is not found in the manifest.
46
+ #
47
+ # Raises +Ruact::ManifestError+ when the resolved name is not found. The
48
+ # error message includes a Damerau-Levenshtein closest-match suggestion
49
+ # (Story 7.4) when a manifest entry within distance 2 exists, or a
50
+ # file-path hint suggesting where to add the missing component otherwise.
51
+ # When +controller_path+ is given the closest-match scan biases toward
52
+ # co-located keys so a typo inside +posts/show.html.erb+ surfaces the
53
+ # +posts/_like_button+ suggestion before the shared +LikeButton+ entry.
47
54
  def reference_for(name, controller_path: nil)
48
55
  @reference_cache ||= {}
49
56
  key = resolve_key(name, controller_path)
50
57
  @reference_cache[key] ||= begin
51
58
  entry = entries_by_name[key]
52
- unless entry
53
- raise ManifestError,
54
- "Component #{name.inspect} not found in manifest — " \
55
- "Did you run the Vite build? Run 'npm run build' or start the Vite dev server."
56
- end
59
+ raise ManifestError, build_unknown_component_message(name, controller_path) unless entry
57
60
 
58
- Flight::ClientReference.new(module_id: entry["id"], export_name: name)
61
+ Flight::ClientReference.new(module_id: entry["id"], export_name: entry["name"])
59
62
  end
60
63
  end
61
64
 
@@ -73,39 +76,149 @@ module Ruact
73
76
  end
74
77
 
75
78
  # Build from an already-parsed Hash (useful in tests).
79
+ # The +@reference_cache+ ivar is initialized eagerly so the freeze +
80
+ # first-lookup path works even when +data+ is empty (otherwise
81
+ # +reference_for+ would raise +FrozenError+ trying to memoize on a
82
+ # frozen instance).
76
83
  def self.from_hash(data)
77
84
  manifest = new
78
85
  manifest.instance_variable_set(:@data, data)
86
+ manifest.instance_variable_set(:@reference_cache, {})
79
87
  manifest
80
88
  end
81
89
 
82
90
  private
83
91
 
92
+ # Story 7.4: build the ManifestError message for a missing component,
93
+ # using the AC3 verbatim multi-line "ruact:" shape with a Damerau-
94
+ # Levenshtein closest-match suggestion (or a file-path fallback hint).
95
+ def build_unknown_component_message(name, controller_path = nil)
96
+ suggestion = closest_match_for(name, entries_by_name.keys, controller_path)
97
+ hint = if suggestion
98
+ %(Did you mean "#{suggestion}"?)
99
+ else
100
+ "Did you mean to add app/javascript/components/#{name}.jsx and rebuild Vite?"
101
+ end
102
+
103
+ <<~MSG.strip
104
+ ruact: Component #{name.inspect} not found in manifest.
105
+ #{hint}
106
+ Did you run the Vite build? Run 'npm run build' or start the Vite dev server.
107
+ MSG
108
+ end
109
+
110
+ # Returns the manifest key whose comparable name is within Damerau-
111
+ # Levenshtein distance 2 of +name+ (case-insensitive), preferring the
112
+ # smallest distance. Returns +nil+ if no key qualifies.
113
+ #
114
+ # Comparison-key normalization (so a typo like "LikeButtoon" can match
115
+ # the co-located key "posts/_like_button"):
116
+ #
117
+ # - Shared PascalCase keys (e.g. "LikeButton") compare as-is.
118
+ # - Co-located keys (e.g. "posts/_like_button") compare as the basename
119
+ # in PascalCase ("LikeButton"); the original key is what gets returned
120
+ # so the developer sees the actual manifest entry name.
121
+ #
122
+ # When +controller_path+ is given, the matching logic prefers keys
123
+ # scoped to that path (e.g. "posts/_*") so a typo inside posts/show
124
+ # surfaces "posts/_like_button" before the shared "LikeButton" — even
125
+ # if both tie at the same distance.
126
+ def closest_match_for(name, pool, controller_path = nil)
127
+ target = name.downcase
128
+ best_key = nil
129
+ best_distance = 3 # threshold + 1
130
+ best_in_scope = false
131
+
132
+ pool.each do |key|
133
+ comparable = comparable_name_for(key).downcase
134
+ distance = self.class.send(:damerau_levenshtein_distance, target, comparable)
135
+ next if distance > 2
136
+
137
+ in_scope = controller_path && key.start_with?("#{controller_path}/")
138
+ next unless distance < best_distance || (distance == best_distance && in_scope && !best_in_scope)
139
+
140
+ best_distance = distance
141
+ best_key = key
142
+ best_in_scope = in_scope
143
+ end
144
+
145
+ best_key
146
+ end
147
+
148
+ # Normalize a manifest key for comparison purposes. Co-located keys
149
+ # ("posts/_like_button") collapse to their PascalCase basename
150
+ # ("LikeButton"); shared keys are returned as-is.
151
+ def comparable_name_for(key)
152
+ return key unless key.include?("/")
153
+
154
+ basename = key.split("/").last.delete_prefix("_")
155
+ basename.split("_").map(&:capitalize).join
156
+ end
157
+
158
+ # Damerau-Levenshtein distance — like classic Levenshtein but treats
159
+ # an adjacent transposition (e.g. "ke"↔"ek") as a single edit. Component
160
+ # names are short (≤ 30 chars in practice) so the full O(m·n) DP table
161
+ # is fine; the readability win over the two-row Levenshtein trick is
162
+ # worth the extra ~30 cells of allocation in the failure path.
163
+ # rubocop:disable Metrics/AbcSize
164
+ def self.damerau_levenshtein_distance(left, right)
165
+ return right.length if left.empty?
166
+ return left.length if right.empty?
167
+
168
+ m = left.length
169
+ n = right.length
170
+ d = Array.new(m + 1) { Array.new(n + 1, 0) }
171
+ (0..m).each { |i| d[i][0] = i }
172
+ (0..n).each { |j| d[0][j] = j }
173
+
174
+ (1..m).each do |i|
175
+ (1..n).each do |j|
176
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
177
+ d[i][j] = [
178
+ d[i - 1][j] + 1, # deletion
179
+ d[i][j - 1] + 1, # insertion
180
+ d[i - 1][j - 1] + cost # substitution
181
+ ].min
182
+ if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1]
183
+ d[i][j] = [d[i][j], d[i - 2][j - 2] + cost].min
184
+ end
185
+ end
186
+ end
187
+
188
+ d[m][n]
189
+ end
190
+ # rubocop:enable Metrics/AbcSize
191
+ private_class_method :damerau_levenshtein_distance
192
+
84
193
  # Returns the manifest key to use for +name+ given an optional +controller_path+.
85
194
  # Co-located key format: "<controller_path>/_<underscored_name>" (e.g. "posts/_like_button").
86
195
  # Co-located takes precedence when both keys exist.
87
196
  def resolve_key(name, controller_path)
88
197
  return name unless controller_path
89
198
 
90
- co_located = "#{controller_path}/_#{rsc_underscore(name)}"
199
+ co_located = "#{controller_path}/_#{pascal_to_snake_case(name)}"
91
200
  include?(co_located) ? co_located : name
92
201
  end
93
202
 
94
203
  # Converts PascalCase component names to snake_case without requiring ActiveSupport.
95
204
  # Equivalent to ActiveSupport::Inflector.underscore for PascalCase inputs.
96
- def rsc_underscore(name)
205
+ def pascal_to_snake_case(name)
97
206
  name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
98
207
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
99
208
  .downcase
100
209
  end
101
210
 
102
211
  def data
103
- @data ||= {}
212
+ @data || {}
104
213
  end
105
214
 
106
- # Index by component name for fast lookup
215
+ # Index by component name. Today the manifest hash is already keyed by
216
+ # name, so this is a thin alias rather than a new index. Avoid lazy
217
+ # memoization here because the manifest is frozen after +load+ — any
218
+ # +@entries_by_name ||= ...+ assignment on a frozen instance would
219
+ # raise +FrozenError+.
107
220
  def entries_by_name
108
- @entries_by_name ||= data
221
+ data
109
222
  end
110
223
 
111
224
  def by_module_id(id)
@@ -2,31 +2,272 @@
2
2
 
3
3
  module Ruact
4
4
  # Holds gem-wide configuration. Instantiated once via Ruact.config.
5
- # Configure via Ruact.configure { |c| c.attr = value } in an initializer.
5
+ # Configure via `Ruact.configure { |c| c.attr = value }` in an initializer.
6
+ #
7
+ # Frozen after `Ruact.configure` returns (Story 7.3) — direct post-boot
8
+ # mutation (`Ruact.config.attr = value` outside the block) raises
9
+ # `Ruact::ConfigurationError` with the offending attribute, the caller's
10
+ # file:line, and the suggested fix. Re-calling `Ruact.configure` after boot
11
+ # replaces the configuration atomically and emits a `[ruact]` warning.
6
12
  class Configuration
7
- # @return [String, nil] Path to react-client-manifest.json.
8
- # Defaults to Rails.root.join("public/react-client-manifest.json") when nil.
9
- attr_accessor :manifest_path
10
-
11
- # @return [Boolean] When true, objects without explicit rsc_props declaration
12
- # raise Ruact::SerializationError. Defaults to false in development, true in production.
13
- attr_accessor :strict_serialization
14
-
15
- # @return [Float] Seconds before a deferred Suspense chunk times out. Default: 5.0.
16
- attr_accessor :suspense_timeout
17
-
18
- # @return [String] Base URL of the Vite dev server. Default: "http://localhost:5173".
19
- attr_accessor :vite_dev_server
20
-
21
- def initialize
22
- @manifest_path = nil
23
- @strict_serialization = begin
24
- Rails.env.production?
25
- rescue StandardError
26
- false
13
+ # The set of public attributes; new attributes added here automatically
14
+ # inherit the freeze contract via the `define_method` writer below.
15
+ ATTRIBUTES = %i[
16
+ manifest_path
17
+ strict_serialization
18
+ suspense_timeout
19
+ vite_dev_server
20
+ current_user_resolver
21
+ dev_error_payload_enabled
22
+ max_upload_bytes
23
+ query_route_prefix
24
+ query_parent_controller
25
+ ].freeze
26
+
27
+ # @!attribute [r] manifest_path
28
+ # @return [String, nil] Path to react-client-manifest.json.
29
+ # Defaults to Rails.root.join("public/react-client-manifest.json") when nil.
30
+ #
31
+ # @!attribute [r] strict_serialization
32
+ # @return [Boolean] When true, objects without explicit ruact_props declaration
33
+ # raise Ruact::SerializationError. Defaults to false in development, true in production.
34
+ #
35
+ # @!attribute [r] suspense_timeout
36
+ # @return [Float] Seconds before a deferred Suspense chunk times out. Default: 5.0.
37
+ #
38
+ # @!attribute [r] vite_dev_server
39
+ # @return [String] Base URL of the Vite dev server. Default: "http://localhost:5173".
40
+ #
41
+ # @!attribute [r] current_user_resolver
42
+ # @return [Proc, nil] Story 8.3 — Lambda invoked by the standalone
43
+ # server-action dispatcher when a block reads `current_user`. Receives
44
+ # `request.env` (Hash) and returns the authenticated user (or nil).
45
+ # Memoized per-dispatch; left nil by default so apps that don't use
46
+ # standalone actions never get a phantom `current_user` resolver.
47
+ # @example Devise
48
+ # Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }
49
+ # @example Hand-rolled session
50
+ # Ruact.configure { |c| c.current_user_resolver = ->(env) { User.find_by(id: env['rack.session'][:user_id]) } }
51
+ #
52
+ # @!attribute [r] dev_error_payload_enabled
53
+ # @return [Boolean, nil] Story 8.4 — When true, server-action failures
54
+ # respond with a verbose JSON payload (action name, error class,
55
+ # message, split backtrace, contextual suggestion, validation errors).
56
+ # When false, the wire body carries only the four baseline fields
57
+ # (`_ruact_server_action_error`, `action_name`, `error_class`,
58
+ # `message`) so React components can render their own UI without
59
+ # accidental backtrace leakage. Default `nil` — the endpoint
60
+ # controller resolves nil to `Rails.env.development? || Rails.env.test?`,
61
+ # keeping the Configuration trivially constructible in non-Rails specs.
62
+ # @example Force production-shape errors in development
63
+ # Ruact.configure { |c| c.dev_error_payload_enabled = false }
64
+ #
65
+ # @!attribute [r] max_upload_bytes
66
+ # @return [Integer, nil] Story 8.5 — upper bound (in bytes) on the
67
+ # `Content-Length` of `multipart/form-data` and
68
+ # `application/x-www-form-urlencoded` requests dispatched through
69
+ # `POST /__ruact/fn/:name`. When the inbound `Content-Length` exceeds
70
+ # this value, the endpoint controller raises
71
+ # `Ruact::UploadTooLargeError` BEFORE Rack's multipart parser runs,
72
+ # producing a 413 with the Story 8.4 structured error body.
73
+ # Default: `10 * 1024 * 1024` (10 MB). Set to `nil` to disable the
74
+ # gem-side guard — typical when a reverse proxy (`client_max_body_size`)
75
+ # or host middleware already owns the operational cap. Chunked-transfer
76
+ # requests (no `Content-Length` header) bypass the guard regardless of
77
+ # this setting; the action body is responsible for any belt-and-suspenders
78
+ # check via `params[:file].size` / `params[:file].byte_size` in that case.
79
+ # @note This is a controller-level "fail fast at the boundary" knob, not
80
+ # a stream-safety guarantee — Rack's multipart parser will still buffer
81
+ # bodies up to its own limits before the guard rejects. For very large
82
+ # uploads route through Active Storage Direct Upload or a presigned S3
83
+ # URL; see `website/docs/api/server-actions.md` "File uploads" section.
84
+ # @example Raise the limit to 25 MB
85
+ # Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 }
86
+ # @example Disable the gem-side guard (reverse proxy owns the cap)
87
+ # Ruact.configure { |c| c.max_upload_bytes = nil }
88
+ #
89
+ # @!attribute [r] query_route_prefix
90
+ # @return [String] Story 9.4 — URL prefix under which the `ruact_queries`
91
+ # routing macro draws one named GET route per public query method
92
+ # (default `"/q"` → `GET /q/<jsIdentifier>`). Must be a String starting
93
+ # with `/` and without a trailing slash (the macro joins prefix and
94
+ # identifier with `/`). Changing the prefix is configuration, never code.
95
+ # @example Mount queries under /api/queries
96
+ # Ruact.configure { |c| c.query_route_prefix = "/api/queries" }
97
+ #
98
+ # @!attribute [r] query_parent_controller
99
+ # @return [String] Story 9.4 — class NAME of the controller the gem's
100
+ # internal query dispatch controller inherits from (default
101
+ # `"ApplicationController"` — the Devise `parent_controller` pattern).
102
+ # Kept as a String and constantized lazily at route-draw time, NOT at
103
+ # configure time: `ApplicationController` does not exist when the gem
104
+ # loads. The host's REAL callback chain (`authenticate_user!`, tenant
105
+ # scoping, Pundit) runs before any query class is instantiated (FR89).
106
+ # @example Dispatch queries through an API base controller
107
+ # Ruact.configure { |c| c.query_parent_controller = "Api::BaseController" }
108
+ ATTRIBUTES.each do |attr|
109
+ attr_reader attr
110
+
111
+ define_method("#{attr}=") do |value|
112
+ if frozen?
113
+ location = caller_locations(1, 1).first
114
+ raise Ruact::ConfigurationError, build_error_message(attr, location)
115
+ end
116
+ validate_attribute_value!(attr, value)
117
+ instance_variable_set("@#{attr}", value)
118
+ end
119
+ end
120
+
121
+ # Build a fresh Configuration. When +template+ is given, dup every public
122
+ # attribute from it so the draft is mutable — used by `Ruact.configure` for
123
+ # atomic-replacement cloning. The dup is required because the template is
124
+ # always a published (frozen) Configuration with deep-frozen attribute
125
+ # values, and AC1 requires the DSL inside the configure block to behave
126
+ # identically regardless of whether this is the first call or a later one
127
+ # (including idiomatic in-place mutation of inherited values).
128
+ #
129
+ # `dup` is safe for every supported attribute type: Strings produce an
130
+ # unfrozen copy; nil/true/false/Numerics/Symbols dup to themselves (they
131
+ # are inherently immutable, so the dup is a no-op).
132
+ #
133
+ # @param template [Ruact::Configuration, nil] optional source to clone from
134
+ def initialize(template: nil)
135
+ if template
136
+ ATTRIBUTES.each do |attr|
137
+ value = template.public_send(attr)
138
+ # Procs are immutable from the outside (Story 8.3 — current_user_resolver).
139
+ # Duping creates a different Proc instance, breaking identity comparisons
140
+ # across re-configurations. Procs are inherently re-entrant safe (no
141
+ # mutable internal state surface) so the dup is unnecessary; the freeze
142
+ # at seal! time is enough.
143
+ cloned = value.is_a?(Proc) ? value : value.dup
144
+ instance_variable_set("@#{attr}", cloned)
145
+ end
146
+ else
147
+ @manifest_path = nil
148
+ @strict_serialization = begin
149
+ Rails.env.production?
150
+ rescue StandardError
151
+ false
152
+ end
153
+ @suspense_timeout = 5.0
154
+ @vite_dev_server = "http://localhost:5173"
155
+ @current_user_resolver = nil
156
+ @dev_error_payload_enabled = nil
157
+ @max_upload_bytes = 10 * 1024 * 1024
158
+ @query_route_prefix = "/q"
159
+ @query_parent_controller = "ApplicationController"
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ # Internal — called by `Ruact.configure` and `Ruact.config` (via
166
+ # `__send__(:seal!)`) immediately before publication. Deep-freezes every
167
+ # attribute value so the shallow `Object#freeze` on the Configuration
168
+ # cannot be bypassed by in-place mutation of an attribute value (e.g.
169
+ # `Ruact.config.manifest_path.replace`) after publication. Returns self
170
+ # (frozen).
171
+ #
172
+ # Values remain mutable inside the `Ruact.configure` block (AC1: the DSL
173
+ # inside the block is unchanged; the freeze happens after the block
174
+ # returns, not before). Only on `seal!` are values deep-frozen.
175
+ #
176
+ # Defined as `private` so it does not appear on the public API surface
177
+ # of `Ruact::Configuration` (AC1/AC7/AC9: public API surface unchanged).
178
+ # External callers reaching into `Ruact.config.seal!` get a NoMethodError.
179
+ #
180
+ # @api private
181
+ # @return [Ruact::Configuration] self, frozen
182
+ def seal!
183
+ ATTRIBUTES.each do |attr|
184
+ value = public_send(attr)
185
+ next if value.nil? || value.frozen?
186
+
187
+ # Story 8.3 review — Procs CAN be frozen (`.freeze` flips the frozen
188
+ # flag; no operational effect on `.call`). Freezing in place preserves
189
+ # object identity (vital for code that compares the resolver across
190
+ # re-configurations) AND keeps the deep-freeze contract honest —
191
+ # `Ruact.config.current_user_resolver.freeze` later would otherwise
192
+ # silently no-op an already-frozen reference, but a caller probing
193
+ # `frozen?` would see the right answer.
194
+ if value.is_a?(Proc)
195
+ value.freeze
196
+ else
197
+ instance_variable_set("@#{attr}", value.dup.freeze)
198
+ end
199
+ end
200
+ freeze
201
+ end
202
+
203
+ # Story 8.5 review patch — attribute-specific writer-time validation. The
204
+ # generic writer otherwise stores any value, which then surfaces as a
205
+ # generic 500 (`Integer <= String` etc.) on the FIRST in-flight request
206
+ # instead of a configuration-time error. Limit the surface to attributes
207
+ # whose runtime contract is narrower than "any value" — currently only
208
+ # `max_upload_bytes` (must be nil or a non-negative Integer). Other
209
+ # attributes keep their pre-existing "store any value" contract.
210
+ #
211
+ # Negative integers would otherwise turn into a global 413 — every
212
+ # request with a Content-Length above zero exceeds the configured limit.
213
+ # Booleans, Strings, Floats, Symbols turn into a non-comparable type
214
+ # error at request time. Both cases land here at boot/configure time
215
+ # with a legible message pointing at the offending value.
216
+ def validate_attribute_value!(attr, value)
217
+ case attr
218
+ when :max_upload_bytes then validate_max_upload_bytes!(value)
219
+ when :query_route_prefix then validate_query_route_prefix!(value)
220
+ when :query_parent_controller then validate_query_parent_controller!(value)
27
221
  end
28
- @suspense_timeout = 5.0
29
- @vite_dev_server = "http://localhost:5173"
222
+ end
223
+
224
+ def validate_max_upload_bytes!(value)
225
+ return if value.nil?
226
+ return if value.is_a?(Integer) && value >= 0
227
+
228
+ raise Ruact::ConfigurationError,
229
+ "Ruact::Configuration#max_upload_bytes must be nil or a non-negative Integer; " \
230
+ "got #{value.inspect} (#{value.class.name}). " \
231
+ "Set to nil to disable the gem-side guard, or pass a positive Integer (bytes)."
232
+ end
233
+
234
+ # Story 9.4 — the prefix is joined with the jsIdentifier as
235
+ # `"#{prefix}/#{js}"` at route-draw time, so a missing leading slash would
236
+ # draw a relative path and a trailing slash would draw `//`. Both are
237
+ # configuration-time errors, not first-request 500s.
238
+ def validate_query_route_prefix!(value)
239
+ unless value.is_a?(String) && value.start_with?("/")
240
+ raise Ruact::ConfigurationError,
241
+ "Ruact::Configuration#query_route_prefix must be a String starting with \"/\"; " \
242
+ "got #{value.inspect} (#{value.class.name})."
243
+ end
244
+ return unless value.length > 1 && value.end_with?("/")
245
+
246
+ raise Ruact::ConfigurationError,
247
+ "Ruact::Configuration#query_route_prefix must not end with \"/\" " \
248
+ "(the ruact_queries macro joins the prefix and the query identifier with \"/\"); " \
249
+ "got #{value.inspect}."
250
+ end
251
+
252
+ # Story 9.4 — kept as a String on purpose: the name is constantized lazily
253
+ # at route-draw time (`ApplicationController` does not exist when the gem
254
+ # loads or when the initializer runs).
255
+ def validate_query_parent_controller!(value)
256
+ return if value.is_a?(String) && !value.empty?
257
+
258
+ raise Ruact::ConfigurationError,
259
+ "Ruact::Configuration#query_parent_controller must be a non-empty String " \
260
+ "(the controller class NAME, constantized lazily at route-draw time); " \
261
+ "got #{value.inspect} (#{value.class.name})."
262
+ end
263
+
264
+ def build_error_message(attr, location)
265
+ <<~MSG.strip
266
+ ruact: cannot mutate Ruact::Configuration##{attr} after initialization.
267
+ Attempted at: #{location.path}:#{location.lineno}
268
+ Wrap the change in Ruact.configure { |c| c.#{attr} = ... } in config/initializers/ruact.rb and restart the process.
269
+ Why: Ruact::Configuration is frozen after initialization (Story 7.3) so that runtime config drift cannot cause environment-dependent behavior.
270
+ MSG
30
271
  end
31
272
  end
32
273
  end