ruact 0.0.5 → 0.0.6
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Story 13.2 (FR96) — the canonical record-reference primitive: a **signed,
|
|
5
|
+
# scoped, expiring** SignedGlobalID token instead of a raw id or attribute
|
|
6
|
+
# dump.
|
|
7
|
+
#
|
|
8
|
+
# A controller that puts `@post.id` (or an attribute hash) into props hands
|
|
9
|
+
# the client a *forgeable, unscoped, non-expiring* reference: swap `id: 7`
|
|
10
|
+
# for `id: 8` and, if the action trusts `params[:id]` raw, it reaches a record
|
|
11
|
+
# it should not. `Ruact.signed_global_id` mints a `SignedGlobalID` instead —
|
|
12
|
+
# HMAC-signed by the app secret (tamper-proof), bound to a `for:` purpose
|
|
13
|
+
# (scoped to one use-site), and `expires_in:`-bounded — and
|
|
14
|
+
# `Ruact.locate_signed` resolves it back, raising
|
|
15
|
+
# {Ruact::InvalidSignedGlobalIDError} (→ a clean 4xx) on a tampered, expired,
|
|
16
|
+
# or wrong-purpose token. This is the structural antidote to forged-reference
|
|
17
|
+
# attacks, the inbound-safety counterpart to the serialize-only invariant
|
|
18
|
+
# (Story 13.1).
|
|
19
|
+
#
|
|
20
|
+
# This is **opt-in and explicit** — it follows the same grain as
|
|
21
|
+
# `ruact_props` (an explicit allowlist, not auto-detection). The developer
|
|
22
|
+
# reaches for the helper; ruact never silently rewrites every
|
|
23
|
+
# `ActiveRecord::Base` in props, and never auto-coerces inbound params into
|
|
24
|
+
# records.
|
|
25
|
+
#
|
|
26
|
+
# @example Outbound — sign a reference in props, resolve it in an action
|
|
27
|
+
# # controller producing props
|
|
28
|
+
# def edit
|
|
29
|
+
# @post_ref = Ruact.signed_global_id(@post, for: :post_edit, expires_in: 1.hour)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # server function receiving the reference back from the client
|
|
33
|
+
# def update
|
|
34
|
+
# post = Ruact.locate_signed(params[:post_ref], for: :post_edit)
|
|
35
|
+
# post.update!(post_params)
|
|
36
|
+
# end
|
|
37
|
+
module SignedReferences
|
|
38
|
+
# Sentinel marking "the caller omitted this keyword", kept distinct from an
|
|
39
|
+
# explicit `nil`. Omission falls back to the configured default and, failing
|
|
40
|
+
# that, raises loudly (AC1: "omitting both is a loud error, not a silent
|
|
41
|
+
# insecure default"). An explicit `expires_in: nil` is honored as a
|
|
42
|
+
# deliberate, reviewed non-expiring token.
|
|
43
|
+
UNSET = Object.new
|
|
44
|
+
def UNSET.inspect = "Ruact::SignedReferences::UNSET"
|
|
45
|
+
UNSET.freeze
|
|
46
|
+
private_constant :UNSET
|
|
47
|
+
|
|
48
|
+
# Mint a `SignedGlobalID` token string for a record, scoped to a `for:`
|
|
49
|
+
# purpose and bounded by `expires_in:`.
|
|
50
|
+
#
|
|
51
|
+
# @param record [GlobalID::Identification] an ActiveRecord (or any
|
|
52
|
+
# GlobalID-locatable) record — anything responding to `#to_sgid`.
|
|
53
|
+
# @param for [Symbol, String] the purpose binding the token to one
|
|
54
|
+
# use-site. Omitted → {Ruact::Configuration#signed_global_id_default_purpose};
|
|
55
|
+
# if neither is set (or it resolves to `nil`), raises {Ruact::Error} —
|
|
56
|
+
# ruact refuses to sign an unscoped reference.
|
|
57
|
+
# @param expires_in [ActiveSupport::Duration, nil] token lifetime. Omitted
|
|
58
|
+
# → {Ruact::Configuration#signed_global_id_default_expires_in}; if neither
|
|
59
|
+
# is set, raises {Ruact::Error}. Pass an explicit `nil` to deliberately
|
|
60
|
+
# mint a non-expiring token (a reviewed per-call choice).
|
|
61
|
+
# @return [String] the signed token, safe to serialize as a prop (it is a
|
|
62
|
+
# plain `String`, so the Flight serializer carries it unchanged).
|
|
63
|
+
# @raise [Ruact::Error] when the record is not GlobalID-locatable, or when a
|
|
64
|
+
# purpose/expiry cannot be resolved.
|
|
65
|
+
def signed_global_id(record, for: UNSET, expires_in: UNSET)
|
|
66
|
+
__ruact_require_global_id!
|
|
67
|
+
purpose = __ruact_resolve_purpose(binding.local_variable_get(:for))
|
|
68
|
+
expiry = __ruact_resolve_expiry(expires_in)
|
|
69
|
+
|
|
70
|
+
unless record.respond_to?(:to_sgid)
|
|
71
|
+
raise Ruact::Error,
|
|
72
|
+
"Ruact.signed_global_id expects a GlobalID-locatable record (one responding to " \
|
|
73
|
+
"#to_sgid, e.g. an ActiveRecord model); got #{record.class}."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
record.to_sgid(for: purpose, expires_in: expiry).to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolve a signed token back to its record, verifying signature, expiry,
|
|
80
|
+
# and purpose. The inverse of {#signed_global_id}.
|
|
81
|
+
#
|
|
82
|
+
# @param token [String, nil] the signed token received from the client.
|
|
83
|
+
# @param for [Symbol, String] the purpose the token must match — resolved
|
|
84
|
+
# the same way as {#signed_global_id} (omitted → config → raise).
|
|
85
|
+
# @return [GlobalID::Identification] the located record.
|
|
86
|
+
# @raise [Ruact::InvalidSignedGlobalIDError] when the token is tampered,
|
|
87
|
+
# expired, or scoped to a different purpose (`locate_signed` returns
|
|
88
|
+
# `nil`). The structured-error chain maps this to a clean 4xx — no
|
|
89
|
+
# `ActiveRecord::RecordNotFound` leak, no raw-id trust.
|
|
90
|
+
# @raise [Ruact::Error] when a purpose cannot be resolved.
|
|
91
|
+
def locate_signed(token, for: UNSET)
|
|
92
|
+
__ruact_require_global_id!
|
|
93
|
+
purpose = __ruact_resolve_purpose(binding.local_variable_get(:for))
|
|
94
|
+
record = GlobalID::Locator.locate_signed(token, for: purpose)
|
|
95
|
+
return record unless record.nil?
|
|
96
|
+
|
|
97
|
+
raise Ruact::InvalidSignedGlobalIDError,
|
|
98
|
+
"Signed reference could not be verified for purpose #{purpose.inspect} " \
|
|
99
|
+
"(it may be tampered, expired, or scoped to a different purpose)."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Lazy-require globalid only when a signed-reference helper is actually
|
|
105
|
+
# called — the gem keeps a single hard runtime dependency (nokogiri) and
|
|
106
|
+
# stays loadable in pure-Ruby contexts (mirrors the class-name-string
|
|
107
|
+
# matching in ErrorRendering that avoids requiring ActiveRecord at load).
|
|
108
|
+
# globalid ships with every Rails app, so a host never hits the rescue.
|
|
109
|
+
def __ruact_require_global_id!
|
|
110
|
+
require "global_id"
|
|
111
|
+
rescue LoadError
|
|
112
|
+
raise Ruact::Error,
|
|
113
|
+
"Ruact signed references require the `globalid` gem, which ships with Rails. " \
|
|
114
|
+
"Add `gem \"globalid\"` to your Gemfile if you are running ruact outside a Rails app."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Purpose must ALWAYS be present — an unscoped token is never acceptable, so
|
|
118
|
+
# both omission-without-default AND an explicit `for: nil` raise.
|
|
119
|
+
def __ruact_resolve_purpose(arg)
|
|
120
|
+
resolved = arg.equal?(UNSET) ? Ruact.config.signed_global_id_default_purpose : arg
|
|
121
|
+
return resolved unless resolved.nil?
|
|
122
|
+
|
|
123
|
+
raise Ruact::Error,
|
|
124
|
+
"Ruact signed references require a purpose: pass `for:` (a Symbol/String scoping the " \
|
|
125
|
+
"token to one use-site) or set `Ruact.config.signed_global_id_default_purpose`. " \
|
|
126
|
+
"Refusing to sign or resolve an unscoped reference."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Expiry distinguishes omission from a deliberate `nil`. An explicit value
|
|
130
|
+
# (including `nil` → non-expiring) is honored; pure omission falls to the
|
|
131
|
+
# configured default and, if that too is unset, raises — never a silent
|
|
132
|
+
# non-expiring token. The resolved value is then type-checked: only `nil`
|
|
133
|
+
# (the one reviewed non-expiring escape hatch) or an `ActiveSupport::Duration`
|
|
134
|
+
# (anything responding to `#from_now`, which is what globalid calls) is
|
|
135
|
+
# allowed — so a stray `false` (which globalid would silently treat as
|
|
136
|
+
# non-expiring) or a bare Integer (which globalid would crash on) is rejected
|
|
137
|
+
# loudly, closing the second silent non-expiring path.
|
|
138
|
+
def __ruact_resolve_expiry(arg)
|
|
139
|
+
omitted = arg.equal?(UNSET)
|
|
140
|
+
expiry = omitted ? Ruact.config.signed_global_id_default_expires_in : arg
|
|
141
|
+
|
|
142
|
+
if omitted && expiry.nil?
|
|
143
|
+
raise Ruact::Error,
|
|
144
|
+
"Ruact.signed_global_id requires an expiry: pass `expires_in:` (an " \
|
|
145
|
+
"ActiveSupport::Duration like `15.minutes`, or an explicit `nil` to deliberately mint a " \
|
|
146
|
+
"non-expiring token) or set `Ruact.config.signed_global_id_default_expires_in`. " \
|
|
147
|
+
"Refusing to silently mint a non-expiring reference."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
unless expiry.nil? || expiry.respond_to?(:from_now)
|
|
151
|
+
raise Ruact::Error,
|
|
152
|
+
"Ruact signed-reference expiry must be an ActiveSupport::Duration (e.g. `15.minutes`) " \
|
|
153
|
+
"or an explicit `nil` (a deliberate non-expiring token); got #{expiry.inspect}. " \
|
|
154
|
+
"A bare Integer or `false` is rejected to avoid an accidental non-expiring token."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
expiry
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
extend SignedReferences
|
|
162
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Damerau-Levenshtein string distance + a "did you mean?" closest-match
|
|
5
|
+
# helper, factored out of {Ruact::ClientManifest} (Story 7.4) so the
|
|
6
|
+
# Story 13.5 component-contract validator can reuse the SAME closest-match
|
|
7
|
+
# grain for typo'd prop/slot names ("did you mean `postId`?") without
|
|
8
|
+
# duplicating the algorithm.
|
|
9
|
+
#
|
|
10
|
+
# Names are short (≤ 30 chars in practice) so the full O(m·n) DP table is
|
|
11
|
+
# fine — the readability win over the two-row trick is worth ~30 cells.
|
|
12
|
+
module StringDistance
|
|
13
|
+
# Damerau-Levenshtein distance — like classic Levenshtein but treats an
|
|
14
|
+
# adjacent transposition (e.g. "ke"↔"ek") as a single edit.
|
|
15
|
+
#
|
|
16
|
+
# @param left [String]
|
|
17
|
+
# @param right [String]
|
|
18
|
+
# @return [Integer] the edit distance
|
|
19
|
+
# rubocop:disable Metrics/AbcSize
|
|
20
|
+
def self.damerau_levenshtein(left, right)
|
|
21
|
+
return right.length if left.empty?
|
|
22
|
+
return left.length if right.empty?
|
|
23
|
+
|
|
24
|
+
m = left.length
|
|
25
|
+
n = right.length
|
|
26
|
+
d = Array.new(m + 1) { Array.new(n + 1, 0) }
|
|
27
|
+
(0..m).each { |i| d[i][0] = i }
|
|
28
|
+
(0..n).each { |j| d[0][j] = j }
|
|
29
|
+
|
|
30
|
+
(1..m).each do |i|
|
|
31
|
+
(1..n).each do |j|
|
|
32
|
+
cost = left[i - 1] == right[j - 1] ? 0 : 1
|
|
33
|
+
d[i][j] = [
|
|
34
|
+
d[i - 1][j] + 1, # deletion
|
|
35
|
+
d[i][j - 1] + 1, # insertion
|
|
36
|
+
d[i - 1][j - 1] + cost # substitution
|
|
37
|
+
].min
|
|
38
|
+
if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1]
|
|
39
|
+
d[i][j] = [d[i][j], d[i - 2][j - 2] + cost].min
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
d[m][n]
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable Metrics/AbcSize
|
|
47
|
+
|
|
48
|
+
# Returns the entry in +pool+ within Damerau-Levenshtein distance +max+ of
|
|
49
|
+
# +name+ (case-insensitive), preferring the smallest distance. Returns +nil+
|
|
50
|
+
# when nothing qualifies.
|
|
51
|
+
#
|
|
52
|
+
# @param name [String] the typo'd name to match
|
|
53
|
+
# @param pool [Array<String>] candidate names
|
|
54
|
+
# @param max [Integer] inclusive distance threshold (default 2)
|
|
55
|
+
# @return [String, nil]
|
|
56
|
+
def self.closest_match(name, pool, max: 2)
|
|
57
|
+
target = name.downcase
|
|
58
|
+
best = nil
|
|
59
|
+
best_distance = max + 1
|
|
60
|
+
|
|
61
|
+
pool.each do |candidate|
|
|
62
|
+
distance = damerau_levenshtein(target, candidate.downcase)
|
|
63
|
+
next if distance > max || distance >= best_distance
|
|
64
|
+
|
|
65
|
+
best_distance = distance
|
|
66
|
+
best = candidate
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
best
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require_relative "server_functions/validation_errors"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
# Story 13.3 (FR98) — the per-request validation-error collector, shared by
|
|
8
|
+
# {Ruact::Server} (Bucket-2 JSON-body injection + redirect flash stash) and
|
|
9
|
+
# {Ruact::Controller} (page-render `errors` prop + redirect-back flash read).
|
|
10
|
+
#
|
|
11
|
+
# This is the **opt-in** half of the Inertia-style `errors` round-trip
|
|
12
|
+
# (design (B), the explicit-allowlist grain that 13.1/13.2 reinforced — NO
|
|
13
|
+
# auto-injection, NO globally reserved prop on every response). A host action
|
|
14
|
+
# opts in with a single call after its save attempt:
|
|
15
|
+
#
|
|
16
|
+
# def create
|
|
17
|
+
# @post = Post.new(post_params)
|
|
18
|
+
# if @post.save
|
|
19
|
+
# redirect_to @post
|
|
20
|
+
# else
|
|
21
|
+
# ruact_errors(@post) # registers {attr => [full messages]}
|
|
22
|
+
# render :new # (Bucket 1) or fall through (Bucket 2)
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Because the canonical shape derives from `record.errors` (empty on a valid
|
|
27
|
+
# record), the SAME `ruact_errors(@post)` call yields `{}` on success and the
|
|
28
|
+
# populated map on failure — the "always present, same code path" invariant of
|
|
29
|
+
# AC1, delivered per-action without reserving a global prop name or disturbing
|
|
30
|
+
# the Story 9.2 Bucket-2 `204 No Content` contract for an untouched collector.
|
|
31
|
+
#
|
|
32
|
+
# The reader form (no argument) returns the always-present hash for binding to
|
|
33
|
+
# a form component on the page render:
|
|
34
|
+
#
|
|
35
|
+
# <PostForm post={@post} errors={ruact_errors} />
|
|
36
|
+
module ValidationErrorsCollector
|
|
37
|
+
extend ActiveSupport::Concern
|
|
38
|
+
|
|
39
|
+
# Flash key the Bucket-1 redirect-back round-trip stashes the canonical
|
|
40
|
+
# errors under (single-use, session-backed — the exact Inertia semantics).
|
|
41
|
+
RUACT_ERRORS_FLASH_KEY = :ruact_errors
|
|
42
|
+
|
|
43
|
+
# `view_assigns` keys (ivar names without the leading `@`) that must NEVER
|
|
44
|
+
# serialize into a Bucket-2 body: the two collector internals (Rails only
|
|
45
|
+
# filters a fixed set of `@_`-named ivars, not every `@__`-prefixed one), and
|
|
46
|
+
# the reserved `errors` output key itself (a stray dev ivar literally named
|
|
47
|
+
# `@errors` is dropped so it can neither clobber the round-trip key nor trip
|
|
48
|
+
# strict serialization — the collector is the source of truth).
|
|
49
|
+
RESERVED_ASSIGN_KEYS = %w[__ruact_errors __ruact_errors_touched errors].freeze
|
|
50
|
+
|
|
51
|
+
# Sentinel distinguishing the COLLECT call (`ruact_errors(record)`, argument
|
|
52
|
+
# given) from the READ call (`ruact_errors`, no argument). `nil` is a valid
|
|
53
|
+
# collect argument (it normalizes to `{}`), so it cannot be the sentinel.
|
|
54
|
+
NOT_GIVEN = Object.new
|
|
55
|
+
private_constant :NOT_GIVEN
|
|
56
|
+
|
|
57
|
+
included do
|
|
58
|
+
# Expose the reader to the view so a template can bind
|
|
59
|
+
# `errors={ruact_errors}` on a component. Guarded for non-controller hosts
|
|
60
|
+
# (which never reach the page-render path).
|
|
61
|
+
helper_method :ruact_errors if respond_to?(:helper_method)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Dual-purpose helper.
|
|
65
|
+
#
|
|
66
|
+
# ruact_errors(@post) → COLLECT: normalize the record's errors into the
|
|
67
|
+
# canonical `{attr => [full messages]}` shape, merge it into the
|
|
68
|
+
# per-request collector, mark the collector touched, and return the
|
|
69
|
+
# normalized hash (so it can also be used inline).
|
|
70
|
+
# ruact_errors → READ: return the always-present collector (`{}` by
|
|
71
|
+
# default), for binding to a form component on the page render.
|
|
72
|
+
#
|
|
73
|
+
# Merging across multiple records is additive per attribute (later calls
|
|
74
|
+
# union their messages onto earlier ones), so an action validating several
|
|
75
|
+
# records surfaces them under one `errors` object.
|
|
76
|
+
#
|
|
77
|
+
# @overload ruact_errors(record)
|
|
78
|
+
# @param record [#errors, ActiveModel::Errors, Hash, nil] the failed record
|
|
79
|
+
# @return [Hash{String=>Array<String>}] the normalized errors for +record+
|
|
80
|
+
# @overload ruact_errors
|
|
81
|
+
# @return [Hash{String=>Array<String>}] the always-present collector
|
|
82
|
+
def ruact_errors(record = NOT_GIVEN)
|
|
83
|
+
return __ruact_errors if record.equal?(NOT_GIVEN)
|
|
84
|
+
|
|
85
|
+
normalized = ServerFunctions::ValidationErrors.normalize(record)
|
|
86
|
+
@__ruact_errors_touched = true
|
|
87
|
+
__ruact_errors.merge!(normalized) do |_attribute, existing, incoming|
|
|
88
|
+
existing | incoming
|
|
89
|
+
end
|
|
90
|
+
normalized
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The per-request collector. A private ivar (NOT a `view_assigns` ivar) so it
|
|
96
|
+
# never collides with the dev's own assigns during Bucket-2 serialization.
|
|
97
|
+
def __ruact_errors
|
|
98
|
+
@__ruact_errors ||= {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether `ruact_errors` was called in a writing form this request (or the
|
|
102
|
+
# collector was seeded from a redirect-back flash). Gates the Bucket-2
|
|
103
|
+
# injection so an UNTOUCHED collector preserves the 9.2 `204 No Content`
|
|
104
|
+
# contract — a populated `{}` (opted-in success) is still "touched".
|
|
105
|
+
def __ruact_errors_touched?
|
|
106
|
+
@__ruact_errors_touched == true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Stash the canonical errors in `flash` so they survive a Bucket-1
|
|
110
|
+
# redirect-back (the re-render is a fresh GET). No-op when the collector was
|
|
111
|
+
# never touched, or when the host has no usable session/flash (an API-only
|
|
112
|
+
# host without sessions degrades to the Bucket-2 body path rather than
|
|
113
|
+
# crashing — AC4 constraint).
|
|
114
|
+
def __ruact_stash_errors_in_flash
|
|
115
|
+
return unless __ruact_errors_touched?
|
|
116
|
+
return unless respond_to?(:flash)
|
|
117
|
+
|
|
118
|
+
flash[RUACT_ERRORS_FLASH_KEY] = __ruact_errors
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Seed the collector from a redirect-back flash on the re-rendered GET so the
|
|
124
|
+
# reader exposes the surviving errors as a page-render prop. `flash` is
|
|
125
|
+
# single-use (Rails sweeps it after this request), so the prop does not
|
|
126
|
+
# persist beyond the re-render.
|
|
127
|
+
def __ruact_read_errors_from_flash
|
|
128
|
+
return unless respond_to?(:flash)
|
|
129
|
+
|
|
130
|
+
stashed = flash[RUACT_ERRORS_FLASH_KEY]
|
|
131
|
+
return if stashed.nil?
|
|
132
|
+
|
|
133
|
+
@__ruact_errors_touched = true
|
|
134
|
+
__ruact_errors.merge!(ServerFunctions::ValidationErrors.normalize(stashed))
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
3
6
|
module Ruact
|
|
4
7
|
# ActionView helper module included in ActionView::Base via Railtie.
|
|
5
8
|
# Provides the +__ruact_component__+ method that ERB templates call after the
|
|
@@ -27,5 +30,104 @@ module Ruact
|
|
|
27
30
|
token = ctx.register(name, props)
|
|
28
31
|
"<!-- #{token} -->".html_safe
|
|
29
32
|
end
|
|
33
|
+
|
|
34
|
+
# Story 14.2 (FR104) — emits ruact's full JavaScript asset block: the
|
|
35
|
+
# dev/prod bootstrap entry `<script>` tags (re-targeting the virtual entry
|
|
36
|
+
# `virtual:ruact/bootstrap` — in dev the react-refresh preamble + `@vite/client`
|
|
37
|
+
# + the bootstrap module; in prod the hashed URL read from the Vite manifest)
|
|
38
|
+
# AND, when a Flight payload is given, the `__FLIGHT_DATA` inline bootstrap
|
|
39
|
+
# `<script>` the entry reads on boot.
|
|
40
|
+
#
|
|
41
|
+
# This is the SINGLE implementation of the JS asset markup —
|
|
42
|
+
# `Ruact::Controller#ruact_html_shell` delegates to it so the controller's
|
|
43
|
+
# generated shell and any view that calls the helper emit byte-identical tags
|
|
44
|
+
# (no drift). Available in every view via the railtie's
|
|
45
|
+
# `ActionView::Base.include(Ruact::ViewHelper)`.
|
|
46
|
+
#
|
|
47
|
+
# @param flight_payload [String, nil] the per-render Flight wire payload to
|
|
48
|
+
# inline as `__FLIGHT_DATA`; omit (or pass nil) to emit only the entry tags.
|
|
49
|
+
# @return [ActiveSupport::SafeBuffer] the asset markup, html_safe
|
|
50
|
+
# @example In a layout
|
|
51
|
+
# <%= ruact_js_assets %>
|
|
52
|
+
def ruact_js_assets(flight_payload = nil)
|
|
53
|
+
parts = []
|
|
54
|
+
parts << ruact_flight_data_script(flight_payload) unless flight_payload.nil?
|
|
55
|
+
parts << ruact_vite_tags
|
|
56
|
+
parts.join("\n").html_safe
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
|
|
62
|
+
# Flight payload onto the global queue the bootstrap entry drains on boot.
|
|
63
|
+
# `</script>` in the payload is escaped to prevent an HTML/XSS breakout
|
|
64
|
+
# (mandatory whenever Flight data is inlined into a `<script>` tag).
|
|
65
|
+
def ruact_flight_data_script(flight_payload)
|
|
66
|
+
escaped_payload = flight_payload.gsub("</script>", '<\/script>')
|
|
67
|
+
<<~HTML.strip
|
|
68
|
+
<script>
|
|
69
|
+
(function() {
|
|
70
|
+
var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
|
|
71
|
+
d.push(#{escaped_payload.inspect});
|
|
72
|
+
})();
|
|
73
|
+
</script>
|
|
74
|
+
HTML
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The bootstrap entry `<script>` tags — dev serves the virtual module from
|
|
78
|
+
# the Vite dev server (with the react-refresh preamble + `@vite/client`);
|
|
79
|
+
# prod reads the hashed URL from the Vite manifest. Both target the SAME
|
|
80
|
+
# virtual entry id (`Ruact.bootstrap_virtual_id`) so dev/prod stay in lockstep.
|
|
81
|
+
def ruact_vite_tags
|
|
82
|
+
if Rails.env.development? && vite_dev_running?
|
|
83
|
+
# @vitejs/plugin-react normally injects this preamble by processing index.html.
|
|
84
|
+
# Since our HTML is generated by Rails (not Vite), we inject it manually.
|
|
85
|
+
# Without it, every JSX file throws "can't detect preamble" at runtime.
|
|
86
|
+
react_preamble = <<~JS
|
|
87
|
+
<script type="module">
|
|
88
|
+
import RefreshRuntime from 'http://localhost:5173/@react-refresh';
|
|
89
|
+
RefreshRuntime.injectIntoGlobalHook(window);
|
|
90
|
+
window.$RefreshReg$ = () => {};
|
|
91
|
+
window.$RefreshSig$ = () => (type) => type;
|
|
92
|
+
window.__vite_plugin_react_preamble_installed__ = true;
|
|
93
|
+
</script>
|
|
94
|
+
JS
|
|
95
|
+
|
|
96
|
+
react_preamble + <<~HTML
|
|
97
|
+
<script type="module" src="http://localhost:5173/@vite/client"></script>
|
|
98
|
+
<script type="module" src="#{ruact_bootstrap_dev_url}"></script>
|
|
99
|
+
HTML
|
|
100
|
+
else
|
|
101
|
+
# Production: read the hashed URL from the Vite manifest, keyed on the
|
|
102
|
+
# virtual entry id (the manifest key Vite emits for the virtual input).
|
|
103
|
+
entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
|
|
104
|
+
src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
|
|
105
|
+
%(<script type="module" src="#{src}"></script>)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The Vite dev-server URL for the virtual bootstrap. Vite serves virtual
|
|
110
|
+
# modules under `/@id/`, encoding the resolved id's leading NUL (`\0`) as
|
|
111
|
+
# `__x00__` — so `\0virtual:ruact/bootstrap` is served at
|
|
112
|
+
# `/@id/__x00__virtual:ruact/bootstrap`. A plain `/virtual:...` URL falls
|
|
113
|
+
# through to the dev server's HTML fallback, so this encoding is required.
|
|
114
|
+
def ruact_bootstrap_dev_url
|
|
115
|
+
"http://localhost:5173/@id/__x00__#{Ruact.bootstrap_virtual_id}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def vite_dev_running?
|
|
119
|
+
require "socket"
|
|
120
|
+
Socket.tcp("localhost", 5173, connect_timeout: 1).close
|
|
121
|
+
true
|
|
122
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def vite_manifest_entry(src_path)
|
|
127
|
+
manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
|
|
128
|
+
return nil unless File.exist?(manifest_path)
|
|
129
|
+
|
|
130
|
+
JSON.parse(File.read(manifest_path))[src_path]
|
|
131
|
+
end
|
|
30
132
|
end
|
|
31
133
|
end
|
data/lib/ruact.rb
CHANGED
|
@@ -4,7 +4,10 @@ require_relative "ruact/version"
|
|
|
4
4
|
require_relative "ruact/errors"
|
|
5
5
|
require_relative "ruact/configuration"
|
|
6
6
|
require_relative "ruact/serializable"
|
|
7
|
+
require_relative "ruact/signed_references"
|
|
7
8
|
require_relative "ruact/flight"
|
|
9
|
+
require_relative "ruact/string_distance"
|
|
10
|
+
require_relative "ruact/component_contract"
|
|
8
11
|
require_relative "ruact/erb_preprocessor"
|
|
9
12
|
require_relative "ruact/render_context"
|
|
10
13
|
require_relative "ruact/html_converter"
|
|
@@ -13,7 +16,6 @@ require_relative "ruact/render_pipeline"
|
|
|
13
16
|
require_relative "ruact/view_helper"
|
|
14
17
|
require_relative "ruact/erb_preprocessor_hook"
|
|
15
18
|
require_relative "ruact/server_functions"
|
|
16
|
-
require_relative "ruact/server_action"
|
|
17
19
|
require_relative "ruact/query"
|
|
18
20
|
# Railtie loads ruact/controller when inside a Rails app
|
|
19
21
|
require_relative "ruact/railtie" if defined?(Rails)
|
|
@@ -22,24 +24,6 @@ module Ruact
|
|
|
22
24
|
class << self
|
|
23
25
|
attr_accessor :manifest, :streaming_mode
|
|
24
26
|
|
|
25
|
-
# Registry of `ruact_action` declarations. Populated by Story 8.1's
|
|
26
|
-
# controller macro; consumed by {Ruact::ServerFunctions::Snapshot} when
|
|
27
|
-
# writing the Rails↔Vite bridge JSON.
|
|
28
|
-
#
|
|
29
|
-
# @return [Ruact::ServerFunctions::Registry] lazy-initialized singleton.
|
|
30
|
-
def action_registry
|
|
31
|
-
@action_registry ||= ServerFunctions::Registry.new
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Registry of `ruact_query` declarations. Populated by Story 9.1's
|
|
35
|
-
# controller macro; consumed by {Ruact::ServerFunctions::Snapshot} when
|
|
36
|
-
# writing the Rails↔Vite bridge JSON.
|
|
37
|
-
#
|
|
38
|
-
# @return [Ruact::ServerFunctions::Registry] lazy-initialized singleton.
|
|
39
|
-
def query_registry
|
|
40
|
-
@query_registry ||= ServerFunctions::Registry.new
|
|
41
|
-
end
|
|
42
|
-
|
|
43
27
|
# Story 8.4 — Absolute path to the gem's `lib/` root. Used by
|
|
44
28
|
# {Ruact::ServerFunctions::BacktraceCleaner} to classify backtrace frames as
|
|
45
29
|
# APP or GEM. Memoised at first call so the per-frame `start_with?` check
|
|
@@ -60,6 +44,22 @@ module Ruact
|
|
|
60
44
|
File.expand_path("../vendor/javascript/vite-plugin-ruact/index.js", __dir__)
|
|
61
45
|
end
|
|
62
46
|
|
|
47
|
+
# Story 14.2 (FR104) — the SINGLE source of truth for the bootstrap entry id.
|
|
48
|
+
# ruact's React entry is served as the virtual module `virtual:ruact/bootstrap`
|
|
49
|
+
# (by the bundled Vite plugin) instead of a `app/javascript/application.jsx`
|
|
50
|
+
# file in the user's tree. Every reference point must agree on this id:
|
|
51
|
+
# - the generated `vite.config` `rollupOptions.input`
|
|
52
|
+
# - the dev `<script src>` (`/@id/__x00__virtual:ruact/bootstrap`)
|
|
53
|
+
# - the production Vite-manifest lookup key
|
|
54
|
+
# - the plugin's `resolveId`/`load` (`BOOTSTRAP_VIRTUAL_ID` in index.js)
|
|
55
|
+
# `Ruact::ViewHelper#ruact_js_assets` and `templates/vite.config.js.tt` both
|
|
56
|
+
# read this constant so they can never drift (the regression AC3 guards).
|
|
57
|
+
#
|
|
58
|
+
# @return [String] the bootstrap virtual-module id ("virtual:ruact/bootstrap")
|
|
59
|
+
def bootstrap_virtual_id
|
|
60
|
+
"virtual:ruact/bootstrap"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
63
|
# Yields a mutable Configuration draft for block-style setup. The draft is
|
|
64
64
|
# frozen and atomically swapped into `Ruact.config` when the block returns.
|
|
65
65
|
# Mutating `Ruact.config` outside this block raises
|
data/lib/tasks/ruact.rake
CHANGED
|
@@ -8,67 +8,24 @@ namespace :ruact do
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
namespace :server_functions do
|
|
11
|
-
# Story 8.0a — manual / CI / production codegen entry point. Mirrors
|
|
12
|
-
# Railtie hook (config.to_prepare) for environments where the dev server
|
|
11
|
+
# Story 8.0a / 9.9 — manual / CI / production codegen entry point. Mirrors
|
|
12
|
+
# the Railtie hook (config.to_prepare) for environments where the dev server
|
|
13
13
|
# is not running (CI, deploy pipelines, container build steps).
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# 2. `Snapshot.read_for_codegen` re-loads the persisted JSON — this is
|
|
20
|
-
# the on-disk source of truth that the Vite plugin consumes, and
|
|
21
|
-
# reusing it (instead of freshly dumping with a new timestamp)
|
|
22
|
-
# keeps the TS module byte-stable on unchanged registries.
|
|
23
|
-
# 3. `Codegen.generate_ts!` writes the TS module via the same write-
|
|
24
|
-
# if-changed guard.
|
|
15
|
+
# Story 9.9 — the route-driven (v2) codegen is the sole writer. The task
|
|
16
|
+
# forces the route table to load, then writes the v2 snapshot to the REAL
|
|
17
|
+
# bridge (`tmp/cache/ruact/server-functions.json`) and renders the TS module
|
|
18
|
+
# (`app/javascript/.ruact/server-functions.ts`), both write-if-changed.
|
|
25
19
|
#
|
|
26
20
|
# Exit codes: 0 on success or no-op rewrites; 1 on
|
|
27
|
-
# `Ruact::ConfigurationError` (
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
desc "Regenerate app/javascript/.ruact/server-functions.ts from the gem registries (Story 8.0a)"
|
|
21
|
+
# `Ruact::ConfigurationError` (a server-function naming collision, an
|
|
22
|
+
# invalid name, or a corrupted snapshot rejected by the codegen guards).
|
|
23
|
+
desc "Regenerate app/javascript/.ruact/server-functions.ts from the Rails route table (Story 9.9)"
|
|
31
24
|
task generate: :environment do
|
|
32
25
|
require "ruact/server_functions"
|
|
33
|
-
require "json"
|
|
34
|
-
|
|
35
|
-
json_path = Rails.root.join("tmp/cache/ruact/server-functions.json")
|
|
36
|
-
ts_path = Rails.root.join("app/javascript/.ruact/server-functions.ts")
|
|
37
26
|
|
|
38
27
|
begin
|
|
39
|
-
|
|
40
|
-
action_registry: Ruact.action_registry,
|
|
41
|
-
query_registry: Ruact.query_registry,
|
|
42
|
-
path: json_path
|
|
43
|
-
)
|
|
44
|
-
# Pass-2 patch 2026-05-14 — the JSON can disappear OR be partially
|
|
45
|
-
# written between `generate!` and `File.read` (a concurrent rake
|
|
46
|
-
# invocation flushing mid-write, a tmpdir wipe by Spring, an
|
|
47
|
-
# externally-managed `tmp/cache` cleaner). Both `Errno::ENOENT` AND
|
|
48
|
-
# `JSON::ParserError` indicate the same TOCTOU window — re-invoke
|
|
49
|
-
# `generate!` once with the same registries; if the second read
|
|
50
|
-
# still fails we surface the error with a clear "[ruact] error"
|
|
51
|
-
# envelope so the caller sees a real failure rather than an
|
|
52
|
-
# unwrapped Errno / parser backtrace.
|
|
53
|
-
snapshot = begin
|
|
54
|
-
JSON.parse(File.read(json_path)).transform_keys(&:to_sym)
|
|
55
|
-
rescue Errno::ENOENT, JSON::ParserError
|
|
56
|
-
Ruact::ServerFunctions::Snapshot.generate!(
|
|
57
|
-
action_registry: Ruact.action_registry,
|
|
58
|
-
query_registry: Ruact.query_registry,
|
|
59
|
-
path: json_path
|
|
60
|
-
)
|
|
61
|
-
JSON.parse(File.read(json_path)).transform_keys(&:to_sym)
|
|
62
|
-
end
|
|
63
|
-
Ruact::ServerFunctions::Codegen.generate_ts!(
|
|
64
|
-
snapshot: snapshot,
|
|
65
|
-
output_path: ts_path
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
# Story 9.3 — also emit the route-driven (v2) parallel target
|
|
69
|
-
# (`server-functions.next.{json,ts}`). Vite does not render the `.next`
|
|
70
|
-
# bridge, so the rake is the production/CI path that materializes it for
|
|
71
|
-
# parity + inspection. The real `server-functions.ts` above stays v1.
|
|
28
|
+
Rails.application.routes_reloader.execute_unless_loaded
|
|
72
29
|
Ruact::ServerFunctions.write_v2_snapshot!(
|
|
73
30
|
route_set: Rails.application.routes, root: Rails.root, logger: Rails.logger
|
|
74
31
|
)
|