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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.5"
4
+ VERSION = "0.0.6"
5
5
  end
@@ -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 the
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
- # Pipeline:
16
- # 1. `Snapshot.generate!` writes the JSON bridge IF its registry
17
- # payload differs from the on-disk version (write-if-changed by
18
- # payload, not by timestamp).
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` (invalid symbol shape per AC7, cross-
28
- # registry collision, invalid kind, or a corrupted snapshot rejected by
29
- # the codegen's identifier guard).
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
- Ruact::ServerFunctions::Snapshot.generate!(
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
  )
@@ -0,0 +1,3 @@
1
+ <div>
2
+ <DemoButton label={"new-post"} errors={ruact_errors} />
3
+ </div>