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
@@ -35,13 +35,83 @@ module Phlex
35
35
  # #82/#87); we only ADD the outcome finalizer. `action` is safe to read
36
36
  # up front (it comes from the request, not the verified token).
37
37
  event = { component: nil, action: reactive_action_name.to_s, outcome: nil }
38
- Phlex::Reactive.instrument("action", event) do
39
- create_action(event)
38
+ # Mint any reply.defer directive tokens UNDER the actor's binding (issue
39
+ # #165 security), so the defer endpoint accepts them ONLY back from this
40
+ # same actor. The defer token is built in response_streams (inside this
41
+ # block via the Defer builder), so the binding must be established here.
42
+ Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
43
+ Phlex::Reactive.instrument("action", event) do
44
+ create_action(event)
45
+ end
46
+ end
47
+ end
48
+
49
+ # The defer endpoint (issue #165) — the pull lane's render leg. Verifies
50
+ # the purpose-scoped, short-TTL defer token (an ACTION token is rejected
51
+ # here by signature — purpose confusion fails closed), rebuilds the
52
+ # component from its signed identity, and returns its replace (or morph,
53
+ # per the SIGNED mode) stream. No action runs and no transaction opens —
54
+ # this is a read. Authorization: the base controller's auth applies as on
55
+ # every reactive request; a component that guards visibility can raise a
56
+ # registered authorization error from from_identity/render (→ 403) or
57
+ # return false from render? (→ 204: keep content, clear pending).
58
+ def deferred
59
+ event = { component: nil, outcome: nil }
60
+ # Verify UNDER the actor's binding (issue #165 security): a defer token
61
+ # minted for another actor's session fails the binding-scoped purpose,
62
+ # so a leaked token can't be exchanged here for this actor's render (and
63
+ # its embedded fresh identity token). Unbound requests (no session) are
64
+ # unchanged.
65
+ Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
66
+ Phlex::Reactive.instrument("defer", event) do
67
+ deferred_action(event)
68
+ end
40
69
  end
41
70
  end
42
71
 
43
72
  private
44
73
 
74
+ # The defer body, mirroring create_action's shape: fill the event payload
75
+ # on every exit path, reuse the shared rescue → reactive_error plumbing.
76
+ def deferred_action(event)
77
+ payload = verified_defer_payload
78
+ component_class = resolve_component(payload["c"])
79
+ event[:component] = component_class.name
80
+ component = component_class.from_identity(payload)
81
+
82
+ if component.respond_to?(:render?) && !component.render?
83
+ event[:outcome] = :no_content
84
+ return head :no_content
85
+ end
86
+
87
+ event[:outcome] = :ok
88
+ stream = payload["m"] == "morph" ? component.to_stream_morph : component.to_stream_replace
89
+ render turbo_stream: stream
90
+ rescue Phlex::Reactive::InvalidToken => e
91
+ event[:outcome] = :invalid_token
92
+ reactive_error(:bad_request, e.message, kind: e.diagnostic || :tampered)
93
+ rescue ActiveRecord::RecordNotFound
94
+ event[:outcome] = :not_found
95
+ reactive_error(:not_found, record_not_found_message(payload), kind: :not_found)
96
+ rescue *authorization_errors => e
97
+ event[:outcome] = :unauthorized
98
+ reactive_error(:forbidden, deferred_authorization_message(e, component_class), kind: :forbidden)
99
+ end
100
+
101
+ def verified_defer_payload
102
+ token = params.require(:token)
103
+ Phlex::Reactive.verify_defer(token) || raise(Phlex::Reactive::InvalidToken.new(
104
+ "defer token invalid — expired (defer_token_ttl is #{Phlex::Reactive.defer_token_ttl}s), " \
105
+ "tampered, or an ACTION token posted to the defer endpoint (the purposes are disjoint)",
106
+ diagnostic: :tampered
107
+ ))
108
+ end
109
+
110
+ def deferred_authorization_message(error, component_class)
111
+ "#{error.class.name} raised rebuilding/rendering #{component_class&.name} for a deferred " \
112
+ "segment — the signature proves identity, not permission"
113
+ end
114
+
45
115
  # The action body, run inside the instrument block so its payload (`event`)
46
116
  # can be finalized on every exit path. Kept separate so `create` stays a
47
117
  # thin instrument wrapper.
