Module: Ruact::Server

Extended by:
ActiveSupport::Concern
Includes:
Ruact::ServerFunctions::ErrorRendering, ValidationErrorsCollector
Defined in:
lib/ruact/server.rb

Overview

Story 9.1 (route-driven redesign, Phase A) — the v2 server-functions marker concern.

class PostsController < ApplicationController
include Ruact::Server        # the ONLY marker — no per-action DSL

def create                   # non-GET routed action → callable server function
  @post = Post.create!(title: params[:title])
  redirect_to @post
end
end

Per the 2026-06-02 ADR addendum (Story 9-0, docs/internal/decisions/server-functions-api.md), exposure is decided by routes.rb — the Story 9.3 codegen reads Rails.application.routes filtered to non-GET routes on controllers that include this concern. The concern itself registers NOTHING and emits NOTHING; at this story it is a pure marker plus the new home of the two salvaged Epic-8 subsystems, running on the host controller's own callback chain:

  • Story 8.4 structured-error chainrescue_from StandardError (+ an explicit ActionController::InvalidAuthenticityToken registration that preempts Rails' default handle_unverified_request, Pitfall #1). On function-call requests (#__ruact_function_call?) an uncaught exception renders the structured JSON payload (discriminator _ruact_server_action_error: true, four baseline fields, dev/prod split via Ruact.config.dev_error_payload_enabled, status mapping 422/403/ 413/500). On every other request shape — including GET/HEAD requests regardless of their Accept header — the handler re-raises, so GET pages, default_render, and Phase-1 behavior stay byte-for-byte untouched. Host rescue_from declarations — whether inherited from a parent class or declared in the host's own body — keep precedence: the chain only catches what the host did not.
  • Story 8.5 upload guardprepend_before_action enforcing Ruact.config.max_upload_bytes against the wire Content-Length BEFORE Rack's multipart parser. The three carve-outs are preserved: nil limit, non-multipart/urlencoded content type, absent Content-Length. New here (D2): GET/HEAD requests skip the guard entirely. The 413 renders structured for ALL request shapes (D1) — a meaningful 413 beats a re-raised 500 for native form submits too. Contract simplification: the concern assumes the host includes it after protect_from_forgery; no runtime callback-order verifier runs here.

Both bodies live in Ruact::ServerFunctions::ErrorRendering (Story 9.9 — this concern is now the sole home; the v1 endpoint that previously shared it was demolished). Dual-bucket response negotiation (ivar serialization, $redirect, 204, Vary: Accept) is Story 9.2; this concern only contributes the discrimination predicate 9.2 will reuse.

Defined Under Namespace

Modules: ClassMethods

Constant Summary

Constants included from ValidationErrorsCollector

ValidationErrorsCollector::RESERVED_ASSIGN_KEYS, ValidationErrorsCollector::RUACT_ERRORS_FLASH_KEY

Instance Method Summary collapse

Methods included from ValidationErrorsCollector

#ruact_errors

Instance Method Details

#default_renderObject

Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the action finished without an explicit render on a function-call request (#__ruact_function_call?Accept: application/json, non-GET), serialize the action's exposed instance variables (Rails view_assigns, verbatim — the same set a view would see) as a JSON object keyed by ivar name, or 204 No Content when none were set. Any other request shape falls through to super so Bucket-1 rendering — the host's Ruact::Controller Flight re-render, then Rails — is byte-for-byte unchanged (AC1).

The exposed-ivar set is Rails' own view_assigns with no custom filtering: Rails already excludes its protected @_-prefixed internals (including the CSRF @_marked_for_same_origin_verification flag), so what remains is exactly what the action assigned. Each value is serialized through the ruact_props / Ruact::Serializable / strict_serialization rules (Ruact::ServerFunctions::BucketTwoPayload); a single ivar stays keyed (no magic unwrap).



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/ruact/server.rb', line 195

def default_render(*)
  return super unless __ruact_function_call?

  # Story 13.3 (FR98) — the collector's framework-internal ivars (and a stray
  # dev `@errors`) are dropped from the serialized assigns; Rails' own
  # `view_assigns` only filters a fixed set of `@_` ivars, not every `@__`
  # one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
  assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)

  # Story 13.3 (FR98, AC3) — when the host opted into the validation-error
  # round-trip (`ruact_errors(@record)` was called this request), inject the
  # collected errors under the reserved JSON key `"errors"` alongside the
  # serialized ivars, even on an otherwise-empty assigns set: an opted-in
  # success must still surface `{"errors": {}}` so the client's `await`
  # result carries `result.errors` on a single code path.
  if __ruact_errors_touched?
    payload = ServerFunctions::BucketTwoPayload.build(
      assigns, strict: Ruact.config.strict_serialization
    )
    payload["errors"] = __ruact_errors
    return render(json: payload)
  end

  # An UNTOUCHED collector changes nothing — the 9.2 `204 No Content`
  # empty-Bucket-2 contract is preserved.
  return head(:no_content) if assigns.empty?

  render json: ServerFunctions::BucketTwoPayload.build(
    assigns, strict: Ruact.config.strict_serialization
  )
end

#redirect_to(options = {}, response_options = {}) ⇒ Object

Story 9.2 AC3 (D2) — on a function-call request, redirect_to surfaces as a JSON redirect directive — body "$redirect" => "<path>" (the runtime follows it client-side; re-targeting/following is Story 9.3) instead of a 302 or a Flight redirect row. Any other request shape falls through to super so the Bucket-1 Flight redirect row / Rails 302 is unchanged (AC1).

Review round 1 — reuses Rails' OWN redirect machinery (_compute_redirect_to_location, _ensure_url_is_http_header_safe, _enforce_open_redirect_protection) so the nil-check, header-safety, and open-redirect protection (allow_other_host / raise_on_open_redirects) match Bucket 1 / stock Rails exactly — a cross-host redirect_to raises UnsafeRedirectError instead of leaking an external $redirect. Same- origin targets collapse to a path; an allowed external origin keeps the absolute URL.

Raises:

  • (ActionController::ActionControllerError)


241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/ruact/server.rb', line 241

def redirect_to(options = {}, response_options = {})
  return super unless __ruact_function_call?

  raise ActionController::ActionControllerError, "Cannot redirect to nil!" unless options
  raise AbstractController::DoubleRenderError if response_body

  allow_other_host = response_options.delete(:allow_other_host)
  location = _compute_redirect_to_location(request, options)
  _ensure_url_is_http_header_safe(location)
  location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)

  render json: { "$redirect" => __ruact_redirect_path(location) }
end