Module: Phlex::Reactive

Defined in:
lib/phlex/reactive.rb,
lib/phlex/reactive/js.rb,
lib/phlex/reactive/apm.rb,
lib/phlex/reactive/mcp.rb,
lib/phlex/reactive/defer.rb,
lib/phlex/reactive/reply.rb,
lib/phlex/reactive/doctor.rb,
lib/phlex/reactive/engine.rb,
lib/phlex/reactive/stream.rb,
lib/phlex/reactive/effects.rb,
lib/phlex/reactive/version.rb,
lib/phlex/reactive/response.rb,
lib/phlex/reactive/component.rb,
lib/phlex/reactive/inspector.rb,
lib/phlex/reactive/apm/sentry.rb,
lib/phlex/reactive/mcp/runner.rb,
lib/phlex/reactive/mcp/server.rb,
lib/phlex/reactive/streamable.rb,
lib/phlex/reactive/apm/adapter.rb,
lib/phlex/reactive/apm/datadog.rb,
lib/phlex/reactive/param_schema.rb,
lib/phlex/reactive/test_helpers.rb,
lib/phlex/reactive/apm/appsignal.rb,
lib/phlex/reactive/authorization.rb,
lib/phlex/reactive/component/dsl.rb,
lib/phlex/reactive/mcp/base_tool.rb,
lib/phlex/reactive/apm/subscriber.rb,
lib/phlex/reactive/component/lazy.rb,
lib/phlex/reactive/log_subscriber.rb,
lib/phlex/reactive/client_bindings.rb,
lib/phlex/reactive/show_conditions.rb,
lib/phlex/reactive/inspector/report.rb,
lib/phlex/reactive/component/helpers.rb,
lib/phlex/reactive/component/identity.rb,
lib/phlex/reactive/component/registry.rb,
lib/phlex/reactive/deferred_render_job.rb,
lib/phlex/reactive/mcp/tools/find_tool.rb,
lib/phlex/reactive/test_helpers/system.rb,
lib/phlex/reactive/mcp/tools/config_tool.rb,
lib/phlex/reactive/mcp/tools/doctor_tool.rb,
lib/phlex/reactive/test_helpers/matchers.rb,
lib/phlex/reactive/mcp/tools/actions_tool.rb,
lib/phlex/reactive/mcp/tools/components_tool.rb,
app/controllers/phlex/reactive/actions_controller.rb,
lib/generators/phlex/reactive/claude/claude_generator.rb,
lib/generators/phlex/reactive/install/install_generator.rb,
lib/generators/phlex/reactive/component/component_generator.rb

Overview

phlex-reactive: reactive Phlex components for Rails.

Two cooperating mixins, one client runtime, one endpoint:

* Phlex::Reactive::Streamable — gives a component a stable `id` and class
methods to render itself as a Turbo Stream (`.replace`, `.append`, ...)
and to broadcast itself (`.broadcast_replace_to`, ...). The server->client
half (controller responses + background broadcasts).

* Phlex::Reactive::Component — declares client-invokable `action`s and
emits a signed identity token + the wiring the generic `reactive`
Stimulus controller needs. The client->server half (clicks, form input).

Both halves converge on ONE re-render unit: the component, targeted by its id. See the README for the mental model and examples.

Defined Under Namespace

Modules: APM, Authorization, ClientBindings, Component, Defer, Effects, Generators, Inspector, MCP, ShowConditions, Streamable, TestHelpers Classes: ActionContext, ActionsController, AuthorizationNotVerified, DeferredRenderJob, Doctor, Engine, Error, InvalidToken, JS, LogSubscriber, ParamSchema, Reply, Response, Stream, UnknownParamType

Constant Summary collapse

IDENTITY_PURPOSE =

Purpose string bound into every identity token's signature so a token minted for phlex-reactive can't be replayed against another verifier use.

"phlex-reactive/identity"
DEFER_PURPOSE =

