ruact 0.0.5 → 0.0.7

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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +74 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "net/http"
5
+ require "tmpdir"
6
+
7
+ module Ruact
8
+ RSpec.describe ManifestResolver do
9
+ # spec_helper pins resolve/resolve_soft to the boot manifest suite-wide for
10
+ # hermeticity; this spec is the one place that exercises the REAL paths.
11
+ before do
12
+ allow(described_class).to receive(:resolve).and_call_original
13
+ allow(described_class).to receive(:resolve_soft).and_call_original
14
+ end
15
+
16
+ let(:manifest_hash) do
17
+ {
18
+ "LikeButton" => {
19
+ "id" => "/LikeButton.jsx",
20
+ "name" => "LikeButton",
21
+ "chunks" => ["/LikeButton.jsx"]
22
+ }
23
+ }
24
+ end
25
+ let(:manifest_json) { JSON.generate(manifest_hash) }
26
+
27
+ # A real Net::HTTPSuccess (HTTPOK < HTTPSuccess) whose body we override, so
28
+ # the resolver's `response.is_a?(Net::HTTPSuccess)` branch is exercised for
29
+ # real without touching the network.
30
+ def http_ok(body)
31
+ response = Net::HTTPOK.new("1.1", "200", "OK")
32
+ response.define_singleton_method(:body) { body }
33
+ response
34
+ end
35
+
36
+ def in_dev
37
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
38
+ end
39
+
40
+ describe ".resolve in development (the boot-race)" do
41
+ before do
42
+ in_dev
43
+ # Simulate the race: boot read the missing file, so the cached manifest is nil.
44
+ allow(Ruact).to receive(:manifest).and_return(nil)
45
+ end
46
+
47
+ it "resolves from the Vite dev server over HTTP (no 500, ref resolvable)" do
48
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
49
+
50
+ manifest = described_class.resolve
51
+
52
+ expect(manifest).to be_a(ClientManifest)
53
+ ref = manifest.reference_for("LikeButton")
54
+ expect(ref.module_id).to eq("/LikeButton.jsx")
55
+ end
56
+
57
+ it "fetches the manifest from the configured vite_dev_server URL" do
58
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
59
+
60
+ described_class.resolve
61
+
62
+ expect(Net::HTTP).to have_received(:start).with(
63
+ "localhost", 5173, hash_including(:open_timeout, :read_timeout)
64
+ )
65
+ end
66
+
67
+ it "does not double-slash the path when vite_dev_server has a trailing slash" do
68
+ allow(described_class).to receive(:base_url).and_return("http://localhost:5173/")
69
+ http = instance_double(Net::HTTP)
70
+ allow(http).to receive(:get).and_return(http_ok(manifest_json))
71
+ allow(Net::HTTP).to receive(:start).and_yield(http).and_return(http_ok(manifest_json))
72
+
73
+ described_class.resolve
74
+
75
+ expect(http).to have_received(:get).with("/__ruact/manifest")
76
+ end
77
+
78
+ it "speaks TLS when vite_dev_server is https" do
79
+ allow(described_class).to receive(:base_url).and_return("https://localhost:5173")
80
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
81
+
82
+ described_class.resolve
83
+
84
+ expect(Net::HTTP).to have_received(:start).with(
85
+ "localhost", 5173, hash_including(use_ssl: true)
86
+ )
87
+ end
88
+
89
+ it "falls back to the on-disk file when the dev server is unreachable" do
90
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
91
+
92
+ Dir.mktmpdir do |dir|
93
+ path = File.join(dir, "react-client-manifest.json")
94
+ File.write(path, manifest_json)
95
+ allow(described_class).to receive(:file_path).and_return(path)
96
+
97
+ manifest = described_class.resolve
98
+ expect(manifest).to be_a(ClientManifest)
99
+ expect(manifest.reference_for("LikeButton").module_id).to eq("/LikeButton.jsx")
100
+ end
101
+ end
102
+
103
+ it "raises a clear, actionable error (not NoMethodError) when neither HTTP nor file is available" do
104
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
105
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
106
+
107
+ expect { described_class.resolve }.to raise_error(ManifestError, %r{Vite dev server.*bin/dev}m)
108
+ end
109
+
110
+ it "ignores a non-2xx dev-server response and falls back" do
111
+ allow(Net::HTTP).to receive(:start).and_return(Net::HTTPNotFound.new("1.1", "404", "Not Found"))
112
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
113
+
114
+ expect { described_class.resolve }.to raise_error(ManifestError)
115
+ end
116
+ end
117
+
118
+ describe ".resolve_soft in development (fail-open for FR100)" do
119
+ before do
120
+ in_dev
121
+ allow(Ruact).to receive(:manifest).and_return(nil)
122
+ end
123
+
124
+ it "returns nil (no raise) when nothing is resolvable" do
125
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
126
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
127
+
128
+ expect(described_class.resolve_soft).to be_nil
129
+ end
130
+
131
+ it "returns the dev manifest when the server is up" do
132
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
133
+ expect(described_class.resolve_soft).to be_a(ClientManifest)
134
+ end
135
+ end
136
+
137
+ describe "production / non-development (untouched)" do
138
+ it "returns Ruact.manifest verbatim without any HTTP call" do
139
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
140
+ allow(Net::HTTP).to receive(:start)
141
+ boot_manifest = ClientManifest.from_hash(manifest_hash)
142
+ allow(Ruact).to receive(:manifest).and_return(boot_manifest)
143
+
144
+ expect(described_class.resolve).to be(boot_manifest)
145
+ expect(Net::HTTP).not_to have_received(:start)
146
+ end
147
+
148
+ it "returns Ruact.manifest in the test environment too (no dev fetch)" do
149
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("test"))
150
+ allow(Net::HTTP).to receive(:start)
151
+ boot_manifest = ClientManifest.from_hash(manifest_hash)
152
+ allow(Ruact).to receive(:manifest).and_return(boot_manifest)
153
+
154
+ expect(described_class.resolve).to be(boot_manifest)
155
+ expect(Net::HTTP).not_to have_received(:start)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -22,6 +22,10 @@ require "action_view/railtie"
22
22
 
