Module: Phlex::Reactive
- Defined in:
- lib/phlex/reactive.rb,
lib/phlex/reactive/js.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/version.rb,
lib/phlex/reactive/response.rb,
lib/phlex/reactive/component.rb,
lib/phlex/reactive/inspector.rb,
lib/phlex/reactive/mcp/runner.rb,
lib/phlex/reactive/mcp/server.rb,
lib/phlex/reactive/streamable.rb,
lib/phlex/reactive/param_schema.rb,
lib/phlex/reactive/test_helpers.rb,
lib/phlex/reactive/authorization.rb,
lib/phlex/reactive/component/dsl.rb,
lib/phlex/reactive/mcp/base_tool.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/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: Authorization, ClientBindings, Component, Defer, 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_reactiveand get component-level visibility. Payloads carry NAMES/outcome/sizes ONLY — never the token, params, or state. "phlex_reactive"- ACTIONS_CONTROLLER =
The controller a correctly-mounted action path resolves to. Used by the route guard below.
"phlex/reactive/actions"- VERSION =
"0.10.0"
Class Attribute Summary collapse
- .action_path ⇒ Object
-
.authorization_errors ⇒ Object
Exception classes the action endpoint renders as 403.
- .authorization_methods ⇒ Object
- .base_controller_name ⇒ Object
- .debug ⇒ Object
- .defer_job_queue ⇒ Object
- .defer_path ⇒ Object
- .defer_token_ttl ⇒ Object
-
.error_flash ⇒ Object
A user-visible flash rendered on every endpoint rescue path (issue #100).
-
.flash_component ⇒ Object
Component class used to render STRING flash content (issue #77).
-
.flash_target ⇒ Object
DOM id of the host-app container a Response#flash appends into.
- .log_events ⇒ Object
- .renderer ⇒ Object
- .verbose_errors ⇒ Object
- .verifier ⇒ Object
- .verify_authorized ⇒ Object
Class Method Summary collapse
-
.action_route_ok?(path = action_path) ⇒ Boolean
True when a POST to
pathresolves to the gem's ActionsController. -
.around_action(&block) ⇒ Object
Register a COMPONENT-AWARE around_action wrapper (issue #112).
-
.around_actions ⇒ Object
The registered around_action wrappers, oldest first.
- .base_controller ⇒ Object
-
.current_connection_id ⇒ Object
The acting client's SSE connection id during an action, or nil.
-
.current_defer_binding ⇒ Object
The acting client's defer binding during a request, or nil.
-
.defer_binding_for(request) ⇒ Object
Resolve a request to its defer binding: the id of an ALREADY-PERSISTED session, else nil.
-
.defer_purpose ⇒ Object
The purpose string for the CURRENT actor's defer tokens: the base DEFER_PURPOSE, plus the binding when one is present.
-
.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.
-
.defer_transport ⇒ Object
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).
- .defer_transport=(value) ⇒ Object
-
.freeze_param_types! ⇒ Object
Freeze the registry so no further param_type registration is accepted — the initializer-only contract.
-
.instrument(event, payload = {}) ⇒ Object
Emit an
<event>.phlex_reactiveActiveSupport::Notifications event around a block, yielding the mutable payload so a rescue can finalize the outcome (issue #107). -
.off_request_view_context ⇒ Object
The off-request view context for the current thread, built once and reused for both the stream builder and standalone component renders.
- .off_request_view_context_generation ⇒ Object
-
.param_type(name, &block) ⇒ Object
Register an app-defined param type (issue #109).
-
.param_type?(name) ⇒ Boolean
A declared type symbol is known iff it's a registry key.
-
.param_types ⇒ Object
The param-type registry: Symbol => callable(value) -> coerced | DROP.
- .param_types_frozen? ⇒ Boolean
-
.pgbus? ⇒ Boolean
Necessary but NOT sufficient — the Streams entrypoint exists.
-
.pgbus_streams? ⇒ Boolean
The reactive-Streams capability: Stream#broadcast accepts :exclude (the pgbus >= 0.9.2 shape).
-
.register_token_upgrader(from_version, &block) ⇒ Object
Register an upgrader that rewrites a payload signed at
from_versioninto the shape offrom_version + 1(issue #111). -
.render(component) ⇒ Object
Render a Phlex component to HTML with a full (off-request) view context.
-
.request_bound_view_context(controller_class) ⇒ Object
Build an off-request view context whose controller has a REAL
request. -
.reset_around_actions! ⇒ Object
Drop all registered around_action wrappers.
-
.reset_param_types! ⇒ Object
Drop the registry back to the built-ins and unfreeze it.
-
.reset_stream_builder! ⇒ Object
(also: reset_flash_builder!)
Invalidate the per-thread context + builder for ALL threads by bumping the generation; each thread rebuilds lazily on next use.
-
.reset_token_upgraders! ⇒ Object
Drop all registered upgraders.
-
.sign(payload) ⇒ Object
Signs a payload hash into an identity token, stamping the current TOKEN_VERSION (issue #111).
-
.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.
-
.stream_builder ⇒ Object
(also: flash_builder)
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_update companion.
-
.token_upgraders ⇒ Object
from_version => callable(payload) -> migrated payload.
-
.upgrade_token(payload) ⇒ Object
Migrate a verified payload from whatever version it was signed at up to TOKEN_VERSION (issue #111).
-
.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.
-
.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.
-
.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.
- .with_connection_id(connection_id) ⇒ Object
- .with_defer_binding(binding) ⇒ Object
Class Attribute Details
.action_path ⇒ Object
142 143 144 |
# File 'lib/phlex/reactive.rb', line 142 def action_path @action_path ||= "/reactive/actions" end |
.authorization_errors ⇒ Object
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 end |
.authorization_methods ⇒ Object
219 220 221 222 223 |
# File 'lib/phlex/reactive.rb', line 219 def return @authorization_methods if defined?(@authorization_methods) %i[authorize! authorize allowed_to?] end |
.base_controller_name ⇒ Object
650 651 652 |
# File 'lib/phlex/reactive.rb', line 650 def base_controller_name @base_controller_name ||= "ActionController::Base" end |
.debug ⇒ Object
188 189 190 191 192 |
# File 'lib/phlex/reactive.rb', line 188 def debug return @debug if defined?(@debug) false end |
.defer_job_queue ⇒ Object
411 412 413 |
# File 'lib/phlex/reactive.rb', line 411 def defer_job_queue @defer_job_queue ||= "default" end |
.defer_path ⇒ Object
381 382 383 |
# File 'lib/phlex/reactive.rb', line 381 def defer_path @defer_path ||= "/reactive/defer" end |
.defer_token_ttl ⇒ Object
373 374 375 |
# File 'lib/phlex/reactive.rb', line 373 def defer_token_ttl @defer_token_ttl ||= 120 end |
.error_flash ⇒ Object
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.
561 562 563 |
# File 'lib/phlex/reactive.rb', line 561 def error_flash @error_flash end |
.flash_component ⇒ Object
Component class used to render STRING flash content (issue #77). Instantiated as flash_component.new(level:, content:) and rendered through the existing render path, replacing the built-in
569 570 571 |
# File 'lib/phlex/reactive.rb', line 569 def flash_component @flash_component end |
.flash_target ⇒ Object
DOM id of the host-app container a Response#flash appends into. Default "flash"; override to match your layout's flash region.
547 548 549 |
# File 'lib/phlex/reactive.rb', line 547 def flash_target @flash_target ||= "flash" end |
.log_events ⇒ Object
170 171 172 173 174 |
# File 'lib/phlex/reactive.rb', line 170 def log_events return @log_events if defined?(@log_events) false end |
.renderer ⇒ Object
337 338 339 |
# File 'lib/phlex/reactive.rb', line 337 def renderer @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil end |
.verbose_errors ⇒ Object
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 |
.verifier ⇒ Object
333 334 335 |
# File 'lib/phlex/reactive.rb', line 333 def verifier @verifier ||= default_verifier end |
.verify_authorized ⇒ Object
204 205 206 207 208 |
# File 'lib/phlex/reactive.rb', line 204 def 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.
749 750 751 752 753 754 755 756 757 758 759 760 761 |
# File 'lib/phlex/reactive.rb', line 749 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.
313 314 315 316 317 |
# File 'lib/phlex/reactive.rb', line 313 def around_action(&block) raise ::ArgumentError, "Phlex::Reactive.around_action requires a block" unless block around_actions << block end |
.around_actions ⇒ Object
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).
322 323 324 |
# File 'lib/phlex/reactive.rb', line 322 def around_actions @around_actions ||= [] end |
.base_controller ⇒ Object
654 655 656 |
# File 'lib/phlex/reactive.rb', line 654 def base_controller base_controller_name.constantize end |
.current_connection_id ⇒ Object
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.
728 729 730 |
# File 'lib/phlex/reactive.rb', line 728 def current_connection_id Thread.current[:phlex_reactive_connection_id] end |
.current_defer_binding ⇒ Object
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.
473 474 475 |
# File 'lib/phlex/reactive.rb', line 473 def current_defer_binding Thread.current[:phlex_reactive_defer_binding] 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.
499 500 501 502 503 504 505 506 |
# File 'lib/phlex/reactive.rb', line 499 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_purpose ⇒ Object
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.
465 466 467 468 |
# File 'lib/phlex/reactive.rb', line 465 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).
537 538 539 540 541 542 543 |
# File 'lib/phlex/reactive.rb', line 537 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_transport ⇒ Object
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.
390 391 392 |
# File 'lib/phlex/reactive.rb', line 390 def defer_transport @defer_transport ||= :auto end |
.defer_transport=(value) ⇒ Object
394 395 396 397 398 399 400 401 402 403 |
# File 'lib/phlex/reactive.rb', line 394 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 |
.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.
266 267 268 269 |
# File 'lib/phlex/reactive.rb', line 266 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.
288 289 290 |
# File 'lib/phlex/reactive.rb', line 288 def instrument(event, payload = {}, &) ::ActiveSupport::Notifications.instrument("#{event}.#{INSTRUMENTATION_NAMESPACE}", payload, &) end |
.off_request_view_context ⇒ Object
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.
604 605 606 |
# File 'lib/phlex/reactive.rb', line 604 def off_request_view_context off_request_view_context_cache[:view_context] end |
.off_request_view_context_generation ⇒ Object
624 625 626 |
# File 'lib/phlex/reactive.rb', line 624 def off_request_view_context_generation @off_request_view_context_generation ||= 0 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 }
238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/phlex/reactive.rb', line 238 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.
259 260 261 |
# File 'lib/phlex/reactive.rb', line 259 def param_type?(name) param_types.key?(name.to_sym) end |
.param_types ⇒ Object
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.
253 254 255 |
# File 'lib/phlex/reactive.rb', line 253 def param_types @param_types ||= Phlex::Reactive::ParamSchema.built_in_types.dup end |
.param_types_frozen? ⇒ Boolean
271 272 273 |
# File 'lib/phlex/reactive.rb', line 271 def param_types_frozen? @param_types_frozen ||= false end |
.pgbus? ⇒ Boolean
Necessary but NOT sufficient — the Streams entrypoint exists.
514 515 516 517 518 |
# File 'lib/phlex/reactive.rb', line 514 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.
523 524 525 526 527 528 529 530 531 |
# File 'lib/phlex/reactive.rb', line 523 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
706 707 708 709 710 |
# File 'lib/phlex/reactive.rb', line 706 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.
577 578 579 580 581 582 |
# File 'lib/phlex/reactive.rb', line 577 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 |
.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.
354 355 356 357 358 359 360 361 362 363 |
# File 'lib/phlex/reactive.rb', line 354 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)) 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.
329 330 331 |
# File 'lib/phlex/reactive.rb', line 329 def reset_around_actions! @around_actions = [] 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.
277 278 279 280 |
# File 'lib/phlex/reactive.rb', line 277 def reset_param_types! @param_types = Phlex::Reactive::ParamSchema.built_in_types.dup @param_types_frozen = false end |
.reset_stream_builder! ⇒ Object Also known as: reset_flash_builder!
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.
614 615 616 |
# File 'lib/phlex/reactive.rb', line 614 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.
719 720 721 |
# File 'lib/phlex/reactive.rb', line 719 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.
671 672 673 |
# File 'lib/phlex/reactive.rb', line 671 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.
439 440 441 442 |
# File 'lib/phlex/reactive.rb', line 439 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_builder ⇒ Object Also known as: flash_builder
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_update 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.
592 593 594 |
# File 'lib/phlex/reactive.rb', line 592 def stream_builder off_request_view_context_cache[:builder] end |
.token_upgraders ⇒ Object
from_version => callable(payload) -> migrated payload. Sparse: an entry exists only for a version that actually changed shape.
714 715 716 |
# File 'lib/phlex/reactive.rb', line 714 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.
685 686 687 688 689 690 691 692 693 694 695 |
# File 'lib/phlex/reactive.rb', line 685 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 |
.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.
663 664 665 666 |
# File 'lib/phlex/reactive.rb', line 663 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).
454 455 456 457 458 |
# File 'lib/phlex/reactive.rb', line 454 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.
766 767 768 769 770 771 772 773 774 775 776 |
# File 'lib/phlex/reactive.rb', line 766 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
732 733 734 735 736 737 738 |
# File 'lib/phlex/reactive.rb', line 732 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
477 478 479 480 481 482 483 |
# File 'lib/phlex/reactive.rb', line 477 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 |