phlex-reactive 0.9.3 → 0.9.5

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +282 -2
  4. data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
  5. data/app/javascript/phlex/reactive/inspect.js +225 -0
  6. data/app/javascript/phlex/reactive/inspect.min.js +4 -0
  7. data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/reactive_controller.js +659 -10
  9. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  10. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  11. data/lib/generators/phlex/reactive/claude/USAGE +15 -0
  12. data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
  13. data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
  14. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
  15. data/lib/phlex/reactive/authorization.rb +118 -0
  16. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
  17. data/lib/phlex/reactive/component/dsl.rb +70 -0
  18. data/lib/phlex/reactive/component/helpers.rb +348 -21
  19. data/lib/phlex/reactive/component/identity.rb +10 -1
  20. data/lib/phlex/reactive/component/lazy.rb +79 -0
  21. data/lib/phlex/reactive/component/registry.rb +7 -1
  22. data/lib/phlex/reactive/component.rb +1 -0
  23. data/lib/phlex/reactive/defer.rb +363 -0
  24. data/lib/phlex/reactive/deferred_render_job.rb +116 -0
  25. data/lib/phlex/reactive/doctor.rb +79 -11
  26. data/lib/phlex/reactive/engine.rb +16 -2
  27. data/lib/phlex/reactive/inspector/report.rb +140 -0
  28. data/lib/phlex/reactive/inspector.rb +253 -0
  29. data/lib/phlex/reactive/log_subscriber.rb +10 -0
  30. data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
  31. data/lib/phlex/reactive/mcp/runner.rb +24 -0
  32. data/lib/phlex/reactive/mcp/server.rb +47 -0
  33. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
  34. data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
  35. data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
  36. data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
  37. data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
  38. data/lib/phlex/reactive/mcp.rb +58 -0
  39. data/lib/phlex/reactive/reply.rb +18 -0
  40. data/lib/phlex/reactive/response.rb +47 -2
  41. data/lib/phlex/reactive/streamable.rb +5 -1
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +253 -3
  44. data/lib/tasks/phlex_reactive.rake +28 -0
  45. metadata +22 -1
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module MCP
6
+ module Tools
7
+ # phlex_reactive_config — a REDACTED config summary. It reports the
8
+ # page-stable, non-secret configuration a developer needs to understand
9
+ # the install, and NEVER the verifier, secret_key_base, or any token. The
10
+ # allowlist below is explicit: a field is reported only if it appears here.
11
+ class ConfigTool < BaseTool
12
+ tool_name "phlex_reactive_config"
13
+ title "phlex-reactive config"
14
+ description <<~DESC
15
+ A redacted summary of the phlex-reactive configuration: gem version,
16
+ action_path, base_controller_name, renderer, verify_authorized +
17
+ authorization_methods, verbose_errors/debug/log_events, flash_target,
18
+ and whether pgbus is present/streams-capable. NEVER reports the
19
+ verifier, secret_key_base, or any signed token. Read-only.
20
+ DESC
21
+
22
+ input_schema(properties: {}, required: [])
23
+
24
+ def self.call(server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
25
+ json_response(
26
+ version: Phlex::Reactive::VERSION,
27
+ action_path: Phlex::Reactive.action_path,
28
+ base_controller_name: Phlex::Reactive.base_controller_name,
29
+ renderer: renderer_name,
30
+ verify_authorized: Phlex::Reactive.verify_authorized,
31
+ authorization_methods: Phlex::Reactive.authorization_methods,
32
+ authorization_errors: Phlex::Reactive.authorization_errors.map(&:to_s),
33
+ verbose_errors: Phlex::Reactive.verbose_errors,
34
+ debug: Phlex::Reactive.debug,
35
+ log_events: Phlex::Reactive.log_events,
36
+ flash_target: Phlex::Reactive.flash_target,
37
+ pgbus: pgbus?,
38
+ pgbus_streams: pgbus_streams?
39
+ )
40
+ end
41
+
42
+ # The renderer is a controller CLASS (or nil) — report its name, never
43
+ # the object (which could inspect to something with state).
44
+ def self.renderer_name
45
+ renderer = Phlex::Reactive.renderer
46
+ renderer.respond_to?(:name) ? renderer.name : renderer.class.name
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ # Is pgbus present and stream-capable? Delegates to the config readers
52
+ # when they exist; otherwise probes directly (defined? + respond_to?),
53
+ # so the report is correct regardless of pgbus version.
54
+ # Delegate to the gem's own capability gates (issue #165) — the
55
+ # canonical runtime probes (`pgbus_streams?` inspects
56
+ # Pgbus::Streams::Stream#broadcast's parameters directly, never a live
57
+ # stream call). Reported as plain booleans; degrade to false if the
58
+ # readers ever raise.
59
+ def self.pgbus?
60
+ !!Phlex::Reactive.pgbus?
61
+ rescue StandardError
62
+ false
63
+ end
64
+
65
+ def self.pgbus_streams?
66
+ !!Phlex::Reactive.pgbus_streams?
67
+ rescue StandardError
68
+ false
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module MCP
6
+ module Tools
7
+ # phlex_reactive_doctor — the install validator's checks as structured
8
+ # JSON (name/status/message/fix), the same data `bin/rails
9
+ # phlex_reactive:doctor` prints as text.
10
+ class DoctorTool < BaseTool
11
+ tool_name "phlex_reactive_doctor"
12
+ title "phlex-reactive doctor"
13
+ description <<~DESC
14
+ Validate the phlex-reactive install and return each check as structured
15
+ JSON: name, status (ok/fail/unknown), message, and a fix line for any
16
+ non-passing check. The same checks `bin/rails phlex_reactive:doctor`
17
+ prints — route, Stimulus registration, csrf, verifier round-trip, base
18
+ controller, every action has a method, stable #id, and the advisory
19
+ authorization heuristic. Read-only.
20
+ DESC
21
+
22
+ input_schema(properties: {}, required: [])
23
+
24
+ def self.call(server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
25
+ eager_load_app!
26
+ doctor = Phlex::Reactive::Doctor.new
27
+ checks = doctor.checks.map do
28
+ { name: it.name, status: it.status, message: it.message, fix: it.fix }
29
+ end
30
+ json_response(checks: checks, ok: !doctor.failures?)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module MCP
6
+ module Tools
7
+ # phlex_reactive_find — fuzzy-search components by name and return the
8
+ # ranked matches, each with full per-action detail INCLUDING the Prism-
9
+ # extracted method definition source. The tool for "where is X defined and
10
+ # what does its action do?".
11
+ class FindTool < BaseTool
12
+ tool_name "phlex_reactive_find"
13
+ title "phlex-reactive find"
14
+ description <<~DESC
15
+ Fuzzy-find reactive components by name (exact > prefix > substring >
16
+ subsequence, on both the demodulized and full name). Returns the ranked
17
+ matches, each with its actions' param schema, source location,
18
+ authorization heuristic, and the full method-definition SOURCE
19
+ (extracted with Prism). Read-only.
20
+
21
+ NOTE: unlike the other tools (which report names/paths/schemas only),
22
+ this one returns the action's source code verbatim — so anything
23
+ hardcoded in an action body is surfaced too. Don't embed secrets or
24
+ credentials in action definitions (read them from ENV/credentials).
25
+ DESC
26
+
27
+ input_schema(
28
+ properties: {
29
+ query: { type: "string", description: "The search string (component name fragment)." }
30
+ },
31
+ required: ["query"]
32
+ )
33
+
34
+ def self.call(query:, server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
35
+ eager_load_app!
36
+ matches = Phlex::Reactive::Inspector.find(query).map { match_hash(it) }
37
+ json_response(query: query, matches: matches)
38
+ end
39
+
40
+ def self.match_hash(info)
41
+ {
42
+ name: info.name,
43
+ path: Phlex::Reactive::Inspector::Report.location_str(info.path),
44
+ record_key: info.record_key,
45
+ state_keys: info.state_keys,
46
+ actions: info.actions.map { action_detail(it) }
47
+ }
48
+ end
49
+
50
+ def self.action_detail(action)
51
+ {
52
+ name: action.name,
53
+ params: action.params,
54
+ source_location: Phlex::Reactive::Inspector::Report.location_str(action.source_location),
55
+ authorization_call_detected: action.authorization_call_detected?,
56
+ # The full method SOURCE — this is the one field that exposes an
57
+ # action body verbatim (see the tool description). A secret hardcoded
58
+ # in an action would surface here; keep secrets out of action bodies.
59
+ definition: action.definition
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The read-only diagnostic MCP server (issue #168): a stdio server exposing
6
+ # five read-only tools (doctor, components, actions, find, config) so Claude
7
+ # Code inside a HOST app can introspect the live reactive registry when
8
+ # debugging. It mirrors pgbus's MCP subtree.
9
+ #
10
+ # The `mcp` gem is OPTIONAL and NEVER a gemspec runtime dependency — this
11
+ # whole subtree references the optional gem's constants (MCP::Tool,
12
+ # MCP::Server), so it is kept OUT of the Zeitwerk loader (loader.ignore in
13
+ # lib/phlex/reactive.rb) and loaded explicitly via MCP.load!.
14
+ #
15
+ # Phlex::Reactive::MCP.load! # lazy-require the gem + the tool tree
16
+ # Phlex::Reactive::MCP::Runner.run # drive the stdio transport
17
+ #
18
+ # Entry point for a host app: `bin/rails phlex_reactive:mcp` (a rake task, not
19
+ # an exe — introspection needs the booted Rails app). Consumer .mcp.json:
20
+ #
21
+ # { "mcpServers": { "phlex-reactive": {
22
+ # "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }
23
+ module MCP
24
+ # Every file under lib/phlex/reactive/mcp/, in DEPENDENCY ORDER (base_tool
25
+ # before the tools that subclass it; the tools before the server that lists
26
+ # them; the server before the runner that builds it). Loaded by load! after
27
+ # the optional gem itself.
28
+ REQUIRES = %w[
29
+ phlex/reactive/mcp/base_tool
30
+ phlex/reactive/mcp/tools/doctor_tool
31
+ phlex/reactive/mcp/tools/components_tool
32
+ phlex/reactive/mcp/tools/actions_tool
33
+ phlex/reactive/mcp/tools/find_tool
34
+ phlex/reactive/mcp/tools/config_tool
35
+ phlex/reactive/mcp/server
36
+ phlex/reactive/mcp/runner
37
+ ].freeze
38
+
39
+ module_function
40
+
41
+ # Lazy-require the optional `mcp` gem, then the tool tree in dependency
42
+ # order. Idempotent (plain require). ONLY `require "mcp"` is rescued — a
43
+ # LoadError from an internal path propagates verbatim so a typo isn't
44
+ # masked as "add the mcp gem".
45
+ def load!
46
+ begin
47
+ require "mcp"
48
+ rescue LoadError
49
+ raise Phlex::Reactive::Error,
50
+ "The phlex-reactive MCP diagnostic server needs the `mcp` gem. " \
51
+ "Add `gem \"mcp\"` to your Gemfile (a dev/test group is fine) and run `bundle install`."
52
+ end
53
+
54
+ REQUIRES.each { require it }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -99,6 +99,24 @@ module Phlex
99
99
  def streams(*strings)
100
100
  Response.streams(@component, *strings)
101
101
  end
102
+
103
+ # Defer an expensive segment (issue #165): `reply.defer(Totals.new(...))`.
104
+ #
105
+ # With NO prior verb, the bound component's token is refreshed via a tiny
106
+ # token-only stream (Response.streams), NOT a full self-replace: a full
107
+ # replace would re-render the acting component SYNCHRONOUSLY on the request
108
+ # thread — and if you defer your OWN subject (`reply.defer(self)`), that is
109
+ # the very render you meant to move OFF the critical path, so the defer
110
+ # would be silently defeated (a double render). The token-only refresh
111
+ # rolls the signed identity forward without any synchronous render; the
112
+ # deferred segment's directive rides alongside. Chain off a verb when you
113
+ # DO want a synchronous piece too:
114
+ #
115
+ # reply.streams(volume_cell).defer(SessionTotals.new(workout: @workout))
116
+ # reply.replace.defer(Totals.new(order: @order)) # explicit self re-render
117
+ def defer(component, placeholder: nil, morph: false)
118
+ Response.streams(@component).defer(component, placeholder:, morph:)
119
+ end
102
120
  end
103
121
  end
104
122
  end
@@ -312,16 +312,29 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
312
312
  # NOT trip refresh_token? — it exists only to default #js's target to the
313
313
  # bound component's id, so reply.morph.js(js.focus("@root")) scopes to the
314
314
  # morphed root without the caller repeating the id.
315
+ # No deferred segments — the shared default so the common (non-defer)
316
+ # reply never allocates an empty array per Response.
317
+ NO_SEGMENTS = [].freeze
318
+
315
319
  def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil,
316
- subject_component: nil)
320
+ subject_component: nil, deferred_segments: NO_SEGMENTS)
317
321
  @streams = streams.freeze
318
322
  @redirect_url = redirect_url
319
323
  @render_self = render_self
320
324
  @token_component = token_component
321
325
  @subject_component = subject_component
326
+ @deferred_segments = deferred_segments.freeze
322
327
  freeze
323
328
  end
324
329
 
330
+ # The recorded reply.defer segments (issue #165), in call order. The
331
+ # Response only RECORDS them; the endpoint (via the Defer module) builds
332
+ # the placeholder + directive streams AFTER the action's transaction
333
+ # committed and picks the delivery lane.
334
+ attr_reader :deferred_segments
335
+
336
+ def deferred? = !@deferred_segments.empty?
337
+
325
338
  # Append extra turbo-stream strings (a sibling component, a flash).
326
339
  # Returns a NEW Response (immutable).
327
340
  def stream(*more)
@@ -330,7 +343,39 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
330
343
  redirect_url: @redirect_url,
331
344
  render_self: @render_self,
332
345
  token_component: @token_component,
333
- subject_component: @subject_component
346
+ subject_component: @subject_component,
347
+ deferred_segments: @deferred_segments
348
+ )
349
+ end
350
+
351
+ # Record a DEFERRED segment (issue #165): `component`'s render is taken
352
+ # off the actor's critical path — the reply carries a pending marker (or
353
+ # `placeholder:`) plus a delivery directive, and the real HTML reaches
354
+ # the SAME actor when the render completes (pull fetch or pgbus push,
355
+ # picked at the endpoint). `morph: true` makes the arrival morph in place.
356
+ # Returns a NEW Response (immutable). Chain it like any other verb:
357
+ #
358
+ # reply.streams(volume_cell_stream).defer(SessionTotals.new(workout:))
359
+ #
360
+ # Dead constructs fail HERE, loudly: defer on a redirect (the client is
361
+ # navigating away), a non-reactive component (its identity could never be
362
+ # rebuilt at render time), a bogus placeholder type.
363
+ def defer(component, placeholder: nil, morph: false)
364
+ if redirect?
365
+ raise Phlex::Reactive::Error,
366
+ "reply.defer on a redirect reply is dead — the client is navigating away, " \
367
+ "so the deferred render could never land"
368
+ end
369
+ Phlex::Reactive::Defer.validate_segment!(component, placeholder)
370
+
371
+ self.class.new(
372
+ streams: @streams,
373
+ redirect_url: @redirect_url,
374
+ render_self: @render_self,
375
+ token_component: @token_component,
376
+ subject_component: @subject_component,
377
+ deferred_segments: @deferred_segments +
378
+ [Phlex::Reactive::Defer::Segment.new(component:, placeholder:, morph:)]
334
379
  )
335
380
  end
336
381
 
@@ -157,7 +157,11 @@ module Phlex
157
157
  # known only after rendering, so we mutate the payload inside the block.
158
158
  event = { component: name, bytesize: 0 }
159
159
  Phlex::Reactive.instrument("render", event) do
160
- html = component.render_in(turbo_view_context)
160
+ # Machinery renders are REAL (issue #165): a reactive_lazy component
161
+ # emits its actual template here — the lazy shell is for the
162
+ # page-embedded initial mount only. Two fiber-local writes; the
163
+ # render bench holds flat.
164
+ html = Phlex::Reactive::Defer.with_real_render { component.render_in(turbo_view_context) }
161
165
  event[:bytesize] = html.bytesize
162
166
  html
163
167
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.9.3"
5
+ VERSION = "0.9.5"
6
6
  end
7
7
  end
@@ -56,10 +56,34 @@ module Phlex
56
56
  # the stdlib one an app catches.
57
57
  class UnknownParamType < ::ArgumentError; end
58
58
 
59
+ # Raised when the default-ON verify_authorized guard (issue #168) finds an
60
+ # action completed WITHOUT any authorization call — the fail-closed
61
+ # presence-side complement to authorization_errors. It fires INSIDE the
62
+ # action's transaction, so the mutation rolls back, and it is NOT rescued
63
+ # into a 4xx: it bubbles as a developer error (a 500 your error tracker
64
+ # must see) because a missing authorize! is a bug, not a client fault. The
65
+ # message names the component#action and all three remedies (call an
66
+ # authorization method / mark_authorized! / declare skip_verify_authorized).
67
+ class AuthorizationNotVerified < Error; end
68
+
59
69
  # Purpose string bound into every identity token's signature so a token
60
70
  # minted for phlex-reactive can't be replayed against another verifier use.
61
71
  IDENTITY_PURPOSE = "phlex-reactive/identity"
62
72
 
73
+ # Purpose string for DEFER tokens (issue #165) — the short-TTL identity a
74
+ # reply.defer directive carries so the client can fetch the expensive
75
+ # render off the actor's critical path. A distinct purpose makes the two
76
+ # token families non-interchangeable BY SIGNATURE: an action token is
77
+ # rejected at the defer endpoint (it must not become a render oracle) and a
78
+ # defer token can never invoke an action (it carries no action grant).
79
+ DEFER_PURPOSE = "phlex-reactive/defer"
80
+
81
+ # The defer_transport values (issue #165): :auto picks push (pgbus durable
82
+ # one-shot stream + ActiveJob) when capable, else pull (parallel fetch);
83
+ # :fetch forces pull; :stream requests push but still degrades to pull with
84
+ # a warning when the capability is absent (degrade, never break).
85
+ DEFER_TRANSPORTS = %i[auto fetch stream].freeze
86
+
63
87
  # The current identity-token payload version (issue #111), stamped into every
64
88
  # signed token under the "v" key. It exists so the NEXT breaking shape change
65
89
  # (a rename, per-token expiry, a nonce) can upgrade tokens already in flight
@@ -167,6 +191,37 @@ module Phlex
167
191
  false
168
192
  end
169
193
 
194
+ # verify_authorized (issue #168): the default-ON runtime guard. When true,
195
+ # an action that completes without any authorization call raises
196
+ # AuthorizationNotVerified inside its transaction (fail-closed — the
197
+ # mutation rolls back). Default ON is a deliberate, pre-1.0 breaking change:
198
+ # a forgotten authorize! becomes a loud 500, not a silent hole. Opt out per
199
+ # component with `skip_verify_authorized`, mark a bespoke check with
200
+ # `mark_authorized!`, or turn it off globally here. The `defined?` guard
201
+ # (not `||=`) makes an explicit `= false` stick.
202
+ attr_writer :verify_authorized
203
+
204
+ def verify_authorized
205
+ return @verify_authorized if defined?(@verify_authorized)
206
+
207
+ true
208
+ end
209
+
210
+ # The method names verify_authorized's interceptor wraps to detect an
211
+ # authorization call (issue #168). Defaults to the common set across
212
+ # authorization libraries — Pundit (`authorize`), CanCanCan (`authorize!`),
213
+ # ActionPolicy (`authorize!`/`allowed_to?`). Set in an initializer to match
214
+ # your app's helper. `mark_authorized!` always counts, regardless of this
215
+ # list. The `defined?` guard makes an explicit override (including a
216
+ # narrower list) stick.
217
+ attr_writer :authorization_methods
218
+
219
+ def authorization_methods
220
+ return @authorization_methods if defined?(@authorization_methods)
221
+
222
+ %i[authorize! authorize allowed_to?]
223
+ end
224
+
170
225
  # Register an app-defined param type (issue #109). The block receives the
171
226
  # raw client value and returns the coerced value — or Phlex::Reactive::
172
227
  # ParamSchema::DROP to reject it (the keyword default then applies, keeping
@@ -307,6 +362,186 @@ module Phlex
307
362
  instance.view_context
308
363
  end
309
364
 
365
+ # --- Deferred reply segments (issue #165) --------------------------
366
+
367
+ # Lifetime (seconds) of a defer token. It only needs to cover the
368
+ # reply→fetch gap (or reply→job→SSE on the push lane), so it stays short:
369
+ # a leaked defer token is a render oracle for exactly one component
370
+ # identity until this expires. nil resets to the default.
371
+ attr_writer :defer_token_ttl
372
+
373
+ def defer_token_ttl
374
+ @defer_token_ttl ||= 120
375
+ end
376
+
377
+ # The path the defer endpoint is mounted at (the pull lane's target).
378
+ # Default "/reactive/defer"; set before boot if it collides.
379
+ attr_writer :defer_path
380
+
381
+ def defer_path
382
+ @defer_path ||= "/reactive/defer"
383
+ end
384
+
385
+ # How deferred segments reach the actor: :auto (push iff capable, else
386
+ # pull), :fetch (always pull), :stream (push; degrades to pull with a
387
+ # warning when the capability is absent). Validated at assignment — a
388
+ # typo'd transport must fail at the initializer, not silently at reply
389
+ # time. nil resets to the default.
390
+ def defer_transport
391
+ @defer_transport ||= :auto
392
+ end
393
+
394
+ def defer_transport=(value)
395
+ value = value&.to_sym
396
+ unless value.nil? || DEFER_TRANSPORTS.include?(value)
397
+ raise ::ArgumentError,
398
+ "Phlex::Reactive.defer_transport must be one of #{DEFER_TRANSPORTS.map(&:inspect).join(", ")} " \
399
+ "(got #{value.inspect})"
400
+ end
401
+
402
+ @defer_transport = value
403
+ end
404
+
405
+ # The ActiveJob queue the push lane's DeferredRenderJob runs on. Default
406
+ # "default"; point it at a fast queue in production — a deferred segment
407
+ # is a UX-latency render, and letting it starve behind heavy jobs defeats
408
+ # the point. nil resets to the default.
409
+ attr_writer :defer_job_queue
410
+
411
+ def defer_job_queue
412
+ @defer_job_queue ||= "default"
413
+ end
414
+
415
+ # Signs a defer payload (issue #165): same shape + version stamp as the
416
+ # identity token, but purpose-scoped to DEFER_PURPOSE and expiring after
417
+ # defer_token_ttl. verify/verify_defer are therefore mutually exclusive
418
+ # by construction — see the DEFER_PURPOSE comment.
419
+ # SECURITY (the leaked-token exchange): the defer endpoint re-renders the
420
+ # real component, whose root carries a fresh NON-expiring identity (action)
421
+ # token — so a leaked defer token replayed by ANOTHER actor within its TTL
422
+ # would hand that actor a permanent action token for the same identity.
423
+ # The purpose is therefore ALSO scoped to the minting actor's binding (the
424
+ # session id, via with_defer_binding), so a defer token minted under
425
+ # binding A fails verification under binding B — the exchange can't cross
426
+ # actors. Unbound (no session — the ActionController::Base default) is the
427
+ # documented today-behavior; `authorize!` in the action stays the real
428
+ # authority in every case.
429
+ # Sign a defer token. `unbound: true` (the reactive_lazy channel) mints
430
+ # under the PLAIN DEFER_PURPOSE, never the /binding suffix — because a
431
+ # lazy shell renders during the PAGE render, on a fresh visit where the
432
+ # session doesn't exist yet (Rails establishes it DURING that response),
433
+ # so it can't be bound; and it lives in the actor's own page, a small leak
434
+ # surface. reply.defer tokens (the default) mint under the current actor's
435
+ # binding — they live in an action HTTP response that can transit proxies/
436
+ # logs, the real cross-infrastructure leak vector. See docs/security +
437
+ # the README defer security note. `authorize!` in the action is the real
438
+ # authority for the harvested-action-token step in both cases.
439
+ def sign_defer(payload, unbound: false)
440
+ purpose = unbound ? DEFER_PURPOSE : defer_purpose
441
+ verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose:, expires_in: defer_token_ttl)
442
+ end
443
+
444
+ # Returns the verified, version-upgraded defer payload, or nil when the
445
+ # token is tampered, expired, carries the wrong purpose (an action token
446
+ # OR a BOUND token minted under a DIFFERENT actor binding), or a version
447
+ # this code doesn't understand — all fail closed through the endpoint's
448
+ # InvalidToken → 400 path. Tries the current-binding purpose first (the
449
+ # reply.defer, actor-bound case), then falls back to the plain
450
+ # DEFER_PURPOSE (the unbound lazy case) — so an unbound lazy token
451
+ # resolves under ANY binding, while a bound token minted under session-A
452
+ # still fails under session-B (it was signed with /session-A, which
453
+ # matches NEITHER /session-B nor the plain purpose).
454
+ def verify_defer(token)
455
+ payload = verifier.verified(token, purpose: defer_purpose)
456
+ payload ||= verifier.verified(token, purpose: DEFER_PURPOSE) if current_defer_binding
457
+ payload && upgrade_token(payload)
458
+ end
459
+
460
+ # The purpose string for the CURRENT actor's defer tokens: the base
461
+ # DEFER_PURPOSE, plus the binding when one is present. Binding is folded
462
+ # into the PURPOSE (not the payload) so it's part of the signature's
463
+ # domain separation — a mismatched binding is a verification failure, not
464
+ # a value the endpoint must remember to compare.
465
+ def defer_purpose
466
+ binding = current_defer_binding
467
+ binding ? "#{DEFER_PURPOSE}/#{binding}" : DEFER_PURPOSE
468
+ end
469
+
470
+ # The acting client's defer binding during a request, or nil. Set by the
471
+ # ActionsController from defer_binding_for(request) — threaded exactly
472
+ # like current_connection_id.
473
+ def current_defer_binding
474
+ Thread.current[:phlex_reactive_defer_binding]
475
+ end
476
+
477
+ def with_defer_binding(binding)
478
+ previous = Thread.current[:phlex_reactive_defer_binding]
479
+ Thread.current[:phlex_reactive_defer_binding] = binding.presence
480
+ yield
481
+ ensure
482
+ Thread.current[:phlex_reactive_defer_binding] = previous
483
+ end
484
+
485
+ # Resolve a request to its defer binding: the id of an ALREADY-PERSISTED
486
+ # session, else nil. The `exists?` gate is load-bearing — a bare
487
+ # `session.id` LAZILY generates an id even for an empty, never-written
488
+ # session, and that id is NOT persisted (no Set-Cookie), so two requests
489
+ # for the same read-only page get DIFFERENT lazy ids and a bound token
490
+ # minted on one is rejected on the other. Only a persisted session (an
491
+ # authenticated app wrote one at login) has a stable id across the mint
492
+ # (page render / action) and the verify (defer endpoint) — exactly the
493
+ # case where cross-actor exchange is the real threat. A read-only page
494
+ # with no session mints + verifies UNBOUND consistently (the TTL +
495
+ # `authorize!` remain the bound). Tolerant of a store that lazily raises:
496
+ # degrade to nil (unbound), never a 500. Override to bind to your own
497
+ # actor identity (a stable user id, an API-token digest) — recommended for
498
+ # a token-authenticated API with no cookie session.
499
+ def defer_binding_for(request)
500
+ session = request.session
501
+ return nil unless session.respond_to?(:exists?) && session.exists?
502
+
503
+ session.id&.to_s
504
+ rescue StandardError
505
+ nil
506
+ end
507
+
508
+ # --- pgbus capability gates (issue #165) ---------------------------
509
+ # Runtime capability detection, never `defined?(Pgbus)` alone or a
510
+ # version string (the core optionality invariant): pgbus < 0.9.2 also
511
+ # defines ::Pgbus, so the Streams gate probes the ACTUAL keyword.
512
+
513
+ # Necessary but NOT sufficient — the Streams entrypoint exists.
514
+ def pgbus?
515
+ return false unless defined?(::Pgbus)
516
+
517
+ ::Pgbus.respond_to?(:stream)
518
+ end
519
+
520
+ # The reactive-Streams capability: Stream#broadcast accepts :exclude
521
+ # (the pgbus >= 0.9.2 shape). This is the gate that prevents
522
+ # `ArgumentError: unknown keyword :exclude` on an old pgbus.
523
+ def pgbus_streams?
524
+ return false unless pgbus?
525
+ return false unless defined?(::Pgbus::Streams::Stream)
526
+
527
+ ::Pgbus::Streams::Stream.instance_method(:broadcast)
528
+ .parameters.any? { |_type, name| name == :exclude }
529
+ rescue ::NameError
530
+ false
531
+ end
532
+
533
+ # Everything the defer PUSH lane needs at runtime: streams-capable pgbus,
534
+ # server-side signed-src minting (SignedName.sign — the element's src is
535
+ # built off-request), and ActiveJob to run the render off the request
536
+ # thread. Anything missing → the pull lane (which is always available).
537
+ def defer_push_capable?
538
+ return false unless pgbus_streams?
539
+ return false unless defined?(::Pgbus::Streams::SignedName) &&
540
+ ::Pgbus::Streams::SignedName.respond_to?(:sign)
541
+
542
+ defined?(::ActiveJob::Base) ? true : false
543
+ end
544
+
310
545
  # DOM id of the host-app container a Response#flash appends into.
311
546
  # Default "flash"; override to match your layout's flash region.
312
547
  def flash_target
@@ -340,7 +575,10 @@ module Phlex
340
575
  # access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as
341
576
  # Response#with content.
342
577
  def render(component)
343
- component.render_in(off_request_view_context)
578
+ # A machinery render (issue #165): a reactive_lazy component embedded
579
+ # in a Response/flash renders its REAL template — the lazy shell is
580
+ # for the page-embedded initial mount only.
581
+ Phlex::Reactive::Defer.with_real_render { component.render_in(off_request_view_context) }
344
582
  end
345
583
 
346
584
  # A Turbo::Streams::TagBuilder bound to an off-request view context, used
@@ -597,8 +835,8 @@ loader.tag = "phlex-reactive"
597
835
  lib = File.expand_path("..", __dir__)
598
836
  loader.push_dir(lib)
599
837
  # js.rb defines JS and component/dsl.rb defines Component::DSL (acronyms), not
600
- # the default-inflected `Js`/`Dsl`.
601
- loader.inflector.inflect("js" => "JS", "dsl" => "DSL")
838
+ # the default-inflected `Js`/`Dsl`. mcp.rb defines MCP (issue #168).
839
+ loader.inflector.inflect("js" => "JS", "dsl" => "DSL", "mcp" => "MCP")
602
840
  # The gem-name shim (`require "phlex-reactive"`) is a plain require, not a
603
841
  # managed file.
604
842
  loader.ignore("#{lib}/phlex-reactive.rb")
@@ -615,9 +853,21 @@ loader.ignore("#{lib}/generators")
615
853
  # Zeitwerk's naming — test_helpers.rb requires it explicitly (only when RSpec is
616
854
  # present), and the loader must ignore it.
617
855
  loader.ignore("#{lib}/phlex/reactive/test_helpers/matchers.rb")
856
+ # The MCP diagnostic tool tree (issue #168) subclasses the OPTIONAL `mcp` gem's
857
+ # constants (MCP::Tool) at class-definition time, so the whole mcp/ subdirectory
858
+ # must stay out of the autoloader — Phlex::Reactive::MCP.load! requires it in
859
+ # dependency order only when the gem is present. mcp.rb itself (the load! entry
860
+ # point) references no gem constant at load time, so Zeitwerk autoloads it
861
+ # normally (inflected MCP above); only the gem-dependent subtree is ignored.
862
+ loader.ignore("#{lib}/phlex/reactive/mcp")
618
863
  # The engine is required explicitly below (only when Rails is present) and must
619
864
  # not be eager-loaded before that.
620
865
  loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
866
+ # The defer push-lane job subclasses ActiveJob::Base, which is NOT a gem
867
+ # dependency — eager-loading it in an app without ActiveJob would raise at
868
+ # boot. The constant autoloads on first reference, which only happens behind
869
+ # Phlex::Reactive.defer_push_capable? (that gate requires ActiveJob::Base).
870
+ loader.do_not_eager_load("#{__dir__}/reactive/deferred_render_job.rb")
621
871
  loader.setup
622
872
 
623
873
  require "phlex/reactive/engine" if defined?(Rails::Engine)