hibiki_rails 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/README.md +252 -0
- data/app/assets/javascripts/hibiki.js +231 -0
- data/config/importmap.rb +7 -0
- data/lib/generators/hibiki/rails/generator_helpers.rb +62 -0
- data/lib/generators/hibiki/rails/install/install_generator.rb +112 -0
- data/lib/generators/hibiki/rails/install/templates/channel.rb.tt +8 -0
- data/lib/generators/hibiki/rails/install/templates/connection.rb.tt +8 -0
- data/lib/generators/hibiki/rails/install/templates/hibiki_controller.js.tt +7 -0
- data/lib/generators/hibiki/rails/island/island_generator.rb +63 -0
- data/lib/generators/hibiki/rails/island/templates/channel.rb.tt +23 -0
- data/lib/generators/hibiki/rails/island/templates/display.html.erb.tt +5 -0
- data/lib/generators/hibiki/rails/island/templates/island.html.erb.tt +14 -0
- data/lib/generators/hibiki/rails/phlex/phlex_generator.rb +73 -0
- data/lib/generators/hibiki/rails/phlex/templates/channel.rb.tt +19 -0
- data/lib/generators/hibiki/rails/phlex/templates/component.rb.tt +29 -0
- data/lib/generators/hibiki/rails/phlex/templates/island_component.rb.tt +17 -0
- data/lib/generators/hibiki/rails/stimulus/stimulus_generator.rb +78 -0
- data/lib/generators/hibiki/rails/stimulus/templates/channel.rb.tt +23 -0
- data/lib/generators/hibiki/rails/stimulus/templates/controller.js.tt +16 -0
- data/lib/generators/hibiki/rails/stimulus/templates/display.html.erb.tt +5 -0
- data/lib/generators/hibiki/rails/stimulus/templates/island.html.erb.tt +11 -0
- data/lib/hibiki/rails/broadcasts.rb +52 -0
- data/lib/hibiki/rails/channel.rb +113 -0
- data/lib/hibiki/rails/debounce.rb +41 -0
- data/lib/hibiki/rails/engine.rb +34 -0
- data/lib/hibiki/rails/graph_actor.rb +60 -0
- data/lib/hibiki/rails/helpers.rb +50 -0
- data/lib/hibiki/rails/registry.rb +50 -0
- data/lib/hibiki/rails/version.rb +7 -0
- data/lib/hibiki/rails.rb +28 -0
- data/lib/hibiki_rails.rb +4 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 73beec766683bf4a92c8bebcb19b62e58686f50f68e3b16f0a958c2d0d155de1
|
|
4
|
+
data.tar.gz: 8ce7469020b298e4e94b7fa7daef8bafb5443a891fc90163339750f400aa9228
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3f5fc7f1fe7a4f72047d86e054612e9144e5815cc1a96d2fa87737573f9701ce2b2c6d713992822ee6cd90cae7b6cae5b4647bb6001b3f863dee2f38354ec131
|
|
7
|
+
data.tar.gz: 67f0275c2e22bd291fc8940f84a99775c9e1acac5e3542ffc68edd9c896e26f9e786270904bfa75029e78c872984560fdeca6499532f4128c4f65a1c420ab7b2
|
data/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# hibiki_rails
|
|
2
|
+
|
|
3
|
+
Rails glue for [hibiki](https://github.com/planetaska/hibiki):
|
|
4
|
+
connection-scoped signal graphs over ActionCable, pushing re-rendered HTML
|
|
5
|
+
to the page — either through Turbo Streams, or over the channel's own
|
|
6
|
+
subscription to the gem's packaged client (see "The packaged client").
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
cable action arrives → mutate signals → effects render partials →
|
|
10
|
+
Turbo Streams broadcast → Turbo morphs the DOM
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A graph lives per cable connection (in practice: per browser tab), built
|
|
14
|
+
when the channel subscribes and disposed when it unsubscribes. Effects
|
|
15
|
+
subscribe to whatever signals they read; when an action writes a signal,
|
|
16
|
+
exactly the affected effects re-render and broadcast.
|
|
17
|
+
|
|
18
|
+
Incubating inside the hibiki repo while the core gem is pre-release; will
|
|
19
|
+
be extracted to its own repository once hibiki 0.1.0 ships and this API
|
|
20
|
+
stabilizes. Rails >= 8.0 (what's tested), Ruby >= 3.4.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class CounterChannel < ApplicationCable::Channel
|
|
26
|
+
include Hibiki::Rails::Channel
|
|
27
|
+
|
|
28
|
+
# Runs once on the graph's own thread, inside Hibiki.root.
|
|
29
|
+
def build_graph
|
|
30
|
+
@count = Hibiki::State.new(0)
|
|
31
|
+
@step = Hibiki::State.new(1)
|
|
32
|
+
doubled = Hibiki::Derived.new { @count.value * 2 }
|
|
33
|
+
|
|
34
|
+
Hibiki::Effect.new do
|
|
35
|
+
broadcast_replace target: "count", partial: "counter/count",
|
|
36
|
+
locals: { count: @count.value, doubled: doubled.value }
|
|
37
|
+
end
|
|
38
|
+
Hibiki::Effect.new do
|
|
39
|
+
broadcast_replace target: "step", partial: "counter/step",
|
|
40
|
+
locals: { step: @step.value }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Actions are plain methods that touch signals directly: each one runs
|
|
45
|
+
# on the graph thread inside one Hibiki.batch, so N writes still mean
|
|
46
|
+
# one re-run per affected effect.
|
|
47
|
+
def increment = @count.value += @step.value
|
|
48
|
+
def burst = 10.times { @count.value += 1 }
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The page supplies a per-page-load graph id (`cid`) and listens on the
|
|
53
|
+
matching stream:
|
|
54
|
+
|
|
55
|
+
```erb
|
|
56
|
+
<div data-controller="counter" data-counter-cid-value="<%= @cid %>">
|
|
57
|
+
<%= turbo_stream_from "counter", @cid %>
|
|
58
|
+
<%= render "count", count: 0, doubled: 0 %> <%# placeholder, see below %>
|
|
59
|
+
...
|
|
60
|
+
</div>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
with `@cid = SecureRandom.uuid` in the controller action. The channel
|
|
64
|
+
broadcasts to `[channel_name, cid]` — override `stream_name` (and/or
|
|
65
|
+
`cid`) to derive identity differently.
|
|
66
|
+
|
|
67
|
+
## What the concern does
|
|
68
|
+
|
|
69
|
+
- `subscribed` — rejects without a `cid` param, then runs `build_graph`
|
|
70
|
+
inside `Hibiki.root` on a dedicated worker thread (a `GraphActor`).
|
|
71
|
+
ActionCable dispatches on a thread pool with no per-channel ordering;
|
|
72
|
+
hibiki's threading model is confinement — so cable threads only enqueue,
|
|
73
|
+
and the graph lives on exactly one thread.
|
|
74
|
+
- every action — the whole body is posted to that thread wrapped in one
|
|
75
|
+
`Hibiki.batch`. `rescue_from` still applies (it runs on the graph
|
|
76
|
+
thread); what it doesn't handle goes to `Rails.error` (source
|
|
77
|
+
`"hibiki_rails"`).
|
|
78
|
+
- `unsubscribed` — disposes the root (running `on_cleanup` hooks) and
|
|
79
|
+
stops the worker, draining what was already queued.
|
|
80
|
+
- dev reloading — an Engine hook disposes every live graph before code
|
|
81
|
+
reloads (stale effects would run old class versions forever); cable
|
|
82
|
+
clients auto-reconnect and rebuild. Graph state resets on reload, like
|
|
83
|
+
any remount.
|
|
84
|
+
|
|
85
|
+
## Broadcast helpers
|
|
86
|
+
|
|
87
|
+
Available inside effects (all bound to `stream_name`):
|
|
88
|
+
|
|
89
|
+
- `broadcast_replace(target:, **rendering)` — `partial:`/`locals:`,
|
|
90
|
+
`html:`, or anything Turbo's renderer accepts.
|
|
91
|
+
- `broadcast_morph(target:, **rendering)` — replace via Turbo 8 morphing
|
|
92
|
+
(keeps focus/scroll).
|
|
93
|
+
- `broadcast_refresh` — tell the page to refresh itself.
|
|
94
|
+
- `broadcast_refresh_effect(wait: 0.25) { ...read signals... }` — the
|
|
95
|
+
morph-everything style: tracks whatever the block reads and answers
|
|
96
|
+
changes with a debounced refresh, one per burst of actions rather than
|
|
97
|
+
one per action.
|
|
98
|
+
|
|
99
|
+
## The packaged client
|
|
100
|
+
|
|
101
|
+
The gem vendors its own JavaScript, turbo-rails-style: the engine puts
|
|
102
|
+
`hibiki.js` on the app's asset path and merges the `"hibiki-rails"` pin into the
|
|
103
|
+
import map, so importmap-rails apps have no install step beyond
|
|
104
|
+
registering the controller — `bin/rails g hibiki:rails:install` does it
|
|
105
|
+
(plus the `Helpers` include below, the `ApplicationCable` boilerplate,
|
|
106
|
+
and the `@rails/actioncable` pin — a stock app has neither until its
|
|
107
|
+
first `rails g channel`), or create the one-line shim yourself:
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
// app/javascript/controllers/hibiki_controller.js
|
|
111
|
+
export { default } from "hibiki-rails" // registers as "hibiki" — the helpers hardcode that identifier
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The registration is a file-backed shim on purpose: importmap apps
|
|
115
|
+
eager-load it from the controllers directory, jsbundling apps get the
|
|
116
|
+
matching import/register pair in `controllers/index.js` (the install
|
|
117
|
+
generator appends it), and because it is derived from a real controller
|
|
118
|
+
file, `bin/rails stimulus:manifest:update` regenerates it instead of
|
|
119
|
+
dropping it.
|
|
120
|
+
|
|
121
|
+
(jsbundling/vite apps: `npm install hibiki-rails` — the [npm
|
|
122
|
+
package](https://www.npmjs.com/package/hibiki-rails) is the same module the
|
|
123
|
+
engine vendors, and pulls in `@rails/actioncable`; release in lockstep with
|
|
124
|
+
the gem.)
|
|
125
|
+
|
|
126
|
+
The client is one generic Stimulus controller that drives any *island*: a
|
|
127
|
+
DOM subtree bound to one channel subscription. Islands are stamped with
|
|
128
|
+
the opt-in `Hibiki::Rails::Helpers` — include it where you want the bare
|
|
129
|
+
names (`ApplicationHelper` for ERB, individual Phlex components); the gem
|
|
130
|
+
never includes it for you:
|
|
131
|
+
|
|
132
|
+
```erb
|
|
133
|
+
<%= tag.div(**hibiki_island(TodosChannel, cid: @cid)) do %>
|
|
134
|
+
<%= render TodoList.new %> <%# placeholder; replaced by DOM id %>
|
|
135
|
+
<%= tag.form(**on(:add, event: :submit)) do %>
|
|
136
|
+
<input type="text" name="title">
|
|
137
|
+
<button>add</button>
|
|
138
|
+
<% end %>
|
|
139
|
+
<% end %>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `hibiki_island(channel, cid:)` — the island root: one subscription,
|
|
143
|
+
identified by the page's `cid`.
|
|
144
|
+
- `on(action, event:, with:)` — forward a DOM event (`:click` default,
|
|
145
|
+
`:change`, `:submit`) as a channel action, with `with:` as its payload.
|
|
146
|
+
A changed control also sends `{ name => value }`; a submitted form sends
|
|
147
|
+
its FormData and is reset after performing.
|
|
148
|
+
|
|
149
|
+
Transport is the channel's own subscription in both directions: render
|
|
150
|
+
effects call `transmit({ html: })` and the client swaps each fragment in
|
|
151
|
+
by its root DOM id (`Hibiki::Phlex.render_effect` pairs naturally):
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
def build_graph
|
|
155
|
+
@list = TodoList.new
|
|
156
|
+
Hibiki::Phlex.render_effect(@list) { |html| transmit({ html: }) }
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Because the client registers its `received` callback at subscribe time —
|
|
161
|
+
before the server ever runs `build_graph` — the effects' first transmits
|
|
162
|
+
always land: no Turbo stream, no connected-wait, and the server-rendered
|
|
163
|
+
initial HTML is only a paint-avoidance placeholder. One rule carries over
|
|
164
|
+
from any replace-fragment design: never transmit a fragment containing
|
|
165
|
+
the input the user is currently typing in.
|
|
166
|
+
|
|
167
|
+
The `data-hibiki-*` attributes the helpers emit are a private contract
|
|
168
|
+
with the vendored JS — they version together; don't hand-write them in
|
|
169
|
+
app code. The protocol itself is Stimulus-free (Stimulus only hosts the
|
|
170
|
+
controller lifecycle), so a hand-rolled client can drive the same
|
|
171
|
+
attributes: `toy-phlex/` in the parent repo does it in ~40 lines. The
|
|
172
|
+
helper interface's shape is inspired by
|
|
173
|
+
[phlex-reactive](https://phlex-reactive.zoolutions.llc)'s `on(...)`
|
|
174
|
+
actions.
|
|
175
|
+
|
|
176
|
+
## Generators
|
|
177
|
+
|
|
178
|
+
Each supported shape has a generator that scaffolds it as a *working*
|
|
179
|
+
mini-example — one state, one derived, one action, one effect; run it,
|
|
180
|
+
render the output from any page, click `+1`, watch it live-update — meant
|
|
181
|
+
to be reshaped in place, not filled in from scratch:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
bin/rails g hibiki:rails:install # one-time wiring: register line,
|
|
185
|
+
# Helpers include, ApplicationCable
|
|
186
|
+
# boilerplate + actioncable pin
|
|
187
|
+
# (idempotent)
|
|
188
|
+
bin/rails g hibiki:rails:stimulus NAME [VIEW_PATH] # channel + ChannelController
|
|
189
|
+
# subclass + view partial
|
|
190
|
+
bin/rails g hibiki:rails:island NAME [VIEW_PATH] # channel + helpers-stamped view
|
|
191
|
+
# partial, no per-component JS
|
|
192
|
+
bin/rails g hibiki:rails:phlex NAME # channel + Phlex component +
|
|
193
|
+
# island wrapper (needs hibiki_phlex)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`VIEW_PATH` is the views directory under `app/views` (defaults to `NAME`);
|
|
197
|
+
the emitted partial is self-contained (`cid` defaults to a per-render
|
|
198
|
+
uuid), so `<%= render "counter/counter" %>` — or `<%= render
|
|
199
|
+
CounterIsland.new %>` for the Phlex shape — is the only line a page needs.
|
|
200
|
+
The `stimulus` shape works with zero wiring; `island` and `phlex` need the
|
|
201
|
+
one-time `hibiki:rails:install` (they print a hint when it's missing).
|
|
202
|
+
Namespaced names work (`admin/counter` pins `static channel` where the
|
|
203
|
+
Stimulus identifier can't infer it). In apps without an importmap
|
|
204
|
+
(jsbundling/vite), where `controllers/index.js` has no eager loader, the
|
|
205
|
+
`stimulus` generator also appends the controller's import/register pair
|
|
206
|
+
to it — the same lines `stimulus:manifest:update` would emit.
|
|
207
|
+
|
|
208
|
+
## The initial-state pattern (Turbo transport)
|
|
209
|
+
|
|
210
|
+
Islands on the Turbo-broadcast transport instead (the "Usage" example
|
|
211
|
+
above) have an ordering problem the transmit transport doesn't: the
|
|
212
|
+
graph's effects do their first run inside `subscribed` — usually before
|
|
213
|
+
the page's `turbo_stream_from` subscription has confirmed — so the first
|
|
214
|
+
broadcast would be lost. Fix the ordering on the client with the packaged
|
|
215
|
+
`streamConnected` helper: wait for Turbo to stamp the `connected`
|
|
216
|
+
attribute on the stream source, then subscribe the graph channel.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
// in the Stimulus controller driving the channel
|
|
220
|
+
import { streamConnected } from "hibiki-rails"
|
|
221
|
+
|
|
222
|
+
async connect() {
|
|
223
|
+
this.consumer = createConsumer()
|
|
224
|
+
await streamConnected(this.element.querySelector("turbo-cable-stream-source"))
|
|
225
|
+
this.subscription = this.consumer.subscriptions.create(
|
|
226
|
+
{ channel: "CounterChannel", cid: this.cidValue }, {}
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
With that in place the server-rendered initial HTML is only a
|
|
232
|
+
paint-avoidance placeholder — the first broadcast always lands and
|
|
233
|
+
replaces it, so it doesn't have to match the graph's initial state.
|
|
234
|
+
|
|
235
|
+
## Error handling layers
|
|
236
|
+
|
|
237
|
+
1. `rescue_from` on the channel — handles action errors, on the graph
|
|
238
|
+
thread.
|
|
239
|
+
2. `Hibiki.error_handler = ->(error, effect) { ... }` — app-level routing
|
|
240
|
+
for effect errors raised during a flush (the gem does not set this).
|
|
241
|
+
3. The graph worker's per-job rescue — everything unhandled lands in
|
|
242
|
+
`Rails.error.report(..., source: "hibiki_rails")`. Override per channel
|
|
243
|
+
via `build_graph_actor` and `GraphActor.new(on_error:)`.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
bundle exec rake # specs + rubocop (same as CI)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The spec suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`);
|
|
252
|
+
the live end-to-end proof app is `spike/` in the parent repo.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The packaged client for hibiki_rails. Two shapes, one plumbing:
|
|
2
|
+
//
|
|
3
|
+
// 1. The generic controller (HibikiController, register as "hibiki"):
|
|
4
|
+
// drives any island stamped by the Hibiki::Rails::Helpers Ruby
|
|
5
|
+
// helpers. Stimulus is the lifecycle host only (connect/disconnect
|
|
6
|
+
// across Turbo navigation, morphs, and dynamic insertion); the wire
|
|
7
|
+
// protocol is hibiki-owned data attributes, so server-side components
|
|
8
|
+
// never write Stimulus vocabulary and a non-Stimulus client can speak
|
|
9
|
+
// the same attributes (toy-phlex's vanilla driver is the proof).
|
|
10
|
+
// 2. The subclassable base (ChannelController): for apps that prefer the
|
|
11
|
+
// familiar Stimulus structure (data-controller="counter",
|
|
12
|
+
// data-action="counter#increment"). The base owns the consumer, the
|
|
13
|
+
// subscription lifecycle, and transport handling; plain actions are
|
|
14
|
+
// auto-forwarded to the channel, so a subclass only declares a method
|
|
15
|
+
// when it needs something custom:
|
|
16
|
+
//
|
|
17
|
+
// import { ChannelController } from "hibiki-rails"
|
|
18
|
+
//
|
|
19
|
+
// // identifier "counter" infers CounterChannel (override with
|
|
20
|
+
// // `static channel = "..."` when the names don't line up)
|
|
21
|
+
// export default class extends ChannelController {
|
|
22
|
+
// setStep(event) {
|
|
23
|
+
// this.perform("set_step", { step: event.target.value })
|
|
24
|
+
// }
|
|
25
|
+
// }
|
|
26
|
+
//
|
|
27
|
+
// Both shapes speak both transports. Transmit: the server's render
|
|
28
|
+
// effects `transmit({ html: })` fragments that are swapped in by their
|
|
29
|
+
// root DOM id; `received` is registered at subscribe time — before the
|
|
30
|
+
// server runs build_graph — so the effects' first transmits always land
|
|
31
|
+
// (the server-rendered initial HTML is only a paint-avoidance
|
|
32
|
+
// placeholder). Turbo broadcasts: when the controller's element contains
|
|
33
|
+
// its own <turbo-cable-stream-source> (turbo_stream_from), the graph's
|
|
34
|
+
// first broadcast is lost unless that stream has already confirmed ITS
|
|
35
|
+
// subscription — so connect awaits it (streamConnected) before
|
|
36
|
+
// subscribing, and rendering flows back over the Turbo stream while
|
|
37
|
+
// `received` stays idle.
|
|
38
|
+
//
|
|
39
|
+
// The attribute contract of the generic controller (private to this gem —
|
|
40
|
+
// emitted by the Ruby helpers, interpreted here, versioned together):
|
|
41
|
+
//
|
|
42
|
+
// island root data-controller="hibiki"
|
|
43
|
+
// data-hibiki-channel-value="CounterChannel"
|
|
44
|
+
// data-hibiki-cid-value="<per-page-load id>"
|
|
45
|
+
// controls data-hibiki-on="<event>-><action>" e.g. "click->increment"
|
|
46
|
+
// data-hibiki-with='{"index":3}' optional JSON payload
|
|
47
|
+
//
|
|
48
|
+
// Register the generic controller under the identifier "hibiki" (the
|
|
49
|
+
// helpers hardcode it):
|
|
50
|
+
//
|
|
51
|
+
// import HibikiController from "hibiki-rails"
|
|
52
|
+
// application.register("hibiki", HibikiController)
|
|
53
|
+
import { Controller } from "@hotwired/stimulus"
|
|
54
|
+
import { createConsumer } from "@rails/actioncable"
|
|
55
|
+
|
|
56
|
+
// One consumer shared by every controller — ActionCable multiplexes
|
|
57
|
+
// subscriptions over a single websocket. Never disconnected: islands come
|
|
58
|
+
// and go with the DOM, the socket stays.
|
|
59
|
+
let consumer
|
|
60
|
+
|
|
61
|
+
// camelCase Stimulus method name → snake_case Ruby channel action.
|
|
62
|
+
const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
|
|
63
|
+
|
|
64
|
+
// The subclassable base: one channel subscription per controller element,
|
|
65
|
+
// identified by a per-page-load cid (data-<identifier>-cid-value).
|
|
66
|
+
export class ChannelController extends Controller {
|
|
67
|
+
static values = { cid: String }
|
|
68
|
+
|
|
69
|
+
async connect() {
|
|
70
|
+
this.aborted = false
|
|
71
|
+
consumer ??= createConsumer()
|
|
72
|
+
this.defineForwarders()
|
|
73
|
+
// Turbo-broadcast transport: wait for the element's own stream source
|
|
74
|
+
// to confirm before subscribing, so the graph's dependency-collecting
|
|
75
|
+
// first run broadcasts into a live stream. No source (transmit
|
|
76
|
+
// transport) → this path is fully synchronous, exactly as before.
|
|
77
|
+
const source = this.streamSource()
|
|
78
|
+
if (source) await streamConnected(source)
|
|
79
|
+
if (this.aborted) return // disconnected during the await
|
|
80
|
+
this.subscription = consumer.subscriptions.create(
|
|
81
|
+
{ channel: this.channelName(), cid: this.cidValue },
|
|
82
|
+
{ received: (data) => this.received(data) }
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
disconnect() {
|
|
87
|
+
this.aborted = true
|
|
88
|
+
this.subscription?.unsubscribe()
|
|
89
|
+
this.subscription = undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// DOM → server: what declared action methods call.
|
|
93
|
+
perform(action, payload = {}) {
|
|
94
|
+
this.subscription.perform(action, payload)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Server → DOM (transmit transport): swap each transmitted fragment in
|
|
98
|
+
// by its root id. Broadcast-transport channels never transmit, and a
|
|
99
|
+
// non-html transmit is not ours to interpret. Subclasses may override.
|
|
100
|
+
received({ html }) {
|
|
101
|
+
if (!html) return
|
|
102
|
+
const template = document.createElement("template")
|
|
103
|
+
template.innerHTML = html
|
|
104
|
+
for (const fragment of [...template.content.children]) {
|
|
105
|
+
document.getElementById(fragment.id)?.replaceWith(fragment)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `static channel = "..."` wins; otherwise infer Rails-style from the
|
|
110
|
+
// identifier: "counter" → CounterChannel, "my-thing" → MyThingChannel.
|
|
111
|
+
channelName() {
|
|
112
|
+
if (this.constructor.channel) return this.constructor.channel
|
|
113
|
+
const pascal = this.identifier
|
|
114
|
+
.split(/[-_]/)
|
|
115
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
116
|
+
.join("")
|
|
117
|
+
return `${pascal}Channel`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Auto-forwarding: every data-action token addressed to this identifier
|
|
121
|
+
// whose method the subclass did NOT declare gets a generated forwarder
|
|
122
|
+
// that performs the underscored action with no payload — so plain
|
|
123
|
+
// forwards need zero code. Declared methods always win. Methods are
|
|
124
|
+
// defined at connect: action NAMES first appearing in later-inserted
|
|
125
|
+
// markup aren't discovered (names already seen keep working anywhere,
|
|
126
|
+
// Stimulus binds the elements itself).
|
|
127
|
+
defineForwarders() {
|
|
128
|
+
for (const control of this.element.querySelectorAll("[data-action]")) {
|
|
129
|
+
for (const token of control.dataset.action.trim().split(/\s+/)) {
|
|
130
|
+
const method = this.forwardableMethod(token)
|
|
131
|
+
if (method && typeof this[method] !== "function") {
|
|
132
|
+
this[method] = () => this.perform(underscore(method))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Token forms: "identifier#method", "event->identifier#method", plus
|
|
139
|
+
// trailing :options — returns the method name, or null when the token
|
|
140
|
+
// addresses another controller.
|
|
141
|
+
forwardableMethod(token) {
|
|
142
|
+
const descriptor = token.includes("->") ? token.split("->")[1] : token
|
|
143
|
+
const [identifier, method] = descriptor.split("#")
|
|
144
|
+
if (identifier !== this.identifier || !method) return null
|
|
145
|
+
return method.split(":")[0]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The element's own <turbo-cable-stream-source>, if any. Scoped like
|
|
149
|
+
// forward() below: a nested controller's source belongs to IT.
|
|
150
|
+
streamSource() {
|
|
151
|
+
return [...this.element.querySelectorAll("turbo-cable-stream-source")].find(
|
|
152
|
+
(s) => s.closest(`[data-controller~="${this.identifier}"]`) === this.element
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The generic controller: adds the data-hibiki-* wire protocol on top of
|
|
158
|
+
// the base's plumbing.
|
|
159
|
+
export default class HibikiController extends ChannelController {
|
|
160
|
+
static values = { channel: String } // cid inherited from the base
|
|
161
|
+
|
|
162
|
+
// The island stamps its channel; no inference.
|
|
163
|
+
channelName() {
|
|
164
|
+
return this.channelValue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async connect() {
|
|
168
|
+
// Root-scoped delegation (bound to the island, not document): controls
|
|
169
|
+
// inside server-replaced fragments keep working with no rebinding.
|
|
170
|
+
// Set up synchronously so disconnect can always tear them down.
|
|
171
|
+
this.listeners = ["click", "change", "submit"].map((type) => {
|
|
172
|
+
const handler = (event) => this.forward(event)
|
|
173
|
+
this.element.addEventListener(type, handler)
|
|
174
|
+
return [type, handler]
|
|
175
|
+
})
|
|
176
|
+
await super.connect()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
disconnect() {
|
|
180
|
+
for (const [type, handler] of this.listeners) {
|
|
181
|
+
this.element.removeEventListener(type, handler)
|
|
182
|
+
}
|
|
183
|
+
super.disconnect()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// DOM → server: forward a control's event as a channel action. Nested
|
|
187
|
+
// islands: events bubble to every ancestor island's listener, so each
|
|
188
|
+
// controller only acts when the control belongs to ITS island.
|
|
189
|
+
forward(event) {
|
|
190
|
+
const control = event.target.closest("[data-hibiki-on]")
|
|
191
|
+
if (!control) return
|
|
192
|
+
if (control.closest('[data-controller~="hibiki"]') !== this.element) return
|
|
193
|
+
|
|
194
|
+
const token = control.dataset.hibikiOn
|
|
195
|
+
.split(/\s+/)
|
|
196
|
+
.find((t) => t.startsWith(`${event.type}->`))
|
|
197
|
+
if (!token) return
|
|
198
|
+
|
|
199
|
+
const action = token.slice(event.type.length + 2)
|
|
200
|
+
const payload = control.dataset.hibikiWith
|
|
201
|
+
? JSON.parse(control.dataset.hibikiWith)
|
|
202
|
+
: {}
|
|
203
|
+
if (event.type === "submit") {
|
|
204
|
+
event.preventDefault()
|
|
205
|
+
Object.assign(payload, Object.fromEntries(new FormData(control)))
|
|
206
|
+
} else if (event.type === "change" && control.name) {
|
|
207
|
+
payload[control.name] = control.value
|
|
208
|
+
}
|
|
209
|
+
this.perform(action, payload)
|
|
210
|
+
if (event.type === "submit") control.reset()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export { HibikiController }
|
|
215
|
+
|
|
216
|
+
// The Turbo-broadcast race helper the base uses internally, still
|
|
217
|
+
// exported for custom (non-Stimulus) clients: resolves once Turbo stamps
|
|
218
|
+
// the `connected` attribute on the given <turbo-cable-stream-source>.
|
|
219
|
+
export function streamConnected(element) {
|
|
220
|
+
if (element.hasAttribute("connected")) return Promise.resolve()
|
|
221
|
+
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
const observer = new MutationObserver(() => {
|
|
224
|
+
if (element.hasAttribute("connected")) {
|
|
225
|
+
observer.disconnect()
|
|
226
|
+
resolve()
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
observer.observe(element, { attributeFilter: ["connected"] })
|
|
230
|
+
})
|
|
231
|
+
}
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Merged into the host app's import map by the Engine's importmap
|
|
4
|
+
# initializer (importmap-rails reads every path in config.importmap.paths).
|
|
5
|
+
# The app supplies the "@hotwired/stimulus" and "@rails/actioncable" pins —
|
|
6
|
+
# both are part of the Rails 8 default stack.
|
|
7
|
+
pin "hibiki-rails", to: "hibiki.js"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Hibiki
|
|
6
|
+
module Rails
|
|
7
|
+
module Generators
|
|
8
|
+
# Shared naming and wiring-detection helpers for the hibiki:rails:*
|
|
9
|
+
# generators. The naming helpers ride on NamedBase's name parts
|
|
10
|
+
# (regular_class_path / file_name); the wiring predicates only need
|
|
11
|
+
# destination_root, so the install generator uses them too.
|
|
12
|
+
module GeneratorHelpers
|
|
13
|
+
INDEX_JS = "app/javascript/controllers/index.js"
|
|
14
|
+
SHIM = "app/javascript/controllers/hibiki_controller.js"
|
|
15
|
+
APPLICATION_HELPER = "app/helpers/application_helper.rb"
|
|
16
|
+
REGISTER_FRAGMENT = 'application.register("hibiki"'
|
|
17
|
+
IMPORTMAP = "config/importmap.rb"
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# First streamable of the concern's [channel_name, cid] convention:
|
|
22
|
+
# ActionCable's channel_name for Admin::CounterChannel is
|
|
23
|
+
# "admin:counter".
|
|
24
|
+
def stream_name = name_parts.join(":")
|
|
25
|
+
|
|
26
|
+
# The Stimulus identifier Rails derives from
|
|
27
|
+
# app/javascript/controllers/<file_path>_controller.js.
|
|
28
|
+
def identifier = name_parts.join("--").tr("_", "-")
|
|
29
|
+
|
|
30
|
+
# Root id of the replaced fragment — namespaced with the component's
|
|
31
|
+
# own name so several generated islands can share a page.
|
|
32
|
+
def display_dom_id = "#{name_parts.join('_')}_display"
|
|
33
|
+
|
|
34
|
+
def nested? = !regular_class_path.empty?
|
|
35
|
+
|
|
36
|
+
def name_parts = regular_class_path + [file_name]
|
|
37
|
+
|
|
38
|
+
# The install generator's file-backed shim is the wiring artifact;
|
|
39
|
+
# the index.js fragment covers hand-wired (or pre-shim) apps.
|
|
40
|
+
def hibiki_registered? = exists?(SHIM) || wired?(INDEX_JS, REGISTER_FRAGMENT)
|
|
41
|
+
|
|
42
|
+
def helpers_included? = wired?(APPLICATION_HELPER, "Hibiki::Rails::Helpers")
|
|
43
|
+
|
|
44
|
+
# importmap-rails apps eager-load the controllers directory;
|
|
45
|
+
# jsbundling apps register each controller in index.js by hand.
|
|
46
|
+
def importmap? = exists?(IMPORTMAP)
|
|
47
|
+
|
|
48
|
+
def exists?(path) = File.exist?(File.join(destination_root, path))
|
|
49
|
+
|
|
50
|
+
def wired?(path, fragment)
|
|
51
|
+
full = File.join(destination_root, path)
|
|
52
|
+
File.exist?(full) && File.read(full).include?(fragment)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def manual_wiring(path, snippet)
|
|
56
|
+
say_status :skip, "#{path} not found — add this yourself:", :yellow
|
|
57
|
+
say snippet
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require_relative "../generator_helpers"
|
|
5
|
+
|
|
6
|
+
module Hibiki
|
|
7
|
+
module Rails
|
|
8
|
+
module Generators
|
|
9
|
+
# Wiring only — the client itself stays vendored in the engine (the
|
|
10
|
+
# data-hibiki-* attribute names are a private Ruby↔JS contract that
|
|
11
|
+
# versions inside this gem, so the client is never copied into the
|
|
12
|
+
# app; the shim it creates only re-exports it). Every action is
|
|
13
|
+
# idempotent by content check, so rerunning is safe.
|
|
14
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
15
|
+
include GeneratorHelpers
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
desc "Wires the packaged hibiki client: registers the \"hibiki\" Stimulus " \
|
|
20
|
+
"controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
|
|
21
|
+
"creates the ApplicationCable boilerplate, and pins @rails/actioncable."
|
|
22
|
+
|
|
23
|
+
# Exactly the lines `stimulus:manifest:update` emits for the shim,
|
|
24
|
+
# so a later manifest run converges instead of duplicating.
|
|
25
|
+
REGISTER = <<~JS
|
|
26
|
+
|
|
27
|
+
import HibikiController from "./hibiki_controller"
|
|
28
|
+
application.register("hibiki", HibikiController)
|
|
29
|
+
JS
|
|
30
|
+
|
|
31
|
+
INCLUDE_LINE = " include Hibiki::Rails::Helpers\n"
|
|
32
|
+
|
|
33
|
+
# Stock Rails apps don't have these until the first `rails g
|
|
34
|
+
# channel` — the hibiki:rails:* generators write their channels
|
|
35
|
+
# directly, so install supplies the boilerplate they inherit.
|
|
36
|
+
APPLICATION_CABLE = {
|
|
37
|
+
"channel.rb.tt" => "app/channels/application_cable/channel.rb",
|
|
38
|
+
"connection.rb.tt" => "app/channels/application_cable/connection.rb"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# Same event: the stock importmap has no @rails/actioncable pin
|
|
42
|
+
# until the first `rails g channel` adds it. The packaged client
|
|
43
|
+
# imports it, so pin it here (the asset ships in actioncable).
|
|
44
|
+
PIN = <<~RUBY
|
|
45
|
+
|
|
46
|
+
pin "@rails/actioncable", to: "actioncable.esm.js"
|
|
47
|
+
RUBY
|
|
48
|
+
|
|
49
|
+
# The registration lives in a file-backed shim so that
|
|
50
|
+
# `stimulus:manifest:update` (which rewrites index.js wholesale from
|
|
51
|
+
# the *_controller.js files) re-derives it instead of dropping it.
|
|
52
|
+
def create_shim_controller
|
|
53
|
+
template "hibiki_controller.js.tt", SHIM
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Importmap apps eager-load the shim from the controllers directory;
|
|
57
|
+
# a manifest-style index.js (jsbundling) must name it explicitly.
|
|
58
|
+
def register_controller
|
|
59
|
+
return legacy_registration_hint if importmap?
|
|
60
|
+
return say_status :identical, INDEX_JS, :blue if wired?(INDEX_JS, REGISTER_FRAGMENT)
|
|
61
|
+
return manual_wiring(INDEX_JS, REGISTER) unless exists?(INDEX_JS)
|
|
62
|
+
|
|
63
|
+
append_to_file INDEX_JS, REGISTER
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def include_helpers
|
|
67
|
+
return say_status :identical, APPLICATION_HELPER, :blue if helpers_included?
|
|
68
|
+
return manual_wiring(APPLICATION_HELPER, INCLUDE_LINE) unless exists?(APPLICATION_HELPER)
|
|
69
|
+
|
|
70
|
+
inject_into_file APPLICATION_HELPER, INCLUDE_LINE,
|
|
71
|
+
after: /module ApplicationHelper\s*\n/
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Presence is enough — an app's own ApplicationCable (customized
|
|
75
|
+
# or not) is never touched.
|
|
76
|
+
def create_application_cable
|
|
77
|
+
APPLICATION_CABLE.each do |source, destination|
|
|
78
|
+
next say_status :exist, destination, :blue if exists?(destination)
|
|
79
|
+
|
|
80
|
+
template source, destination
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pin_actioncable
|
|
85
|
+
return bundler_note unless importmap?
|
|
86
|
+
return say_status :identical, IMPORTMAP, :blue if wired?(IMPORTMAP, "@rails/actioncable")
|
|
87
|
+
|
|
88
|
+
append_to_file IMPORTMAP, PIN
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Installs that predate the shim registered straight in index.js;
|
|
94
|
+
# left in place next to the eager-loaded shim that would register
|
|
95
|
+
# "hibiki" twice.
|
|
96
|
+
def legacy_registration_hint
|
|
97
|
+
return unless wired?(INDEX_JS, REGISTER_FRAGMENT)
|
|
98
|
+
|
|
99
|
+
say_status :hint, "#{INDEX_JS} still registers \"hibiki\" directly — " \
|
|
100
|
+
"remove those lines; the eager-loaded " \
|
|
101
|
+
"hibiki_controller.js shim replaces them", :yellow
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def bundler_note
|
|
105
|
+
say_status :skip, "#{IMPORTMAP} not found — with a JS bundler, " \
|
|
106
|
+
"npm/yarn add hibiki-rails instead (it pulls in " \
|
|
107
|
+
"@rails/actioncable)", :yellow
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|