phlex-reactive 0.1.0
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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +421 -0
- data/app/controllers/phlex/reactive/actions_controller.rb +122 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +149 -0
- data/lib/phlex/reactive/component.rb +169 -0
- data/lib/phlex/reactive/engine.rb +45 -0
- data/lib/phlex/reactive/streamable.rb +147 -0
- data/lib/phlex/reactive/version.rb +7 -0
- data/lib/phlex/reactive.rb +116 -0
- data/lib/phlex-reactive.rb +4 -0
- metadata +153 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The ONE generic controller behind every reactive Phlex component. It
|
|
4
|
+
// replaces the per-feature Stimulus controllers you'd otherwise hand-write
|
|
5
|
+
// for interactive components. A component declares its actions in Ruby (via
|
|
6
|
+
// Phlex::Reactive::Component); this controller binds DOM events to a single
|
|
7
|
+
// HTTP round trip and lets Turbo morph the re-rendered component back in.
|
|
8
|
+
//
|
|
9
|
+
// Wire format (client -> server), POST <action path>, turbo-stream Accept:
|
|
10
|
+
// { token: "<signed identity>", act: "<action>", params: {...} }
|
|
11
|
+
// (`act`, not `action`: `action` is a reserved Rails routing param.)
|
|
12
|
+
// The token is a MessageVerifier-signed { component, gid } — NO state is sent.
|
|
13
|
+
// The response is a <turbo-stream> that replaces the component by its id.
|
|
14
|
+
//
|
|
15
|
+
// Server -> client live updates use the SAME element id, pushed over the
|
|
16
|
+
// stream transport (pgbus SSE / Action Cable) via the Streamable
|
|
17
|
+
// .broadcast_* methods — so a click and a background broadcast converge on
|
|
18
|
+
// one re-render unit.
|
|
19
|
+
//
|
|
20
|
+
// Register this controller eagerly (not lazily) so a click immediately after
|
|
21
|
+
// page load is never missed. The phlex-reactive engine auto-pins it with
|
|
22
|
+
// preload: true for importmap apps; see the README for esbuild/webpack.
|
|
23
|
+
export default class extends Controller {
|
|
24
|
+
static values = {
|
|
25
|
+
token: String, // signed identity token (component + record gid/state)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
29
|
+
|
|
30
|
+
// Serialize requests per component. Each round trip rewrites the signed
|
|
31
|
+
// token in the DOM (state lives in the token, not the client). If events
|
|
32
|
+
// fire faster than round trips complete, concurrent requests would all read
|
|
33
|
+
// the SAME stale token and clobber each other (last-write-wins). Chaining on
|
|
34
|
+
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
35
|
+
// it always uses the freshest token.
|
|
36
|
+
dispatch(event) {
|
|
37
|
+
this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(event))
|
|
38
|
+
return this.queue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async #perform(event) {
|
|
42
|
+
const { action, params } = event.params
|
|
43
|
+
if (!action) return
|
|
44
|
+
|
|
45
|
+
// Stop native behavior (button submit / form navigation): the reactive
|
|
46
|
+
// round trip replaces it.
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
|
|
49
|
+
// Auto-collect named field values inside this component so a button-
|
|
50
|
+
// triggered action still receives sibling inputs (Livewire-style).
|
|
51
|
+
// Explicit params (data-reactive-params-param) win over collected fields.
|
|
52
|
+
const fieldParams = this.#collectFields()
|
|
53
|
+
|
|
54
|
+
const body = JSON.stringify({
|
|
55
|
+
token: this.#currentToken,
|
|
56
|
+
act: action,
|
|
57
|
+
params: { ...fieldParams, ...this.#parseParams(params) },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
this.element.setAttribute("aria-busy", "true")
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(this.#actionPath(), {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
Accept: "text/vnd.turbo-stream.html",
|
|
68
|
+
"X-CSRF-Token": this.#csrfToken(),
|
|
69
|
+
},
|
|
70
|
+
body,
|
|
71
|
+
credentials: "same-origin",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (response.redirected) {
|
|
75
|
+
console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
console.error(`[phlex-reactive] action failed: HTTP ${response.status}`, await response.text())
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const contentType = response.headers.get("Content-Type") || ""
|
|
84
|
+
if (!contentType.includes("turbo-stream")) {
|
|
85
|
+
console.error(`[phlex-reactive] expected a turbo-stream, got "${contentType}" — no update applied`)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const html = await response.text()
|
|
90
|
+
// Capture the new token from the response synchronously, so the next
|
|
91
|
+
// queued request uses it without waiting for the async DOM morph.
|
|
92
|
+
this.#currentToken = this.#extractToken(html) ?? this.#currentToken
|
|
93
|
+
// Turbo applies the <turbo-stream> ops (replace/morph by id), preserving
|
|
94
|
+
// focus/scroll/listeners on unchanged nodes.
|
|
95
|
+
window.Turbo.renderStreamMessage(html)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error("[phlex-reactive] action error", error)
|
|
98
|
+
} finally {
|
|
99
|
+
this.element.removeAttribute("aria-busy")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get #currentToken() {
|
|
104
|
+
return this.#tokenCache ?? this.tokenValue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
set #currentToken(value) {
|
|
108
|
+
this.#tokenCache = value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#extractToken(html) {
|
|
112
|
+
const match = html.match(/data-reactive-token-value="([^"]+)"/)
|
|
113
|
+
return match?.[1]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#collectFields() {
|
|
117
|
+
const fields = {}
|
|
118
|
+
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
119
|
+
if (field.type === "checkbox") {
|
|
120
|
+
fields[field.name] = field.checked
|
|
121
|
+
} else if (field.type === "radio") {
|
|
122
|
+
if (field.checked) fields[field.name] = field.value
|
|
123
|
+
} else {
|
|
124
|
+
fields[field.name] = field.value
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
return fields
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#parseParams(raw) {
|
|
131
|
+
if (!raw) return {}
|
|
132
|
+
try {
|
|
133
|
+
return typeof raw === "string" ? JSON.parse(raw) : raw
|
|
134
|
+
} catch {
|
|
135
|
+
return {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#actionPath() {
|
|
140
|
+
return (
|
|
141
|
+
document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
|
|
142
|
+
"/reactive/actions"
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#csrfToken() {
|
|
147
|
+
return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Component turns a self-contained Phlex component into a Livewire-style
|
|
6
|
+
# reactive unit: declare actions in Ruby, and the generic `reactive`
|
|
7
|
+
# Stimulus controller wires clicks/inputs to an HTTP round trip that
|
|
8
|
+
# re-renders the component and morphs it back into the DOM. No per-feature
|
|
9
|
+
# Stimulus controllers, no hand-picked Turbo targets.
|
|
10
|
+
#
|
|
11
|
+
# Include alongside Phlex::Reactive::Streamable (which provides #id and the
|
|
12
|
+
# re-render machinery).
|
|
13
|
+
#
|
|
14
|
+
# === Security model (the decisive design choice) ===
|
|
15
|
+
# We do NOT ship component STATE to the browser (no snapshot). The DOM
|
|
16
|
+
# carries a signed IDENTITY:
|
|
17
|
+
#
|
|
18
|
+
# * Record-backed (the common case): reactive_record :todo signs the
|
|
19
|
+
# record's GlobalID. The server re-finds it via GlobalID — the client
|
|
20
|
+
# can neither forge the component class nor swap the record. State =
|
|
21
|
+
# the database.
|
|
22
|
+
# * State-backed (record-less, e.g. a counter): reactive_state :count
|
|
23
|
+
# signs the listed instance variables. Use only when there is genuinely
|
|
24
|
+
# no record to re-find.
|
|
25
|
+
#
|
|
26
|
+
# Actions are DEFAULT-DENY: only methods declared with `action :name` may be
|
|
27
|
+
# invoked. The signature proves the token is ours, NOT that this user may
|
|
28
|
+
# act — your action must still authorize the record. Action params pass
|
|
29
|
+
# through a declared schema; nothing else reaches the method.
|
|
30
|
+
#
|
|
31
|
+
# Usage (record-backed):
|
|
32
|
+
# class Todos::Item < ApplicationComponent
|
|
33
|
+
# include Phlex::Reactive::Streamable
|
|
34
|
+
# include Phlex::Reactive::Component
|
|
35
|
+
#
|
|
36
|
+
# reactive_record :todo
|
|
37
|
+
# action :toggle
|
|
38
|
+
# action :rename, params: { title: :string }
|
|
39
|
+
#
|
|
40
|
+
# def initialize(todo:) = @todo = todo
|
|
41
|
+
# def id = dom_id(@todo)
|
|
42
|
+
#
|
|
43
|
+
# def toggle = (authorize!(@todo, :update?); @todo.toggle!(:done))
|
|
44
|
+
# def rename(title:) = (authorize!(@todo, :update?); @todo.update!(title:))
|
|
45
|
+
#
|
|
46
|
+
# def view_template
|
|
47
|
+
# li(id:, **reactive_attrs) do
|
|
48
|
+
# button(**on(:toggle)) { @todo.done? ? "✓" : "○" }
|
|
49
|
+
# span { @todo.title }
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
# end
|
|
53
|
+
module Component
|
|
54
|
+
extend ActiveSupport::Concern
|
|
55
|
+
|
|
56
|
+
# A declared, client-invokable action and its param schema.
|
|
57
|
+
Action = Data.define(:name, :params)
|
|
58
|
+
|
|
59
|
+
class_methods do
|
|
60
|
+
# Declare the ActiveRecord (GlobalID-able) record this component is
|
|
61
|
+
# rebuilt from. The signed token carries its GlobalID; the server
|
|
62
|
+
# re-finds it on each action. State lives in the DB.
|
|
63
|
+
def reactive_record(name)
|
|
64
|
+
@reactive_record_key = name.to_sym
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reactive_record_key
|
|
68
|
+
return @reactive_record_key if defined?(@reactive_record_key)
|
|
69
|
+
|
|
70
|
+
superclass.respond_to?(:reactive_record_key) ? superclass.reactive_record_key : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Opt into signed STATE for record-less components only.
|
|
74
|
+
# reactive_state :count, :open
|
|
75
|
+
def reactive_state(*names)
|
|
76
|
+
reactive_state_keys.concat(names.map(&:to_sym))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def reactive_state_keys
|
|
80
|
+
@reactive_state_keys ||= (superclass.respond_to?(:reactive_state_keys) ? superclass.reactive_state_keys.dup : [])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Declare a client-invokable action with an optional param schema.
|
|
84
|
+
# action :increment
|
|
85
|
+
# action :rename, params: { title: :string }
|
|
86
|
+
# Param types: :string (default), :integer, :float, :boolean.
|
|
87
|
+
def action(name, params: {})
|
|
88
|
+
reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reactive_actions
|
|
92
|
+
@reactive_actions ||= (superclass.respond_to?(:reactive_actions) ? superclass.reactive_actions.dup : {})
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reactive_action(name)
|
|
96
|
+
reactive_actions[name.to_sym]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reactive_action?(name)
|
|
100
|
+
reactive_actions.key?(name.to_sym)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Rebuild a component instance from a verified identity payload. Called
|
|
104
|
+
# by the action endpoint after the token signature is verified.
|
|
105
|
+
def from_identity(payload)
|
|
106
|
+
if reactive_record_key
|
|
107
|
+
record = GlobalID::Locator.locate(payload.fetch("gid"))
|
|
108
|
+
raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
|
|
109
|
+
|
|
110
|
+
new(reactive_record_key => record)
|
|
111
|
+
else
|
|
112
|
+
state = payload.fetch("s", {})
|
|
113
|
+
kwargs = reactive_state_keys.to_h { |k| [k, state[k.to_s]] }.compact
|
|
114
|
+
new(**kwargs)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Root-element attributes: marks the element reactive and carries the
|
|
120
|
+
# signed identity token. Spread onto the root:
|
|
121
|
+
# div(id:, **reactive_attrs) { ... }
|
|
122
|
+
def reactive_attrs
|
|
123
|
+
{
|
|
124
|
+
data: {
|
|
125
|
+
controller: "reactive",
|
|
126
|
+
reactive_token_value: reactive_token
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Attributes for an element that triggers an action.
|
|
132
|
+
# button(**on(:toggle)) { "○" }
|
|
133
|
+
# form(**on(:save, event: "submit")) { ... }
|
|
134
|
+
#
|
|
135
|
+
# Extra keyword args become explicit params merged over collected form
|
|
136
|
+
# fields. For click triggers we force type="button" so a bare button
|
|
137
|
+
# inside a <form> can't submit it and cause a full-page navigation.
|
|
138
|
+
def on(action_name, event: "click", **params)
|
|
139
|
+
attrs = {
|
|
140
|
+
data: {
|
|
141
|
+
action: "#{event}->reactive#dispatch",
|
|
142
|
+
reactive_action_param: action_name.to_s,
|
|
143
|
+
reactive_params_param: params.to_json
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
attrs[:type] = "button" if event == "click"
|
|
147
|
+
attrs
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Signed { c, gid } (record-backed) or { c, s } (state-backed).
|
|
153
|
+
def reactive_token
|
|
154
|
+
payload =
|
|
155
|
+
if self.class.reactive_record_key
|
|
156
|
+
record = instance_variable_get(:"@#{self.class.reactive_record_key}")
|
|
157
|
+
{ "c" => self.class.name, "gid" => record.to_gid.to_s }
|
|
158
|
+
else
|
|
159
|
+
state = self.class.reactive_state_keys.to_h do |k|
|
|
160
|
+
[k.to_s, instance_variable_get(:"@#{k}").as_json]
|
|
161
|
+
end
|
|
162
|
+
{ "c" => self.class.name, "s" => state }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
Phlex::Reactive.sign(payload)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
# Rails engine: mounts the action endpoint, makes the client runtime
|
|
8
|
+
# available to the asset pipeline, and auto-pins it for importmap apps so
|
|
9
|
+
# `phlex-reactive` works with zero manual wiring on a standard Rails+Phlex
|
|
10
|
+
# app. (Configurable — see config/ options on Phlex::Reactive.)
|
|
11
|
+
class Engine < ::Rails::Engine
|
|
12
|
+
isolate_namespace Phlex::Reactive
|
|
13
|
+
|
|
14
|
+
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create.
|
|
15
|
+
# Apps can change the path with Phlex::Reactive.action_path before boot.
|
|
16
|
+
initializer "phlex_reactive.routes" do |app|
|
|
17
|
+
app.routes.append do
|
|
18
|
+
post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Make reactive_controller.js available to Propshaft/Sprockets so it can
|
|
23
|
+
# be included via the asset pipeline or pinned in importmap.
|
|
24
|
+
initializer "phlex_reactive.assets" do |app|
|
|
25
|
+
if app.config.respond_to?(:assets)
|
|
26
|
+
app.config.assets.paths << root.join("app/javascript").to_s
|
|
27
|
+
app.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Auto-pin the client controller for importmap apps so it loads without
|
|
32
|
+
# manual configuration. Apps that don't use importmap include it via the
|
|
33
|
+
# asset pipeline instead (see README).
|
|
34
|
+
initializer "phlex_reactive.importmap", after: "importmap" do |app|
|
|
35
|
+
if defined?(::Importmap::Map) && app.respond_to?(:importmap)
|
|
36
|
+
app.importmap.pin(
|
|
37
|
+
"phlex/reactive/reactive_controller",
|
|
38
|
+
to: "phlex/reactive/reactive_controller.js",
|
|
39
|
+
preload: true
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Streamable gives a self-contained Phlex component the ability to render
|
|
6
|
+
# ITSELF as a Turbo Stream and to broadcast itself over a stream. Every
|
|
7
|
+
# streamable component must implement `#id` returning a stable DOM id —
|
|
8
|
+
# that id is the Turbo Stream target, so you never hand-pick targets.
|
|
9
|
+
#
|
|
10
|
+
# Class methods (use in controllers):
|
|
11
|
+
# render turbo_stream: Counter.replace(counter)
|
|
12
|
+
# render turbo_stream: [Row.append(target: "items", model: @item),
|
|
13
|
+
# Totals.update(@order)]
|
|
14
|
+
#
|
|
15
|
+
# Broadcast methods (use in models/jobs/actions):
|
|
16
|
+
# Counter.broadcast_replace_to(counter, model: counter)
|
|
17
|
+
# Row.broadcast_append_to(@list, target: "items", model: @item)
|
|
18
|
+
#
|
|
19
|
+
# Convention: the `id` you set on the root element in `view_template` must
|
|
20
|
+
# equal what `#id` returns, so replace/broadcast_replace target it.
|
|
21
|
+
#
|
|
22
|
+
# NOTE: we intentionally do NOT include Turbo::Streams::ActionHelper — it
|
|
23
|
+
# pulls in ActionView::Helpers::TagHelper, which overrides Phlex's internal
|
|
24
|
+
# `tag` method and breaks rendering. We use Turbo::Streams::TagBuilder
|
|
25
|
+
# directly instead.
|
|
26
|
+
module Streamable
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
class_methods do
|
|
30
|
+
# The keyword the positional model maps to in `initialize`. Convention:
|
|
31
|
+
# demodulized, underscored class name (Invoice -> :invoice,
|
|
32
|
+
# InvoiceItem -> :invoice_item). Override when it differs.
|
|
33
|
+
def model_param_name
|
|
34
|
+
name.demodulize.underscore.to_sym
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def component_args(model, options)
|
|
38
|
+
{ model_param_name => model }.merge(options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def turbo_stream_builder
|
|
42
|
+
::Turbo::Streams::TagBuilder.new(renderer)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Render a component to HTML with a full Rails view context. Routing
|
|
46
|
+
# through the controller renderer keeps dom_id/url_for/t() working
|
|
47
|
+
# during a re-render or broadcast.
|
|
48
|
+
def render_component(component)
|
|
49
|
+
renderer.render(component, layout: false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def replace(model = nil, **options)
|
|
53
|
+
component = build(model, options)
|
|
54
|
+
turbo_stream_builder.replace(component.id, html: render_component(component))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update(model = nil, **options)
|
|
58
|
+
component = build(model, options)
|
|
59
|
+
turbo_stream_builder.update(component.id, html: render_component(component))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def append(target:, model: nil, **options)
|
|
63
|
+
component = build(model, options)
|
|
64
|
+
turbo_stream_builder.append(target, html: render_component(component))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def prepend(target:, model: nil, **options)
|
|
68
|
+
component = build(model, options)
|
|
69
|
+
turbo_stream_builder.prepend(target, html: render_component(component))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def remove(model = nil, **options)
|
|
73
|
+
component = build(model, options)
|
|
74
|
+
turbo_stream_builder.remove(component.id)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- Broadcasts (server -> client over the stream transport) ---
|
|
78
|
+
# With pgbus installed, Turbo::StreamsChannel broadcasts route over
|
|
79
|
+
# Postgres SSE instead of Action Cable, transactionally.
|
|
80
|
+
#
|
|
81
|
+
# Pass RAW key parts as *streamables (e.g. broadcast_append_to(@list, :items))
|
|
82
|
+
# or (model, :symbol). Do NOT pass a pre-built stream key string — the
|
|
83
|
+
# broadcaster builds the key itself, and double-keying can trip the
|
|
84
|
+
# transport's separator guard.
|
|
85
|
+
|
|
86
|
+
def broadcast_replace_to(*streamables, model: nil, **options)
|
|
87
|
+
component = build(model, options)
|
|
88
|
+
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
89
|
+
*streamables, target: component.id, html: render_component(component)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def broadcast_update_to(*streamables, model: nil, **options)
|
|
94
|
+
component = build(model, options)
|
|
95
|
+
::Turbo::StreamsChannel.broadcast_update_to(
|
|
96
|
+
*streamables, target: component.id, html: render_component(component)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def broadcast_append_to(*streamables, target:, model: nil, **options)
|
|
101
|
+
component = build(model, options)
|
|
102
|
+
::Turbo::StreamsChannel.broadcast_append_to(
|
|
103
|
+
*streamables, target:, html: render_component(component)
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def broadcast_prepend_to(*streamables, target:, model: nil, **options)
|
|
108
|
+
component = build(model, options)
|
|
109
|
+
::Turbo::StreamsChannel.broadcast_prepend_to(
|
|
110
|
+
*streamables, target:, html: render_component(component)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def broadcast_remove_to(*streamables, model: nil, **options)
|
|
115
|
+
component = build(model, options)
|
|
116
|
+
::Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: component.id)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build(model, options)
|
|
122
|
+
new(**(model ? component_args(model, options) : options))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def renderer
|
|
126
|
+
Phlex::Reactive.renderer
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Required: the stable DOM id used as the Turbo Stream target. It MUST
|
|
131
|
+
# match the id set on the component's root element in `view_template`.
|
|
132
|
+
def id
|
|
133
|
+
raise NotImplementedError, "#{self.class} must implement #id for Turbo Stream targeting"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Render THIS already-built instance as a replace stream (used by the
|
|
137
|
+
# reactive action endpoint after an action mutated state).
|
|
138
|
+
def to_stream_replace
|
|
139
|
+
self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_stream_update
|
|
143
|
+
self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
require "globalid"
|
|
5
|
+
|
|
6
|
+
module Phlex
|
|
7
|
+
# phlex-reactive: reactive Phlex components for Rails.
|
|
8
|
+
#
|
|
9
|
+
# Two cooperating mixins, one client runtime, one endpoint:
|
|
10
|
+
#
|
|
11
|
+
# * Phlex::Reactive::Streamable — gives a component a stable `id` and class
|
|
12
|
+
# methods to render itself as a Turbo Stream (`.replace`, `.append`, ...)
|
|
13
|
+
# and to broadcast itself (`.broadcast_replace_to`, ...). The server->client
|
|
14
|
+
# half (controller responses + background broadcasts).
|
|
15
|
+
#
|
|
16
|
+
# * Phlex::Reactive::Component — declares client-invokable `action`s and
|
|
17
|
+
# emits a signed identity token + the wiring the generic `reactive`
|
|
18
|
+
# Stimulus controller needs. The client->server half (clicks, form input).
|
|
19
|
+
#
|
|
20
|
+
# Both halves converge on ONE re-render unit: the component, targeted by its
|
|
21
|
+
# `id`. See the README for the mental model and examples.
|
|
22
|
+
module Reactive
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
# Raised when a signed identity token fails verification (tampered, expired,
|
|
26
|
+
# or signed with a different key).
|
|
27
|
+
class InvalidToken < Error; end
|
|
28
|
+
|
|
29
|
+
# Purpose string bound into every identity token's signature so a token
|
|
30
|
+
# minted for phlex-reactive can't be replayed against another verifier use.
|
|
31
|
+
IDENTITY_PURPOSE = "phlex-reactive/identity"
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# The message verifier used to sign/verify component identity tokens.
|
|
35
|
+
# Defaults to a purpose-scoped verifier derived from secret_key_base.
|
|
36
|
+
# Override to use a dedicated key.
|
|
37
|
+
attr_writer :verifier
|
|
38
|
+
|
|
39
|
+
# The controller class used to render components with a full Rails view
|
|
40
|
+
# context (url helpers, CSRF, i18n) during re-renders and broadcasts.
|
|
41
|
+
# Defaults to ActionController::Base. Set to your ApplicationController if
|
|
42
|
+
# components rely on app-level helpers or Current attributes.
|
|
43
|
+
attr_writer :renderer
|
|
44
|
+
|
|
45
|
+
# The controller the reactive ActionsController inherits from, given as a
|
|
46
|
+
# String (resolved lazily to avoid load-order issues). Defaults to
|
|
47
|
+
# "ActionController::Base"; set to "ApplicationController" to inherit your
|
|
48
|
+
# app's auth/CSRF/Current. If you do, ensure the action path isn't
|
|
49
|
+
# force-redirected for logged-out users when you have public components.
|
|
50
|
+
attr_writer :base_controller_name
|
|
51
|
+
|
|
52
|
+
# Exception classes the action endpoint renders as 403. Append your
|
|
53
|
+
# authorization library's error (Pundit::NotAuthorizedError,
|
|
54
|
+
# ActionPolicy::Unauthorized, ...).
|
|
55
|
+
attr_accessor :authorization_errors
|
|
56
|
+
|
|
57
|
+
# The path the action endpoint is mounted at. Default "/reactive/actions".
|
|
58
|
+
# Set before boot if it collides with an app route. The client runtime
|
|
59
|
+
# reads it from a <meta name="phlex-reactive-action-path"> tag if present,
|
|
60
|
+
# falling back to this default.
|
|
61
|
+
attr_writer :action_path
|
|
62
|
+
|
|
63
|
+
def action_path
|
|
64
|
+
@action_path ||= "/reactive/actions"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def verifier
|
|
68
|
+
@verifier ||= default_verifier
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def renderer
|
|
72
|
+
@renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def base_controller_name
|
|
76
|
+
@base_controller_name ||= "ActionController::Base"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def base_controller
|
|
80
|
+
base_controller_name.constantize
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the verified payload hash, or nil if the token is invalid.
|
|
84
|
+
def verify(token)
|
|
85
|
+
verifier.verified(token, purpose: IDENTITY_PURPOSE)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Signs a payload hash into an identity token.
|
|
89
|
+
def sign(payload)
|
|
90
|
+
verifier.generate(payload, purpose: IDENTITY_PURPOSE)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def default_verifier
|
|
96
|
+
unless defined?(::Rails) && ::Rails.application
|
|
97
|
+
raise Error, "Phlex::Reactive.verifier is unset and Rails.application is unavailable; " \
|
|
98
|
+
"set Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(secret)"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
::Rails.application.message_verifier(IDENTITY_PURPOSE)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
self.authorization_errors = []
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
loader = Zeitwerk::Loader.new
|
|
110
|
+
loader.tag = "phlex-reactive"
|
|
111
|
+
loader.push_dir(File.expand_path("..", __dir__))
|
|
112
|
+
loader.ignore("#{__dir__}/../phlex-reactive.rb")
|
|
113
|
+
loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
|
|
114
|
+
loader.setup
|
|
115
|
+
|
|
116
|
+
require "phlex/reactive/engine" if defined?(::Rails::Engine)
|