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,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Deferred reply segments (issue #165): the machinery behind reply.defer —
6
+ # "placeholder now, the real render to the ACTOR when ready".
7
+ #
8
+ # A Response only RECORDS segments (see Response#defer); this module turns
9
+ # them into wire streams at the ENDPOINT, after the action's transaction
10
+ # committed — so a rolled-back action can never leak a directive — and picks
11
+ # the delivery lane:
12
+ #
13
+ # * pull (:fetch) — the directive carries a purpose-scoped, short-TTL
14
+ # defer token; the client POSTs it to the defer endpoint OFF the action
15
+ # queue and applies the rendered stream when it lands. Works on every
16
+ # transport (it's just HTTP) — this is the universal lane.
17
+ # * push (:stream) — pgbus durable one-shot stream + ActiveJob: the reply
18
+ # carries a signed SSE src; a job renders and broadcasts durably, and
19
+ # the since-id=0 replay closes the broadcast-before-subscribe race.
20
+ # Capability-gated (Phlex::Reactive.defer_push_capable?); anything
21
+ # missing degrades to pull.
22
+ module Defer
23
+ # One recorded deferred segment. `component` is the built (but not yet
24
+ # rendered) reactive component; `placeholder` is nil (keep current
25
+ # content, mark pending), true (the component's deferred_placeholder /
26
+ # the built-in shell), a String, or a Phlex component; `morph` makes the
27
+ # ARRIVAL morph instead of replace (issue #28 semantics).
28
+ Segment = Data.define(:component, :placeholder, :morph)
29
+
30
+ # The custom turbo-stream action the client's defer handler registers.
31
+ DIRECTIVE_ACTION = "reactive:defer"
32
+
33
+ # The stable marker every one-shot push-lane stream key starts with — how
34
+ # pgbus's orphan sweep and an operator recognize a phlex-reactive defer
35
+ # queue (issue #165).
36
+ DEFER_KEY_MARKER = "prdefer_"
37
+ # Minimum random hex chars on the key (64 bits) — collision-safe.
38
+ DEFER_KEY_MIN_SUFFIX = 16
39
+ # pgbus's MAX_QUEUE_NAME_LENGTH (its QueueNameValidator); the live
40
+ # queue_prefix is subtracted from it to get the usable budget.
41
+ PGBUS_MAX_QUEUE_NAME = 47
42
+ # pgbus's default queue_prefix, used when the live one can't be read.
43
+ DEFAULT_PGBUS_QUEUE_PREFIX = "pgbus"
44
+
45
+ # Thread/fiber-local flag marking a REAL reactive-machinery render —
46
+ # inside it a reactive_lazy component renders its actual template instead
47
+ # of the placeholder shell. Set by Streamable.render_component (replies,
48
+ # broadcasts, the defer endpoint/job, the class stream builders) and
49
+ # Phlex::Reactive.render; a page-embedded initial mount never passes
50
+ # through either, so it gets the shell.
51
+ REAL_RENDER_KEY = :phlex_reactive_defer_real_render
52
+
53
+ class << self
54
+ def real_render?
55
+ Thread.current[REAL_RENDER_KEY] ? true : false
56
+ end
57
+
58
+ def with_real_render
59
+ previous = Thread.current[REAL_RENDER_KEY]
60
+ Thread.current[REAL_RENDER_KEY] = true
61
+ yield
62
+ ensure
63
+ Thread.current[REAL_RENDER_KEY] = previous
64
+ end
65
+
66
+ # Which lane deferred segments take, resolved PER REPLY (capability is
67
+ # probed live — cheap: without pgbus it's one defined? short-circuit).
68
+ # :fetch forces pull; :stream requests push and DEGRADES to pull with a
69
+ # one-time warning when the capability is absent (never break); :auto
70
+ # picks push iff fully capable.
71
+ def resolve_via
72
+ case Phlex::Reactive.defer_transport
73
+ when :fetch then :fetch
74
+ when :stream
75
+ return :stream if Phlex::Reactive.defer_push_capable?
76
+
77
+ warn_stream_degraded
78
+ :fetch
79
+ else
80
+ Phlex::Reactive.defer_push_capable? ? :stream : :fetch
81
+ end
82
+ end
83
+
84
+ # The wire streams for ONE segment on the given lane, in apply order:
85
+ # the optional placeholder shell FIRST (so the pending state paints
86
+ # before the directive kicks off delivery), then the directive.
87
+ def streams_for(segment, via: resolve_via)
88
+ streams = []
89
+ shell = placeholder_stream(segment)
90
+ streams << shell if shell
91
+ streams << directive_stream(segment, via)
92
+ streams
93
+ end
94
+
95
+ # Validate reply.defer arguments LOUDLY at the call site — a dead
96
+ # segment (a component the endpoint could never rebuild, a bogus
97
+ # placeholder) must fail in the action that wrote it, not silently at
98
+ # reply render or, worse, at fetch time.
99
+ def validate_segment!(component, placeholder)
100
+ unless component.class.include?(Phlex::Reactive::Component)
101
+ raise ::ArgumentError,
102
+ "reply.defer expects a Phlex::Reactive::Component instance (its identity is what the " \
103
+ "deferred render is rebuilt from) — got #{component.class}"
104
+ end
105
+
106
+ return if placeholder.nil? || placeholder == true ||
107
+ placeholder.is_a?(::String) || placeholder.is_a?(::Phlex::SGML)
108
+
109
+ raise ::ArgumentError,
110
+ "reply.defer placeholder: must be nil (keep current content), true (the component's " \
111
+ "deferred_placeholder or the built-in shell), a String, or a Phlex component — " \
112
+ "got #{placeholder.class}"
113
+ end
114
+
115
+ private
116
+
117
+ # The delivery directive: `<turbo-stream action="reactive:defer">`
118
+ # targeting the deferred component's own #id. The pull form carries the
119
+ # purpose-scoped, short-TTL defer token; morph mode rides INSIDE the
120
+ # signed payload so the client can't flip it. Wrapped in a Stream so the
121
+ # endpoint's token guards read it structurally (it never carries
122
+ # data-reactive-token-value, so it can never count as a token refresh).
123
+ def directive_stream(segment, via)
124
+ target = segment.component.id
125
+ html = %(<turbo-stream action="#{DIRECTIVE_ACTION}" \
126
+ target="#{ERB::Util.html_escape(target)}"#{directive_attrs(segment, via)}></turbo-stream>)
127
+ Phlex::Reactive::Stream.wrap(
128
+ html.html_safe, action: DIRECTIVE_ACTION, target: target, renders_root: false
129
+ )
130
+ end
131
+
132
+ def directive_attrs(segment, via)
133
+ case via
134
+ when :stream then push_directive_attrs(segment)
135
+ else fetch_directive_attrs(segment)
136
+ end
137
+ end
138
+
139
+ def fetch_directive_attrs(segment)
140
+ token = mint_token(segment)
141
+ %( data-reactive-defer-via="fetch" data-reactive-defer-token="#{ERB::Util.html_escape(token)}")
142
+ end
143
+
144
+ # The push lane's directive: mint a one-shot stream key, sign its SSE
145
+ # src (server-side — no view context needed), enqueue the render job,
146
+ # and hand the client the subscription coordinates. since-id="0" on a
147
+ # FRESH key replays the job's durable broadcast even when it beats the
148
+ # client's subscription (the rails#52420-class race, closed by pgbus's
149
+ # connect-time read_after).
150
+ #
151
+ # Degrade, never break: this runs AFTER the action committed, so a
152
+ # signing/enqueue failure must not 500 a reply whose mutation already
153
+ # persisted — warn and fall back to the fetch directive instead (the
154
+ # pull lane needs nothing but our own endpoint).
155
+ def push_directive_attrs(segment)
156
+ key = one_shot_stream_key
157
+ src = signed_stream_src(key)
158
+ enqueue_push_render(segment, key)
159
+ # A fallback defer token rides ALONGSIDE the stream src: the server
160
+ # picks push on SERVER-side capability alone, but the browser may not
161
+ # have the pgbus client loaded (no <pgbus-stream-source>). Rather than
162
+ # dead-end the shimmer, the client degrades to the fetch lane with this
163
+ # token. It's the same purpose-scoped, actor-bound, short-TTL token the
164
+ # fetch lane uses — redeeming it at the defer endpoint is identical.
165
+ token = mint_token(segment)
166
+ %( data-reactive-defer-via="stream" data-reactive-defer-src="#{ERB::Util.html_escape(src)}" \
167
+ data-reactive-defer-since-id="0" data-reactive-defer-token="#{ERB::Util.html_escape(token)}")
168
+ rescue ::StandardError => e
169
+ warn_push_failed(e)
170
+ fetch_directive_attrs(segment)
171
+ end
172
+
173
+ # A one-shot key that FITS pgbus's live queue-name budget. pgbus computes
174
+ # 47 − queue_prefix.length − 1 (default prefix "pgbus" → 41); a longer
175
+ # app-configured prefix shrinks it, and a FIXED-width key would raise
176
+ # StreamNameTooLong in the JOB — after the directive already shipped, so
177
+ # the shimmer would hang forever. We size the random suffix to the live
178
+ # budget instead. The stable DEFER_KEY_MARKER is kept (it's how the
179
+ # orphan sweep / an operator recognizes these), and the suffix is hex
180
+ # ([a-f0-9] — never hyphens: pgbus's sanitizer STRIPS them, colliding two
181
+ # keys that differ only by hyphen).
182
+ #
183
+ # When the prefix is so long the COLLISION-SAFE minimum can't fit the
184
+ # budget (budget < marker + min-suffix), we RAISE rather than clamp to a
185
+ # too-long key: push_directive_attrs' rescue then degrades to :fetch
186
+ # (a working lane) instead of enqueuing a doomed push that raises
187
+ # StreamNameTooLong in the job once the directive is already on the wire.
188
+ #
189
+ # The budget is pgbus's OWN formula (Key.queue_name_budget), not a
190
+ # hardcoded 47 − prefix − 1 — the single source of truth, so a future
191
+ # change to MAX_QUEUE_NAME_LENGTH or the prefix is tracked automatically.
192
+ # The minted key is finally gated through Pgbus.stream_key! (which does
193
+ # NOT shrink — pgbus only auto-fits AR records, not random strings — it
194
+ # returns the key unchanged when it fits and raises loudly otherwise), so
195
+ # a sizing mistake degrades to :fetch here instead of surfacing later.
196
+ def one_shot_stream_key
197
+ budget = pgbus_queue_name_budget
198
+ suffix_chars = budget - DEFER_KEY_MARKER.length
199
+ if suffix_chars < DEFER_KEY_MIN_SUFFIX
200
+ raise Phlex::Reactive::Error,
201
+ "pgbus queue_prefix is too long (budget #{budget}) for a collision-safe defer key — " \
202
+ "deferring via :fetch instead"
203
+ end
204
+
205
+ # SecureRandom.hex(n) yields 2n chars; halve (ceil) then trim to fit.
206
+ key = "#{DEFER_KEY_MARKER}#{SecureRandom.hex((suffix_chars / 2.0).ceil)[0, suffix_chars]}"
207
+ # Validate against pgbus itself (raises if our sizing is ever wrong).
208
+ ::Pgbus.respond_to?(:stream_key!) ? ::Pgbus.stream_key!(key) : key
209
+ end
210
+
211
+ # pgbus's live queue-name budget: prefer its own formula (single source
212
+ # of truth), fall back to computing it from the prefix, then to the
213
+ # documented default — each guarded so an older pgbus without the newer
214
+ # API still degrades cleanly rather than raising.
215
+ def pgbus_queue_name_budget
216
+ return ::Pgbus::Streams::Key.queue_name_budget if pgbus_key_budget_available?
217
+
218
+ PGBUS_MAX_QUEUE_NAME - pgbus_queue_prefix_length - 1
219
+ rescue ::StandardError
220
+ PGBUS_MAX_QUEUE_NAME - DEFAULT_PGBUS_QUEUE_PREFIX.length - 1
221
+ end
222
+
223
+ def pgbus_key_budget_available?
224
+ defined?(::Pgbus::Streams::Key) &&
225
+ ::Pgbus::Streams::Key.respond_to?(:queue_name_budget)
226
+ end
227
+
228
+ # The live queue-name prefix length pgbus budgets against (default
229
+ # "pgbus" → 5). Read defensively — an old pgbus without the accessor
230
+ # falls back to the documented default.
231
+ def pgbus_queue_prefix_length
232
+ if ::Pgbus.respond_to?(:configuration) &&
233
+ ::Pgbus.configuration.respond_to?(:queue_prefix) &&
234
+ (prefix = ::Pgbus.configuration.queue_prefix)
235
+ return prefix.to_s.length
236
+ end
237
+
238
+ DEFAULT_PGBUS_QUEUE_PREFIX.length
239
+ rescue ::StandardError
240
+ DEFAULT_PGBUS_QUEUE_PREFIX.length
241
+ end
242
+
243
+ # Replicates pgbus_stream_from's src minting off-view: the signed name
244
+ # rides the URL path; the base is the configured streams_path, the
245
+ # engine's mounted helper, or pgbus's documented default mount.
246
+ def signed_stream_src(key)
247
+ signed = ::Pgbus::Streams::SignedName.sign(key)
248
+ "#{pgbus_streams_base_path.delete_suffix("/")}/#{signed}"
249
+ end
250
+
251
+ def pgbus_streams_base_path
252
+ if ::Pgbus.respond_to?(:configuration) && (configured = ::Pgbus.configuration.streams_path)
253
+ return configured
254
+ end
255
+
256
+ ::Pgbus::Engine.routes.url_helpers.streams_path
257
+ rescue ::NameError
258
+ "/pgbus/streams"
259
+ end
260
+
261
+ def enqueue_push_render(segment, key)
262
+ component = segment.component
263
+ Phlex::Reactive::DeferredRenderJob.perform_later(
264
+ component.class.name,
265
+ component.send(:reactive_identity_payload),
266
+ key,
267
+ component.id,
268
+ segment.morph
269
+ )
270
+ end
271
+
272
+ def warn_push_failed(error)
273
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
274
+
275
+ ::Rails.logger.warn(
276
+ "[phlex-reactive] defer push lane failed (#{error.class}: #{error.message}) — " \
277
+ "falling back to the fetch directive for this segment."
278
+ )
279
+ end
280
+
281
+ # Sign the component's identity (the SAME payload shape as the action
282
+ # token — reactive_identity_payload, so the families can't drift) under
283
+ # the defer purpose + TTL. Private on the component; reached via send
284
+ # exactly like the identity internals are elsewhere off-instance.
285
+ def mint_token(segment)
286
+ payload = segment.component.send(:reactive_identity_payload)
287
+ payload = payload.merge("m" => "morph") if segment.morph
288
+ Phlex::Reactive.sign_defer(payload)
289
+ end
290
+
291
+ # The pending shell: replaces the target with a div that OWNS the
292
+ # component's id (so any placeholder content works — the shell is the
293
+ # stream target) and carries the pending markers the client/CSS hook
294
+ # onto. nil when the segment keeps current content (the client's
295
+ # directive handler marks the live element instead). Deliberately NO
296
+ # defer token on the shell — the directive owns delivery; a token attr
297
+ # here would double-fetch via the lazy-mount connect probe.
298
+ def placeholder_stream(segment)
299
+ inner = placeholder_inner_html(segment)
300
+ return nil if inner.nil?
301
+
302
+ target = segment.component.id
303
+ shell = %(<div id="#{ERB::Util.html_escape(target)}" class="reactive-defer-placeholder" \
304
+ data-reactive-defer-pending="true" aria-busy="true">#{inner}</div>)
305
+ Phlex::Reactive::Stream.wrap(
306
+ Phlex::Reactive.stream_builder.replace(target, html: shell.html_safe),
307
+ action: "replace", target: target, renders_root: true
308
+ )
309
+ end
310
+
311
+ # Resolve the segment's placeholder to inner HTML — nil means "no
312
+ # shell". Same content contract as flash/also_update: a Phlex component
313
+ # renders through the configured renderer (auto-escaped), a plain
314
+ # String is DATA and gets escaped, an html_safe String is intentional
315
+ # markup and passes verbatim (ERB::Util.html_escape's own contract).
316
+ def placeholder_inner_html(segment)
317
+ value = resolved_placeholder(segment)
318
+ return nil if value == :none
319
+
320
+ case value
321
+ when ::Phlex::SGML then Phlex::Reactive.render(value)
322
+ else ERB::Util.html_escape(value.to_s)
323
+ end
324
+ end
325
+
326
+ # `true` asks the COMPONENT: deferred_placeholder (private ok) when
327
+ # defined, else the built-in empty shell (the pending markers + the
328
+ # .reactive-defer-placeholder class are the whole skeleton). nil from
329
+ # the method degrades to the empty shell too — the caller explicitly
330
+ # asked for a placeholder.
331
+ def resolved_placeholder(segment)
332
+ case segment.placeholder
333
+ when nil then :none
334
+ when true
335
+ component = segment.component
336
+ if component.respond_to?(:deferred_placeholder, true)
337
+ component.send(:deferred_placeholder) || "".html_safe
338
+ else
339
+ "".html_safe
340
+ end
341
+ else
342
+ segment.placeholder
343
+ end
344
+ end
345
+
346
+ # A forced :stream without the capability is a CONFIG mistake — warn
347
+ # loudly but once per process (a deferred reply can fire per keystroke;
348
+ # per-reply spam would bury the signal), then degrade to :fetch.
349
+ def warn_stream_degraded
350
+ return if @stream_degraded_warned
351
+
352
+ @stream_degraded_warned = true
353
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
354
+
355
+ ::Rails.logger.warn(
356
+ "[phlex-reactive] defer_transport is :stream but the push lane is unavailable " \
357
+ "(needs pgbus reactive Streams + SignedName + ActiveJob) — deferring via :fetch instead."
358
+ )
359
+ end
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The push lane's render leg (issue #165): rebuild the deferred component
6
+ # from its identity payload OFF the request thread, render it, and
7
+ # broadcast the result DURABLY to the one-shot stream the actor's
8
+ # <pgbus-stream-source> subscribes to. Durable is load-bearing: pgbus's
9
+ # since-id replay only covers PGMQ-persisted messages, and that replay is
10
+ # what closes the broadcast-before-subscribe race.
11
+ #
12
+ # Every broadcast payload ends with a remove of the client's source element
13
+ # (id reactive-defer-src-<target>), so the subscription tears itself down
14
+ # with the content it delivered — no client-side arrival bookkeeping.
15
+ #
16
+ # A gone record (deleted while the job sat in the queue) or render? false
17
+ # broadcasts the CLEANUP instead: reactive:js ops clearing the pending
18
+ # markers + the teardown. The shimmer must never hang, and a deleted record
19
+ # must not retry (rescued, not retried — there is nothing to render).
20
+ #
21
+ # NOT eager-loaded (see the loader config in phlex/reactive.rb): the class
22
+ # body needs ActiveJob::Base, which isn't a gem dependency — the constant
23
+ # is only referenced behind Phlex::Reactive.defer_push_capable?.
24
+ class DeferredRenderJob < ::ActiveJob::Base
25
+ queue_as { Phlex::Reactive.defer_job_queue }
26
+
27
+ def perform(component_class_name, payload, stream_key, target, morph)
28
+ klass = component_class_name.constantize
29
+ # Defense in depth (the args come from our own enqueue, but fail
30
+ # closed): only reactive components may be rebuilt and rendered. This is
31
+ # a PROGRAMMING error (a bad enqueue), not a recoverable runtime failure
32
+ # — it raises loudly and broadcasts NOTHING, OUTSIDE the render rescue
33
+ # below (a component with actions can't be forged past the signed
34
+ # identity anyway, so this never fires for a real defer).
35
+ unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
36
+ raise ::ArgumentError,
37
+ "#{component_class_name} is not a reactive component — refusing the deferred render"
38
+ end
39
+
40
+ render_and_broadcast(klass, payload, stream_key, target, morph)
41
+ end
42
+
43
+ private
44
+
45
+ # The render/broadcast leg, wrapped so ANY failure past a successful
46
+ # enqueue (a gone record, a render raising, from_identity blowing up) still
47
+ # resolves the actor's pending state: the stream lane has no client-side
48
+ # timeout, so a silent job death would hang the shimmer forever. On failure
49
+ # we broadcast the cleanup (pending-clear ops + source teardown). If that
50
+ # cleanup broadcast SUCCEEDS the actor is unstuck and we swallow the
51
+ # original error (a retry can't help — a re-render would raise the same way
52
+ # and the actor already saw the content clear); a non-RecordNotFound is
53
+ # logged for the operator. If the cleanup broadcast ITSELF fails (Postgres
54
+ # down) there is nothing deliverable — that propagates so ActiveJob's retry
55
+ # policy gets a chance next attempt.
56
+ def render_and_broadcast(klass, payload, stream_key, target, morph)
57
+ component = klass.from_identity(payload)
58
+ return broadcast_cleanup(stream_key, target) if component.respond_to?(:render?) && !component.render?
59
+
60
+ stream = morph ? component.to_stream_morph : component.to_stream_replace
61
+ broadcast_payload(stream_key, stream.to_s + teardown_stream(target))
62
+ rescue ::StandardError => e
63
+ log_deferred_failure(e) unless e.is_a?(::ActiveRecord::RecordNotFound)
64
+ broadcast_cleanup(stream_key, target)
65
+ end
66
+
67
+ # The durable one-shot broadcast. Pgbus.stream(key, durable: true)
68
+ # forces the PGMQ lane regardless of the app's stream default — the
69
+ # replay guarantee depends on it. We send the RAW <turbo-stream> HTML
70
+ # string exactly as pgbus's own broadcast_render does; pgbus's SSE
71
+ # envelope flattens newlines out of the data: line (turbo-stream HTML is
72
+ # newline-insensitive between tags, so this is lossless for markup — the
73
+ # same behavior as every other pgbus broadcast, not a defer-specific
74
+ # concern). The one-shot queue is reclaimed by pgbus's orphan sweep, NOT
75
+ # dropped here — an eager drop would destroy a not-yet-consumed message
76
+ # and reopen the broadcast-before-subscribe race (see the README push-lane
77
+ # note + Phlex::Reactive.defer_transport).
78
+ def broadcast_payload(stream_key, html)
79
+ ::Pgbus.stream(stream_key, durable: true).broadcast(html)
80
+ end
81
+
82
+ # Log a genuinely unexpected deferred-render failure (not the routine
83
+ # deleted-while-queued case) so an operator sees it — the actor gets a
84
+ # clean pending-clear regardless, which would otherwise hide the cause.
85
+ def log_deferred_failure(error)
86
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
87
+
88
+ ::Rails.logger.error(
89
+ "[phlex-reactive] DeferredRenderJob failed (#{error.class}: #{error.message}) — " \
90
+ "broadcasting cleanup so the actor's pending state resolves."
91
+ )
92
+ end
93
+
94
+ # Nothing to render — clear the actor's pending markers (reactive:js
95
+ # remove_attr ops, @root-scoped to the target) and tear the source down.
96
+ def broadcast_cleanup(stream_key, target)
97
+ ops = Phlex::Reactive::JS.new
98
+ .remove_attr(:root, "data-reactive-defer-pending")
99
+ .remove_attr(:root, "aria-busy")
100
+ js = Phlex::Reactive::Response.js_stream(ops, target: target)
101
+ broadcast_payload(stream_key, js.to_s + teardown_stream(target))
102
+ end
103
+
104
+ # Remove the actor's <pgbus-stream-source> by its deterministic id — the
105
+ # client minted it as reactive-defer-src-<target>; its
106
+ # disconnectedCallback closes the SSE connection. html_safe by
107
+ # construction (the one interpolation is escaped) — and REQUIRED: the
108
+ # render stream is a SafeBuffer, and `safe + plain` would HTML-escape
109
+ # this whole tag into the payload.
110
+ def teardown_stream(target)
111
+ %(<turbo-stream action="remove" target="reactive-defer-src-#{ERB::Util.html_escape(target)}"></turbo-stream>)
112
+ .html_safe
113
+ end
114
+ end
115
+ end
116
+ end
@@ -69,12 +69,14 @@ module Phlex
69
69
  components = registered_components
70
70
  [
71
71
  route_check,
72
+ defer_route_check,
72
73
  stimulus_check,
73
74
  csrf_check,
74
75
  verifier_check,
75
76
  base_controller_check,
76
77
  action_check(components),
77
- id_check(components)
78
+ id_check(components),
79
+ authorization_check(components)
78
80
  ]
79
81
  end
80
82
 
@@ -84,13 +86,26 @@ module Phlex
84
86
  # catch-all route shadows it otherwise (issue #26). Reuses the shipped
85
87
  # guard verbatim — it already handles routes-not-yet-drawn.
86
88
  def route_check
87
- path = Phlex::Reactive.action_path
89
+ path_check(Phlex::Reactive.action_path, :route, "Phlex::Reactive.action_path")
90
+ end
91
+
92
+ # Same shadow class for the defer endpoint (issue #165): a shadowed defer
93
+ # route makes every reply.defer / reactive_lazy fetch 404 — the pending
94
+ # marker clears into data-reactive-error="defer" client-side, but the
95
+ # root cause is invisible without this check.
96
+ def defer_route_check
97
+ path_check(Phlex::Reactive.defer_path, :defer_route, "Phlex::Reactive.defer_path")
98
+ end
99
+
100
+ # Shared body for the two endpoint-route checks: both POST to the gem's
101
+ # ActionsController, so action_route_ok? answers for either path.
102
+ def path_check(path, name, setting)
88
103
  if Phlex::Reactive.action_route_ok?(path)
89
- Check.new(:ok, "POST #{path} routes to #{Doctor.actions_controller}", name: :route)
104
+ Check.new(:ok, "POST #{path} routes to #{Doctor.actions_controller}", name: name)
90
105
  else
91
- Check.new(:fail, "POST #{path} does not resolve to #{Doctor.actions_controller}", name: :route,
106
+ Check.new(:fail, "POST #{path} does not resolve to #{Doctor.actions_controller}", name: name,
92
107
  fix: "A host catch-all route (match \"*path\", ...) likely shadows it. Exempt " \
93
- "#{path.delete_prefix("/")} from the catch-all, or set Phlex::Reactive.action_path " \
108
+ "#{path.delete_prefix("/")} from the catch-all, or set #{setting} " \
94
109
  "to an unshadowed path.")
95
110
  end
96
111
  end
@@ -180,6 +195,23 @@ module Phlex
180
195
  "derive a default id from.")
181
196
  end
182
197
 
198
+ # ADVISORY (issue #168): the presence-side static heuristic complementing
199
+ # the runtime verify_authorized guard. Flags a non-skipped action whose
200
+ # component defines NONE of Phlex::Reactive.authorization_methods anywhere
201
+ # in its ancestry AND whose body (Prism-scanned via the Inspector) makes no
202
+ # authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
203
+ # a helper may authorize indirectly, so this is a hint, not a verdict.
204
+ def authorization_check(components)
205
+ suspects = components.flat_map { unauthorized_action_labels(it) }
206
+ return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
207
+
208
+ Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
209
+ name: :authorization,
210
+ fix: "This is a heuristic (a helper may authorize indirectly). If each is intentional, " \
211
+ "confirm it authorizes; if an action is genuinely public, declare " \
212
+ "`skip_verify_authorized`. See the Debugging & tooling docs page.")
213
+ end
214
+
183
215
  # --- rendering --------------------------------------------------------
184
216
 
185
217
  # The full plain-text report: a line per check, plus an indented fix under
@@ -224,20 +256,20 @@ module Phlex
224
256
  # name.safe_constantize is the class itself — which also excludes anonymous
225
257
  # classes (name nil) and test fixtures that fake `def self.name` without a
226
258
  # matching constant, keeping the whole-app scan honest.
259
+ #
260
+ # The predicate lives in Inspector (issue #168) — the one read layer shared
261
+ # by the rake tasks, the MCP tools, and this Doctor — so it stays in sync
262
+ # with resolve_component's rebuild path in exactly one place.
227
263
  def registered_components
228
264
  Phlex::Reactive::Streamable.registered_classes.select { constant_backed_component?(it) }
229
265
  end
230
266
 
231
267
  def constant_backed_component?(klass)
232
- reactive_component?(klass) && klass.name && klass.name.safe_constantize.equal?(klass)
233
- rescue StandardError
234
- false
268
+ Phlex::Reactive::Inspector.constant_backed_component?(klass)
235
269
  end
236
270
 
237
271
  def reactive_component?(klass)
238
- klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
239
- rescue StandardError
240
- false
272
+ Phlex::Reactive::Inspector.reactive_component?(klass)
241
273
  end
242
274
 
243
275
  # "Klass#action" for every declared action on `klass` that has no public
@@ -258,6 +290,42 @@ module Phlex
258
290
  false
259
291
  end
260
292
 
293
+ # "Klass#action" for every declared action on `klass` that (advisory)
294
+ # appears unauthorized: the component defines no authorization method in its
295
+ # ancestry, the action isn't skipped, and the Prism scan (via the shared
296
+ # Inspector) finds no authorization / mark_authorized! call in its body.
297
+ def unauthorized_action_labels(klass)
298
+ return [] if component_defines_authorization_method?(klass)
299
+
300
+ info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
301
+ return [] unless info
302
+
303
+ info.actions
304
+ .reject { skips_authorization?(klass, it.name) }
305
+ .reject(&:authorization_call_detected?)
306
+ .map { "#{klass}##{it.name}" }
307
+ rescue StandardError
308
+ []
309
+ end
310
+
311
+ # Does the class define any CONFIGURED authorization method anywhere in its
312
+ # ancestry (public or private)? If so, it plausibly authorizes through a
313
+ # helper the static scan can't follow — stay quiet. Deliberately reads
314
+ # Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
315
+ # folds in mark_authorized! — every component inherits that instance helper,
316
+ # so including it would silence the check for everything.
317
+ def component_defines_authorization_method?(klass)
318
+ Phlex::Reactive.authorization_methods.any? do
319
+ klass.method_defined?(it) || klass.private_method_defined?(it)
320
+ end
321
+ rescue StandardError
322
+ false
323
+ end
324
+
325
+ def skips_authorization?(klass, action_name)
326
+ klass.respond_to?(:skip_verify_authorized?) && klass.skip_verify_authorized?(action_name)
327
+ end
328
+
261
329
  def stimulus_missing_check
262
330
  Check.new(:fail, "the reactive controller is not registered in any Stimulus entrypoint", name: :stimulus,
263
331
  fix: "Add to your entrypoint (e.g. app/javascript/controllers/index.js):\n " \
@@ -11,11 +11,14 @@ module Phlex
11
11
  class Engine < ::Rails::Engine
12
12
  isolate_namespace Phlex::Reactive
13
13
 
14
- # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create.
15
- # Apps can change the path with Phlex::Reactive.action_path before boot.
14
+ # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
15
+ # and POST /reactive/defer -> #deferred (the pull-lane defer endpoint,
16
+ # issue #165). Apps can change the paths with Phlex::Reactive.action_path /
17
+ # .defer_path before boot.
16
18
  initializer "phlex_reactive.routes" do
17
19
  it.routes.append do
18
20
  post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
21
+ post Phlex::Reactive.defer_path, to: "phlex/reactive/actions#deferred", as: :phlex_reactive_defer
19
22
  end
20
23
  end
21
24
 
@@ -34,6 +37,8 @@ module Phlex
34
37
  phlex/reactive/confirm.min.js.map
35
38
  phlex/reactive/compute.min.js
36
39
  phlex/reactive/compute.min.js.map
40
+ phlex/reactive/inspect.min.js
41
+ phlex/reactive/inspect.min.js.map
37
42
  ]
38
43
  end
39
44
  end
@@ -71,6 +76,15 @@ module Phlex
71
76
  to: "phlex/reactive/compute.min.js",
72
77
  preload: true
73
78
  )
79
+ # The on-demand client inspector (issue #168). NOT preloaded — it is a
80
+ # dev/debugging tool loaded only when you `import("phlex/reactive/inspect")`
81
+ # from the console, so no page pays for it. The pin makes that dynamic
82
+ # import resolve without any app wiring.
83
+ it.importmap.pin(
84
+ "phlex/reactive/inspect",
85
+ to: "phlex/reactive/inspect.min.js",
86
+ preload: false
87
+ )
74
88
  end
75
89
  end
76
90