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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# Immutable record describing a single registered server function. Stored by
|
|
6
|
+
# {Ruact::ServerFunctions::Registry}; serialized into the JSON snapshot by
|
|
7
|
+
# {Ruact::ServerFunctions::Snapshot}.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] ruby_symbol
|
|
10
|
+
# @return [Symbol] the symbol the controller registered (e.g. `:create_post`).
|
|
11
|
+
# @!attribute [r] js_identifier
|
|
12
|
+
# @return [String] result of {Ruact::ServerFunctions::NameBridge.to_js_identifier}
|
|
13
|
+
# — cached at registration time so the snapshot writer never re-derives it.
|
|
14
|
+
# @!attribute [r] kind
|
|
15
|
+
# @return [Symbol] `:action` or `:query`. Informational at codegen time
|
|
16
|
+
# (Story 8.0 decision 2A-i: both kinds POST through the same accessor).
|
|
17
|
+
# @!attribute [r] controller
|
|
18
|
+
# @return [Class, nil] the controller class that registered the function;
|
|
19
|
+
# used for collision-error messages and downstream tooling. Nil is allowed
|
|
20
|
+
# for tests / Rails-console registrations.
|
|
21
|
+
# @!attribute [r] block
|
|
22
|
+
# @return [Proc, nil] the implementation block supplied by the controller
|
|
23
|
+
# macro. Story 8.0a stores it untouched; Stories 8.1 / 9.1 invoke it.
|
|
24
|
+
RegistryEntry = Data.define(:ruby_symbol, :js_identifier, :kind, :controller, :block)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -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
|