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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -0
- data/README.md +282 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +659 -10
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +348 -21
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- 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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|