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
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "ruact/server_functions/name_bridge"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ # Story 9.3 — derives v2 server-function entries from the Rails route table.
9
+ #
10
+ # The route table is the single source of truth (FR61): every non-GET routed
11
+ # action on a controller that includes {Ruact::Server} is a callable server
12
+ # function. This module is the route-driven replacement for the v1 registry
13
+ # source consumed by {Ruact::ServerFunctions::Snapshot} — it reads routes,
14
+ # not `ruact_action` declarations.
15
+ #
16
+ # Pure by construction: {.collect} takes the route set and two resolver
17
+ # callables (host predicate + override lookup). The railtie passes the real
18
+ # constant-resolving implementations; unit specs inject lambdas so the
19
+ # derivation table is testable without booting controllers.
20
+ #
21
+ # ## Derivation table (locked, ADR addendum 2026-06-09)
22
+ #
23
+ # `js_identifier = lowerCamel(action) + Namespace*(Pascal) + Resource(Pascal)`
24
+ #
25
+ # - **Resource word** — singular for the RESTful writes (`create`/`update`/
26
+ # `destroy`) and for any member route (path carries `:id`); plural for a
27
+ # custom collection route. Examples: `posts#create` → `createPost`,
28
+ # `posts#publish` (member) → `publishPost`, `posts#publish_all`
29
+ # (collection) → `publishAllPosts`, `resource :session` `#create` →
30
+ # `createSession`.
31
+ # - **Namespace** — PascalCased and inserted between verb and resource
32
+ # (prefix, NOT flat): `admin/posts#create` → `createAdminPost`,
33
+ # `admin/reports/posts#create` → `createAdminReportsPost`. Prefixing keeps
34
+ # the merged JS namespace collision-free by construction (a flat scheme
35
+ # would force `admin/posts#create` and `posts#create` to collide).
36
+ # - **PATCH/PUT** — `resources` emits both verbs for `update`; they collapse
37
+ # to one entry with `http_method: "PATCH"` (Rails' primary verb).
38
+ #
39
+ # @see docs/internal/decisions/server-functions-api.md "Story 9.3"
40
+ module RouteSource
41
+ # Verbs that expose a callable server function. GET/HEAD are pages.
42
+ MUTATION_VERBS = %w[POST PUT PATCH DELETE].freeze
43
+
44
+ # The RESTful writes whose JS name uses the SINGULAR resource even though
45
+ # `create` is technically a collection route.
46
+ RESTFUL_WRITES = %w[create update destroy].freeze
47
+
48
+ # When the same controller#action is routed under several verbs (the
49
+ # `update` PATCH/PUT pair), keep the first by this priority.
50
+ VERB_PRIORITY = { "PATCH" => 0, "PUT" => 1, "POST" => 2, "DELETE" => 3 }.freeze
51
+
52
+ class << self
53
+ # Collects v2 mutation entries from +route_set+.
54
+ #
55
+ # @param route_set [#routes] anything exposing `#routes` (a
56
+ # `ActionDispatch::Routing::RouteSet`, or `Rails.application.routes`).
57
+ # @param host_predicate [#call] `controller_path(String) -> Boolean` —
58
+ # true when that controller includes {Ruact::Server}. Defaults to real
59
+ # constant resolution.
60
+ # @param overrides_for [#call] `controller_path(String) -> Hash{String=>String}`
61
+ # — the `ruact_function_name` override map (action name → js identifier)
62
+ # for that controller. Defaults to real constant resolution.
63
+ # @return [Array<Hash>] entries (string keys) sorted by `js_identifier`;
64
+ # shape: `js_identifier`, `kind` (always `"action"`), `http_method`,
65
+ # `path`, `segments` (Array<String>), `controller`, `action`.
66
+ def collect(route_set, host_predicate: nil, overrides_for: nil)
67
+ host_predicate ||= method(:default_host?)
68
+ overrides_for ||= method(:default_overrides_for)
69
+
70
+ by_key = {}
71
+ route_set.routes.each do |route|
72
+ verb = route.verb.to_s
73
+ next unless MUTATION_VERBS.include?(verb)
74
+
75
+ controller = route.defaults[:controller]
76
+ action = route.defaults[:action]
77
+ next if controller.nil? || action.nil?
78
+ next unless host_predicate.call(controller)
79
+
80
+ key = [controller, action]
81
+ existing = by_key[key]
82
+ # PATCH/PUT collapse: keep the higher-priority verb only.
83
+ next if existing && verb_rank(verb) >= verb_rank(existing["http_method"])
84
+
85
+ by_key[key] = build_entry(route, verb, controller, action, overrides_for)
86
+ end
87
+
88
+ entries = by_key.values.sort_by { |entry| entry["js_identifier"] }
89
+ detect_collisions!(entries)
90
+ entries
91
+ end
92
+
93
+ private
94
+
95
+ # Story 9.3 AC4 — two distinct routes that map to the same JS identifier
96
+ # (after rename overrides) would emit two `export const` lines the same
97
+ # name; fail loudly at boot naming BOTH origins so the dev knows exactly
98
+ # which routes to disambiguate (via `ruact_function_name`). Mirrors the
99
+ # cross-registry collision raise in {Ruact::ServerFunctions::Snapshot}.
100
+ def detect_collisions!(entries)
101
+ entries.group_by { |entry| entry["js_identifier"] }.each do |js_id, group|
102
+ next if group.size < 2
103
+
104
+ origins = group.map { |entry| "#{entry['controller']}##{entry['action']}" }
105
+ raise Ruact::ConfigurationError,
106
+ "server-function naming collision: #{origins.join(' and ')} " \
107
+ "both map to JS identifier \"#{js_id}\" — disambiguate with " \
108
+ "`ruact_function_name :<action>, as: \"<other-name>\"`"
109
+ end
110
+ end
111
+
112
+ def build_entry(route, verb, controller, action, overrides_for)
113
+ override = overrides_for.call(controller)[action.to_s]
114
+ {
115
+ "js_identifier" => override || derive_identifier(controller, action, route),
116
+ "kind" => "action",
117
+ "http_method" => verb,
118
+ "path" => clean_path(route),
119
+ "segments" => route.required_parts.map(&:to_s),
120
+ "controller" => controller,
121
+ "action" => action
122
+ }
123
+ end
124
+
125
+ # `lowerCamel(action) + Namespace*(Pascal) + Resource(Pascal)`.
126
+ def derive_identifier(controller, action, route)
127
+ segments = controller.split("/")
128
+ resource_base = segments.last
129
+ namespace = segments[0..-2]
130
+
131
+ singular = RESTFUL_WRITES.include?(action) || member_route?(route, resource_base)
132
+ resource_word = singular ? resource_base.singularize : resource_base
133
+
134
+ lower_camel(action) +
135
+ namespace.map { |part| pascal(part) }.join +
136
+ pascal(resource_word)
137
+ end
138
+
139
+ # A route is a MEMBER route when its path carries a dynamic segment for
140
+ # THIS resource — i.e. the resource's own basename is immediately
141
+ # followed by a `:param` in the path (`/posts/:id/publish`,
142
+ # `/posts/:slug` under `param: :slug`). This is more robust than checking
143
+ # `required_parts.include?(:id)`: it honors custom `param:` names AND
144
+ # correctly classifies a NESTED collection route (`/posts/:post_id/
145
+ # comments/flag_all` — whose only dynamic part is the PARENT `:post_id`)
146
+ # as a collection, not a member.
147
+ def member_route?(route, resource_base)
148
+ route.path.spec.to_s.match?(%r{/#{Regexp.escape(resource_base)}/:[^/(]+})
149
+ end
150
+
151
+ # snake_case → lowerCamel, leading underscore preserved (mirrors
152
+ # {NameBridge}'s rule for the action portion). Not run through NameBridge
153
+ # directly: NameBridge validates the WHOLE symbol against reserved words,
154
+ # but here the action is only a fragment of the final identifier.
155
+ def lower_camel(str)
156
+ str = str.to_s
157
+ leading = str.start_with?("_") ? "_" : ""
158
+ body = str.sub(/\A_+/, "")
159
+ leading + body.gsub(/_+([a-z0-9])/) { Regexp.last_match(1).upcase }
160
+ end
161
+
162
+ # snake_case → PascalCase.
163
+ def pascal(str)
164
+ camel = lower_camel(str).sub(/\A_+/, "")
165
+ camel.empty? ? camel : camel[0].upcase + camel[1..]
166
+ end
167
+
168
+ # `/posts/:id(.:format)` → `/posts/:id`. Drops the trailing format
169
+ # optional; defensively strips any remaining optional `( … )` group.
170
+ def clean_path(route)
171
+ spec = route.path.spec.to_s
172
+ spec = spec.delete_suffix("(.:format)")
173
+ spec.gsub(/\([^)]*\)/, "")
174
+ end
175
+
176
+ def verb_rank(verb)
177
+ VERB_PRIORITY.fetch(verb, 99)
178
+ end
179
+
180
+ # Real resolvers — used in the railtie/rake paths.
181
+ def default_host?(controller)
182
+ klass = host_class(controller)
183
+ !klass.nil? && klass.include?(Ruact::Server)
184
+ end
185
+
186
+ def default_overrides_for(controller)
187
+ klass = host_class(controller)
188
+ return {} unless klass.respond_to?(:__ruact_function_name_overrides)
189
+
190
+ klass.__ruact_function_name_overrides
191
+ end
192
+
193
+ def host_class(controller)
194
+ "#{controller}_controller".camelize.safe_constantize
195
+ rescue StandardError
196
+ nil
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ # Pure functions that build the JSON-shaped Hash representing both server-
9
+ # function registries. Serialized to `tmp/cache/ruact/server-functions.json`
10
+ # by {.generate!}; the Vite plugin reads that file and emits the TS module.
11
+ #
12
+ # The "functions" array is sorted by `ruby_symbol` for deterministic output
13
+ # so that fingerprint comparisons (used by the write-if-changed guard) are
14
+ # stable across runs. Cross-registry JS-identifier collisions are detected
15
+ # here (the per-registry `Registry#register` only sees its own entries; a
16
+ # `ruact_action :foo` colliding with a `ruact_query :foo` is invisible to
17
+ # both registries in isolation).
18
+ module Snapshot
19
+ # Bump only when the on-disk schema changes incompatibly. The Vite plugin
20
+ # must be updated in lockstep.
21
+ VERSION = 1
22
+
23
+ # Story 9.3 — the route-driven snapshot schema. v2 entries are produced by
24
+ # {Ruact::ServerFunctions::RouteSource} (route table), not the registries.
25
+ VERSION_V2 = 2
26
+
27
+ class << self
28
+ # Builds the snapshot Hash for both registries. Pure. See also
29
+ # {.generate!} (writes to disk) and {.functions_payload} (fingerprint
30
+ # surface).
31
+ #
32
+ # @param action_registry [Ruact::ServerFunctions::Registry]
33
+ # @param query_registry [Ruact::ServerFunctions::Registry]
34
+ # @param now [Time] timestamp to stamp into `generated_at` (UTC, ISO-8601).
35
+ # @return [Hash] the serializable snapshot.
36
+ # @raise [Ruact::ConfigurationError] when a JS identifier is registered
37
+ # in both registries (cross-registry collision; see {.functions_payload}).
38
+ def dump(action_registry, query_registry, now: Time.now.utc)
39
+ {
40
+ version: VERSION,
41
+ generated_at: now.utc.iso8601,
42
+ functions: functions_payload(action_registry, query_registry)
43
+ }
44
+ end
45
+
46
+ # Returns the payload-only array of function entries, sorted by
47
+ # `ruby_symbol`. Used both inside {.dump} and as the fingerprint surface
48
+ # by {.generate!}'s short-circuit (so timestamp churn alone never causes
49
+ # a rewrite). Detects cross-registry JS-identifier collisions and
50
+ # raises before emitting — a `ruact_action :foo` and `ruact_query :foo`
51
+ # would emit two `export const foo` lines at codegen, which `tsc` rejects.
52
+ #
53
+ # @return [Array<Hash>] each entry has string keys per the JSON contract.
54
+ # @raise [Ruact::ConfigurationError] when the action and query registries
55
+ # both contain entries that map to the same JS identifier.
56
+ def functions_payload(action_registry, query_registry)
57
+ combined = action_registry.entries.values + query_registry.entries.values
58
+ detect_cross_registry_collision!(combined)
59
+ combined.sort_by { |entry| entry.ruby_symbol.to_s }.map do |entry|
60
+ {
61
+ "ruby_symbol" => entry.ruby_symbol.to_s,
62
+ "js_identifier" => entry.js_identifier,
63
+ "kind" => entry.kind.to_s,
64
+ "controller" => describe_controller(entry.controller)
65
+ }
66
+ end
67
+ end
68
+
69
+ # Builds the snapshot and writes it to +path+, but only if the
70
+ # functions list differs from the on-disk snapshot. This is the central
71
+ # short-circuit that prevents `config.to_prepare` from rewriting the
72
+ # file on every request (Story 8.0a pitfall #1): the JSON's
73
+ # `generated_at` is freshly stamped only when the registry actually
74
+ # changed; otherwise the on-disk content stays byte-identical.
75
+ #
76
+ # The short-circuit compares **both** `version` and `functions` against
77
+ # the on-disk snapshot — a schema bump (`VERSION` increment) forces a
78
+ # rewrite even when the registry payload is unchanged, so the Vite
79
+ # plugin never reads a stale-version snapshot after a gem upgrade.
80
+ #
81
+ # @param action_registry [Ruact::ServerFunctions::Registry]
82
+ # @param query_registry [Ruact::ServerFunctions::Registry]
83
+ # @param path [String, Pathname] absolute path to the snapshot JSON.
84
+ # @param now [Time] timestamp used when (and only when) the file is rewritten.
85
+ # @return [Boolean] true if the file was written; false if no change.
86
+ def generate!(action_registry:, query_registry:, path:, now: Time.now.utc)
87
+ new_functions = functions_payload(action_registry, query_registry)
88
+
89
+ existing_version, existing_functions = read_existing_snapshot(path)
90
+ return false if existing_version == VERSION && existing_functions == new_functions
91
+
92
+ snapshot = {
93
+ version: VERSION,
94
+ generated_at: now.utc.iso8601,
95
+ functions: new_functions
96
+ }
97
+ SnapshotWriter.write_if_changed!(path: path, content: "#{JSON.pretty_generate(snapshot)}\n")
98
+ end
99
+
100
+ # Story 9.3 — wraps route-derived +entries+ into a version-2 snapshot
101
+ # Hash (the shape {Codegen.render} dispatches on). Pure.
102
+ #
103
+ # @param entries [Array<Hash>] from {RouteSource.collect}.
104
+ # @return [Hash]
105
+ def dump_v2(entries, now: Time.now.utc)
106
+ {
107
+ version: VERSION_V2,
108
+ generated_at: now.utc.iso8601,
109
+ functions: entries
110
+ }
111
+ end
112
+
113
+ # Story 9.3 — write-if-changed for the route-driven (v2) bridge. Mirrors
114
+ # {.generate!}: `generated_at` is freshly stamped only when the entries
115
+ # changed, so a stable route table never churns the file (and never
116
+ # re-triggers downstream TS rendering). A schema mismatch (`version`)
117
+ # forces a rewrite even when entries are unchanged.
118
+ #
119
+ # @param entries [Array<Hash>] from {RouteSource.collect}.
120
+ # @param path [String, Pathname] absolute path to the v2 bridge JSON.
121
+ # @return [Boolean] true if written, false if unchanged.
122
+ def generate_v2!(entries:, path:, now: Time.now.utc)
123
+ existing_version, existing_functions = read_existing_snapshot(path)
124
+ return false if existing_version == VERSION_V2 && existing_functions == entries
125
+
126
+ snapshot = { version: VERSION_V2, generated_at: now.utc.iso8601, functions: entries }
127
+ SnapshotWriter.write_if_changed!(path: path, content: "#{JSON.pretty_generate(snapshot)}\n")
128
+ end
129
+
130
+ # Story 9.3 — the Decision-#6 ownership primitive. True when the app has
131
+ # ANY v1 declaration (`ruact_action` / `ruact_query`). Story 9.8 consults
132
+ # this to decide whether route-driven codegen takes over the real
133
+ # `server-functions.ts`; in Story 9.3 the v2 codegen always writes the
134
+ # parallel `.next` target regardless, so this is informational here.
135
+ #
136
+ # @return [Boolean]
137
+ def v1_declarations?(action_registry, query_registry)
138
+ !(action_registry.entries.empty? && query_registry.entries.empty?)
139
+ end
140
+
141
+ private
142
+
143
+ def detect_cross_registry_collision!(entries)
144
+ by_js_id = entries.group_by(&:js_identifier).select { |_, group| group.size >= 2 }
145
+ return if by_js_id.empty?
146
+
147
+ # If both rows are the same Ruby symbol it is a within-registry duplicate
148
+ # (caught by Registry#register's own collision detection). A genuine
149
+ # cross-registry collision is any group whose entries span more than
150
+ # one kind — i.e. one action + one query share the same js_identifier.
151
+ cross = by_js_id.find do |_, group|
152
+ kinds = group.map(&:kind).uniq
153
+ kinds.size > 1
154
+ end
155
+ return unless cross
156
+
157
+ js_id, group = cross
158
+ # AC7 exact shape: `:foo_bar (in FooController) and :foo__bar (in BarController)`
159
+ # — no `kind:` annotation. The kind differentiation is implicit in
160
+ # the cross-registry collision being detected at all.
161
+ parts = group.map do |entry|
162
+ ":#{entry.ruby_symbol} (in #{describe_controller(entry.controller)})"
163
+ end
164
+ # AC7 shape: "[ruact] error: server-function naming collision: ..."
165
+ # The rake task wraps the bare message with "[ruact] error: " — keep
166
+ # the prefix in sync with the within-registry message in
167
+ # Registry#detect_collision! so the rake stdout reads identically
168
+ # for both kinds of collision.
169
+ raise Ruact::ConfigurationError,
170
+ "server-function naming collision: " \
171
+ "#{parts.join(' and ')} both map to JS identifier \"#{js_id}\""
172
+ end
173
+
174
+ def describe_controller(controller)
175
+ return nil if controller.nil?
176
+
177
+ controller.respond_to?(:name) && controller.name ? controller.name : controller.inspect
178
+ end
179
+
180
+ # Reads `(version, functions)` from the on-disk snapshot. Returns
181
+ # `[nil, nil]` when the file is missing, vanished between the stat and
182
+ # the read (TOCTOU race fix — `File.exist?` removed; we catch `ENOENT`
183
+ # from `File.read` directly), or malformed.
184
+ def read_existing_snapshot(path)
185
+ parsed = JSON.parse(File.read(path))
186
+ return [nil, nil] unless parsed.is_a?(Hash)
187
+
188
+ [parsed["version"], parsed["functions"]]
189
+ rescue Errno::ENOENT, JSON::ParserError
190
+ [nil, nil]
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ # Atomic, byte-aware file writer used by both the JSON snapshot bridge and
9
+ # the Ruby-side TypeScript codegen. Two responsibilities:
10
+ #
11
+ # 1. **Write-if-changed**: compare the SHA-256 of the desired bytes with the
12
+ # on-disk bytes; if equal, no-op. This is the leg of the dev-reload
13
+ # pitfall mitigation (Story 8.0a pitfall #1) — paired with the
14
+ # payload-only fingerprint inside `Snapshot.generate!`, it guarantees
15
+ # that a request without registry changes produces zero writes.
16
+ # 2. **Atomic publication**: write to a tmpfile in the same directory, then
17
+ # rename. Readers (the Vite-plugin chokidar watcher) never observe a
18
+ # half-written file.
19
+ #
20
+ # Parent directories are created as needed.
21
+ module SnapshotWriter
22
+ class << self
23
+ # @param path [String, Pathname] absolute destination path.
24
+ # @param content [String] bytes to write.
25
+ # @return [Boolean] true if the file was written; false if unchanged.
26
+ # @raise [Ruact::ConfigurationError] when the parent directory cannot be
27
+ # created (typically a read-only filesystem mounted into the app).
28
+ def write_if_changed!(path:, content:) # rubocop:disable Naming/PredicateMethod
29
+ path = path.to_s
30
+ # TOCTOU-safe read: catch `ENOENT` from `File.read` rather than
31
+ # gating on `File.exist?` — between the stat and the read the file
32
+ # may be removed by another process (e.g. `rails tmp:clear`).
33
+ existing = begin
34
+ File.read(path)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+ return false if existing == content
39
+
40
+ dir = File.dirname(path)
41
+ ensure_writable!(dir)
42
+
43
+ # Random suffix so two same-process writes of identical content do
44
+ # not race over the same temp filename (e.g. JSON-snapshot writer
45
+ # + Ruby TS codegen running back-to-back inside the rake task on
46
+ # an unchanged registry — the digest-prefix-only tmp name was
47
+ # deterministic, which collided).
48
+ tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(8)}"
49
+ File.binwrite(tmp, content)
50
+ File.rename(tmp, path)
51
+ true
52
+ end
53
+
54
+ private
55
+
56
+ def ensure_writable!(dir)
57
+ FileUtils.mkdir_p(dir)
58
+ rescue SystemCallError => e
59
+ raise Ruact::ConfigurationError,
60
+ "ruact: cannot create #{dir} for server-functions snapshot: #{e.message}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 8.3 — per-dispatch execution context for a standalone server
6
+ # action. The dispatcher allocates a fresh instance per request,
7
+ # `instance_exec`s the action block against it, and discards the
8
+ # instance once the response is written.
9
+ #
10
+ # Exposes:
11
+ # - `params` — the action-call args, as `ActionController::Parameters`
12
+ # (same shape as the controller-hosted path from Story 8.1).
13
+ # - `request` — the live `ActionDispatch::Request`.
14
+ # - `session` — the host middleware's session.
15
+ # - `cookies` — the live `ActionDispatch::Cookies::CookieJar`.
16
+ # - `headers` — `request.headers`.
17
+ # - `current_user` — memoized; reads `request.env['ruact.current_user']`
18
+ # when present, otherwise invokes
19
+ # {Ruact::Configuration#current_user_resolver} (a lambda taking
20
+ # `request.env`). Raises {Ruact::CurrentUserNotConfiguredError} when
21
+ # neither path yields a value AND the block actually reads it.
22
+ #
23
+ # Does NOT expose `render` / `redirect_to` / `head` — those are
24
+ # controller-context methods. The block's return value IS the response;
25
+ # raise {Ruact::ActionError} for non-2xx returns.
26
+ class StandaloneContext
27
+ attr_reader :params, :request
28
+
29
+ # @param params [ActionController::Parameters] action-call args.
30
+ # @param request [ActionDispatch::Request] the live request.
31
+ def initialize(params:, request:)
32
+ @params = params
33
+ @request = request
34
+ @current_user_read = false
35
+ @current_user_memo = nil
36
+ @current_user_resolved = false
37
+ end
38
+
39
+ def session
40
+ @request.session
41
+ end
42
+
43
+ def cookies
44
+ @request.cookie_jar
45
+ end
46
+
47
+ def headers
48
+ @request.headers
49
+ end
50
+
51
+ # Memoized current_user accessor. Sets a flag so the dispatcher can
52
+ # emit a dev-only warning when a block never reads `current_user`
53
+ # (Pitfall #4 in the story spec).
54
+ def current_user
55
+ @current_user_read = true
56
+ return @current_user_memo if @current_user_resolved
57
+
58
+ env = @request.env
59
+ if env.key?("ruact.current_user")
60
+ @current_user_memo = env["ruact.current_user"]
61
+ @current_user_resolved = true
62
+ return @current_user_memo
63
+ end
64
+
65
+ resolver = Ruact.config.current_user_resolver
66
+ raise Ruact::CurrentUserNotConfiguredError unless resolver
67
+
68
+ @current_user_memo = resolver.call(env)
69
+ @current_user_resolved = true
70
+ @current_user_memo
71
+ end
72
+
73
+ # @api private — Pitfall #4 dev-mode warning flag.
74
+ def __ruact_current_user_read?
75
+ @current_user_read
76
+ end
77
+
78
+ # Inhibits accidental controller-context calls inside a standalone
79
+ # block. The error message names the supported alternatives so the
80
+ # developer can immediately fix the call.
81
+ def render(*_args, **_kwargs)
82
+ raise NoMethodError,
83
+ "StandaloneContext does not expose `render` — return a value from " \
84
+ "the block (it becomes the JSON response) or raise " \
85
+ "`Ruact::ActionError.new(status:, body:)` for non-2xx responses."
86
+ end
87
+
88
+ def redirect_to(*_args, **_kwargs)
89
+ raise NoMethodError,
90
+ "StandaloneContext does not expose `redirect_to` — return a value " \
91
+ "from the block (it becomes the JSON response) or raise " \
92
+ "`Ruact::ActionError.new(status:, body:)` for non-2xx responses."
93
+ end
94
+
95
+ def head(*_args, **_kwargs)
96
+ raise NoMethodError,
97
+ "StandaloneContext does not expose `head` — return `nil` to render " \
98
+ "204 No Content, or raise `Ruact::ActionError.new(status:, body:)` " \
99
+ "for other non-2xx responses."
100
+ end
101
+ end
102
+ end
103
+ end