phlex-reactive 0.9.2 → 0.9.4
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 +213 -0
- data/README.md +333 -1
- 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 +651 -2
- 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 +240 -0
- 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
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
+
elsif result.render_self? && streams.none? { stream_carries_token?(it) }
|
|
238
332
|
streams = [component.to_stream_replace, *streams]
|
|
239
333
|
end
|
|
240
|
-
|
|
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
|
+
}
|