ruact 0.0.2 → 0.0.4
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 +88 -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 +1779 -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 +100 -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 +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -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 +111 -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 +598 -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 +508 -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 +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -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 +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- 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 +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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 './
|
|
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
|
|
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
|
-
#
|
|
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}/_#{
|
|
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
|
|
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
|
|
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
|
-
|
|
221
|
+
data
|
|
109
222
|
end
|
|
110
223
|
|
|
111
224
|
def by_module_id(id)
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|