@@ -60,10 +130,21 @@ module Phlex
60
130
  component = component_class.from_identity(payload)
61
131
  coerced = coerce_params(action_def, component_class:, action_name: action_def.name)
62
132
 
133
+ # verify_authorized (issue #168): instrument the class ONCE (idempotent,
134
+ # per class object) so its authorization methods mark the tracking cell.
135
+ # Only when the feature is on — zero cost otherwise.
136
+ Phlex::Reactive::Authorization.instrument!(component_class) if Phlex::Reactive.verify_authorized
137
+
63
138
  result = run_action(component, action_def, coerced)
64
139
 
65
140
  event[:outcome] = :ok
66
141
  render turbo_stream: response_streams(result, component)
142
+ rescue Phlex::Reactive::AuthorizationNotVerified
143
+ # A developer error (a forgotten authorize!), NOT a client fault — it
144
+ # bubbles as a 500 so error trackers fire, but we tag the event first so
145
+ # the action.phlex_reactive outcome stays accurate, then re-raise.
146
+ event[:outcome] = :unverified
147
+ raise
67
148
  rescue Phlex::Reactive::InvalidToken => e
68
149
  # The component name here came from an UNVERIFIED token — do NOT report it
69
150
  # as trusted. Leave event[:component] nil.
@@ -153,10 +234,21 @@ module Phlex
153
234
  Phlex::Reactive.with_connection_id(request.headers["X-Pgbus-Connection"]) do
154
235
  with_around_actions(component, action_def, coerced) do
155
236
  transaction_wrapper do
156
- if coerced.any?
157
- component.public_send(action_def.name, **coerced)
158
- else
159
- component.public_send(action_def.name)
237
+ # verify_authorized (issue #168): open the tracking window, run the
238
+ # action, then verify INSIDE the transaction so an unverified action
239
+ # rolls its mutation back (fail-closed). with_tracking is a bare
240
+ # yield's-worth of overhead; verify! is a no-op when the feature is
241
+ # off / the action is skipped / something marked. The interceptor
242
+ # (create_action's instrument!) marks the window on a real
243
+ # authorization call.
244
+ Phlex::Reactive::Authorization.with_tracking do
245
+ result = if coerced.any?
246
+ component.public_send(action_def.name, **coerced)
247
+ else
248
+ component.public_send(action_def.name)
249
+ end
250
+ Phlex::Reactive::Authorization.verify!(component.class, action_def)
251
+ result
160
252
  end
161
253
  end
162
254
  end
@@ -214,9 +306,11 @@ module Phlex
214
306
  # the dedupe is scoped to the actor's own target, NOT a global substring,
215
307
  # so a partial reply that legitimately includes another reactive
216
308
  # component's stream (which carries its OWN token) still refreshes ours.
309
+ #
310
+ # (refresh_token? implies render_self? is false — .streams/collections set
311
+ # both — so folding this into the if/elsif below is behavior-preserving.)
217
312
  if result.refresh_token? && !carries_token_for?(streams, result.token_component)
218
- return [*streams, result.token_component.to_stream_token]
219
- end
313
+ streams = [*streams, result.token_component.to_stream_token]
220
314
 
221
315
  # Guarantee the component's signed identity token is refreshed unless the
222
316
  # Response opted out (remove/redirect navigate away — handled above). The
@@ -234,10 +328,26 @@ module Phlex
234
328
  # ground-truth flag (rx_carries_token?), a raw string from a substring
235
329
  # scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
236
330
  # this guard would regress update/morph of self on an aliased id.
237
- if result.render_self? && streams.none? { stream_carries_token?(it) }
331
+ elsif result.render_self? && streams.none? { stream_carries_token?(it) }
238
332
  streams = [component.to_stream_replace, *streams]
239
333
  end
240
- streams
334
+
335
+ append_deferred_streams(streams, result)
336
+ end
337
+
338
+ # Deferred segments (issue #165) ride LAST — after every render stream and
339
+ # after the reactive:js op stream — so their placeholder/directive apply
340
+ # to the fully-updated DOM (Turbo applies in document order) and delivery
341
+ # kicks off only once the reply's own paints are in flight. This runs
342
+ # AFTER run_action returned, i.e. after the action's transaction
343
+ # COMMITTED — a rolled-back action takes the rescue paths and no directive
344
+ # (or push-lane enqueue) can ever leak. The common non-defer reply pays
345
+ # one empty? check.
346
+ def append_deferred_streams(streams, result)
347
+ return streams unless result.deferred?
348
+
349
+ via = Phlex::Reactive::Defer.resolve_via
350
+ [*streams, *result.deferred_segments.flat_map { Phlex::Reactive::Defer.streams_for(it, via:) }]
241
351
  end