Purpose string for DEFER tokens (issue #165) — the short-TTL identity a reply.defer directive carries so the client can fetch the expensive render off the actor's critical path. A distinct purpose makes the two token families non-interchangeable BY SIGNATURE: an action token is rejected at the defer endpoint (it must not become a render oracle) and a defer token can never invoke an action (it carries no action grant).

"phlex-reactive/defer"
DEFER_TRANSPORTS =

The defer_transport values (issue #165): :auto picks push (pgbus durable one-shot stream + ActiveJob) when capable, else pull (parallel fetch); :fetch forces pull; :stream requests push but still degrades to pull with a warning when the capability is absent (degrade, never break).

%i[auto fetch stream].freeze
TOKEN_VERSION =

The current identity-token payload version (issue #111), stamped into every signed token under the "v" key. It exists so the NEXT breaking shape change (a rename, per-token expiry, a nonce) can upgrade tokens already in flight instead of breaking every open page at deploy. A token minted before this existed carries no "v" — treated as version 0 (today's shape). Bump this and register a register_token_upgrader(old_version) when you change the shape.

1
INSTRUMENTATION_NAMESPACE =

The ActiveSupport::Notifications namespace for the gem's hot-path events (issue #107): action.phlex_reactive, render.phlex_reactive, broadcast.phlex_reactive. APM tools (AppSignal, Datadog, Skylight) auto-subscribe to *.phlex_reactive and get component-level visibility. Payloads carry NAMES/outcome/sizes ONLY — never the token, params, or state.

"phlex_reactive"
ERROR_CONTEXT_KEYS =

The name-only keys forwarded to error reporters (issue #207). Deliberately NOT the whole event hash: ActiveSupport::Notifications mutates the SAME hash during error unwinding, adding :exception / :exception_object — a reporter that RETAINS the hash would later observe those, breaking the name-only contract. report_error forwards a fresh slice of just these keys.

%i[component action outcome].freeze
ACTIONS_CONTROLLER =

The controller a correctly-mounted action path resolves to. Used by the route guard below.

"phlex/reactive/actions"
VERSION =
"0.12.6"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.action_pathObject



142
143
144
# File 'lib/phlex/reactive.rb', line 142

def action_path
  @action_path ||= "/reactive/actions"
end

.apmObject

Turnkey APM integration (issue #207). Set to a Symbol (:appsignal, :sentry, :datadog), a custom adapter object (responding to record_action/record_error), or nil (the default — off). A Symbol resolves to a built-in adapter LAZILY, at engine attach time, so load order doesn't matter; a set-but-undetectable SDK logs ONE warning at boot and no-ops (the pgbus optionality invariant applied to APMs — no vendor SDK is ever a hard dependency). When set and available, each reactive action is named Component#action in the APM (not one blurry ActionsController#create), and an action-body error is reported to the tracker with component/action tags. Default nil.



186
187
188
# File 'lib/phlex/reactive.rb', line 186

def apm
  @apm
end

.authorization_errorsObject

Exception classes the action endpoint renders as 403. Append your authorization library's error (Pundit::NotAuthorizedError, ActionPolicy::Unauthorized, ...).



134
135
136
# File 'lib/phlex/reactive.rb', line 134

def authorization_errors
  @authorization_errors
end

.authorization_methodsObject



231
232
233
234
235
# File 'lib/phlex/reactive.rb', line 231

def authorization_methods
  return @authorization_methods if defined?(@authorization_methods)

  %i[authorize! authorize allowed_to?]
end

.base_controller_nameObject



897
898
899
# File 'lib/phlex/reactive.rb', line 897

def base_controller_name
  @base_controller_name ||= "ActionController::Base"
end

.debugObject



200
201
202
203
204
# File 'lib/phlex/reactive.rb', line 200

def debug
  return @debug if defined?(@debug)

  false
end

.defer_job_queueObject



581
582
583
# File 'lib/phlex/reactive.rb', line 581

def defer_job_queue
  @defer_job_queue ||= "default"
end

.defer_pathObject



551
552
553
# File 'lib/phlex/reactive.rb', line 551

def defer_path
  @defer_path ||= "/reactive/defer"
end

.defer_token_ttlObject



543
544
545
# File 'lib/phlex/reactive.rb', line 543

def defer_token_ttl
  @defer_token_ttl ||= 120
end

.error_flashObject

A user-visible flash rendered on every endpoint rescue path (issue #100). Default nil = today's behavior (a bare head, or the verbose_errors plain-text diagnostic). Set a lambda ->(kind) { "message" } (kind is :tampered/:unknown_class/:not_reactive_class/:forbidden/:not_found) and the ActionsController ALSO renders a turbo-stream flash into flash_target — at the SAME status it returns today (statuses never change). Composes with verbose_errors: the turbo-stream flash wins the response body, the diagnostic still goes to the log.



758
759
760
# File 'lib/phlex/reactive.rb', line 758

def error_flash
  @error_flash
end

.flash_componentObject

A CALLABLE that builds a flash component from STRING flash content (issue #182). Given (level, content), it returns a Phlex component the gem renders in place of the built-in

wrapper — so the APP owns the kwarg mapping (no hardcoded new(level:, content:) contract that collides with a real app's flash component):

Phlex::Reactive.flash_component = ->(level, content) { MyFlash.new(level:, message: content) }

Default nil (the built-in wrapper). Phlex component content passed to reply.flash always renders verbatim and bypasses this.



770
771
772
# File 'lib/phlex/reactive.rb', line 770

def flash_component
  @flash_component
end

.flash_targetObject

DOM id of the host-app container a Response#flash appends into. Default "flash"; override to match your layout's flash region.



717
718
719
# File 'lib/phlex/reactive.rb', line 717

def flash_target
  @flash_target ||= "flash"
end

.log_eventsObject



170
171
172
173
174
# File 'lib/phlex/reactive.rb', line 170

def log_events
  return @log_events if defined?(@log_events)

  false
end

.rendererObject



491
492
493
# File 'lib/phlex/reactive.rb', line 491

def renderer
  @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
end

.resolved_apm_adapterObject

The APM adapter resolved at engine attach time (issue #207), or nil. Held so report_error can reach record_error without re-running detection per request. Set by APM.attach!; nil when no APM is configured or the SDK was absent.



485
486
487
# File 'lib/phlex/reactive.rb', line 485

def resolved_apm_adapter
  @resolved_apm_adapter
end

.verbose_errorsObject



157
158
159
160
161
# File 'lib/phlex/reactive.rb', line 157

def verbose_errors
  return @verbose_errors if defined?(@verbose_errors)

  defined?(::Rails.env) && ::Rails.env.local?
end

.verifierObject



487
488
489
# File 'lib/phlex/reactive.rb', line 487

def verifier
  @verifier ||= default_verifier
end

.verify_authorizedObject



216
217
218
219
220
# File 'lib/phlex/reactive.rb', line 216

def verify_authorized
  return @verify_authorized if defined?(@verify_authorized)

  true
end

Class Method Details

.action_route_ok?(path = action_path) ⇒ Boolean

True when a POST to path resolves to the gem's ActionsController. A host catch-all route (match "*path", ...) appended above the engine's route SHADOWS it, so every reactive POST 404s and none of the controller runs — the opaque "is the endpoint even mounted?" failure (issue #26). A false here is the signal. Returns false (not raise) when nothing matches.

Returns:

  • (Boolean)


1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
# File 'lib/phlex/reactive.rb', line 1030

def action_route_ok?(path = action_path)
  return false unless defined?(::Rails) && ::Rails.application

  # At after_initialize (when the boot guard runs) the host's routes may not
  # be drawn yet, so recognize_path would see an incomplete set and report a
  # false shadow. Force-load routes first (idempotent — no-op if already
  # loaded), so the check is correct whether it runs at boot or at runtime.
  ensure_routes_loaded
  recognized = ::Rails.application.routes.recognize_path(path, method: :post)
  recognized[:controller] == ACTIONS_CONTROLLER
rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
  false
end

.around_action(&block) ⇒ Object

Register a COMPONENT-AWARE around_action wrapper (issue #112). The block is folded into the endpoint BETWEEN with_connection_id and the action's transaction — so it sees the resolved component instance, the declared action name, and the coerced params (an ActionContext), and a rejection NEVER opens a transaction. This is the seam for audit logging, component-aware rate limiting, and assertions; the base controller (base_controller_name) remains the seam for HTTP-layer concerns (auth, CSRF, coarse per-IP rate limiting) that don't need the resolved action.

Phlex::Reactive.around_action do |ctx, &action|
RateLimiter.check!(ctx.request.remote_ip, ctx.action_name) # raise -> 403
result = action.call
AuditLog.record!(actor: Current.user, action: ctx.action_name)
result   # <- REQUIRED: return the continuation's value
end

CONTRACT — each wrapper MUST return action.call's value. The endpoint type-checks the action's return for a Phlex::Reactive::Response; a wrapper that returns its logger's result instead silently downgrades every reply to the implicit self-replace. Wrappers nest in registration order with the LAST-registered outermost. Register in an initializer.

Raises:

  • (::ArgumentError)


401
402
403
404
405
# File 'lib/phlex/reactive.rb', line 401

def around_action(&block)
  raise ::ArgumentError, "Phlex::Reactive.around_action requires a block" unless block

  around_actions << block
end

.around_actionsObject

The registered around_action wrappers, oldest first. The endpoint folds them so the last-registered runs outermost; an empty stack is the default hot path (the controller short-circuits with return yield).



410
411
412
# File 'lib/phlex/reactive.rb', line 410

def around_actions
  @around_actions ||= []
end

.base_controllerObject



901
902
903
# File 'lib/phlex/reactive.rb', line 901

def base_controller
  base_controller_name.constantize
end

.broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil, effect: nil, **verb) ⇒ Object

Module-level broadcast (issue #185) — the same broadcast_to as the class form, for a BUILT component payload, so a NON-Streamable target (a count badge, any plain Phlex component) broadcasts WITHOUT hand-rolling the raw channel + render, and IS instrumented:

Phlex::Reactive.broadcast_to(@list, :todos, update: TodoCount.new(list: @list), target: "todos-count")
Phlex::Reactive.broadcast_to(user, :alerts, replace: NotificationsBadge.new(user:))

Container verbs (update:/append:/prepend:) take any component; self-targeting verbs (replace:/remove:) require a Streamable payload (its #id is the target) — a plain component gets a guided error steering to update:. Shares the one broadcast implementation with the class-level form.



809
810
811
812
813
814
815
816
817
818
819
820
821
822
# File 'lib/phlex/reactive.rb', line 809

def broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil,
                 effect: nil, **verb)
  name, payload = Phlex::Reactive::Streamable.extract_module_broadcast_verb(verb)
  component = name == :js ? nil : payload
  keys = each ? each.map { Array(it) } : [streamables]
  # The owner is the payload's class when Streamable (for js ops + instrument),
  # else Streamable itself (a plain component still instruments via the shared
  # path). A nil payload (a bare state-backed default) isn't supported here —
  # the module form is for BUILT component payloads.
  owner = payload.is_a?(Phlex::Reactive::Streamable) ? payload.class : Phlex::Reactive::Streamable
  Phlex::Reactive::Streamable.broadcast_component(
    owner, name, payload, component, keys, morph:, target:, exclude:, visible_to:, effect:
  )
end

.current_connection_idObject

The acting client's SSE connection id during an action, or nil. Set by the ActionsController from the X-Pgbus-Connection header. A component action passes exclude: Phlex::Reactive.current_connection_id (or the reactive_connection_id helper) to suppress the actor's own broadcast echo.



975
976
977
# File 'lib/phlex/reactive.rb', line 975

def current_connection_id
  Thread.current[:phlex_reactive_connection_id]
end

.current_defer_bindingObject

The acting client's defer binding during a request, or nil. Set by the ActionsController from defer_binding_for(request) — threaded exactly like current_connection_id.



643
644
645
# File 'lib/phlex/reactive.rb', line 643

def current_defer_binding
  Thread.current[:phlex_reactive_defer_binding]
end

.current_url_optionsObject

The acting request's url_options during a reactive request, or nil (issue #232). Set by the ActionsController (the action AND defer endpoints) so absolute URL helpers in a reply-rendered component — image_tag on an Active Storage attachment being the everyday case — carry the REQUESTING host/port/protocol instead of the process default (the ActiveStorage::SetCurrent move). The memoized off-request view context merges this over its defaults per call (see request_bound_view_context); nil (jobs, console, plain broadcasts) leaves the defaults untouched — byte-identical to before.



996
997
998
# File 'lib/phlex/reactive.rb', line 996

def current_url_options
  Thread.current[:phlex_reactive_url_options]
end

.defer_binding_for(request) ⇒ Object

Resolve a request to its defer binding: the id of an ALREADY-PERSISTED session, else nil. The exists? gate is load-bearing — a bare session.id LAZILY generates an id even for an empty, never-written session, and that id is NOT persisted (no Set-Cookie), so two requests for the same read-only page get DIFFERENT lazy ids and a bound token minted on one is rejected on the other. Only a persisted session (an authenticated app wrote one at login) has a stable id across the mint (page render / action) and the verify (defer endpoint) — exactly the case where cross-actor exchange is the real threat. A read-only page with no session mints + verifies UNBOUND consistently (the TTL + authorize! remain the bound). Tolerant of a store that lazily raises: degrade to nil (unbound), never a 500. Override to bind to your own actor identity (a stable user id, an API-token digest) — recommended for a token-authenticated API with no cookie session.



669
670
671
672
673
674
675
676
# File 'lib/phlex/reactive.rb', line 669

def defer_binding_for(request)
  session = request.session
  return nil unless session.respond_to?(:exists?) && session.exists?

  session.id&.to_s
rescue StandardError
  nil
end

.defer_purposeObject

The purpose string for the CURRENT actor's defer tokens: the base DEFER_PURPOSE, plus the binding when one is present. Binding is folded into the PURPOSE (not the payload) so it's part of the signature's domain separation — a mismatched binding is a verification failure, not a value the endpoint must remember to compare.



635
636
637
638
# File 'lib/phlex/reactive.rb', line 635

def defer_purpose
  binding = current_defer_binding
  binding ? "#{DEFER_PURPOSE}/#{binding}" : DEFER_PURPOSE
end

.defer_push_capable?Boolean

Everything the defer PUSH lane needs at runtime: streams-capable pgbus, server-side signed-src minting (SignedName.sign — the element's src is built off-request), and ActiveJob to run the render off the request thread. Anything missing → the pull lane (which is always available).

Returns:

  • (Boolean)


707
708
709
710
711
712
713
# File 'lib/phlex/reactive.rb', line 707

def defer_push_capable?
  return false unless pgbus_streams?
  return false unless defined?(::Pgbus::Streams::SignedName) &&
                      ::Pgbus::Streams::SignedName.respond_to?(:sign)

  defined?(::ActiveJob::Base) ? true : false
end

.defer_transportObject

How deferred segments reach the actor: :auto (push iff capable, else pull), :fetch (always pull), :stream (push; degrades to pull with a warning when the capability is absent). Validated at assignment — a typo'd transport must fail at the initializer, not silently at reply time. nil resets to the default.



560
561
562
# File 'lib/phlex/reactive.rb', line 560

def defer_transport
  @defer_transport ||= :auto
end

.defer_transport=(value) ⇒ Object



564
565
566
567
568
569
570
571
572
573
# File 'lib/phlex/reactive.rb', line 564

def defer_transport=(value)
  value = value&.to_sym
  unless value.nil? || DEFER_TRANSPORTS.include?(value)
    raise ::ArgumentError,
      "Phlex::Reactive.defer_transport must be one of #{DEFER_TRANSPORTS.map(&:inspect).join(", ")} " \
      "(got #{value.inspect})"
  end

  @defer_transport = value
end

.effectsObject

Global effects opt-in (issue #215). nil (the default) = OFF: no root attrs are emitted anywhere, the wire is byte-identical to pre-effects, and the client interceptor sees nothing to do. Setting it is the opt-in AND the app-wide default set — refined per component with reactive_effects and per call with effect: (most specific wins):

Phlex::Reactive.effects = true   # { enter: :fade, exit: :fade, update: :highlight }
Phlex::Reactive.effects = { enter: :slide, update: :highlight }

Stored pre-normalized (wire strings), validated at WRITE time — a typo'd effect name raises here, not at render time.



734
735
736
# File 'lib/phlex/reactive.rb', line 734

def effects
  defined?(@effects) ? @effects : nil
end

.effects=(value) ⇒ Object



738
739
740
741
# File 'lib/phlex/reactive.rb', line 738

def effects=(value)
  @effects = Phlex::Reactive::Effects.normalize_config(value)
  @effects_generation = effects_generation + 1
end

.effects_generationObject

Monotonic write counter for effects= — the cache key half that lets each component class memoize its RESOLVED effect attrs (reactive_attrs is the token-signing hot path) without ever serving a stale config.



746
747
748
# File 'lib/phlex/reactive.rb', line 746

def effects_generation
  @effects_generation ||= 0
end

.flash_builderObject

Issue #182: the pre-#113 aliases are removed (they contradicted the clean-break rule — two names for one thing). Each raises a guided error naming the real method. The engine (to_prepare) already uses the new names.

Raises:

  • (NoMethodError)


861
862
863
864
# File 'lib/phlex/reactive.rb', line 861

def flash_builder
  raise NoMethodError,
    "Phlex::Reactive.flash_builder was removed in issue #182 — use stream_builder"
end

.freeze_param_schemas!Object



326
327
328
329
# File 'lib/phlex/reactive.rb', line 326

def freeze_param_schemas!
  param_schemas.freeze
  @param_schemas_frozen = true
end

.freeze_param_types!Object

Freeze the registry so no further param_type registration is accepted — the initializer-only contract. Called by the engine's after_initialize. Idempotent.



278
279
280
281
# File 'lib/phlex/reactive.rb', line 278

def freeze_param_types!
  param_types.freeze
  @param_types_frozen = true
end

.instrument(event, payload = {}) ⇒ Object

Emit an <event>.phlex_reactive ActiveSupport::Notifications event around a block, yielding the mutable payload so a rescue can finalize the outcome (issue #107). ASN.instrument is cheap when nothing is subscribed (the hot paths depend on this — proven by the render bench), so this wraps the render/broadcast/action paths unconditionally. The payload must carry NAMES/outcome/sizes ONLY — never token/params/state.



376
377
378
# File 'lib/phlex/reactive.rb', line 376

def instrument(event, payload = {}, &)
  ::ActiveSupport::Notifications.instrument("#{event}.#{INSTRUMENTATION_NAMESPACE}", payload, &)
end

.off_request_view_contextObject

The off-request view context for the current thread, built once and reused for both the stream builder and standalone component renders. Cached PER THREAD, not per process: an ActionView context carries mutable output_buffer/view_flow state (render_in's capture swaps it), so sharing one instance across threads can interleave content on a threaded server. Rebuilt when the renderer object changes or the generation is bumped (reset_stream_builder! / Rails code reload), so a reloaded controller is never served stale.



844
845
846
# File 'lib/phlex/reactive.rb', line 844

def off_request_view_context
  off_request_view_context_cache[:view_context]
end

.off_request_view_context_generationObject



871
872
873
# File 'lib/phlex/reactive.rb', line 871

def off_request_view_context_generation
  @off_request_view_context_generation ||= 0
end

.on_action_error(&block) ⇒ Object

Register a block called when a reactive action body raises a previously-uncaught error (issue #207) — the DIY escape hatch for a tracker the gem ships no adapter for, usable WITHOUT choosing an apm Symbol. The block receives (error, context) where context is the name-only event payload ({ component:, action:, outcome: :error }). It runs INSIDE the endpoint's error handling, just before the exception is re-raised — so Rails' own error reporting still fires afterward. Register in an initializer.

Phlex::Reactive.on_action_error do |error, ctx|
Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
end

Raises:

  • (::ArgumentError)


432
433
434
435
436
# File 'lib/phlex/reactive.rb', line 432

def on_action_error(&block)
  raise ::ArgumentError, "Phlex::Reactive.on_action_error requires a block" unless block

  on_action_error_hooks << block
end

.on_action_error_hooksObject

The registered on_action_error hooks, oldest first.



439
440
441
# File 'lib/phlex/reactive.rb', line 439

def on_action_error_hooks
  @on_action_error_hooks ||= []
end

.param_schema(name, schema = nil) ⇒ Object

Register (with a schema Hash) OR read (bare name) a NAMED param schema (issue #184) — a reusable, boot-declared schema so sibling components stop duplicating verbatim constants that drift. Follows the param_type contract: register in an INITIALIZER (the registry freezes after boot).

# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_schema :todo, title: :string, done: :boolean
# then: action :save, params: :todo
# compose:  action :bulk, params: { **Phlex::Reactive.param_schema(:todo), note: :string }

Reading an unknown name raises, listing the registered ones. The stored schema is frozen; the reader returns it (compose with ** for a new Hash).



306
307
308
309
310
311
312
313
314
315
316
# File 'lib/phlex/reactive.rb', line 306

def param_schema(name, schema = nil)
  return fetch_param_schema(name) if schema.nil?

  if param_schemas_frozen?
    raise Error, "Phlex::Reactive.param_schema(#{name.inspect}) called after boot — the param-schema " \
                 "registry is frozen once the app is initialized. Register named schemas in an " \
                 "initializer (config/initializers/phlex_reactive.rb)."
  end

  param_schemas[name.to_sym] = deep_freeze_schema(schema.transform_keys(&:to_sym))
end

.param_schema?(name) ⇒ Boolean

Returns:

  • (Boolean)


322
323
324
# File 'lib/phlex/reactive.rb', line 322

def param_schema?(name)
  param_schemas.key?(name.to_sym)
end

.param_schemasObject



318
319
320
# File 'lib/phlex/reactive.rb', line 318

def param_schemas
  @param_schemas ||= {}
end

.param_schemas_frozen?Boolean

Returns:

  • (Boolean)


331
332
333
# File 'lib/phlex/reactive.rb', line 331

def param_schemas_frozen?
  @param_schemas_frozen ||= false
end

.param_type(name, &block) ⇒ Object

Register an app-defined param type (issue #109). The block receives the raw client value and returns the coerced value — or Phlex::Reactive:: ParamSchema::DROP to reject it (the keyword default then applies, keeping the drop-don't-fabricate contract). Register in an INITIALIZER: the registry is frozen after boot (freeze_param_types!), so a runtime registration raises, and a schema referencing the type is validated at declaration.

# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_type(:money) do |v|
/\A\d+(\.\d{1,2})?\z/.match?(v.to_s) ? BigDecimal(v) : Phlex::Reactive::ParamSchema::DROP
end
# then: action :charge, params: { amount: :money }

Raises:

  • (::ArgumentError)


250
251
252
253
254
255
256
257
258
259
260
# File 'lib/phlex/reactive.rb', line 250

def param_type(name, &block)
  raise ::ArgumentError, "Phlex::Reactive.param_type requires a block" unless block

  if param_types_frozen?
    raise Error, "Phlex::Reactive.param_type(#{name.inspect}) called after boot — the param-type " \
                 "registry is frozen once the app is initialized. Register custom param types in an " \
                 "initializer (config/initializers/phlex_reactive.rb)."
  end

  param_types[name.to_sym] = block
end

.param_type?(name) ⇒ Boolean

A declared type symbol is known iff it's a registry key. ParamSchema asks this at compile so an unknown symbol raises loudly at declaration.

Returns:

  • (Boolean)


271
272
273
# File 'lib/phlex/reactive.rb', line 271

def param_type?(name)
  param_types.key?(name.to_sym)
end

.param_typesObject

The param-type registry: Symbol => callable(value) -> coerced | DROP. Seeded lazily with the built-ins; ParamSchema.compile validates every declared type symbol against it, and #coerce dispatches through it.



265
266
267
# File 'lib/phlex/reactive.rb', line 265

def param_types
  @param_types ||= Phlex::Reactive::ParamSchema.built_in_types.dup
end

.param_types_frozen?Boolean

Returns:

  • (Boolean)


283
284
285
# File 'lib/phlex/reactive.rb', line 283

def param_types_frozen?
  @param_types_frozen ||= false
end

.pgbus?Boolean

Necessary but NOT sufficient — the Streams entrypoint exists.

Returns:

  • (Boolean)


684
685
686
687
688
# File 'lib/phlex/reactive.rb', line 684

def pgbus?
  return false unless defined?(::Pgbus)

  ::Pgbus.respond_to?(:stream)
end

.pgbus_streams?Boolean

The reactive-Streams capability: Stream#broadcast accepts :exclude (the pgbus >= 0.9.2 shape). This is the gate that prevents ArgumentError: unknown keyword :exclude on an old pgbus.

Returns:

  • (Boolean)


693
694
695
696
697
698
699
700
701
# File 'lib/phlex/reactive.rb', line 693

def pgbus_streams?
  return false unless pgbus?
  return false unless defined?(::Pgbus::Streams::Stream)

  ::Pgbus::Streams::Stream.instance_method(:broadcast)
    .parameters.any? { |_type, name| name == :exclude }
rescue ::NameError
  false
end

.register_token_upgrader(from_version, &block) ⇒ Object

Register an upgrader that rewrites a payload signed at from_version into the shape of from_version + 1 (issue #111). Register in load order at boot when you bump TOKEN_VERSION; the block receives the payload hash and returns the migrated hash (the "v" stamp is applied by upgrade_token, so the block only reshapes the data). Example, for a future count → n rename:

Phlex::Reactive.register_token_upgrader(0) do |payload|
payload.merge("s" => { "n" => payload.dig("s", "count") })
end

Raises:

  • (::ArgumentError)


953
954
955
956
957
# File 'lib/phlex/reactive.rb', line 953

def register_token_upgrader(from_version, &block)
  raise ::ArgumentError, "register_token_upgrader requires a block" unless block

  token_upgraders[Integer(from_version)] = block
end

.render(component) ⇒ Object

Render a Phlex component to HTML with a full (off-request) view context. Uses phlex-rails' #render_in against the memoized view context — a direct component.call that skips ActionController's renderer.render machinery (~2x faster, ~half the allocations), with the same HTML and full helper access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as Response#with content.



790
791
792
793
794
795
# File 'lib/phlex/reactive.rb', line 790

def render(component)
  # A machinery render (issue #165): a reactive_lazy component embedded
  # in a Response/flash renders its REAL template — the lazy shell is
  # for the page-embedded initial mount only.
  Phlex::Reactive::Defer.with_real_render { component.render_in(off_request_view_context) }
end

.report_error(error, context) ⇒ Object

Report a previously-uncaught action-body error to the resolved APM adapter AND every registered on_action_error hook (issue #207). Called by the endpoint's error seam just before it re-raises. Each reporter is wrapped in its own rescue so a broken reporter can NEVER turn one 500 into a different 500 (or swallow the original — the endpoint re-raises regardless). context is the mutable event payload; we forward a NAME-ONLY SNAPSHOT (a fresh Hash of ERROR_CONTEXT_KEYS) so a reporter that retains it never picks up the :exception keys ASN adds to the live hash afterward. Best-effort and side-effect only: the return value is ignored.



464
465
466
467
468
469
470
471
472
473
474
# File 'lib/phlex/reactive.rb', line 464

def report_error(error, context)
  snapshot = context.slice(*ERROR_CONTEXT_KEYS)
  adapter = @resolved_apm_adapter
  # Each reporter runs through safely_report on its OWN — one broken reporter
  # (a raising adapter or hook) never prevents the others, and never turns one
  # 500 into a different 500. safely_report takes the reporter as a block arg,
  # so there's no nested-block ambiguity.
  safely_report { adapter.record_error(error, snapshot) } if adapter
  on_action_error_hooks.each { report_hook(it, error, snapshot) }
  nil
end

.report_hook(hook, error, context) ⇒ Object

Run one on_action_error hook through safely_report. Extracted so the per-hook isolation reads as one call (no nested reporter block in the loop).



478
479
480
# File 'lib/phlex/reactive.rb', line 478

def report_hook(hook, error, context)
  safely_report { hook.call(error, context) }
end

.request_bound_view_context(controller_class) ⇒ Object

Build an off-request view context whose controller has a REAL request.

A bare controller.new.view_context (the naive off-request context) has request == nil, so any request-dependent helper raises undefined method 'env' for nil — form_authenticity_token, protect_against_forgery?, and host-aware URL helpers all read request.env (issue #42). We replicate exactly what ActionController::Renderer#render does to set up its mock request — build an ActionDispatch::Request from the renderer's env (which derives the host from the routes' default_url_options), bind the routes, and attach it — then return the controller's view context instead of rendering a template. The result keeps the 0.4.0 render_in speedup (no renderer.render machinery) while restoring the request those helpers need.



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
# File 'lib/phlex/reactive.rb', line 508

def request_bound_view_context(controller_class)
  ar_renderer = controller_class.renderer
  request = ::ActionDispatch::Request.new(ar_renderer.send(:env_for_request))
  request.routes = controller_class._routes

  instance = controller_class.new
  instance.set_request!(request)
  instance.set_response!(controller_class.make_response!(request))

  # Issue #232: during a reactive request the endpoint threads the actor's
  # protocol/host/port via with_url_options; merge them over this
  # instance's defaults so absolute URL helpers in a reply render the
  # REQUESTING host. View contexts delegate url_options to their
  # controller (ActionView::RoutingUrlFor), so this one override covers
  # every helper spelling. Off-request (thread-local nil — jobs, console,
  # broadcasts) returns super's frozen memo untouched: zero-alloc, byte-
  # identical URLs. Defined on the singleton because this instance is
  # MEMOIZED per thread — its request (and super's @_url_options) never
  # changes, so the per-call merge is the only per-request seam.
  def instance.url_options
    overrides = Phlex::Reactive.current_url_options
    overrides ? super.merge(overrides) : super
  end

  instance.view_context
end

.reset_around_actions!Object

Drop all registered around_action wrappers. For test isolation (the shipped hook — specs registering a temporary wrapper reset around every example); never called in production.



417
418
419
# File 'lib/phlex/reactive.rb', line 417

def reset_around_actions!
  @around_actions = []
end

.reset_flash_builder!Object

Raises:

  • (NoMethodError)


866
867
868
869
# File 'lib/phlex/reactive.rb', line 866

def reset_flash_builder!
  raise NoMethodError,
    "Phlex::Reactive.reset_flash_builder! was removed in issue #182 — use reset_stream_builder!"
end

.reset_on_action_error!Object

Drop all registered on_action_error hooks. Test isolation; never in production.



444
445
446
# File 'lib/phlex/reactive.rb', line 444

def reset_on_action_error!
  @on_action_error_hooks = []
end

.reset_param_schemas!Object

Drop the named-schema registry and unfreeze it (tests only).



336
337
338
339
# File 'lib/phlex/reactive.rb', line 336

def reset_param_schemas!
  @param_schemas = {}
  @param_schemas_frozen = false
end

.reset_param_types!Object

Drop the registry back to the built-ins and unfreeze it. For tests that register a temporary type; never called in production.



289
290
291
292
# File 'lib/phlex/reactive.rb', line 289

def reset_param_types!
  @param_types = Phlex::Reactive::ParamSchema.built_in_types.dup
  @param_types_frozen = false
end

.reset_stream_builder!Object

Invalidate the per-thread context + builder for ALL threads by bumping the generation; each thread rebuilds lazily on next use. Registered on Rails' reloader by the engine; also used by specs. Thread-safe (an integer bump, no shared structure to tear down). Renamed from reset_flash_builder! (issue #113); the old name stays a permanent alias below so the engine's to_prepare hook keeps working.



854
855
856
# File 'lib/phlex/reactive.rb', line 854

def reset_stream_builder!
  @off_request_view_context_generation = off_request_view_context_generation + 1
end

.reset_token_upgraders!Object

Drop all registered upgraders. For tests that register a temporary one.



966
967
968
# File 'lib/phlex/reactive.rb', line 966

def reset_token_upgraders!
  @token_upgraders = {}
end

.sign(payload) ⇒ Object

Signs a payload hash into an identity token, stamping the current TOKEN_VERSION (issue #111). The "v" key is the ONLY thing added — a from_identity that ignores unknown keys is unaffected.



918
919
920
# File 'lib/phlex/reactive.rb', line 918

def sign(payload)
  verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose: IDENTITY_PURPOSE)
end

.sign_defer(payload, unbound: false) ⇒ Object

Signs a defer payload (issue #165): same shape + version stamp as the identity token, but purpose-scoped to DEFER_PURPOSE and expiring after defer_token_ttl. verify/verify_defer are therefore mutually exclusive by construction — see the DEFER_PURPOSE comment. SECURITY (the leaked-token exchange): the defer endpoint re-renders the real component, whose root carries a fresh NON-expiring identity (action) token — so a leaked defer token replayed by ANOTHER actor within its TTL would hand that actor a permanent action token for the same identity. The purpose is therefore ALSO scoped to the minting actor's binding (the session id, via with_defer_binding), so a defer token minted under binding A fails verification under binding B — the exchange can't cross actors. Unbound (no session — the ActionController::Base default) is the documented today-behavior; authorize! in the action stays the real authority in every case. Sign a defer token. unbound: true (the reactive_lazy channel) mints under the PLAIN DEFER_PURPOSE, never the /binding suffix — because a lazy shell renders during the PAGE render, on a fresh visit where the session doesn't exist yet (Rails establishes it DURING that response), so it can't be bound; and it lives in the actor's own page, a small leak surface. reply.defer tokens (the default) mint under the current actor's binding — they live in an action HTTP response that can transit proxies/ logs, the real cross-infrastructure leak vector. See docs/security + the README defer security note. authorize! in the action is the real authority for the harvested-action-token step in both cases.



609
610
611
612
# File 'lib/phlex/reactive.rb', line 609

def sign_defer(payload, unbound: false)
  purpose = unbound ? DEFER_PURPOSE : defer_purpose
  verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose:, expires_in: defer_token_ttl)
end

.stream_builderObject

A Turbo::Streams::TagBuilder bound to an off-request view context, used to build standalone streams not tied to a specific component's id — a Response flash append, a reactive_collection row removal, a count companion update, an also() companion. Cached PER THREAD alongside the context it's bound to (see off_request_view_context for why per-thread). Renamed from flash_builder (issue #113): the builder does far more than flashes, so the name misled. flash_builder stays a permanent alias below so the engine's to_prepare and app code keep working.



832
833
834
# File 'lib/phlex/reactive.rb', line 832

def stream_builder
  off_request_view_context_cache[:builder]
end

.token_upgradersObject

from_version => callable(payload) -> migrated payload. Sparse: an entry exists only for a version that actually changed shape.



961
962
963
# File 'lib/phlex/reactive.rb', line 961

def token_upgraders
  @token_upgraders ||= {}
end

.upgrade_token(payload) ⇒ Object

Migrate a verified payload from whatever version it was signed at up to TOKEN_VERSION (issue #111). Runs the registered upgraders oldest → current, then stamps the payload to the current version. Contract:

* no "v"  → version 0 (the shape from before versioning existed). With no
v0 upgrader registered this is a pure passthrough — introducing
versioning invalidates NOTHING already in flight.
* v == current → returned as-is (the hot path — one integer compare).
* v  > current → nil. A rolled-back deploy verifying a token minted by
NEWER code must not guess the newer shape; returning nil fails closed
through the endpoint's existing `|| raise(InvalidToken)` → 400.


932
933
934
935
936
937
938
939
940
941
942
# File 'lib/phlex/reactive.rb', line 932

def upgrade_token(payload)
  version = payload.fetch("v", 0)
  # "v" is inside the signed blob, so only our own key could produce a
  # malformed one — but fail closed rather than 500 on the comparison
  # (String vs Integer) or silently treat a negative "v" as legacy.
  return nil unless version.is_a?(::Integer) && version >= 0
  return payload if version == TOKEN_VERSION
  return nil if version > TOKEN_VERSION

  upgrade_from(payload, version)
end

.url_options_for(request) ⇒ Object

The url_options trio derived from a live request. port is optional_port — nil on the default port — and is INCLUDED even when nil so a default-port request clears any configured default port rather than inheriting it (the merge must own the whole trio).



1017
1018
1019
# File 'lib/phlex/reactive.rb', line 1017

def url_options_for(request)
  { protocol: request.protocol, host: request.host, port: request.optional_port }
end

.verify(token) ⇒ Object

Returns the verified, version-upgraded payload hash, or nil if the token is invalid (bad signature/purpose) OR carries a version this code doesn't understand. The single verify choke point (issue #111): after the signature check we run upgrade_token so an older-shape payload is migrated to the current shape before from_identity ever sees it.



910
911
912
913
# File 'lib/phlex/reactive.rb', line 910

def verify(token)
  payload = verifier.verified(token, purpose: IDENTITY_PURPOSE)
  payload && upgrade_token(payload)
end

.verify_defer(token) ⇒ Object

Returns the verified, version-upgraded defer payload, or nil when the token is tampered, expired, carries the wrong purpose (an action token OR a BOUND token minted under a DIFFERENT actor binding), or a version this code doesn't understand — all fail closed through the endpoint's InvalidToken → 400 path. Tries the current-binding purpose first (the reply.defer, actor-bound case), then falls back to the plain DEFER_PURPOSE (the unbound lazy case) — so an unbound lazy token resolves under ANY binding, while a bound token minted under session-A still fails under session-B (it was signed with /session-A, which matches NEITHER /session-B nor the plain purpose).



624
625
626
627
628
# File 'lib/phlex/reactive.rb', line 624

def verify_defer(token)
  payload = verifier.verified(token, purpose: defer_purpose)
  payload ||= verifier.verified(token, purpose: DEFER_PURPOSE) if current_defer_binding
  payload && upgrade_token(payload)
end

.warn_unless_action_route_mounted!(path: action_path, logger: default_logger) ⇒ Object

Log a clear warning (once, at boot) when the action path doesn't resolve to the gem controller — pointing at the catch-all shadow rather than leaving an adopter to guess. Called from the engine's after_initialize.



1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
# File 'lib/phlex/reactive.rb', line 1047

def warn_unless_action_route_mounted!(path: action_path, logger: default_logger)
  return if action_route_ok?(path)
  return unless logger

  logger.warn(
    "[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
    "A host catch-all route (e.g. match \"*path\", ...) likely shadows it, so reactive " \
    "actions will 404. Exempt #{path.delete_prefix("/")} from the catch-all, or set " \
    "Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
  )
end

.with_connection_id(connection_id) ⇒ Object



979
980
981
982
983
984
985
# File 'lib/phlex/reactive.rb', line 979

def with_connection_id(connection_id)
  previous = Thread.current[:phlex_reactive_connection_id]
  Thread.current[:phlex_reactive_connection_id] = connection_id.presence
  yield
ensure
  Thread.current[:phlex_reactive_connection_id] = previous
end

.with_defer_binding(binding) ⇒ Object



647
648
649
650
651
652
653
# File 'lib/phlex/reactive.rb', line 647

def with_defer_binding(binding)
  previous = Thread.current[:phlex_reactive_defer_binding]
  Thread.current[:phlex_reactive_defer_binding] = binding.presence
  yield
ensure
  Thread.current[:phlex_reactive_defer_binding] = previous
end

.with_url_options(options) ⇒ Object

Thread options for the block, restoring the previous value after. nil CLEARS the actor options — the broadcast render uses this to keep a broadcast fired inside an action on process defaults (subscribers can be on different hosts; "URLs in broadcast-rendered components must be host-relative" is the broadcast contract).



1005
1006
1007
1008
1009
1010
1011
# File 'lib/phlex/reactive.rb', line 1005

def with_url_options(options)
  previous = Thread.current[:phlex_reactive_url_options]
  Thread.current[:phlex_reactive_url_options] = options.presence
  yield
ensure
  Thread.current[:phlex_reactive_url_options] = previous
end