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.
- 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 +86 -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 +1680 -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 +89 -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 +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -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 +75 -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 +446 -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 +429 -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 +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -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 +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- 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 +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../serializable"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Ruact
|
|
8
|
+
module ServerFunctions
|
|
9
|
+
# Story 9.2 — pure serializer for the Bucket-2 (imperative `await fn()`)
|
|
10
|
+
# response body. Takes the host action's exposed instance variables (Rails
|
|
11
|
+
# `view_assigns`, resolved by the caller) and produces a JSON-ready Ruby
|
|
12
|
+
# Hash, keyed by ivar name, applying the SAME prop-exposure policy as the
|
|
13
|
+
# Flight serializer ({Ruact::Flight::Serializer#serialize_unknown}):
|
|
14
|
+
#
|
|
15
|
+
# - {Ruact::Serializable} values expose ONLY their `ruact_props` (secrets
|
|
16
|
+
# never leak), recursing into nested Serializables / collections.
|
|
17
|
+
# - Under `strict_serialization`, a non-Serializable domain object raises
|
|
18
|
+
# {Ruact::SerializationError} (no accidental full-record dump).
|
|
19
|
+
# - Otherwise a vetted `as_json` fallback applies (guards against
|
|
20
|
+
# `as_json` returning self / raising).
|
|
21
|
+
#
|
|
22
|
+
# Unlike the Flight serializer this produces PLAIN JSON-ready values (Hash /
|
|
23
|
+
# Array / scalar) — `render json:` does the final encoding, so JSON
|
|
24
|
+
# primitives (incl. Time/Date) pass through untouched rather than being
|
|
25
|
+
# Flight-encoded.
|
|
26
|
+
#
|
|
27
|
+
# Pure — no Rails / request / `Ruact.config` reads. The caller resolves the
|
|
28
|
+
# exposed-ivar set and the `strict` flag (mirroring the {ErrorPayload}
|
|
29
|
+
# caller/builder split, NFR26 / AC8).
|
|
30
|
+
module BucketTwoPayload
|
|
31
|
+
# JSON scalar + date/time primitives pass through untouched (Rails'
|
|
32
|
+
# `render json:` renders them — e.g. Time → ISO8601). They are NOT
|
|
33
|
+
# subject to the strict prop-exposure policy, matching the Flight
|
|
34
|
+
# serializer's primitive handling.
|
|
35
|
+
PRIMITIVES = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Time, Date, DateTime].freeze
|
|
36
|
+
private_constant :PRIMITIVES
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# @param assigns [Hash] exposed-ivar name (String, no `@`) => value
|
|
40
|
+
# @param strict [Boolean] the resolved `strict_serialization` mode
|
|
41
|
+
# @return [Hash{String=>Object}] JSON-ready hash keyed by ivar name
|
|
42
|
+
# @raise [Ruact::SerializationError] per the strict policy
|
|
43
|
+
def build(assigns, strict:)
|
|
44
|
+
assigns.to_h { |name, value| [name.to_s, serialize(value, strict)] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Story 9.4 (D6) — the per-value branch of the policy, extracted so the
|
|
48
|
+
# query dispatch controller serializes a method's single RETURN VALUE
|
|
49
|
+
# (Array / Hash / scalar / Serializable / nil) through the exact rules
|
|
50
|
+
# {.build} applies to each exposed ivar. One policy, two callers.
|
|
51
|
+
#
|
|
52
|
+
# @param value [Object] the query method's return value
|
|
53
|
+
# @param strict [Boolean] the resolved `strict_serialization` mode
|
|
54
|
+
# @return [Object] JSON-ready value (`nil` stays `nil` → JSON `null`)
|
|
55
|
+
# @raise [Ruact::SerializationError] per the strict policy
|
|
56
|
+
def serialize_value(value, strict:)
|
|
57
|
+
serialize(value, strict)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def serialize(value, strict)
|
|
63
|
+
case value
|
|
64
|
+
when *PRIMITIVES then value
|
|
65
|
+
when Hash then value.to_h { |k, v| [k.to_s, serialize(v, strict)] }
|
|
66
|
+
when Array then value.map { |v| serialize(v, strict) }
|
|
67
|
+
when Ruact::Serializable then serialize(value.ruact_serialize, strict)
|
|
68
|
+
else serialize_object(value, strict)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Non-Serializable, non-primitive object: mirror
|
|
73
|
+
# Flight::Serializer#serialize_unknown's strict/as_json policy.
|
|
74
|
+
def serialize_object(value, strict)
|
|
75
|
+
unless value.respond_to?(:as_json)
|
|
76
|
+
raise Ruact::SerializationError,
|
|
77
|
+
"Cannot serialize #{value.class.name} — include Ruact::Serializable"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if strict
|
|
81
|
+
raise Ruact::SerializationError,
|
|
82
|
+
"Cannot serialize #{value.class.name} — " \
|
|
83
|
+
"include Ruact::Serializable or set strict_serialization: false"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
serialize(as_json_value(value), strict)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def as_json_value(value)
|
|
90
|
+
data =
|
|
91
|
+
begin
|
|
92
|
+
value.as_json
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
raise Ruact::SerializationError,
|
|
95
|
+
"#{value.class.name}#as_json raised #{e.class}: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if data.equal?(value)
|
|
99
|
+
raise Ruact::SerializationError,
|
|
100
|
+
"#{value.class.name}#as_json returned self — would cause infinite recursion. " \
|
|
101
|
+
"Include Ruact::Serializable and declare ruact_props instead"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
data
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ruact/server_functions/name_bridge"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
# Renders the snapshot Hash into the TypeScript module emitted to
|
|
9
|
+
# `app/javascript/.ruact/server-functions.ts`. Pure string-building plus a
|
|
10
|
+
# single write-if-changed call.
|
|
11
|
+
#
|
|
12
|
+
# The output of {.render} MUST be byte-identical to the JS-side codegen in
|
|
13
|
+
# `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`.
|
|
14
|
+
# The cross-implementation parity test under
|
|
15
|
+
# `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs`
|
|
16
|
+
# asserts this invariant; if it fails, fix the offending side rather than
|
|
17
|
+
# normalizing in the assertion (Story 8.0a Task 8.5).
|
|
18
|
+
module Codegen
|
|
19
|
+
# Bumped only when the rendered shape changes. Used by tests to assert
|
|
20
|
+
# cross-implementation parity without coupling to the literal byte string.
|
|
21
|
+
VERSION = 1
|
|
22
|
+
|
|
23
|
+
# Story 9.3 — the route-driven snapshot schema. A version-2 snapshot
|
|
24
|
+
# carries route-derived entries (`http_method` + `path` + `segments`,
|
|
25
|
+
# no `ruby_symbol`) and renders `_makeServerFunction(descriptor)` calls
|
|
26
|
+
# instead of `_makeRef("<sym>")`. {.render} dispatches on `version` so
|
|
27
|
+
# the v1 (registry-driven) path stays byte-for-byte untouched.
|
|
28
|
+
VERSION_V2 = 2
|
|
29
|
+
|
|
30
|
+
RUNTIME_IMPORT = '"ruact/server-functions-runtime"'
|
|
31
|
+
|
|
32
|
+
# Story 8.2 (2026-05-16, refined 2026-05-17 per review patch R1) —
|
|
33
|
+
# ACTION_SIGNATURE is a TS intersection type with TWO call signatures:
|
|
34
|
+
#
|
|
35
|
+
# 1. `(args?: FormData | Record<string, unknown>) => Promise<unknown>`
|
|
36
|
+
# — for direct callers (`await createPost({...})` /
|
|
37
|
+
# `await createPost(formData)` / event handlers), preserving the
|
|
38
|
+
# JSON-decoded response value.
|
|
39
|
+
# 2. `(formData: FormData) => Promise<void>` — assignable to
|
|
40
|
+
# `@types/react@19.x`'s `<form action>` prop, which is typed as
|
|
41
|
+
# `(formData: FormData) => void | Promise<void>`. TS rejects
|
|
42
|
+
# `Promise<unknown>` → `Promise<void>` even via the void-discard
|
|
43
|
+
# rule (Promise generics are invariant), so the intersection is
|
|
44
|
+
# required to make `<form action={createPost}>` typecheck DIRECTLY
|
|
45
|
+
# against the codegen-emitted module — no call-site cast, no
|
|
46
|
+
# wrapper closure.
|
|
47
|
+
#
|
|
48
|
+
# Runtime behavior is unchanged — `_makeRef` always resolves with the
|
|
49
|
+
# JSON-decoded value (or `null` for empty bodies). The intersection is
|
|
50
|
+
# a TYPE-ONLY surface: when callers `await` the result, they see
|
|
51
|
+
# `Promise<unknown>`; when React invokes the function from a
|
|
52
|
+
# `<form action>` prop, the `Promise<void>` overload is selected and
|
|
53
|
+
# the return value is discarded by React.
|
|
54
|
+
#
|
|
55
|
+
# See the 2026-05-17 entry in `gem/docs/internal/decisions/server-functions-api.md`
|
|
56
|
+
# ("R1 — intersection-type refinement") for the option (a)→(a′)
|
|
57
|
+
# evolution and the empirical typecheck-probe that motivated it.
|
|
58
|
+
# Query signatures stay narrow because queries are never reachable via
|
|
59
|
+
# `<form action>` (read-only via `useQuery`).
|
|
60
|
+
ACTION_SIGNATURE =
|
|
61
|
+
"((args?: FormData | Record<string, unknown>) => Promise<unknown>) " \
|
|
62
|
+
"& ((formData: FormData) => Promise<void>)"
|
|
63
|
+
QUERY_SIGNATURE = "() => Promise<unknown>"
|
|
64
|
+
|
|
65
|
+
# Story 8.2 — fixed re-export appended AFTER the per-function block.
|
|
66
|
+
# Emitted in BOTH branches (empty + populated registry) so
|
|
67
|
+
# `import { revalidate } from "@/.ruact/server-functions"` works on
|
|
68
|
+
# day one of any host app. Ruby + JS codegens emit byte-identically.
|
|
69
|
+
REVALIDATE_REEXPORT = "export { revalidate } from #{RUNTIME_IMPORT};\n".freeze
|
|
70
|
+
|
|
71
|
+
# JS identifier shape — same as `NameBridge::VALID_SYMBOL` but expressed
|
|
72
|
+
# in JS-identifier terms (leading letter / underscore / `$`, then alnum
|
|
73
|
+
# / underscore / `$`). The codegen validates every entry it consumes
|
|
74
|
+
# because the JSON bridge is a trust boundary — a malformed snapshot
|
|
75
|
+
# (`functions[].js_identifier == ");\nevil();_makeRef("foo`) would
|
|
76
|
+
# otherwise inject TS at module top level.
|
|
77
|
+
VALID_JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
|
|
78
|
+
|
|
79
|
+
ALLOWED_KINDS = %w[action query].freeze
|
|
80
|
+
|
|
81
|
+
# JS comments (both `//` line comments and `/* … */` block comments via
|
|
82
|
+
# the spec's LineTerminator production) end on LF, CR, U+2028, and U+2029.
|
|
83
|
+
# A snapshot value that smuggles any of these would break out of the
|
|
84
|
+
# leading comment header in the emitted module. The reviewer's Pass-2
|
|
85
|
+
# finding noted that an earlier `/[\r\n]/` guard missed the two Unicode
|
|
86
|
+
# line separators; the regex is widened here and a parity test in
|
|
87
|
+
# `server-functions-codegen.test.mjs` keeps both renderers in sync.
|
|
88
|
+
LINE_TERMINATORS = /[\r\n
]/
|
|
89
|
+
|
|
90
|
+
class << self
|
|
91
|
+
# Renders +snapshot+ into the TS module text. Pure; no I/O.
|
|
92
|
+
#
|
|
93
|
+
# @param snapshot [Hash] result of {Ruact::ServerFunctions::Snapshot.dump};
|
|
94
|
+
# must contain `:version`, `:generated_at`, `:functions` (with string-keyed
|
|
95
|
+
# entries).
|
|
96
|
+
# @return [String] TS module bytes, terminated by a single trailing newline.
|
|
97
|
+
# @raise [Ruact::ConfigurationError] when an entry fails any of the
|
|
98
|
+
# snapshot-trust-boundary guards (line-break in version /
|
|
99
|
+
# generated_at, invalid identifier shape, reserved JS word, kind
|
|
100
|
+
# outside {ALLOWED_KINDS}, or duplicate `js_identifier` — mirror of
|
|
101
|
+
# the JS-side `validateSnapshot` per the 2026-05-14 Re-run patch).
|
|
102
|
+
def render(snapshot)
|
|
103
|
+
unless snapshot.is_a?(Hash)
|
|
104
|
+
raise Ruact::ConfigurationError,
|
|
105
|
+
"ruact server-function codegen: snapshot must be a Hash, got #{snapshot.class}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
version = fetch_snapshot_key!(snapshot, :version, "version")
|
|
109
|
+
generated_at = fetch_snapshot_key!(snapshot, :generated_at, "generated_at")
|
|
110
|
+
functions = fetch_snapshot_key!(snapshot, :functions, "functions")
|
|
111
|
+
|
|
112
|
+
validate_metadata!(version, generated_at)
|
|
113
|
+
|
|
114
|
+
return V2.render(version, generated_at, functions) if version.to_s == VERSION_V2.to_s
|
|
115
|
+
|
|
116
|
+
validate_functions!(functions)
|
|
117
|
+
|
|
118
|
+
io = +""
|
|
119
|
+
io << "// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.\n"
|
|
120
|
+
io << "// Source: tmp/cache/ruact/server-functions.json (version #{version})\n"
|
|
121
|
+
io << "// Generated at: #{generated_at}\n"
|
|
122
|
+
io << "import { _makeRef } from #{RUNTIME_IMPORT};\n"
|
|
123
|
+
|
|
124
|
+
if functions.empty?
|
|
125
|
+
io << "\n// (no server functions registered yet — Stories 8.1 / 9.1 populate)\n"
|
|
126
|
+
# `noUnusedLocals` would otherwise flag the `_makeRef` import. The
|
|
127
|
+
# `void` discard pattern keeps the import alive at zero runtime
|
|
128
|
+
# cost; once an action/query is registered the export below
|
|
129
|
+
# references `_makeRef` directly and this line is omitted.
|
|
130
|
+
io << "void _makeRef;\n"
|
|
131
|
+
else
|
|
132
|
+
io << "\n"
|
|
133
|
+
functions.each do |entry|
|
|
134
|
+
io << render_export(entry)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Story 8.2 — `revalidate()` is always available, so the
|
|
139
|
+
# re-export lands in both branches (empty registry + populated).
|
|
140
|
+
# The codegen owns the canonical import path
|
|
141
|
+
# `@/.ruact/server-functions` and is the only stable surface devs
|
|
142
|
+
# are told to import from in the docs (per the Story 8.0 ADR);
|
|
143
|
+
# without this line, devs would need a second import statement
|
|
144
|
+
# from a less-stable runtime-package path.
|
|
145
|
+
io << "\n"
|
|
146
|
+
io << REVALIDATE_REEXPORT
|
|
147
|
+
|
|
148
|
+
io
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Writes the rendered TS module to +output_path+, only if it changed.
|
|
152
|
+
# See {Ruact::ServerFunctions::SnapshotWriter.write_if_changed!}.
|
|
153
|
+
#
|
|
154
|
+
# @param snapshot [Hash]
|
|
155
|
+
# @param output_path [String, Pathname]
|
|
156
|
+
# @return [Boolean] true if the file was written; false if unchanged.
|
|
157
|
+
def generate_ts!(snapshot:, output_path:)
|
|
158
|
+
SnapshotWriter.write_if_changed!(path: output_path, content: render(snapshot))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def render_export(entry)
|
|
164
|
+
js_id = entry["js_identifier"] || entry[:js_identifier]
|
|
165
|
+
kind = (entry["kind"] || entry[:kind]).to_s
|
|
166
|
+
ruby_sym = entry["ruby_symbol"] || entry[:ruby_symbol]
|
|
167
|
+
|
|
168
|
+
signature = kind == "query" ? QUERY_SIGNATURE : ACTION_SIGNATURE
|
|
169
|
+
|
|
170
|
+
"export const #{js_id}: #{signature} =\n _makeRef(#{json_escape(ruby_sym.to_s)});\n"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Pass-2 patch 2026-05-14 — wrap raw `KeyError` from `Hash#fetch` so
|
|
174
|
+
# the rake / Railtie call sites get a consistent `Ruact::ConfigurationError`
|
|
175
|
+
# for every snapshot-shape failure, not a mixture of error classes.
|
|
176
|
+
def fetch_snapshot_key!(snapshot, sym_key, str_key)
|
|
177
|
+
return snapshot[sym_key] if snapshot.key?(sym_key)
|
|
178
|
+
return snapshot[str_key] if snapshot.key?(str_key)
|
|
179
|
+
|
|
180
|
+
raise Ruact::ConfigurationError,
|
|
181
|
+
"ruact server-function codegen: snapshot is missing required key " \
|
|
182
|
+
"#{sym_key.inspect} (or #{str_key.inspect}); the bridge JSON is " \
|
|
183
|
+
"corrupted — regenerate via `bin/rails ruact:server_functions:generate`."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Mirror of the JS-side `validateSnapshot` (2026-05-14 Re-run parity
|
|
187
|
+
# fix). The Ruby renderer also reads from the on-disk JSON bridge in
|
|
188
|
+
# the rake-task and Railtie paths, so the same trust-boundary guards
|
|
189
|
+
# belong here.
|
|
190
|
+
def validate_metadata!(version, generated_at)
|
|
191
|
+
unless version.is_a?(Integer) || version.is_a?(String)
|
|
192
|
+
raise Ruact::ConfigurationError,
|
|
193
|
+
"ruact server-function codegen: snapshot.version must be an " \
|
|
194
|
+
"Integer or String, got #{version.class}"
|
|
195
|
+
end
|
|
196
|
+
if version.to_s.match?(LINE_TERMINATORS)
|
|
197
|
+
raise Ruact::ConfigurationError,
|
|
198
|
+
"ruact server-function codegen: snapshot.version contains a " \
|
|
199
|
+
"line break (LF, CR, U+2028, or U+2029) — would break out of " \
|
|
200
|
+
"the header comment; snapshot JSON is corrupted."
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
unless generated_at.is_a?(String)
|
|
204
|
+
raise Ruact::ConfigurationError,
|
|
205
|
+
"ruact server-function codegen: snapshot.generated_at must be " \
|
|
206
|
+
"a String, got #{generated_at.class}"
|
|
207
|
+
end
|
|
208
|
+
return unless generated_at.match?(LINE_TERMINATORS)
|
|
209
|
+
|
|
210
|
+
raise Ruact::ConfigurationError,
|
|
211
|
+
"ruact server-function codegen: snapshot.generated_at contains " \
|
|
212
|
+
"a line break (LF, CR, U+2028, or U+2029) — would break out of " \
|
|
213
|
+
"the header comment; snapshot JSON is corrupted."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_functions!(functions)
|
|
217
|
+
unless functions.is_a?(Array)
|
|
218
|
+
raise Ruact::ConfigurationError,
|
|
219
|
+
"ruact server-function codegen: snapshot.functions must be an " \
|
|
220
|
+
"Array, got #{functions.class}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
seen = {}
|
|
224
|
+
functions.each do |entry|
|
|
225
|
+
unless entry.is_a?(Hash)
|
|
226
|
+
raise Ruact::ConfigurationError,
|
|
227
|
+
"ruact server-function codegen: snapshot.functions entry is " \
|
|
228
|
+
"not a Hash: #{entry.inspect}"
|
|
229
|
+
end
|
|
230
|
+
js_id = entry["js_identifier"] || entry[:js_identifier]
|
|
231
|
+
kind = (entry["kind"] || entry[:kind]).to_s
|
|
232
|
+
ruby_sym = entry["ruby_symbol"] || entry[:ruby_symbol]
|
|
233
|
+
|
|
234
|
+
validate_ruby_symbol!(ruby_sym)
|
|
235
|
+
validate_js_identifier!(js_id, ruby_sym)
|
|
236
|
+
validate_kind!(kind, ruby_sym)
|
|
237
|
+
validate_not_reserved!(js_id, ruby_sym)
|
|
238
|
+
validate_no_duplicate!(seen, js_id)
|
|
239
|
+
seen[js_id] = true
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Pass-2 patch 2026-05-14 — without this guard, a missing or empty
|
|
244
|
+
# `ruby_symbol` on a snapshot entry would render `_makeRef("")` and
|
|
245
|
+
# silently emit an export that can never resolve at runtime (the
|
|
246
|
+
# placeholder rejects on call but the empty string is a meaningless
|
|
247
|
+
# registration name). Treat as a corrupt-snapshot signal.
|
|
248
|
+
def validate_ruby_symbol!(ruby_sym)
|
|
249
|
+
return if ruby_sym.is_a?(String) && !ruby_sym.empty?
|
|
250
|
+
return if ruby_sym.is_a?(Symbol) && !ruby_sym.empty?
|
|
251
|
+
|
|
252
|
+
raise Ruact::ConfigurationError,
|
|
253
|
+
"ruact server-function codegen: snapshot.functions entry has " \
|
|
254
|
+
"missing or empty ruby_symbol (got #{ruby_sym.inspect}); the " \
|
|
255
|
+
"bridge JSON is corrupted — regenerate via " \
|
|
256
|
+
"`bin/rails ruact:server_functions:generate`."
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_js_identifier!(js_id, ruby_sym)
|
|
260
|
+
return if js_id.is_a?(String) && js_id.match?(VALID_JS_IDENTIFIER)
|
|
261
|
+
|
|
262
|
+
raise Ruact::ConfigurationError,
|
|
263
|
+
"ruact server-function codegen rejected a snapshot entry: " \
|
|
264
|
+
"ruby_symbol=#{ruby_sym.inspect} js_identifier=#{js_id.inspect} " \
|
|
265
|
+
"is not a valid JS identifier (must match #{VALID_JS_IDENTIFIER.inspect}). " \
|
|
266
|
+
"The snapshot JSON is corrupted or was hand-edited — regenerate via " \
|
|
267
|
+
"`bin/rails ruact:server_functions:generate`."
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_kind!(kind, ruby_sym)
|
|
271
|
+
return if ALLOWED_KINDS.include?(kind)
|
|
272
|
+
|
|
273
|
+
raise Ruact::ConfigurationError,
|
|
274
|
+
"ruact server-function codegen: snapshot.functions entry has " \
|
|
275
|
+
"invalid kind #{kind.inspect} (must be \"action\" or \"query\") " \
|
|
276
|
+
"for ruby_symbol=#{ruby_sym.inspect}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validate_not_reserved!(js_id, ruby_sym)
|
|
280
|
+
if NameBridge::RESERVED_JS_IDENTIFIERS.include?(js_id)
|
|
281
|
+
raise Ruact::ConfigurationError,
|
|
282
|
+
"ruact server-function codegen: js_identifier #{js_id.inspect} " \
|
|
283
|
+
"is a reserved JS word — ruby_symbol=#{ruby_sym.inspect} would " \
|
|
284
|
+
"emit an invalid TS module. NameBridge should have rejected this; " \
|
|
285
|
+
"regenerate via `bin/rails ruact:server_functions:generate`."
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Story 8.2 R12 — even if NameBridge somehow lets a reserved
|
|
289
|
+
# ruact name through (e.g. a hand-edited bridge JSON), the
|
|
290
|
+
# codegen MUST refuse — otherwise the rendered module would
|
|
291
|
+
# bind `revalidate` / `_makeRef` twice (once via the
|
|
292
|
+
# re-export / import, once via the action `export const`)
|
|
293
|
+
# and crash at module load.
|
|
294
|
+
return unless NameBridge::RESERVED_BY_RUACT.include?(js_id)
|
|
295
|
+
|
|
296
|
+
raise Ruact::ConfigurationError,
|
|
297
|
+
"ruact server-function codegen: js_identifier #{js_id.inspect} " \
|
|
298
|
+
"is reserved by the ruact runtime/codegen surface (would clash " \
|
|
299
|
+
"with the module's `revalidate` re-export or `_makeRef` import) — " \
|
|
300
|
+
"ruby_symbol=#{ruby_sym.inspect} cannot be exported. NameBridge " \
|
|
301
|
+
"should have rejected this; regenerate via " \
|
|
302
|
+
"`bin/rails ruact:server_functions:generate`."
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def validate_no_duplicate!(seen, js_id)
|
|
306
|
+
return unless seen.key?(js_id)
|
|
307
|
+
|
|
308
|
+
raise Ruact::ConfigurationError,
|
|
309
|
+
"ruact server-function codegen: duplicate js_identifier " \
|
|
310
|
+
"#{js_id.inspect} in snapshot — two entries would emit " \
|
|
311
|
+
"conflicting `export const` declarations. The snapshot JSON is " \
|
|
312
|
+
"corrupted or was hand-edited — regenerate via " \
|
|
313
|
+
"`bin/rails ruact:server_functions:generate`."
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Wraps `ruby_symbol` in a JSON-escaped string literal so backslashes,
|
|
317
|
+
# double quotes, and control characters cannot break out of the
|
|
318
|
+
# `_makeRef("<here>")` argument.
|
|
319
|
+
def json_escape(str)
|
|
320
|
+
JSON.dump(str)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Story 9.3 — the route-driven (version-2) renderer lives in its own module so
|
|
328
|
+
# the v1 singleton class stays within its size budget. Required after the
|
|
329
|
+
# constants above are defined; `Codegen.render` delegates to it on version 2.
|
|
330
|
+
require_relative "codegen_v2"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ruact/server_functions/name_bridge"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
module Codegen
|
|
9
|
+
# Story 9.3 — renders a version-2 (route-driven) snapshot into the TS
|
|
10
|
+
# module. Each entry is an action targeting a real path + verb, emitted as
|
|
11
|
+
# `_makeServerFunction({ method, path, segments })` instead of v1's
|
|
12
|
+
# `_makeRef("<sym>")`. Lives in its own module (nested in {Codegen}) so the
|
|
13
|
+
# v1 singleton class stays within its size budget; {Codegen.render}
|
|
14
|
+
# delegates here when `snapshot.version == 2`.
|
|
15
|
+
#
|
|
16
|
+
# The output MUST stay byte-identical to the JS-side `renderV2` in
|
|
17
|
+
# `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`;
|
|
18
|
+
# the parity test ("Story 9.3 — route-driven (v2) render + parity") asserts
|
|
19
|
+
# this. Constants (ACTION_SIGNATURE, RUNTIME_IMPORT, REVALIDATE_REEXPORT,
|
|
20
|
+
# LINE_TERMINATORS, VALID_JS_IDENTIFIER) are reused from {Codegen} via
|
|
21
|
+
# lexical scope.
|
|
22
|
+
module V2
|
|
23
|
+
# Verbs a v2 entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
|
|
24
|
+
HTTP_METHODS = %w[POST PUT PATCH DELETE].to_set.freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# @param version [Integer, String]
|
|
28
|
+
# @param generated_at [String]
|
|
29
|
+
# @param functions [Array<Hash>] route-derived entries.
|
|
30
|
+
# @return [String] TS module bytes (single trailing newline).
|
|
31
|
+
# @raise [Ruact::ConfigurationError] on any trust-boundary violation.
|
|
32
|
+
def render(version, generated_at, functions)
|
|
33
|
+
validate_functions!(functions)
|
|
34
|
+
|
|
35
|
+
io = +""
|
|
36
|
+
io << "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n"
|
|
37
|
+
io << "// Source: Rails route table (version #{version})\n"
|
|
38
|
+
io << "// Generated at: #{generated_at}\n"
|
|
39
|
+
io << "import { _makeServerFunction } from #{RUNTIME_IMPORT};\n"
|
|
40
|
+
|
|
41
|
+
if functions.empty?
|
|
42
|
+
io << "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n"
|
|
43
|
+
io << "void _makeServerFunction;\n"
|
|
44
|
+
else
|
|
45
|
+
io << "\n"
|
|
46
|
+
functions.each { |entry| io << render_export(entry) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
io << "\n"
|
|
50
|
+
io << REVALIDATE_REEXPORT
|
|
51
|
+
io
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def render_export(entry)
|
|
57
|
+
js_id = fetch(entry, "js_identifier")
|
|
58
|
+
method = fetch(entry, "http_method")
|
|
59
|
+
path = fetch(entry, "path")
|
|
60
|
+
segments = fetch(entry, "segments") || []
|
|
61
|
+
|
|
62
|
+
descriptor =
|
|
63
|
+
"{ method: #{JSON.dump(method)}, path: #{JSON.dump(path)}, " \
|
|
64
|
+
"segments: [#{segments.map { |s| JSON.dump(s) }.join(', ')}] }"
|
|
65
|
+
|
|
66
|
+
"export const #{js_id}: #{ACTION_SIGNATURE} =\n _makeServerFunction(#{descriptor});\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_functions!(functions)
|
|
70
|
+
unless functions.is_a?(Array)
|
|
71
|
+
raise Ruact::ConfigurationError,
|
|
72
|
+
"ruact server-function codegen: snapshot.functions must be an Array, got #{functions.class}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
seen = {}
|
|
76
|
+
functions.each { |entry| validate_entry!(entry, seen) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_entry!(entry, seen)
|
|
80
|
+
unless entry.is_a?(Hash)
|
|
81
|
+
raise Ruact::ConfigurationError,
|
|
82
|
+
"ruact server-function codegen: snapshot.functions entry is not a Hash: #{entry.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
js_id = fetch(entry, "js_identifier")
|
|
86
|
+
validate_identifier!(js_id, seen)
|
|
87
|
+
validate_kind!(js_id, fetch(entry, "kind").to_s)
|
|
88
|
+
validate_method!(js_id, fetch(entry, "http_method"))
|
|
89
|
+
path = fetch(entry, "path")
|
|
90
|
+
validate_path!(js_id, path)
|
|
91
|
+
validate_segments!(js_id, path, fetch(entry, "segments"))
|
|
92
|
+
seen[js_id] = true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_identifier!(js_id, seen)
|
|
96
|
+
unless js_id.is_a?(String) && js_id.match?(VALID_JS_IDENTIFIER)
|
|
97
|
+
raise Ruact::ConfigurationError,
|
|
98
|
+
"ruact server-function codegen rejected a v2 snapshot entry: " \
|
|
99
|
+
"js_identifier=#{js_id.inspect} is not a valid JS identifier " \
|
|
100
|
+
"(must match #{VALID_JS_IDENTIFIER.inspect}); snapshot JSON is corrupted."
|
|
101
|
+
end
|
|
102
|
+
if NameBridge::RESERVED_JS_IDENTIFIERS.include?(js_id) ||
|
|
103
|
+
NameBridge::RESERVED_BY_RUACT.include?(js_id)
|
|
104
|
+
raise Ruact::ConfigurationError,
|
|
105
|
+
"ruact server-function codegen: js_identifier #{js_id.inspect} is reserved — " \
|
|
106
|
+
"cannot be exported; snapshot JSON is corrupted."
|
|
107
|
+
end
|
|
108
|
+
return unless seen.key?(js_id)
|
|
109
|
+
|
|
110
|
+
raise Ruact::ConfigurationError,
|
|
111
|
+
"ruact server-function codegen: duplicate js_identifier #{js_id.inspect} in snapshot."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_kind!(js_id, kind)
|
|
115
|
+
return if kind == "action"
|
|
116
|
+
|
|
117
|
+
raise Ruact::ConfigurationError,
|
|
118
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
|
|
119
|
+
"kind #{kind.inspect} (v2 entries are always \"action\")."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_method!(js_id, method)
|
|
123
|
+
return if HTTP_METHODS.include?(method)
|
|
124
|
+
|
|
125
|
+
raise Ruact::ConfigurationError,
|
|
126
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
|
|
127
|
+
"http_method #{method.inspect} (must be one of #{HTTP_METHODS.to_a.inspect})."
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_path!(js_id, path)
|
|
131
|
+
unless path.is_a?(String) && path.start_with?("/")
|
|
132
|
+
raise Ruact::ConfigurationError,
|
|
133
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
|
|
134
|
+
"path #{path.inspect} (must be a String beginning with \"/\")."
|
|
135
|
+
end
|
|
136
|
+
return unless path.match?(LINE_TERMINATORS)
|
|
137
|
+
|
|
138
|
+
raise Ruact::ConfigurationError,
|
|
139
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} path contains a " \
|
|
140
|
+
"line break — would break out of the generated call; snapshot JSON is corrupted."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_segments!(js_id, path, segments)
|
|
144
|
+
unless segments.is_a?(Array) && segments.all? { |s| s.is_a?(String) && !s.empty? }
|
|
145
|
+
raise Ruact::ConfigurationError,
|
|
146
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
|
|
147
|
+
"segments #{segments.inspect} (must be an Array of non-empty Strings)."
|
|
148
|
+
end
|
|
149
|
+
# Whole-token match: `:id` must NOT satisfy a declared segment when
|
|
150
|
+
# the path only has `:id_extra` (a substring `include?` would).
|
|
151
|
+
missing = segments.reject { |s| path.match?(/:#{Regexp.escape(s)}(?![A-Za-z0-9_])/) }
|
|
152
|
+
unless missing.empty?
|
|
153
|
+
raise Ruact::ConfigurationError,
|
|
154
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} declares " \
|
|
155
|
+
"segment(s) #{missing.inspect} absent from path #{path.inspect}; snapshot JSON is corrupted."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Bidirectional: every dynamic `:param` in the path MUST be declared
|
|
159
|
+
# in segments, else the runtime would fetch a literal `:param` URL.
|
|
160
|
+
undeclared = path.scan(/:([A-Za-z_][A-Za-z0-9_]*)/).flatten - segments
|
|
161
|
+
return if undeclared.empty?
|
|
162
|
+
|
|
163
|
+
raise Ruact::ConfigurationError,
|
|
164
|
+
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} path #{path.inspect} " \
|
|
165
|
+
"has dynamic segment(s) #{undeclared.inspect} not declared in segments; snapshot JSON is corrupted."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# v2 snapshots use string keys on disk; specs may pass symbol keys.
|
|
169
|
+
def fetch(entry, key)
|
|
170
|
+
entry[key] || entry[key.to_sym]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|