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,15 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs the phlex-reactive debugging toolkit for Claude Code:
|
|
3
|
+
copies the `phlex-reactive-debugging` skill into .claude/skills/ (the
|
|
4
|
+
debugging runbook — doctor → inventory → find → browser scan → MCP) and adds
|
|
5
|
+
the read-only diagnostic MCP server to .mcp.json (only when absent; it never
|
|
6
|
+
rewrites an existing .mcp.json — it prints the snippet instead).
|
|
7
|
+
|
|
8
|
+
The MCP server needs the optional `mcp` gem (`gem "mcp"`); the skill and rake
|
|
9
|
+
tasks work without it.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
bin/rails generate phlex:reactive:claude
|
|
13
|
+
|
|
14
|
+
Creates .claude/skills/phlex-reactive-debugging/ and, when absent, .mcp.json
|
|
15
|
+
with the phlex-reactive server entry.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Phlex
|
|
7
|
+
module Reactive
|
|
8
|
+
module Generators
|
|
9
|
+
# `rails g phlex:reactive:claude`
|
|
10
|
+
#
|
|
11
|
+
# Installs the phlex-reactive debugging toolkit for Claude Code inside a
|
|
12
|
+
# host app (issue #168):
|
|
13
|
+
# * copies the shipped `phlex-reactive-debugging` skill into
|
|
14
|
+
# .claude/skills/ (the debugging runbook: doctor → inventory → find →
|
|
15
|
+
# browser scan → MCP)
|
|
16
|
+
# * adds the read-only MCP server to .claude/../.mcp.json — but ONLY when
|
|
17
|
+
# the file is absent (never rewrite an existing .mcp.json); otherwise it
|
|
18
|
+
# prints the snippet to add by hand.
|
|
19
|
+
#
|
|
20
|
+
# The skill ships INSIDE the gem (under lib/, so the gemspec packages it),
|
|
21
|
+
# not as a template, so it always matches the installed gem version.
|
|
22
|
+
class ClaudeGenerator < ::Rails::Generators::Base
|
|
23
|
+
SKILL_NAME = "phlex-reactive-debugging"
|
|
24
|
+
|
|
25
|
+
# The shipped skills live inside the gem under lib/phlex/reactive/claude/
|
|
26
|
+
# skills/ (packaged by the gemspec). source_root points there so
|
|
27
|
+
# `directory` copies the skill tree by its relative name.
|
|
28
|
+
source_root File.expand_path("../../../../phlex/reactive/claude/skills", __dir__)
|
|
29
|
+
|
|
30
|
+
# The MCP server entry a host app adds to .mcp.json. Introspection needs
|
|
31
|
+
# the booted app, so the command is the rake task (not a standalone exe).
|
|
32
|
+
MCP_ENTRY = {
|
|
33
|
+
"mcpServers" => {
|
|
34
|
+
"phlex-reactive" => {
|
|
35
|
+
"command" => "bin/rails",
|
|
36
|
+
"args" => ["phlex_reactive:mcp"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# The shipped skill directory inside the gem (packaged under lib/).
|
|
42
|
+
def self.skill_source_dir
|
|
43
|
+
File.expand_path("../../../../phlex/reactive/claude/skills/#{SKILL_NAME}", __dir__)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Copy the shipped skill into the host app's .claude/skills/.
|
|
47
|
+
def install_skill
|
|
48
|
+
source = self.class.skill_source_dir
|
|
49
|
+
dest = File.join(".claude", "skills", SKILL_NAME)
|
|
50
|
+
directory(source, dest)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create .mcp.json with the server entry when it's absent; otherwise print
|
|
54
|
+
# the snippet (NEVER rewrite an existing .mcp.json — it may hold other
|
|
55
|
+
# servers the app configured).
|
|
56
|
+
def configure_mcp
|
|
57
|
+
path = ".mcp.json"
|
|
58
|
+
if File.exist?(File.join(destination_root, path))
|
|
59
|
+
say_status :skip, "#{path} exists — add the phlex-reactive server yourself:", :yellow
|
|
60
|
+
say(pretty_entry)
|
|
61
|
+
else
|
|
62
|
+
create_file path, "#{pretty_entry}\n"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def show_post_install
|
|
67
|
+
say_status :info, "phlex-reactive debugging toolkit installed for Claude Code.", :green
|
|
68
|
+
say <<~MSG
|
|
69
|
+
|
|
70
|
+
The `#{SKILL_NAME}` skill teaches the debugging workflow (doctor →
|
|
71
|
+
inventory → find → browser report() → MCP). The MCP server needs the
|
|
72
|
+
optional `mcp` gem — add `gem "mcp"` to your Gemfile to enable it.
|
|
73
|
+
|
|
74
|
+
Try it: bin/rails phlex_reactive:doctor
|
|
75
|
+
MSG
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def pretty_entry
|
|
81
|
+
JSON.pretty_generate(MCP_ENTRY)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -61,6 +61,9 @@ module Phlex
|
|
|
61
61
|
Scaffold one with: rails g phlex:reactive:component Counter
|
|
62
62
|
|
|
63
63
|
Then verify the whole install: bin/rails phlex_reactive:doctor
|
|
64
|
+
|
|
65
|
+
Debugging with Claude Code? Install the toolkit (skill + MCP server):
|
|
66
|
+
rails g phlex:reactive:claude
|
|
64
67
|
MSG
|
|
65
68
|
end
|
|
66
69
|
|
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
# Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
|
|
16
16
|
# Phlex::Reactive.authorization_errors = [ActionPolicy::Unauthorized]
|
|
17
17
|
|
|
18
|
+
# verify_authorized (ON by default): an action that completes WITHOUT any
|
|
19
|
+
# authorization call raises Phlex::Reactive::AuthorizationNotVerified inside the
|
|
20
|
+
# transaction — the mutation rolls back (fail-closed). Satisfy it by calling one
|
|
21
|
+
# of authorization_methods, calling `mark_authorized!` after a bespoke check, or
|
|
22
|
+
# declaring `skip_verify_authorized` on a genuinely public component/action.
|
|
23
|
+
# Set the method names to match your authorization library:
|
|
24
|
+
# Phlex::Reactive.authorization_methods = %i[authorize! authorize allowed_to?]
|
|
25
|
+
# Turn the guard off entirely (not recommended — you lose the fail-closed net):
|
|
26
|
+
# Phlex::Reactive.verify_authorized = false
|
|
27
|
+
|
|
18
28
|
# Diagnostic endpoint error bodies + dropped-param logging (statuses never
|
|
19
29
|
# change). Defaults to Rails.env.local? — on in development AND test, off in production.
|
|
20
30
|
# Phlex::Reactive.verbose_errors = true
|
|
@@ -41,6 +51,52 @@
|
|
|
41
51
|
# <meta name="phlex-reactive-action-path" content="<%%= Phlex::Reactive.action_path %>">
|
|
42
52
|
# Phlex::Reactive.action_path = "/_r/actions"
|
|
43
53
|
|
|
54
|
+
# --- Deferred reply segments (reply.defer + reactive_lazy, issue #165) ------
|
|
55
|
+
# PROFILE FIRST: an app-side N+1 looks exactly like framework lag — make the
|
|
56
|
+
# synchronous path cheap before deferring a segment.
|
|
57
|
+
#
|
|
58
|
+
# How the deferred render reaches the actor (default :auto — a durable pgbus
|
|
59
|
+
# one-shot stream + ActiveJob when both are available, else a parallel fetch to
|
|
60
|
+
# the defer endpoint; :fetch forces the fetch lane, :stream requests push and
|
|
61
|
+
# degrades to fetch with a warning when the capability is absent):
|
|
62
|
+
# Phlex::Reactive.defer_transport = :auto
|
|
63
|
+
#
|
|
64
|
+
# Defer tokens are purpose-scoped and short-lived — the TTL only needs to cover
|
|
65
|
+
# the reply→fetch gap (default 120 seconds):
|
|
66
|
+
# Phlex::Reactive.defer_token_ttl = 120
|
|
67
|
+
#
|
|
68
|
+
# reply.defer tokens are ACTOR-BOUND: signed under the requesting session so a
|
|
69
|
+
# leaked token can't be redeemed in another session. The default binding is the
|
|
70
|
+
# session id; override to bind to your own actor identity (a user id, an API
|
|
71
|
+
# token digest). Apps with no session middleware mint UNBOUND tokens — their
|
|
72
|
+
# bound is then the TTL plus any authorization your component enforces AT RENDER
|
|
73
|
+
# TIME. Note the defer endpoint runs NO action and calls NO authorize! itself
|
|
74
|
+
# (it's a read); protection comes from the component raising a registered
|
|
75
|
+
# authorization error from from_identity/render, or returning false from
|
|
76
|
+
# render?, when it's rebuilt at the defer endpoint.
|
|
77
|
+
# Phlex::Reactive.singleton_class.class_eval do
|
|
78
|
+
# def defer_binding_for(request) = Current.user&.id&.to_s
|
|
79
|
+
# end
|
|
80
|
+
#
|
|
81
|
+
# The push lane's render job queue (default "default"). Point it at a FAST
|
|
82
|
+
# queue in production — a deferred segment is a UX-latency render; letting it
|
|
83
|
+
# starve behind heavy jobs defeats the point:
|
|
84
|
+
# Phlex::Reactive.defer_job_queue = "latency"
|
|
85
|
+
#
|
|
86
|
+
# PUSH LANE QUEUE LIFECYCLE: each deferred segment on the push lane mints a
|
|
87
|
+
# durable one-shot pgbus stream. Its queue is reclaimed by pgbus's age-based
|
|
88
|
+
# orphan-stream sweep (pgbus >= 0.9.10) — make sure the pgbus Dispatcher runs
|
|
89
|
+
# with streams_orphan_threshold set. We never drop it from the job (an eager
|
|
90
|
+
# drop would destroy an unconsumed message and reopen the broadcast-before-
|
|
91
|
+
# subscribe race). On pgbus <= 0.9.9, or if you don't run the reaper, force the
|
|
92
|
+
# (universal, cleanup-free) fetch lane:
|
|
93
|
+
# Phlex::Reactive.defer_transport = :fetch
|
|
94
|
+
#
|
|
95
|
+
# Change the defer endpoint path (default "/reactive/defer"). If you change it,
|
|
96
|
+
# expose it to the client with:
|
|
97
|
+
# <meta name="phlex-reactive-defer-path" content="<%%= Phlex::Reactive.defer_path %>">
|
|
98
|
+
# Phlex::Reactive.defer_path = "/_r/defer"
|
|
99
|
+
|
|
44
100
|
# Client request timeout (default 30s). There is NO server-side setting — a hung
|
|
45
101
|
# request aborts client-side after this window (reactive:error kind "timeout"),
|
|
46
102
|
# so the per-component queue never wedges. Override in your layout head:
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The runtime machinery behind verify_authorized (issue #168): a time-window
|
|
6
|
+
# tracking cell, an interceptor that marks it when a configured authorization
|
|
7
|
+
# method returns, and the enforcement decision the endpoint calls after an
|
|
8
|
+
# action runs.
|
|
9
|
+
#
|
|
10
|
+
# The design mirrors Phlex::Reactive.with_connection_id: a fiber-local cell
|
|
11
|
+
# (Thread.current, which is fiber-local in Ruby, so Falcon's fiber-per-request
|
|
12
|
+
# model is safe) saved and restored around the action. Because tracking is a
|
|
13
|
+
# WINDOW — anything marking during the action counts — authorization performed
|
|
14
|
+
# in a HELPER the action calls is detected too, not just a top-level call.
|
|
15
|
+
module Authorization
|
|
16
|
+
# The fiber-local key holding the tracking cell during an action. A truthy
|
|
17
|
+
# value means "some authorization method has been called (and returned)
|
|
18
|
+
# during this window".
|
|
19
|
+
CELL = :phlex_reactive_authorized
|
|
20
|
+
|
|
21
|
+
# Streamable defines a CLASS method `prepend(target:, model:)` (the Turbo
|
|
22
|
+
# Stream verb), which shadows Ruby's Module#prepend on a component class.
|
|
23
|
+
# Bind the real Module#prepend so instrument! can prepend the interceptor
|
|
24
|
+
# module without dispatching to the stream helper (and its keyword arity).
|
|
25
|
+
MODULE_PREPEND = ::Module.instance_method(:prepend)
|
|
26
|
+
private_constant :MODULE_PREPEND
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Open a fresh tracking window, run the block, and restore the previous
|
|
30
|
+
# cell — exactly the with_connection_id save/restore shape, so nesting is
|
|
31
|
+
# correct and a raise still restores. The inner window starts UNMARKED
|
|
32
|
+
# regardless of an outer mark (a fresh action authorizes for itself).
|
|
33
|
+
def with_tracking
|
|
34
|
+
previous = Thread.current[CELL]
|
|
35
|
+
Thread.current[CELL] = false
|
|
36
|
+
yield
|
|
37
|
+
ensure
|
|
38
|
+
Thread.current[CELL] = previous
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Mark the current window authorized. Called by the interceptor (on a
|
|
42
|
+
# non-raising authorization-method return) and by Component#mark_authorized!
|
|
43
|
+
# for a bespoke check. A no-op semantically outside a window (the cell is
|
|
44
|
+
# simply overwritten and restored by the nearest with_tracking).
|
|
45
|
+
def mark!
|
|
46
|
+
Thread.current[CELL] = true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Has anything marked the current window?
|
|
50
|
+
def marked?
|
|
51
|
+
Thread.current[CELL] == true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The enforcement decision, called by the endpoint AFTER the action runs,
|
|
55
|
+
# INSIDE the transaction (so a raise rolls the mutation back). A no-op
|
|
56
|
+
# unless verify_authorized is on, the action isn't skipped, and nothing
|
|
57
|
+
# marked the window. Otherwise raises AuthorizationNotVerified naming the
|
|
58
|
+
# component#action and the three remedies.
|
|
59
|
+
def verify!(component_class, action_def)
|
|
60
|
+
return unless Phlex::Reactive.verify_authorized
|
|
61
|
+
return if skipped?(component_class, action_def.name)
|
|
62
|
+
return if marked?
|
|
63
|
+
|
|
64
|
+
raise Phlex::Reactive::AuthorizationNotVerified, violation_message(component_class, action_def)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Wrap each configured authorization method the class defines (public OR
|
|
68
|
+
# private, own OR inherited) with a prepended module that calls super and
|
|
69
|
+
# then mark! on a NON-RAISING return — so a denial (which raises) never
|
|
70
|
+
# marks and still propagates to the endpoint's authorization_errors → 403.
|
|
71
|
+
#
|
|
72
|
+
# Idempotent per class OBJECT (memoized on an ivar the class carries), so
|
|
73
|
+
# a Zeitwerk reload's fresh class re-instruments naturally and a repeated
|
|
74
|
+
# call in one process is a no-op. Re-reads authorization_methods each first
|
|
75
|
+
# call, so an initializer that widened the list is honored.
|
|
76
|
+
def instrument!(component_class)
|
|
77
|
+
return if component_class.instance_variable_get(:@reactive_authorization_instrumented)
|
|
78
|
+
|
|
79
|
+
names = Phlex::Reactive.authorization_methods.select do
|
|
80
|
+
component_class.method_defined?(it) || component_class.private_method_defined?(it)
|
|
81
|
+
end
|
|
82
|
+
MODULE_PREPEND.bind_call(component_class, interceptor_for(names)) if names.any?
|
|
83
|
+
component_class.instance_variable_set(:@reactive_authorization_instrumented, true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def skipped?(component_class, action_name)
|
|
89
|
+
component_class.respond_to?(:skip_verify_authorized?) &&
|
|
90
|
+
component_class.skip_verify_authorized?(action_name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build the prepend module: one wrapper per authorization method name,
|
|
94
|
+
# each `super(...)` then mark! (only reached when super didn't raise).
|
|
95
|
+
def interceptor_for(names)
|
|
96
|
+
Module.new do
|
|
97
|
+
names.each do
|
|
98
|
+
define_method(it) do |*args, **kwargs, &block|
|
|
99
|
+
result = super(*args, **kwargs, &block)
|
|
100
|
+
Phlex::Reactive::Authorization.mark!
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def violation_message(component_class, action_def)
|
|
108
|
+
where = [component_class&.name, action_def&.name].compact.join("#")
|
|
109
|
+
methods = Phlex::Reactive.authorization_methods.map { ":#{it}" }.join(", ")
|
|
110
|
+
"#{where} completed without any authorization call — verify_authorized is on. " \
|
|
111
|
+
"Either call one of your authorization methods (#{methods}), call mark_authorized! " \
|
|
112
|
+
"for a bespoke check, or declare `skip_verify_authorized` (whole component) / " \
|
|
113
|
+
"`skip_verify_authorized :#{action_def&.name}` (this action) if it is intentionally public."
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phlex-reactive-debugging
|
|
3
|
+
description: "Debug phlex-reactive in a Rails app: a reactive component that doesn't update, a 400/403/404 from POST /reactive/actions, an AuthorizationNotVerified error, or an audit of which reactive actions authorize. Use when a reactive click does nothing, a Turbo Stream never arrives, or you need to inventory the reactive components/actions in the app."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Debugging phlex-reactive
|
|
7
|
+
|
|
8
|
+
phlex-reactive turns every reactive component into a browser-reachable RPC:
|
|
9
|
+
`POST /reactive/actions` with a signed identity token → verify → rebuild the
|
|
10
|
+
component → run the declared action → render an auto-targeted Turbo Stream. When
|
|
11
|
+
something "just doesn't work," the failure is almost always at one of those
|
|
12
|
+
seams. This skill is the runbook.
|
|
13
|
+
|
|
14
|
+
Work top-down: **validate the install → inventory the server → find the specific
|
|
15
|
+
component → scan the live page → introspect the running app.** Stop as soon as a
|
|
16
|
+
step names the problem.
|
|
17
|
+
|
|
18
|
+
## 1. Validate the install (`doctor`)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bin/rails phlex_reactive:doctor
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Prints ✓/✗/? for the whole install: does `POST /reactive/actions` route to the
|
|
25
|
+
gem (a host catch-all route shadows it otherwise), is the generic `reactive`
|
|
26
|
+
Stimulus controller registered, does the identity verifier round-trip, does every
|
|
27
|
+
declared `action :name` have a public method, does every component resolve a
|
|
28
|
+
stable `#id`, and an **advisory** authorization heuristic. A ✗ prints the fix.
|
|
29
|
+
Run this FIRST — most "nothing happens" reports are a red ✗ here.
|
|
30
|
+
|
|
31
|
+
## 2. Inventory the server (`actions` / `find`)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bin/rails phlex_reactive:actions # every component × action: params, file:line, auth
|
|
35
|
+
bin/rails phlex_reactive:actions FORMAT=json
|
|
36
|
+
bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Answers "what reactive actions exist, where are they defined, and is each
|
|
40
|
+
authorized?" without grepping. The `auth` column is a heuristic (`authorized*` =
|
|
41
|
+
an authorization call was detected in the body; `unverified` = none — a helper
|
|
42
|
+
may still authorize indirectly).
|
|
43
|
+
|
|
44
|
+
## 3. Scan the live page (browser inspector)
|
|
45
|
+
|
|
46
|
+
In the browser console on the affected page:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
(await import("phlex/reactive/inspect")).report()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`report()` prints a `console.table` of every reactive root on the page — its
|
|
53
|
+
`id`, decoded token payload (**component class**, gid, state keys), status
|
|
54
|
+
(error/busy/dirty), the bound **triggers** (action + event + params +
|
|
55
|
+
debounce/confirm), client-only ops, computes, and the named fields a dispatch
|
|
56
|
+
would send. `scan()` returns the same data.
|
|
57
|
+
|
|
58
|
+
**The server↔client mapping is by name.** The `component` and each trigger's
|
|
59
|
+
`action` in `report()` are exactly the identifiers `phlex_reactive:actions`
|
|
60
|
+
lists. So: does the button on the page carry the action you expect? Does its
|
|
61
|
+
`component` match the class you think renders here? A mismatch (or a root with an
|
|
62
|
+
`(opaque token)`) localizes the bug.
|
|
63
|
+
|
|
64
|
+
## 4. Introspect the running app (MCP server)
|
|
65
|
+
|
|
66
|
+
For live-app introspection from inside your editor, run the read-only MCP server
|
|
67
|
+
(needs the optional `mcp` gem — `gem "mcp"`):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
// .mcp.json
|
|
71
|
+
{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It exposes five read-only tools — `phlex_reactive_doctor`,
|
|
75
|
+
`phlex_reactive_components`, `phlex_reactive_actions` (optional `component:`
|
|
76
|
+
filter), `phlex_reactive_find`, `phlex_reactive_config` — the same data as the
|
|
77
|
+
rake tasks, queryable in conversation. Constraint: stdio MCP needs a clean
|
|
78
|
+
stdout, so an initializer that `puts` breaks it.
|
|
79
|
+
|
|
80
|
+
`rails g phlex:reactive:claude` installs this skill and writes the `.mcp.json`
|
|
81
|
+
entry for you.
|
|
82
|
+
|
|
83
|
+
## Failure table
|
|
84
|
+
|
|
85
|
+
| Symptom | Cause | Fix |
|
|
86
|
+
|---------|-------|-----|
|
|
87
|
+
| **400** (bad request) | Tampered / stale token — a token from before a deploy, a `secret_key_base` mismatch, or a `reply.with(...)` stream that skipped the token refresh | Reload the page for a fresh token; confirm `secret_key_base`; ensure custom streams refresh the token |
|
|
88
|
+
| **403** (forbidden) | The action isn't declared (`action :name`), OR your authorizer raised and is registered in `Phlex::Reactive.authorization_errors` | Declare the action; or the 403 is correct — the user isn't allowed |
|
|
89
|
+
| **`AuthorizationNotVerified`** (500) | `verify_authorized` is ON and the action completed without calling an authorization method or `mark_authorized!` | Call your authorization method inside the action, call `mark_authorized!` after a bespoke check, or declare `skip_verify_authorized` if it's genuinely public |
|
|
90
|
+
| **404** (not found) | The record was deleted while a page still showed it (record-backed component) | Expected for a stale page; handle by removing the row or reloading |
|
|
91
|
+
| **Nothing happens on click** | The `reactive` controller isn't registered, or a host catch-all route shadows `POST /reactive/actions` | `bin/rails phlex_reactive:doctor` names both — fix the ✗ |
|
|
92
|
+
| **`importmap` 404 for the client** | The engine's pin was overridden | Re-add `pin "phlex/reactive/reactive_controller"`; doctor flags it |
|
|
93
|
+
| **Component renders but never updates** | The root's `id` and `data-controller="reactive"` landed on DIFFERENT elements | Spread `**reactive_root` (binds id + controller + token to the same element) instead of `**reactive_attrs` on a child |
|
|
94
|
+
|
|
95
|
+
## Reference vs live introspection
|
|
96
|
+
|
|
97
|
+
- **Reference docs** (how a feature works, the API): the hosted docs MCP at
|
|
98
|
+
<https://phlex-reactive.zoolutions.llc/llms.txt> — search/read the published
|
|
99
|
+
guide.
|
|
100
|
+
- **Live-app introspection** (what's in THIS app right now): the local
|
|
101
|
+
`phlex_reactive:mcp` server + the rake tasks + the browser `report()`.
|
|
102
|
+
|
|
103
|
+
For transport-level problems (a broadcast never arrives over pgbus / Action
|
|
104
|
+
Cable), reach for pgbus's own tooling (`pgbus doctor`, its MCP) — phlex-reactive
|
|
105
|
+
is transport-agnostic and doesn't diagnose the wire.
|
|
@@ -58,6 +58,41 @@ module Phlex
|
|
|
58
58
|
Registry.resolve_list(self, :state_keys, :reactive_state_keys)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Lazy initial mount (issue #165): the FIRST (page-embedded) render
|
|
62
|
+
# emits the placeholder shell + a defer token on the root; the client
|
|
63
|
+
# fetches the real content on connect. Reactive machinery renders
|
|
64
|
+
# (replies, broadcasts, the defer endpoint/job) stay REAL. Inherited
|
|
65
|
+
# by subclasses. See Component::Lazy.
|
|
66
|
+
#
|
|
67
|
+
# `tag:` sets the shell element (default :div). Set it to the real
|
|
68
|
+
# root's element for content that can't legally hold a <div> — a
|
|
69
|
+
# `reactive_lazy tag: :tr` component rooting at <tr> inside <tbody>
|
|
70
|
+
# ships a <tr> shell, not a <div> the HTML parser would hoist away.
|
|
71
|
+
def reactive_lazy(tag: :div)
|
|
72
|
+
Registry.write_scalar(self, :lazy, { tag: tag.to_sym })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The RAW resolved lazy declaration ({ tag: } or nil) — the reader the
|
|
76
|
+
# Registry walks up the ancestry. It MUST return the stored value, not
|
|
77
|
+
# a boolean: resolve_scalar inherits whatever the superclass reader
|
|
78
|
+
# returns, so a boolean false would be inherited as the value and read
|
|
79
|
+
# back as "declared" (`!false.nil?` == true) — a subclass of any
|
|
80
|
+
# component would spuriously render the lazy shell.
|
|
81
|
+
def reactive_lazy_declaration
|
|
82
|
+
Registry.resolve_scalar(self, :lazy, :reactive_lazy_declaration)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reactive_lazy?
|
|
86
|
+
!reactive_lazy_declaration.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The shell element tag for a lazy component (:div default), resolved
|
|
90
|
+
# through the registry so a subclass inherits the parent's choice.
|
|
91
|
+
def reactive_lazy_tag
|
|
92
|
+
value = reactive_lazy_declaration
|
|
93
|
+
value.is_a?(::Hash) ? value.fetch(:tag, :div) : :div
|
|
94
|
+
end
|
|
95
|
+
|
|
61
96
|
# Declare a client-invokable action with an optional param schema.
|
|
62
97
|
# action :increment
|
|
63
98
|
# action :rename, params: { title: :string }
|
|
@@ -100,6 +135,41 @@ module Phlex
|
|
|
100
135
|
reactive_actions.key?(name.to_sym)
|
|
101
136
|
end
|
|
102
137
|
|
|
138
|
+
# Opt out of the default-ON verify_authorized guard (issue #168).
|
|
139
|
+
# Bare skips the WHOLE component (a public counter, a client-only
|
|
140
|
+
# filter); with names skips just those actions:
|
|
141
|
+
#
|
|
142
|
+
# skip_verify_authorized # every action
|
|
143
|
+
# skip_verify_authorized :filter, :page # only these
|
|
144
|
+
#
|
|
145
|
+
# Registry #6 — resolves through the superclass at read time like the
|
|
146
|
+
# other five, so a child inherits a parent's skips (and a child's bare
|
|
147
|
+
# skip covers everything). Marks nothing else; a non-skipped action
|
|
148
|
+
# still needs a real authorization call or mark_authorized!.
|
|
149
|
+
def skip_verify_authorized(*names)
|
|
150
|
+
if names.empty?
|
|
151
|
+
Registry.write_scalar(self, :skip_all, true)
|
|
152
|
+
else
|
|
153
|
+
Registry.append(self, :skip_actions, names.map(&:to_sym))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def reactive_skip_all_authorization?
|
|
158
|
+
Registry.resolve_scalar(self, :skip_all, :reactive_skip_all_authorization?) == true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def reactive_skip_authorization_actions
|
|
162
|
+
Registry.resolve_list(self, :skip_actions, :reactive_skip_authorization_actions)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Is verify_authorized skipped for `action_name`? True under a bare
|
|
166
|
+
# component skip OR when the action is in the named list — resolved
|
|
167
|
+
# through Registry so inheritance matches every other declaration.
|
|
168
|
+
def skip_verify_authorized?(action_name)
|
|
169
|
+
reactive_skip_all_authorization? ||
|
|
170
|
+
reactive_skip_authorization_actions.include?(action_name.to_sym)
|
|
171
|
+
end
|
|
172
|
+
|
|
103
173
|
# Declare an add/remove-row collection (issue #35) — the list contract
|
|
104
174
|
# in one place, so actions append/prepend/remove a row WITHOUT
|
|
105
175
|
# re-deriving the container id, count, and empty-state in every action:
|