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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 913a7629041138c8daf5a4538e694a557f77aeffd137fe7320ce1654efff6aae
|
|
4
|
+
data.tar.gz: ea6dc93c905242537e00b05d651b98cbea3933a9188e0ae0e10594c87b997504
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 49f1fbd3b2caa40817f91968e4c39658e8fb02a6ab11850ef8a5a5ba7c5f901b4910e7196642b670c61b74060242f550740937b60c71a8fee3af31c5a080f270
|
|
7
|
+
data.tar.gz: e1fcfbbc3f8c2cb3e22b0929830f7191f59d440b3b1b023a14222547c74fef509face1fdfed4d3a19b3ef13df33f7d4584a1545cc67970cce9ad8de637daf6bf
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Phlex::Reactive::Streamable` — class methods (`replace`, `update`, `append`,
|
|
12
|
+
`prepend`, `remove`) and broadcast methods (`broadcast_replace_to`, ...) that
|
|
13
|
+
render a Phlex component as an auto-targeted Turbo Stream by its stable `id`.
|
|
14
|
+
- `Phlex::Reactive::Component` — declare client-invokable `action`s with a param
|
|
15
|
+
schema; `reactive_record` (record-backed, GlobalID identity) and
|
|
16
|
+
`reactive_state` (record-less, signed state); `reactive_attrs` and `on(...)`
|
|
17
|
+
view helpers.
|
|
18
|
+
- `Phlex::Reactive::ActionsController` — one signed-identity endpoint behind all
|
|
19
|
+
reactive components; default-deny actions, schema-coerced params,
|
|
20
|
+
transactional action execution.
|
|
21
|
+
- Generic `reactive` Stimulus controller — per-component request serialization,
|
|
22
|
+
synchronous token threading, auto field collection; no per-feature controllers.
|
|
23
|
+
- Rails engine — mounts the action endpoint, registers and auto-pins the client
|
|
24
|
+
runtime for importmap apps.
|
|
25
|
+
|
|
26
|
+
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/commits/main
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mhenrixon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# phlex-reactive
|
|
2
|
+
|
|
3
|
+
**Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style
|
|
4
|
+
actions and live cross-tab updates, without writing Stimulus controllers or
|
|
5
|
+
hand-picking Turbo Stream targets.**
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Counter < ApplicationComponent
|
|
9
|
+
include Phlex::Reactive::Streamable
|
|
10
|
+
include Phlex::Reactive::Component
|
|
11
|
+
|
|
12
|
+
reactive_state :count
|
|
13
|
+
action :increment
|
|
14
|
+
action :decrement
|
|
15
|
+
|
|
16
|
+
def initialize(count: 0) = @count = count
|
|
17
|
+
def id = "counter"
|
|
18
|
+
|
|
19
|
+
def increment = @count += 1
|
|
20
|
+
def decrement = @count -= 1
|
|
21
|
+
|
|
22
|
+
def view_template
|
|
23
|
+
div(id:, **reactive_attrs) do
|
|
24
|
+
button(**on(:decrement)) { "−" }
|
|
25
|
+
span { @count }
|
|
26
|
+
button(**on(:increment)) { "+" }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That's the whole counter. **No Stimulus controller. No `.turbo_stream.erb`. No
|
|
33
|
+
route. No hand-picked target.** Click `+` and the count updates in place.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Why
|
|
38
|
+
|
|
39
|
+
Stimulus + Turbo are powerful but tedious. A single interactive widget means a
|
|
40
|
+
Stimulus controller, a `data-*` soup, a `.turbo_stream.erb` view, a controller
|
|
41
|
+
action, and a hand-picked `dom_id` target — repeated for every feature. The
|
|
42
|
+
mental model is "wire everything by hand."
|
|
43
|
+
|
|
44
|
+
phlex-reactive borrows the **mental model** that makes Livewire and Phoenix
|
|
45
|
+
LiveView pleasant — *a component has state and actions; change state and the UI
|
|
46
|
+
follows* — and implements it the Rails way:
|
|
47
|
+
|
|
48
|
+
- **Actions are Ruby methods.** Declare `action :increment`; the client calls it.
|
|
49
|
+
- **Re-render is auto-targeted.** A component owns a stable `id`; the response is
|
|
50
|
+
a `<turbo-stream>` that replaces it. You never pick a target.
|
|
51
|
+
- **The same unit re-renders for clicks AND broadcasts.** A click and a
|
|
52
|
+
background broadcast both produce "replace the component by its id," so live
|
|
53
|
+
cross-tab updates are the same mechanism as local interactivity.
|
|
54
|
+
- **State lives in your database, not the browser.** The DOM carries only a
|
|
55
|
+
*signed identity* (a record's GlobalID), not a snapshot of state — so there's
|
|
56
|
+
no mass-assignment surface and no re-signing protocol.
|
|
57
|
+
- **One tiny client runtime.** A single generic Stimulus controller, registered
|
|
58
|
+
once, handles every reactive component. You don't write per-feature JS.
|
|
59
|
+
|
|
60
|
+
Pair it with [**pgbus**](https://github.com/mhenrixon/pgbus) and your live
|
|
61
|
+
updates become *transactional* (no broadcast for a rolled-back change) and
|
|
62
|
+
*reconnect-safe* (missed messages replay) over Postgres SSE — **no Action Cable,
|
|
63
|
+
no Redis.**
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# Gemfile
|
|
71
|
+
gem "phlex-reactive"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bundle install
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That's it for **importmap** apps — the engine mounts the action endpoint at
|
|
79
|
+
`/reactive/actions` and auto-pins (and preloads) the client runtime. Register
|
|
80
|
+
the controller eagerly so a click immediately after load is never missed:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
// app/javascript/controllers/index.js
|
|
84
|
+
import { application } from "controllers/application"
|
|
85
|
+
import ReactiveController from "phlex/reactive/reactive_controller"
|
|
86
|
+
application.register("reactive", ReactiveController)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
<details>
|
|
90
|
+
<summary>esbuild / webpack / bun</summary>
|
|
91
|
+
|
|
92
|
+
Import and register it from your controllers entrypoint:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { application } from "./application"
|
|
96
|
+
import ReactiveController from "phlex-reactive/reactive_controller"
|
|
97
|
+
application.register("reactive", ReactiveController)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the
|
|
101
|
+
gem; point your bundler at the gem path or copy it in. See
|
|
102
|
+
[docs/installation.md](docs/installation.md).
|
|
103
|
+
</details>
|
|
104
|
+
|
|
105
|
+
**Requirements:** Rails 7.1+, Phlex 2 (`phlex-rails`), Turbo 8+ (for morphing),
|
|
106
|
+
and a Phlex `ApplicationComponent` base class. pgbus is optional but recommended
|
|
107
|
+
for broadcasting.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## The mental model in one picture
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
┌── click / input ──────────────────────────────────────────┐
|
|
115
|
+
│ ▼
|
|
116
|
+
[ button(**on(:increment)) ] POST /reactive/actions { token, act, params }
|
|
117
|
+
▲ │
|
|
118
|
+
│ verify signed token (no state trusted)
|
|
119
|
+
│ rebuild component (record from DB)
|
|
120
|
+
│ run the whitelisted action
|
|
121
|
+
│ re-render → <turbo-stream replace id>
|
|
122
|
+
└──────── Turbo morphs it in ◀───────────────────────────────┘
|
|
123
|
+
|
|
124
|
+
...and for OTHER tabs/users:
|
|
125
|
+
model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Client actions and server broadcasts **converge on one re-render unit**: the
|
|
129
|
+
component, targeted by its `id`.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Quickstart: a live, cross-tab counter
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# app/components/counter.rb — see the top of this README for the full class
|
|
137
|
+
render Counter.new(count: 0)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Open the page in two tabs, click `+` — done. To make it update across tabs when
|
|
141
|
+
the underlying record changes, use a record-backed component (below).
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Two kinds of reactive component
|
|
146
|
+
|
|
147
|
+
### 1. Record-backed (the common case)
|
|
148
|
+
|
|
149
|
+
State lives in an ActiveRecord row. The signed identity is the record's
|
|
150
|
+
GlobalID; the server re-finds it on each action. **Always prefer this.**
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class Todos::Item < ApplicationComponent
|
|
154
|
+
include Phlex::Reactive::Streamable
|
|
155
|
+
include Phlex::Reactive::Component
|
|
156
|
+
|
|
157
|
+
reactive_record :todo
|
|
158
|
+
action :toggle
|
|
159
|
+
action :rename, params: { title: :string }
|
|
160
|
+
|
|
161
|
+
def initialize(todo:) = @todo = todo
|
|
162
|
+
def id = dom_id(@todo) # stable per-record DOM id == Turbo target
|
|
163
|
+
|
|
164
|
+
def toggle
|
|
165
|
+
authorize! @todo, :update? # YOU authorize — the token only proves identity
|
|
166
|
+
@todo.toggle!(:done)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rename(title:)
|
|
170
|
+
authorize! @todo, :update?
|
|
171
|
+
@todo.update!(title:)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def view_template
|
|
175
|
+
li(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
|
|
176
|
+
button(**on(:toggle)) { @todo.done? ? "✓" : "○" }
|
|
177
|
+
span { @todo.title }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### 2. State-backed (record-less widgets)
|
|
184
|
+
|
|
185
|
+
No database row — e.g. a counter or a wizard step. The listed instance vars are
|
|
186
|
+
signed into the token. Keep state small and JSON-serializable.
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
reactive_state :count, :step # signed; rebuilt on each action
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Concrete examples
|
|
195
|
+
|
|
196
|
+
| Example | What it shows |
|
|
197
|
+
|---|---|
|
|
198
|
+
| [Counter](docs/examples/counter.md) | State-backed, the smallest reactive component |
|
|
199
|
+
| [Cross-tab chat](docs/examples/chat.md) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
|
|
200
|
+
| [Live todo list](docs/examples/todo_list.md) | Per-row components, add/toggle/rename/delete, broadcast on change |
|
|
201
|
+
| [Inline edit](docs/examples/inline_edit.md) | Show ↔ edit mode toggle, replacing a Stimulus controller + 3 routes |
|
|
202
|
+
| [Notifications / badges](docs/examples/notifications.md) | Pure broadcast (no client action) — a job pushes a re-render |
|
|
203
|
+
|
|
204
|
+
The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
|
|
205
|
+
[docs/examples/chat.md](docs/examples/chat.md).
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## API reference
|
|
210
|
+
|
|
211
|
+
### `Phlex::Reactive::Streamable`
|
|
212
|
+
|
|
213
|
+
| Method | Use |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
|
|
216
|
+
| `.replace(model = nil, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component |
|
|
217
|
+
| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
|
|
218
|
+
| `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
|
|
219
|
+
| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
|
|
220
|
+
| `#to_stream_replace` / `#to_stream_update` | Stream the *already-built* instance (used internally after an action) |
|
|
221
|
+
|
|
222
|
+
Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
223
|
+
|
|
224
|
+
### `Phlex::Reactive::Component`
|
|
225
|
+
|
|
226
|
+
| Macro / helper | Use |
|
|
227
|
+
|---|---|
|
|
228
|
+
| `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. |
|
|
229
|
+
| `reactive_state :a, :b` | State-backed identity (signed instance vars). Record-less only. |
|
|
230
|
+
| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
|
|
231
|
+
| `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
|
|
232
|
+
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
233
|
+
|
|
234
|
+
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
235
|
+
in the schema is dropped before reaching your method.
|
|
236
|
+
|
|
237
|
+
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
Phlex::Reactive.configure do |c| end if false # (plain accessors below)
|
|
241
|
+
|
|
242
|
+
# Inherit auth/CSRF/Current from your app on the action endpoint:
|
|
243
|
+
Phlex::Reactive.base_controller_name = "ApplicationController"
|
|
244
|
+
|
|
245
|
+
# Render your authorization library's error as 403:
|
|
246
|
+
Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
|
|
247
|
+
# or: [ActionPolicy::Unauthorized]
|
|
248
|
+
|
|
249
|
+
# Use your ApplicationController to render components (app helpers / Current):
|
|
250
|
+
Phlex::Reactive.renderer = ApplicationController
|
|
251
|
+
|
|
252
|
+
# Sign tokens with a dedicated key instead of secret_key_base:
|
|
253
|
+
Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
|
|
254
|
+
|
|
255
|
+
# Change the endpoint path (default "/reactive/actions"):
|
|
256
|
+
Phlex::Reactive.action_path = "/_r/actions"
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
If you set a custom `action_path`, expose it to the client:
|
|
260
|
+
|
|
261
|
+
```erb
|
|
262
|
+
<meta name="phlex-reactive-action-path" content="<%= Phlex::Reactive.action_path %>">
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Security
|
|
268
|
+
|
|
269
|
+
phlex-reactive is built so the easy path is the safe path — but the boundary is
|
|
270
|
+
real, so read this once.
|
|
271
|
+
|
|
272
|
+
- **State is never trusted from the client.** The DOM holds a `MessageVerifier`-
|
|
273
|
+
signed identity (`{component, gid}` or `{component, state}`), not raw state. A
|
|
274
|
+
tampered class, record, or state value fails signature verification → 400.
|
|
275
|
+
- **Actions are default-deny.** Only methods declared with `action :name` are
|
|
276
|
+
invokable. A public method without `action` is unreachable.
|
|
277
|
+
- **You must authorize.** The signature proves the *token is yours*, not that
|
|
278
|
+
*this user may act on this record*. Call your authorizer inside the action
|
|
279
|
+
(`authorize! @todo, :update?`) and register its error in
|
|
280
|
+
`Phlex::Reactive.authorization_errors`.
|
|
281
|
+
- **Params are schema-coerced.** Only declared params reach your method, each
|
|
282
|
+
cast to its declared type. No raw mass assignment.
|
|
283
|
+
- **CSRF + auth are the host app's.** The endpoint inherits from your configured
|
|
284
|
+
`base_controller_name`. Inherit `ApplicationController` to get CSRF and auth —
|
|
285
|
+
but if you have *public* reactive components, ensure the action path isn't
|
|
286
|
+
force-redirected to a login page for logged-out users.
|
|
287
|
+
|
|
288
|
+
See [docs/security.md](docs/security.md) for the threat model and a checklist.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## How it beats Stimulus + Turbo (same feature, less code)
|
|
293
|
+
|
|
294
|
+
A counter, today vs. with phlex-reactive:
|
|
295
|
+
|
|
296
|
+
<table>
|
|
297
|
+
<tr><th>Stimulus + Turbo</th><th>phlex-reactive</th></tr>
|
|
298
|
+
<tr><td>
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
// counter_controller.js
|
|
302
|
+
import { Controller } from "@hotwired/stimulus"
|
|
303
|
+
export default class extends Controller {
|
|
304
|
+
static values = { url: String }
|
|
305
|
+
increment() { this.#post("increment") }
|
|
306
|
+
decrement() { this.#post("decrement") }
|
|
307
|
+
#post(op) {
|
|
308
|
+
fetch(`${this.urlValue}/${op}`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "X-CSRF-Token": token() },
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
```erb
|
|
316
|
+
<%# _counter.html.erb %>
|
|
317
|
+
<div id="<%= dom_id(@counter) %>"
|
|
318
|
+
data-controller="counter"
|
|
319
|
+
data-counter-url-value="<%= counter_path(@counter) %>">
|
|
320
|
+
<button data-action="counter#decrement">−</button>
|
|
321
|
+
<span><%= @counter.value %></span>
|
|
322
|
+
<button data-action="counter#increment">+</button>
|
|
323
|
+
</div>
|
|
324
|
+
```
|
|
325
|
+
```ruby
|
|
326
|
+
# routes + controller
|
|
327
|
+
resources :counters do
|
|
328
|
+
member { post :increment; post :decrement }
|
|
329
|
+
end
|
|
330
|
+
def increment
|
|
331
|
+
@counter.increment!(:value)
|
|
332
|
+
render turbo_stream: turbo_stream.replace(
|
|
333
|
+
dom_id(@counter), partial: "counter",
|
|
334
|
+
locals: { counter: @counter })
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
</td><td>
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
class Counter < ApplicationComponent
|
|
342
|
+
include Phlex::Reactive::Streamable
|
|
343
|
+
include Phlex::Reactive::Component
|
|
344
|
+
|
|
345
|
+
reactive_record :counter
|
|
346
|
+
action :increment
|
|
347
|
+
action :decrement
|
|
348
|
+
|
|
349
|
+
def initialize(counter:) = @counter = counter
|
|
350
|
+
def id = dom_id(@counter)
|
|
351
|
+
|
|
352
|
+
def increment = @counter.increment!(:value)
|
|
353
|
+
def decrement = @counter.decrement!(:value)
|
|
354
|
+
|
|
355
|
+
def view_template
|
|
356
|
+
div(id:, **reactive_attrs) do
|
|
357
|
+
button(**on(:decrement)) { "−" }
|
|
358
|
+
span { @counter.value }
|
|
359
|
+
button(**on(:increment)) { "+" }
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
*One file. No JS. No routes. No partial. No hand-picked target.*
|
|
366
|
+
|
|
367
|
+
</td></tr>
|
|
368
|
+
</table>
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Live updates with pgbus (recommended)
|
|
373
|
+
|
|
374
|
+
[pgbus](https://github.com/mhenrixon/pgbus) replaces Action Cable's transport
|
|
375
|
+
with Postgres SSE and fixes its reliability gaps. With it installed,
|
|
376
|
+
`broadcast_*_to` and `turbo_stream_from` route over pgbus automatically:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
class Message < ApplicationRecord
|
|
380
|
+
broadcasts_to ->(m) { [m.room, :messages] }, durable: true
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- **Transactional**: a broadcast inside a transaction that rolls back never
|
|
385
|
+
fires — *and* the DB change is undone. No "ghost" UI updates.
|
|
386
|
+
- **Reconnect-safe**: a tab that dropped replays missed messages on reconnect
|
|
387
|
+
(`Last-Event-ID` + PGMQ archive).
|
|
388
|
+
- **No race on subscribe**: messages broadcast between render and subscribe are
|
|
389
|
+
replayed, not lost.
|
|
390
|
+
- **No Redis, no Action Cable.**
|
|
391
|
+
|
|
392
|
+
See [docs/broadcasting.md](docs/broadcasting.md) and
|
|
393
|
+
[docs/transport-pgbus.md](docs/transport-pgbus.md).
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## Documentation
|
|
398
|
+
|
|
399
|
+
- [Installation & bundler setups](docs/installation.md)
|
|
400
|
+
- [Mental model & architecture](docs/architecture.md)
|
|
401
|
+
- [Security & threat model](docs/security.md)
|
|
402
|
+
- [Broadcasting & live updates](docs/broadcasting.md)
|
|
403
|
+
- [Transport: pgbus vs Action Cable](docs/transport-pgbus.md)
|
|
404
|
+
- [Testing reactive components](docs/testing.md)
|
|
405
|
+
- Examples: [counter](docs/examples/counter.md) ·
|
|
406
|
+
[chat](docs/examples/chat.md) · [todo list](docs/examples/todo_list.md) ·
|
|
407
|
+
[inline edit](docs/examples/inline_edit.md) ·
|
|
408
|
+
[notifications](docs/examples/notifications.md)
|
|
409
|
+
|
|
410
|
+
## Credits & prior art
|
|
411
|
+
|
|
412
|
+
The mental model is stolen, gratefully, from
|
|
413
|
+
[Laravel Livewire](https://livewire.laravel.com) (public method = action) and
|
|
414
|
+
[Phoenix LiveView](https://www.phoenixframework.org) (a component is a re-render
|
|
415
|
+
unit). The transport and reliability come from
|
|
416
|
+
[pgbus](https://github.com/mhenrixon/pgbus). The rendering is all
|
|
417
|
+
[Phlex](https://www.phlex.fun).
|
|
418
|
+
|
|
419
|
+
## License
|
|
420
|
+
|
|
421
|
+
[MIT](LICENSE.txt).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The single endpoint behind every reactive component. The generic
|
|
6
|
+
# `reactive` Stimulus controller POSTs here with a signed identity token,
|
|
7
|
+
# an action name, and params. We verify the token, rebuild the component
|
|
8
|
+
# (re-finding the record from the DB for record-backed components), run the
|
|
9
|
+
# whitelisted action, and return an auto-targeted Turbo Stream the client
|
|
10
|
+
# morphs in.
|
|
11
|
+
#
|
|
12
|
+
# Customizing in your app:
|
|
13
|
+
# * Authentication — by default this inherits from
|
|
14
|
+
# Phlex::Reactive.base_controller (ActionController::Base). Set it to
|
|
15
|
+
# your ApplicationController to get current_user/Current/CSRF, but make
|
|
16
|
+
# sure the action path isn't force-redirected for logged-out users if
|
|
17
|
+
# you have public reactive components.
|
|
18
|
+
# * Authorization — DO IT IN THE COMPONENT ACTION. The token proves the
|
|
19
|
+
# identity is ours, not that this user may act. Raise from the action
|
|
20
|
+
# (e.g. authorize!), and configure Phlex::Reactive.authorization_errors
|
|
21
|
+
# so it's rendered as 403 here.
|
|
22
|
+
class ActionsController < Phlex::Reactive.base_controller
|
|
23
|
+
# Our JSON body uses keys that collide with Rails' reserved routing
|
|
24
|
+
# params (action/controller) and would be wrapped by wrap_parameters.
|
|
25
|
+
# Disable wrapping so the body lands flat; the action name travels as
|
|
26
|
+
# `act` (not `action`, which is reserved and resolves to "create").
|
|
27
|
+
wrap_parameters false if respond_to?(:wrap_parameters)
|
|
28
|
+
|
|
29
|
+
def create
|
|
30
|
+
payload = verified_payload
|
|
31
|
+
component_class = resolve_component(payload["c"])
|
|
32
|
+
action_def = component_class.reactive_action(reactive_action_name)
|
|
33
|
+
|
|
34
|
+
return head(:forbidden) unless action_def # default-deny
|
|
35
|
+
|
|
36
|
+
component = component_class.from_identity(payload)
|
|
37
|
+
coerced = coerce_params(action_def.params)
|
|
38
|
+
|
|
39
|
+
run_action(component, action_def, coerced)
|
|
40
|
+
|
|
41
|
+
render turbo_stream: component.to_stream_replace
|
|
42
|
+
rescue Phlex::Reactive::InvalidToken
|
|
43
|
+
head :bad_request
|
|
44
|
+
rescue ActiveRecord::RecordNotFound
|
|
45
|
+
head :not_found
|
|
46
|
+
rescue *authorization_errors
|
|
47
|
+
head :forbidden
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Run the action inside a transaction so transactional broadcasts (pgbus
|
|
53
|
+
# broadcasts_to ... durable:) defer to after_commit and never fire for a
|
|
54
|
+
# rolled-back change. Override to add per-request instrumentation.
|
|
55
|
+
def run_action(component, action_def, coerced)
|
|
56
|
+
transaction_wrapper do
|
|
57
|
+
if coerced.any?
|
|
58
|
+
component.public_send(action_def.name, **coerced)
|
|
59
|
+
else
|
|
60
|
+
component.public_send(action_def.name)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def transaction_wrapper(&block)
|
|
66
|
+
if defined?(::ActiveRecord::Base)
|
|
67
|
+
::ActiveRecord::Base.transaction(&block)
|
|
68
|
+
else
|
|
69
|
+
yield
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def verified_payload
|
|
74
|
+
token = params.require(:token)
|
|
75
|
+
Phlex::Reactive.verify(token) || raise(Phlex::Reactive::InvalidToken)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# NB: must NOT be named `action_name` — that's reserved by
|
|
79
|
+
# ActionController dispatch and overriding it recurses fatally.
|
|
80
|
+
def reactive_action_name
|
|
81
|
+
params.require(:act).to_sym
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Coerce client params against the action's declared schema. Anything not
|
|
85
|
+
# in the schema is dropped — no raw mass assignment reaches the component.
|
|
86
|
+
def coerce_params(schema)
|
|
87
|
+
return {} if schema.blank?
|
|
88
|
+
|
|
89
|
+
raw = params.fetch(:params, {})
|
|
90
|
+
schema.each_with_object({}) do |(key, type), out|
|
|
91
|
+
next unless raw.key?(key.to_s)
|
|
92
|
+
|
|
93
|
+
out[key.to_sym] = coerce(raw[key.to_s], type)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def coerce(value, type)
|
|
98
|
+
case type
|
|
99
|
+
when :integer then value.to_i
|
|
100
|
+
when :float then value.to_f
|
|
101
|
+
when :boolean then ActiveModel::Type::Boolean.new.cast(value)
|
|
102
|
+
else value.to_s
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Only components that opt into Reactive may be resolved. The signature
|
|
107
|
+
# already gates this; defense in depth against constant injection.
|
|
108
|
+
def resolve_component(name)
|
|
109
|
+
klass = name.to_s.safe_constantize
|
|
110
|
+
unless klass && klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
|
|
111
|
+
raise Phlex::Reactive::InvalidToken
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
klass
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def authorization_errors
|
|
118
|
+
Phlex::Reactive.authorization_errors
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|