23
23
  require "spec_helper"
24
24
  require "rack/test"
25
+ require "global_id"
26
+ require "active_support/core_ext/integer/time"
27
+ require "active_support/core_ext/numeric/time"
28
+ require "active_support/message_verifier"
25
29
 
26
30
  require "ruact/controller"
27
31
  require "ruact/server"
@@ -183,6 +187,41 @@ module QueryRequestSpecSupport # rubocop:disable Style/OneClassPerFile
183
187
  { "opt" => opt }
184
188
  end
185
189
  end
190
+
191
+ # Story 13.2 (FR96) — a GlobalID-locatable record used to exercise the signed
192
+ # reference round-trip through the real host request cycle.
193
+ class RefPost
194
+ include GlobalID::Identification
195
+
196
+ attr_reader :id
197
+
198
+ def initialize(id)
199
+ @id = id.to_s
200
+ self.class.store[@id] = self
201
+ end
202
+
203
+ def self.store
204
+ @store ||= {}
205
+ end
206
+
207
+ def self.find(id)
208
+ store.fetch(id.to_s)
209
+ end
210
+ end
211
+
212
+ # Story 13.2 (FR96) — a public query (skips auth) that resolves a signed
213
+ # reference handed back from the client. A tampered/expired/wrong-purpose
214
+ # token raises Ruact::InvalidSignedGlobalIDError, which the Story 8.4
215
+ # structured-error chain renders as a clean 400 (no ActiveRecord::RecordNotFound
216
+ # leak, no raw-id trust).
217
+ class SignedRefQuery < ApplicationQuery
218
+ ruact_skip_before_action :require_login
219
+
220
+ def resolve_ref(token:)
221
+ record = Ruact.locate_signed(token, for: :spec_ref)
222
+ { "id" => record.id }
223
+ end
224
+ end
186
225
  end
187
226
 
188
227
  if defined?(ControllerRequestSpecSupport) &&
@@ -191,7 +230,8 @@ if defined?(ControllerRequestSpecSupport) &&
191
230
  ControllerRequestSpecSupport.app_class.routes.append do
192
231
  ruact_queries QueryRequestSpecSupport::CatalogQuery,
