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.
Files changed (131) 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 +88 -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 +1779 -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 +100 -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 +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.2 — unit spec for the pure Bucket-2 response serializer. Pins the
4
+ # prop-exposure policy mirrored from Ruact::Flight::Serializer#serialize_unknown
5
+ # (Serializable → ruact_props only; strict → raise; vetted as_json fallback),
6
+ # producing a plain JSON-ready Hash (no Flight wire encoding). Pure function —
7
+ # no Rails / request / Ruact.config reads (NFR26 / AC8).
8
+
9
+ require "spec_helper"
10
+ require "ruact/server_functions/bucket_two_payload"
11
+
12
+ # Fixtures live OUTSIDE the example group (no leaky constants in the block).
13
+ module B2Fixtures
14
+ # A Serializable model exposing only some attributes.
15
+ class Post
16
+ include Ruact::Serializable
17
+
18
+ attr_reader :id, :title, :secret
19
+
20
+ def initialize(id:, title:, secret:)
21
+ @id = id
22
+ @title = title
23
+ @secret = secret
24
+ end
25
+
26
+ ruact_props :id, :title
27
+ end
28
+
29
+ # Serializable whose prop is itself a Serializable (nested).
30
+ class AuthoredPost
31
+ include Ruact::Serializable
32
+
33
+ attr_reader :title, :author
34
+
35
+ def initialize(title:, author:)
36
+ @title = title
37
+ @author = author
38
+ end
39
+
40
+ ruact_props :title, :author
41
+ end
42
+
43
+ class Author
44
+ include Ruact::Serializable
45
+
46
+ attr_reader :name, :password_digest
47
+
48
+ def initialize(name:, password_digest:)
49
+ @name = name
50
+ @password_digest = password_digest
51
+ end
52
+
53
+ ruact_props :name
54
+ end
55
+
56
+ # A plain object with as_json (AR-like).
57
+ class PlainRecord
58
+ def as_json(_opts = nil)
59
+ { "id" => 7, "leaked" => "everything" }
60
+ end
61
+ end
62
+
63
+ class SelfReturningAsJson
64
+ def as_json(_opts = nil)
65
+ self
66
+ end
67
+ end
68
+
69
+ class RaisingAsJson
70
+ def as_json(_opts = nil)
71
+ raise "boom in as_json"
72
+ end
73
+ end
74
+ end
75
+
76
+ RSpec.describe Ruact::ServerFunctions::BucketTwoPayload, :story_9_2 do
77
+ describe ".build (AC2 — keyed by ivar name, all exposed ivars)" do
78
+ it "keys the result by the assigns names and serializes each value" do
79
+ result = described_class.build(
80
+ { "post" => B2Fixtures::Post.new(id: 1, title: "Hi", secret: "x"), "count" => 3 },
81
+ strict: true
82
+ )
83
+ expect(result).to eq("post" => { "id" => 1, "title" => "Hi" }, "count" => 3)
84
+ end
85
+
86
+ it "does NOT unwrap a single ivar — it stays keyed (no magic unwrap)" do
87
+ result = described_class.build({ "post" => B2Fixtures::Post.new(id: 1, title: "Hi", secret: "x") }, strict: true)
88
+ expect(result).to eq("post" => { "id" => 1, "title" => "Hi" })
89
+ end
90
+ end
91
+
92
+ describe "Serializable policy" do
93
+ it "exposes ONLY ruact_props, never undeclared attributes (no secret leak)" do
94
+ result = described_class.build({ "post" => B2Fixtures::Post.new(id: 1, title: "Hi", secret: "nope") },
95
+ strict: true)
96
+ expect(result.fetch("post")).to eq("id" => 1, "title" => "Hi")
97
+ expect(result.fetch("post")).not_to have_key("secret")
98
+ end
99
+
100
+ it "recurses into a Serializable-valued prop (nested), applying ruact_props at each level" do
101
+ author = B2Fixtures::Author.new(name: "Ada", password_digest: "HASH")
102
+ post = B2Fixtures::AuthoredPost.new(title: "T", author: author)
103
+ result = described_class.build({ "post" => post }, strict: true)
104
+ expect(result.fetch("post")).to eq("title" => "T", "author" => { "name" => "Ada" })
105
+ end
106
+
107
+ it "serializes an Array of Serializables element-wise" do
108
+ posts = [B2Fixtures::Post.new(id: 1, title: "A", secret: "s"),
109
+ B2Fixtures::Post.new(id: 2, title: "B", secret: "s")]
110
+ result = described_class.build({ "posts" => posts }, strict: true)
111
+ expect(result.fetch("posts")).to eq([{ "id" => 1, "title" => "A" }, { "id" => 2, "title" => "B" }])
112
+ end
113
+ end
114
+
115
+ describe "primitive pass-through (NOT subject to strict policy)" do
116
+ it "passes scalars through untouched even under strict" do
117
+ result = described_class.build(
118
+ { "i" => 5, "f" => 1.5, "s" => "x", "t" => true, "n" => nil, "sym" => :ok },
119
+ strict: true
120
+ )
121
+ expect(result).to eq("i" => 5, "f" => 1.5, "s" => "x", "t" => true, "n" => nil, "sym" => :ok)
122
+ end
123
+
124
+ it "passes Time through untouched (Rails render json: handles ISO formatting)" do
125
+ time = Time.utc(2026, 1, 2, 3, 4, 5)
126
+ result = described_class.build({ "at" => time }, strict: true)
127
+ expect(result.fetch("at")).to equal(time)
128
+ end
129
+
130
+ it "stringifies Hash keys and recurses values" do
131
+ assigns = { "meta" => { a: 1, b: B2Fixtures::Post.new(id: 9, title: "N", secret: "s") } }
132
+ result = described_class.build(assigns, strict: true)
133
+ expect(result.fetch("meta")).to eq("a" => 1, "b" => { "id" => 9, "title" => "N" })
134
+ end
135
+ end
136
+
137
+ describe "strict_serialization policy (AC5)" do
138
+ it "raises Ruact::SerializationError for a non-Serializable object under strict" do
139
+ expect { described_class.build({ "rec" => B2Fixtures::PlainRecord.new }, strict: true) }
140
+ .to raise_error(Ruact::SerializationError, /Cannot serialize B2Fixtures::PlainRecord/)
141
+ end
142
+
143
+ it "falls back to as_json when strict is false" do
144
+ result = described_class.build({ "rec" => B2Fixtures::PlainRecord.new }, strict: false)
145
+ expect(result.fetch("rec")).to eq("id" => 7, "leaked" => "everything")
146
+ end
147
+
148
+ it "raises when as_json returns self (infinite-recursion guard), regardless of strict" do
149
+ expect { described_class.build({ "x" => B2Fixtures::SelfReturningAsJson.new }, strict: false) }
150
+ .to raise_error(Ruact::SerializationError, /as_json returned self/)
151
+ end
152
+
153
+ it "wraps an exception raised inside as_json as Ruact::SerializationError" do
154
+ expect { described_class.build({ "x" => B2Fixtures::RaisingAsJson.new }, strict: false) }
155
+ .to raise_error(Ruact::SerializationError, /as_json raised RuntimeError: boom in as_json/)
156
+ end
157
+ end
158
+
159
+ describe "Story 9.4 — .serialize_value (single value, same policy as .build — D6)", :story_9_4 do
160
+ it "serializes a Serializable through ruact_props only" do
161
+ post = B2Fixtures::Post.new(id: 1, title: "Hi", secret: "nope")
162
+ expect(described_class.serialize_value(post, strict: true)).to eq("id" => 1, "title" => "Hi")
163
+ end
164
+
165
+ it "recurses into Arrays of Serializables" do
166
+ posts = [B2Fixtures::Post.new(id: 1, title: "A", secret: "x")]
167
+ expect(described_class.serialize_value(posts, strict: true)).to eq([{ "id" => 1, "title" => "A" }])
168
+ end
169
+
170
+ it "recurses into Hashes, stringifying keys" do
171
+ value = { total: 2, post: B2Fixtures::Post.new(id: 1, title: "A", secret: "x") }
172
+ expect(described_class.serialize_value(value, strict: true))
173
+ .to eq("total" => 2, "post" => { "id" => 1, "title" => "A" })
174
+ end
175
+
176
+ it "passes primitives through untouched" do
177
+ expect(described_class.serialize_value(42, strict: true)).to eq(42)
178
+ end
179
+
180
+ it "passes nil through (the dispatch controller renders it as JSON null — D6)" do
181
+ expect(described_class.serialize_value(nil, strict: true)).to be_nil
182
+ end
183
+
184
+ it "raises Ruact::SerializationError for a non-Serializable under strict" do
185
+ expect { described_class.serialize_value(B2Fixtures::PlainRecord.new, strict: true) }
186
+ .to raise_error(Ruact::SerializationError, /Cannot serialize B2Fixtures::PlainRecord/)
187
+ end
188
+
189
+ it "falls back to as_json when strict is false" do
190
+ expect(described_class.serialize_value(B2Fixtures::PlainRecord.new, strict: false))
191
+ .to eq("id" => 7, "leaked" => "everything")
192
+ end
193
+
194
+ it "is the same policy .build applies per ivar (extraction, not a fork)" do
195
+ post = B2Fixtures::Post.new(id: 3, title: "Same", secret: "x")
196
+ expect(described_class.build({ "post" => post }, strict: true))
197
+ .to eq("post" => described_class.serialize_value(post, strict: true))
198
+ end
199
+ end
200
+ end