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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +252 -0
  3. data/app/assets/javascripts/hibiki.js +231 -0
  4. data/config/importmap.rb +7 -0
  5. data/lib/generators/hibiki/rails/generator_helpers.rb +62 -0
  6. data/lib/generators/hibiki/rails/install/install_generator.rb +112 -0
  7. data/lib/generators/hibiki/rails/install/templates/channel.rb.tt +8 -0
  8. data/lib/generators/hibiki/rails/install/templates/connection.rb.tt +8 -0
  9. data/lib/generators/hibiki/rails/install/templates/hibiki_controller.js.tt +7 -0
  10. data/lib/generators/hibiki/rails/island/island_generator.rb +63 -0
  11. data/lib/generators/hibiki/rails/island/templates/channel.rb.tt +23 -0
  12. data/lib/generators/hibiki/rails/island/templates/display.html.erb.tt +5 -0
  13. data/lib/generators/hibiki/rails/island/templates/island.html.erb.tt +14 -0
  14. data/lib/generators/hibiki/rails/phlex/phlex_generator.rb +73 -0
  15. data/lib/generators/hibiki/rails/phlex/templates/channel.rb.tt +19 -0
  16. data/lib/generators/hibiki/rails/phlex/templates/component.rb.tt +29 -0
  17. data/lib/generators/hibiki/rails/phlex/templates/island_component.rb.tt +17 -0
  18. data/lib/generators/hibiki/rails/stimulus/stimulus_generator.rb +78 -0
  19. data/lib/generators/hibiki/rails/stimulus/templates/channel.rb.tt +23 -0
  20. data/lib/generators/hibiki/rails/stimulus/templates/controller.js.tt +16 -0
  21. data/lib/generators/hibiki/rails/stimulus/templates/display.html.erb.tt +5 -0
  22. data/lib/generators/hibiki/rails/stimulus/templates/island.html.erb.tt +11 -0
  23. data/lib/hibiki/rails/broadcasts.rb +52 -0
  24. data/lib/hibiki/rails/channel.rb +113 -0
  25. data/lib/hibiki/rails/debounce.rb +41 -0
  26. data/lib/hibiki/rails/engine.rb +34 -0
  27. data/lib/hibiki/rails/graph_actor.rb +60 -0
  28. data/lib/hibiki/rails/helpers.rb +50 -0
  29. data/lib/hibiki/rails/registry.rb +50 -0
  30. data/lib/hibiki/rails/version.rb +7 -0
  31. data/lib/hibiki/rails.rb +28 -0
  32. data/lib/hibiki_rails.rb +4 -0
  33. 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
+ }
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stock ActionCable boilerplate (`rails g channel` would create the same
4
+ # file) — the hibiki:rails:* generators emit channels that inherit it.
5
+ module ApplicationCable
6
+ class Channel < ActionCable::Channel::Base
7
+ end
8
+ end