242
352
 
243
353
  # GUARD 2 predicate. A Stream with intact metadata answers structurally
@@ -0,0 +1,225 @@
1
+ // The on-demand client inspector (issue #168) — the browser half of the
2
+ // debugging toolkit, the client twin of `phlex_reactive:actions` / the MCP tools.
3
+ //
4
+ // It is a STANDALONE module (the confirm.js / compute.js precedent): it does NOT
5
+ // touch the hot-path reactive_controller.js and costs NOTHING until you import
6
+ // it. Loaded on demand from the console (the engine pins it), it scans the live
7
+ // DOM and maps every reactive root + bound trigger back to the server
8
+ // Component#action names — so you can answer "what's reactive on this page, what
9
+ // does each control POST, and which component owns it?" without reading source.
10
+ //
11
+ // (await import("phlex/reactive/inspect")).report() // console.table
12
+ // const roots = (await import("phlex/reactive/inspect")).scan() // data
13
+ //
14
+ // The server↔client mapping is BY NAME: scan()'s `component` + each trigger's
15
+ // `action` are exactly the Component#action identifiers `phlex_reactive:actions`
16
+ // and the MCP tools list. The docs page shows the two side by side.
17
+ //
18
+ // It reads the SAME DOM contract reactive_controller.js writes (roots
19
+ // [data-controller~="reactive"] with id + data-reactive-token-value; triggers
20
+ // [data-reactive-action-param] with data-action/params/modifiers; client-only
21
+ // [data-reactive-ops-param]; computes [data-reactive-compute-reducer-param]; the
22
+ // show/filter/text binding families). It never mutates anything — pure read.
23
+
24
+ // Scan `root` (default the whole document) and return one entry per reactive
25
+ // root, deepest-scoped: each trigger is attributed to its NEAREST reactive root,
26
+ // so a nested root doesn't double-count its parent's triggers.
27
+ export function scan(root = document) {
28
+ const roots = Array.from(root.querySelectorAll('[data-controller~="reactive"]'))
29
+ return roots.map((el) => inspectRoot(el, roots))
30
+ }
31
+
32
+ // console.table the inventory (roots + a flattened trigger table), and return
33
+ // the same data scan() does so `report()` is also usable programmatically. Safe
34
+ // on an empty document (logs an empty table, throws nothing).
35
+ export function report(root = document) {
36
+ const roots = scan(root)
37
+ const console = globalThis.console
38
+
39
+ // Guard each console method independently — a host page (or a test harness)
40
+ // may ship a partial console, and report() must never throw over logging.
41
+ if (console && typeof console.log === "function") {
42
+ console.log(`[phlex-reactive] ${roots.length} reactive root(s)`)
43
+ }
44
+ if (console && typeof console.table === "function") {
45
+ console.table(
46
+ roots.map((r) => ({
47
+ id: r.id,
48
+ component: r.component ?? (r.opaque ? "(opaque token)" : null),
49
+ actions: r.triggers.map((t) => t.action).join(", "),
50
+ fields: r.fields.join(", "),
51
+ computes: r.computes.join(", "),
52
+ })),
53
+ )
54
+ }
55
+ return roots
56
+ }
57
+
58
+ // --- one root --------------------------------------------------------------
59
+
60
+ function inspectRoot(el, allRoots) {
61
+ const token = el.getAttribute("data-reactive-token-value")
62
+ const payload = decodeToken(token)
63
+
64
+ return {
65
+ id: el.id || null,
66
+ component: payload.opaque ? null : (payload.c ?? null),
67
+ gid: payload.gid ?? null,
68
+ stateKeys: payload.s ? Object.keys(payload.s) : [],
69
+ tokenVersion: payload.v ?? null,
70
+ opaque: !!payload.opaque,
71
+ status: readStatus(el),
72
+ triggers: ownedTriggers(el, allRoots),
73
+ clientOps: ownedClientOps(el, allRoots),
74
+ computes: ownedComputes(el, allRoots),
75
+ fields: ownedFields(el, allRoots),
76
+ show: ownedShow(el, allRoots),
77
+ text: ownedText(el, allRoots),
78
+ filter: readFilter(el),
79
+ }
80
+ }
81
+
82
+ // The signed identity is base64(JSON)--signature under the Rails 7.1+ JSON
83
+ // message serializer. Decode the segment BEFORE the "--", JSON-parse it, and
84
+ // unwrap the MessageVerifier envelope ({_rails:{data:…}}) to the {c,gid,s,v}
85
+ // payload. Degrade to { opaque: true } on anything that isn't JSON-decodable —
86
+ // a Marshal-serialized payload (older apps) simply isn't readable client-side,
87
+ // and that's fine: the inspector shows "(opaque token)" rather than throwing.
88
+ function decodeToken(token) {
89
+ if (!token) return { opaque: true }
90
+
91
+ try {
92
+ const segment = token.split("--")[0]
93
+ const json = atobUtf8(segment)
94
+ const parsed = JSON.parse(json)
95
+ const data = parsed && parsed._rails && parsed._rails.data ? parsed._rails.data : parsed
96
+ if (!data || typeof data !== "object") return { opaque: true }
97
+ return data
98
+ } catch {
99
+ return { opaque: true }
100
+ }
101
+ }
102
+
103
+ // base64 → string. Uses the platform atob; a malformed/urlsafe segment throws,
104
+ // which decodeToken catches and reports as opaque.
105
+ function atobUtf8(b64) {
106
+ const decode = globalThis.atob || ((s) => Buffer.from(s, "base64").toString("binary"))
107
+ return decode(b64)
108
+ }
109
+
110
+ // The at-a-glance runtime state the controller stamps on the root. Names/flags
111
+ // only — never a value that could be sensitive.
112
+ function readStatus(el) {
113
+ return {
114
+ error: el.getAttribute("data-reactive-error"),
115
+ busy: el.getAttribute("aria-busy") === "true" || el.hasAttribute("data-reactive-busy"),
116
+ dirty: el.hasAttribute("data-reactive-dirty"),
117
+ }
118
+ }
119
+
120
+ // --- ownership scoping -----------------------------------------------------
121
+ //
122
+ // An element belongs to a root when its NEAREST reactive-root ancestor is that
123
+ // root (matching the controller's own ownership walk). closest() finds the
124
+ // nearest ancestor matching the selector; the element itself can be the root
125
+ // (for a root that also carries a binding), so we start the walk from the
126
+ // element and accept `el` as its own owner.
127
+
128
+ const ROOT_SELECTOR = '[data-controller~="reactive"]'
129
+
130
+ function ownedBy(node, root) {
131
+ return node.closest(ROOT_SELECTOR) === root
132
+ }
133
+
134
+ // Query `root` for `selector`, keeping only elements whose nearest reactive
135
+ // root IS `root` — so a nested root's matches are excluded here and attributed
136
+ // to that inner root instead.
137
+ function scopedQuery(root, selector, allRoots) {
138
+ return Array.from(root.querySelectorAll(selector)).filter((node) => ownedBy(node, root))
139
+ }
140
+
141
+ // --- the binding families --------------------------------------------------
142
+
143
+ function ownedTriggers(root, allRoots) {
144
+ return scopedQuery(root, "[data-reactive-action-param]", allRoots).map((el) => ({
145
+ action: el.getAttribute("data-reactive-action-param"),
146
+ event: eventOf(el.getAttribute("data-action")),
147
+ params: el.getAttribute("data-reactive-params-param"),
148
+ debounce: el.getAttribute("data-reactive-debounce-param"),
149
+ throttle: el.getAttribute("data-reactive-throttle-param"),
150
+ confirm: el.getAttribute("data-reactive-confirm-param"),
151
+ optimistic: el.getAttribute("data-reactive-optimistic-param"),
152
+ loading: el.getAttribute("data-reactive-loading-param"),
153
+ }))
154
+ }
155
+
156
+ // The Stimulus data-action descriptor is "event->controller#method"; the event
157
+ // is the part before "->" (a bare "controller#method" has an implicit default
158
+ // event, which we report as null).
159
+ function eventOf(dataAction) {
160
+ if (!dataAction) return null
161
+ const arrow = dataAction.indexOf("->")
162
+ return arrow === -1 ? null : dataAction.slice(0, arrow)
163
+ }
164
+
165
+ // Client-only op triggers ([data-reactive-ops-param]) — a menu toggle, a DOM op
166
+ // chain that never round-trips. Report the parsed ops (names) when decodable.
167
+ function ownedClientOps(root, allRoots) {
168
+ return scopedQuery(root, "[data-reactive-ops-param]", allRoots).map((el) => {
169
+ const raw = el.getAttribute("data-reactive-ops-param")
170
+ let ops = raw
171
+ try {
172
+ ops = JSON.parse(raw)
173
+ } catch {
174
+ // leave the raw string — a malformed ops attr shouldn't break the scan
175
+ }
176
+ return { event: eventOf(el.getAttribute("data-action")), ops }
177
+ })
178
+ }
179
+
180
+ // Compute reducers bound on this root ([data-reactive-compute-reducer-param]) —
181
+ // the client-side data-binding twins of reactive_compute. Report reducer names.
182
+ function ownedComputes(root, allRoots) {
183
+ return scopedQuery(root, "[data-reactive-compute-reducer-param]", allRoots).map((el) =>
184
+ el.getAttribute("data-reactive-compute-reducer-param"),
185
+ )
186
+ }
187
+
188
+ // The named form controls the dispatch would collect and send — the same
189
+ // selector the controller's field walk uses. Names only, never values.
190
+ function ownedFields(root, allRoots) {
191
+ return scopedQuery(root, "input[name], select[name], textarea[name]", allRoots).map((el) =>
192
+ el.getAttribute("name"),
193
+ )
194
+ }
195
+
196
+ // reactive_show bindings: which owned field controls each element's visibility.
197
+ function ownedShow(root, allRoots) {
198
+ return scopedQuery(root, "[data-reactive-show-field]", allRoots).map((el) => ({
199
+ field: el.getAttribute("data-reactive-show-field"),
200
+ equals: el.getAttribute("data-reactive-show-equals"),
201
+ not: el.getAttribute("data-reactive-show-not"),
202
+ in: el.getAttribute("data-reactive-show-in"),
203
+ }))
204
+ }
205
+
206
+ // reactive_text spans (data-reactive-text=<name>) the client writes via
207
+ // textContent — report the binding names.
208
+ function ownedText(root, allRoots) {
209
+ return scopedQuery(root, "[data-reactive-text]", allRoots).map((el) =>
210
+ el.getAttribute("data-reactive-text"),
211
+ )
212
+ }
213
+
214
+ // reactive_filter lives on the ROOT itself (the input/option/group/empty
215
+ // selectors it narrows client-side). Null when the root declares no filter.
216
+ function readFilter(el) {
217
+ const input = el.getAttribute("data-reactive-filter-input")
218
+ if (!input) return null
219
+ return {
220
+ input,
221
+ option: el.getAttribute("data-reactive-filter-option"),
222
+ group: el.getAttribute("data-reactive-filter-group"),
223
+ empty: el.getAttribute("data-reactive-filter-empty"),
224
+ }
225
+ }
@@ -0,0 +1,4 @@
1
+ function K(q=document){let z=Array.from(q.querySelectorAll('[data-controller~="reactive"]'));return z.map((D)=>M(D,z))}function U(q=document){let z=K(q),D=globalThis.console;if(D&&typeof D.log==="function")D.log(`[phlex-reactive] ${z.length} reactive root(s)`);if(D&&typeof D.table==="function")D.table(z.map((G)=>({id:G.id,component:G.component??(G.opaque?"(opaque token)":null),actions:G.triggers.map((H)=>H.action).join(", "),fields:G.fields.join(", "),computes:G.computes.join(", ")})));return z}function M(q,z){let D=q.getAttribute("data-reactive-token-value"),G=P(D);return{id:q.id||null,component:G.opaque?null:G.c??null,gid:G.gid??null,stateKeys:G.s?Object.keys(G.s):[],tokenVersion:G.v??null,opaque:!!G.opaque,status:W(q),triggers:Z(q,z),clientOps:$(q,z),computes:j(q,z),fields:x(q,z),show:B(q,z),text:L(q,z),filter:N(q)}}function P(q){if(!q)return{opaque:!0};try{let z=q.split("--")[0],D=V(z),G=JSON.parse(D),H=G&&G._rails&&G._rails.data?G._rails.data:G;if(!H||typeof H!=="object")return{opaque:!0};return H}catch{return{opaque:!0}}}function V(q){return(globalThis.atob||((D)=>Buffer.from(D,"base64").toString("binary")))(q)}function W(q){return{error:q.getAttribute("data-reactive-error"),busy:q.getAttribute("aria-busy")==="true"||q.hasAttribute("data-reactive-busy"),dirty:q.hasAttribute("data-reactive-dirty")}}var X='[data-controller~="reactive"]';function Y(q,z){return q.closest(X)===z}function I(q,z,D){return Array.from(q.querySelectorAll(z)).filter((G)=>Y(G,q))}function Z(q,z){return I(q,"[data-reactive-action-param]",z).map((D)=>({action:D.getAttribute("data-reactive-action-param"),event:J(D.getAttribute("data-action")),params:D.getAttribute("data-reactive-params-param"),debounce:D.getAttribute("data-reactive-debounce-param"),throttle:D.getAttribute("data-reactive-throttle-param"),confirm:D.getAttribute("data-reactive-confirm-param"),optimistic:D.getAttribute("data-reactive-optimistic-param"),loading:D.getAttribute("data-reactive-loading-param")}))}function J(q){if(!q)return null;let z=q.indexOf("->");return z===-1?null:q.slice(0,z)}function $(q,z){return I(q,"[data-reactive-ops-param]",z).map((D)=>{let G=D.getAttribute("data-reactive-ops-param"),H=G;try{H=JSON.parse(G)}catch{}return{event:J(D.getAttribute("data-action")),ops:H}})}function j(q,z){return I(q,"[data-reactive-compute-reducer-param]",z).map((D)=>D.getAttribute("data-reactive-compute-reducer-param"))}function x(q,z){return I(q,"input[name], select[name], textarea[name]",z).map((D)=>D.getAttribute("name"))}function B(q,z){return I(q,"[data-reactive-show-field]",z).map((D)=>({field:D.getAttribute("data-reactive-show-field"),equals:D.getAttribute("data-reactive-show-equals"),not:D.getAttribute("data-reactive-show-not"),in:D.getAttribute("data-reactive-show-in")}))}function L(q,z){return I(q,"[data-reactive-text]",z).map((D)=>D.getAttribute("data-reactive-text"))}function N(q){let z=q.getAttribute("data-reactive-filter-input");if(!z)return null;return{input:z,option:q.getAttribute("data-reactive-filter-option"),group:q.getAttribute("data-reactive-filter-group"),empty:q.getAttribute("data-reactive-filter-empty")}}export{K as scan,U as report};
2
+
3
+ //# debugId=DAD9B8F0F9C2105E64756E2164756E21
4
+ //# sourceMappingURL=inspect.min.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["inspect.js"],
4
+ "sourcesContent": [
5
+ "// The on-demand client inspector (issue #168) — the browser half of the\n// debugging toolkit, the client twin of `phlex_reactive:actions` / the MCP tools.\n//\n// It is a STANDALONE module (the confirm.js / compute.js precedent): it does NOT\n// touch the hot-path reactive_controller.js and costs NOTHING until you import\n// it. Loaded on demand from the console (the engine pins it), it scans the live\n// DOM and maps every reactive root + bound trigger back to the server\n// Component#action names — so you can answer \"what's reactive on this page, what\n// does each control POST, and which component owns it?\" without reading source.\n//\n// (await import(\"phlex/reactive/inspect\")).report() // console.table\n// const roots = (await import(\"phlex/reactive/inspect\")).scan() // data\n//\n// The server↔client mapping is BY NAME: scan()'s `component` + each trigger's\n// `action` are exactly the Component#action identifiers `phlex_reactive:actions`\n// and the MCP tools list. The docs page shows the two side by side.\n//\n// It reads the SAME DOM contract reactive_controller.js writes (roots\n// [data-controller~=\"reactive\"] with id + data-reactive-token-value; triggers\n// [data-reactive-action-param] with data-action/params/modifiers; client-only\n// [data-reactive-ops-param]; computes [data-reactive-compute-reducer-param]; the\n// show/filter/text binding families). It never mutates anything — pure read.\n\n// Scan `root` (default the whole document) and return one entry per reactive\n// root, deepest-scoped: each trigger is attributed to its NEAREST reactive root,\n// so a nested root doesn't double-count its parent's triggers.\nexport function scan(root = document) {\n const roots = Array.from(root.querySelectorAll('[data-controller~=\"reactive\"]'))\n return roots.map((el) => inspectRoot(el, roots))\n}\n\n// console.table the inventory (roots + a flattened trigger table), and return\n// the same data scan() does so `report()` is also usable programmatically. Safe\n// on an empty document (logs an empty table, throws nothing).\nexport function report(root = document) {\n const roots = scan(root)\n const console = globalThis.console\n\n // Guard each console method independently — a host page (or a test harness)\n // may ship a partial console, and report() must never throw over logging.\n if (console && typeof console.log === \"function\") {\n console.log(`[phlex-reactive] ${roots.length} reactive root(s)`)\n }\n if (console && typeof console.table === \"function\") {\n console.table(\n roots.map((r) => ({\n id: r.id,\n component: r.component ?? (r.opaque ? \"(opaque token)\" : null),\n actions: r.triggers.map((t) => t.action).join(\", \"),\n fields: r.fields.join(\", \"),\n computes: r.computes.join(\", \"),\n })),\n )\n }\n return roots\n}\n\n// --- one root --------------------------------------------------------------\n\nfunction inspectRoot(el, allRoots) {\n const token = el.getAttribute(\"data-reactive-token-value\")\n const payload = decodeToken(token)\n\n return {\n id: el.id || null,\n component: payload.opaque ? null : (payload.c ?? null),\n gid: payload.gid ?? null,\n stateKeys: payload.s ? Object.keys(payload.s) : [],\n tokenVersion: payload.v ?? null,\n opaque: !!payload.opaque,\n status: readStatus(el),\n triggers: ownedTriggers(el, allRoots),\n clientOps: ownedClientOps(el, allRoots),\n computes: ownedComputes(el, allRoots),\n fields: ownedFields(el, allRoots),\n show: ownedShow(el, allRoots),\n text: ownedText(el, allRoots),\n filter: readFilter(el),\n }\n}\n\n// The signed identity is base64(JSON)--signature under the Rails 7.1+ JSON\n// message serializer. Decode the segment BEFORE the \"--\", JSON-parse it, and\n// unwrap the MessageVerifier envelope ({_rails:{data:…}}) to the {c,gid,s,v}\n// payload. Degrade to { opaque: true } on anything that isn't JSON-decodable —\n// a Marshal-serialized payload (older apps) simply isn't readable client-side,\n// and that's fine: the inspector shows \"(opaque token)\" rather than throwing.\nfunction decodeToken(token) {\n if (!token) return { opaque: true }\n\n try {\n const segment = token.split(\"--\")[0]\n const json = atobUtf8(segment)\n const parsed = JSON.parse(json)\n const data = parsed && parsed._rails && parsed._rails.data ? parsed._rails.data : parsed\n if (!data || typeof data !== \"object\") return { opaque: true }\n return data\n } catch {\n return { opaque: true }\n }\n}\n\n// base64 → string. Uses the platform atob; a malformed/urlsafe segment throws,\n// which decodeToken catches and reports as opaque.\nfunction atobUtf8(b64) {\n const decode = globalThis.atob || ((s) => Buffer.from(s, \"base64\").toString(\"binary\"))\n return decode(b64)\n}\n\n// The at-a-glance runtime state the controller stamps on the root. Names/flags\n// only — never a value that could be sensitive.\nfunction readStatus(el) {\n return {\n error: el.getAttribute(\"data-reactive-error\"),\n busy: el.getAttribute(\"aria-busy\") === \"true\" || el.hasAttribute(\"data-reactive-busy\"),\n dirty: el.hasAttribute(\"data-reactive-dirty\"),\n }\n}\n\n// --- ownership scoping -----------------------------------------------------\n//\n// An element belongs to a root when its NEAREST reactive-root ancestor is that\n// root (matching the controller's own ownership walk). closest() finds the\n// nearest ancestor matching the selector; the element itself can be the root\n// (for a root that also carries a binding), so we start the walk from the\n// element and accept `el` as its own owner.\n\nconst ROOT_SELECTOR = '[data-controller~=\"reactive\"]'\n\nfunction ownedBy(node, root) {\n return node.closest(ROOT_SELECTOR) === root\n}\n\n// Query `root` for `selector`, keeping only elements whose nearest reactive\n// root IS `root` — so a nested root's matches are excluded here and attributed\n// to that inner root instead.\nfunction scopedQuery(root, selector, allRoots) {\n return Array.from(root.querySelectorAll(selector)).filter((node) => ownedBy(node, root))\n}\n\n// --- the binding families --------------------------------------------------\n\nfunction ownedTriggers(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-action-param]\", allRoots).map((el) => ({\n action: el.getAttribute(\"data-reactive-action-param\"),\n event: eventOf(el.getAttribute(\"data-action\")),\n params: el.getAttribute(\"data-reactive-params-param\"),\n debounce: el.getAttribute(\"data-reactive-debounce-param\"),\n throttle: el.getAttribute(\"data-reactive-throttle-param\"),\n confirm: el.getAttribute(\"data-reactive-confirm-param\"),\n optimistic: el.getAttribute(\"data-reactive-optimistic-param\"),\n loading: el.getAttribute(\"data-reactive-loading-param\"),\n }))\n}\n\n// The Stimulus data-action descriptor is \"event->controller#method\"; the event\n// is the part before \"->\" (a bare \"controller#method\" has an implicit default\n// event, which we report as null).\nfunction eventOf(dataAction) {\n if (!dataAction) return null\n const arrow = dataAction.indexOf(\"->\")\n return arrow === -1 ? null : dataAction.slice(0, arrow)\n}\n\n// Client-only op triggers ([data-reactive-ops-param]) — a menu toggle, a DOM op\n// chain that never round-trips. Report the parsed ops (names) when decodable.\nfunction ownedClientOps(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-ops-param]\", allRoots).map((el) => {\n const raw = el.getAttribute(\"data-reactive-ops-param\")\n let ops = raw\n try {\n ops = JSON.parse(raw)\n } catch {\n // leave the raw string — a malformed ops attr shouldn't break the scan\n }\n return { event: eventOf(el.getAttribute(\"data-action\")), ops }\n })\n}\n\n// Compute reducers bound on this root ([data-reactive-compute-reducer-param]) —\n// the client-side data-binding twins of reactive_compute. Report reducer names.\nfunction ownedComputes(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-compute-reducer-param]\", allRoots).map((el) =>\n el.getAttribute(\"data-reactive-compute-reducer-param\"),\n )\n}\n\n// The named form controls the dispatch would collect and send — the same\n// selector the controller's field walk uses. Names only, never values.\nfunction ownedFields(root, allRoots) {\n return scopedQuery(root, \"input[name], select[name], textarea[name]\", allRoots).map((el) =>\n el.getAttribute(\"name\"),\n )\n}\n\n// reactive_show bindings: which owned field controls each element's visibility.\nfunction ownedShow(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-show-field]\", allRoots).map((el) => ({\n field: el.getAttribute(\"data-reactive-show-field\"),\n equals: el.getAttribute(\"data-reactive-show-equals\"),\n not: el.getAttribute(\"data-reactive-show-not\"),\n in: el.getAttribute(\"data-reactive-show-in\"),\n }))\n}\n\n// reactive_text spans (data-reactive-text=<name>) the client writes via\n// textContent — report the binding names.\nfunction ownedText(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-text]\", allRoots).map((el) =>\n el.getAttribute(\"data-reactive-text\"),\n )\n}\n\n// reactive_filter lives on the ROOT itself (the input/option/group/empty\n// selectors it narrows client-side). Null when the root declares no filter.\nfunction readFilter(el) {\n const input = el.getAttribute(\"data-reactive-filter-input\")\n if (!input) return null\n return {\n input,\n option: el.getAttribute(\"data-reactive-filter-option\"),\n group: el.getAttribute(\"data-reactive-filter-group\"),\n empty: el.getAttribute(\"data-reactive-filter-empty\"),\n }\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "DAD9B8F0F9C2105E64756E2164756E21",
9
+ "names": []
10
+ }