193
232
  QueryRequestSpecSupport::PublicCatalogQuery,
194
- QueryRequestSpecSupport::SearchQuery
233
+ QueryRequestSpecSupport::SearchQuery,
234
+ QueryRequestSpecSupport::SignedRefQuery
195
235
  end
196
236
  end
197
237
 
@@ -596,3 +636,71 @@ RSpec.describe "Story 9.5: FR88 query kwargs sanitization", :story_9_5 do
596
636
  end
597
637
  end
598
638
  end
639
+
640
+ # Story 13.2 (FR96) — SignedGlobalID references resolved through the REAL host
641
+ # request cycle: a valid token round-trips; a tampered token surfaces as a clean
642
+ # 400 via the Story 8.4 structured-error chain (no ActiveRecord::RecordNotFound
643
+ # leak, no raw-id trust).
644
+ RSpec.describe "Story 13.2: SignedGlobalID record references (FR96)", :story_13_2 do
645
+ include Rack::Test::Methods
646
+
647
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
648
+ let(:app) { app_class.instance }
649
+
650
+ before do
651
+ Rails.logger = Logger.new(IO::NULL)
652
+ ControllerRequestSpecSupport.boot!
653
+ # globalid signing context for the booted app (the 7.9 app sets
654
+ # secret_key_base but does not load the globalid railtie).
655
+ GlobalID.app = "ruact-test"
656
+ SignedGlobalID.verifier = ActiveSupport::MessageVerifier.new("a" * 64)
657
+ end
658
+
659
+ def sign(record, purpose: :spec_ref, expires_in: 1.hour)
660
+ Ruact.signed_global_id(record, for: purpose, expires_in: expires_in)
661
+ end
662
+
663
+ it "round-trips a valid token through the host chain (no auth needed — public query)" do
664
+ record = QueryRequestSpecSupport::RefPost.new("42")
665
+ get "/q/resolveRef", { "token" => sign(record) }
666
+ expect(last_response.status).to(eq(200), "body=#{last_response.body[0, 300]}")
667
+ expect(JSON.parse(last_response.body)).to eq("id" => "42")
668
+ end
669
+
670
+ it "rejects a tampered token as a clean 400 structured error (no RecordNotFound leak)" do
671
+ record = QueryRequestSpecSupport::RefPost.new("99")
672
+ get "/q/resolveRef", { "token" => "#{sign(record)}tamper" }
673
+
674
+ expect(last_response.status).to eq(400)
675
+ body = JSON.parse(last_response.body)
676
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
677
+ expect(body.fetch("action_name")).to eq("resolve_ref")
678
+ expect(body.fetch("error_class")).to eq("Ruact::InvalidSignedGlobalIDError")
679
+ expect(last_response.body).not_to include("RecordNotFound")
680
+ end
681
+
682
+ it "rejects a wrong-purpose token as a clean 400" do
683
+ record = QueryRequestSpecSupport::RefPost.new("7")
684
+ get "/q/resolveRef", { "token" => sign(record, purpose: :other_purpose) }
685
+ expect(last_response.status).to eq(400)
686
+ expect(JSON.parse(last_response.body).fetch("error_class"))
687
+ .to eq("Ruact::InvalidSignedGlobalIDError")
688
+ end
689
+
690
+ it "does not leak dev-only error fields in production payload mode" do
691
+ record = QueryRequestSpecSupport::RefPost.new("5")
692
+ Ruact.configure { |c| c.dev_error_payload_enabled = false }
693
+ get "/q/resolveRef", { "token" => "#{sign(record)}tamper" }
694
+ expect(last_response.status).to eq(400)
695
+ body = JSON.parse(last_response.body)
696
+ # production payload is exactly the four baseline keys — no split backtrace,
697
+ # suggestion, or validation/upload blocks (the real dev-only field names).
698
+ expect(body.keys).to contain_exactly(
699
+ "_ruact_server_action_error", "action_name", "error_class", "message"
700
+ )
701
+ expect(body.keys).not_to include("app_frames", "gem_frames", "suggestion")
702
+ ensure
703
+ Ruact.instance_variable_set(:@config, nil)
704
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
705
+ end
706
+ end