inertia_cable 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d3eef5ebaf526671cbdfa7efd381c2487f9ec19633a4dd6478bd3f399f262c1c
4
+ data.tar.gz: 064c84423d1c5742e146404d410581d5610223e916082e94c71c214336ab3247
5
+ SHA512:
6
+ metadata.gz: b16694b36fc8c42c3661a66d6ef94329b306a4b40a183f866993ed43460d3538ae120b68b5ec5115d83523cb252204fc34721c52fd93ba93e376a3246287caeb
7
+ data.tar.gz: '01828651d1f2f96acbe07e85c5b02cd64153a7f7089a99e77c8d054e6694bd71c8514e6a8a7f864735ca0fac42f7c0358681194c0f7de914206f9ed3577e0799'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Cole Robertson
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,454 @@
1
+ # InertiaCable
2
+
3
+ ActionCable broadcast DSL for [Inertia.js](https://inertiajs.com/) Rails applications. Three lines of code to get real-time updates.
4
+
5
+ InertiaCable broadcasts lightweight JSON signals over ActionCable. The client receives them and calls `router.reload()` to re-fetch props through Inertia's normal HTTP flow. No data travels over the WebSocket — your controller stays the single source of truth.
6
+
7
+ ```
8
+ Model save → after_commit → ActionCable broadcast (signal)
9
+
10
+ React hook subscribes → receives signal → router.reload({ only: ['messages'] })
11
+
12
+ Inertia HTTP request → controller re-evaluates props → React re-renders
13
+ ```
14
+
15
+ ## Table of Contents
16
+
17
+ - [Installation](#installation)
18
+ - [Quick Start](#quick-start)
19
+ - [Model DSL](#model-dsl)
20
+ - [Controller Helper](#controller-helper)
21
+ - [React Hook](#react-hook)
22
+ - [Suppressing Broadcasts](#suppressing-broadcasts)
23
+ - [Server-Side Debounce](#server-side-debounce)
24
+ - [Testing](#testing)
25
+ - [Configuration](#configuration)
26
+ - [Security](#security)
27
+ - [Troubleshooting](#troubleshooting)
28
+ - [Requirements](#requirements)
29
+ - [Development](#development)
30
+ - [License](#license)
31
+
32
+ ## Installation
33
+
34
+ Add the gem to your Gemfile:
35
+
36
+ ```ruby
37
+ gem "inertia_cable"
38
+ ```
39
+
40
+ Install the frontend package:
41
+
42
+ ```bash
43
+ npm install @inertia-cable/react @rails/actioncable
44
+ ```
45
+
46
+ Optionally run the install generator:
47
+
48
+ ```bash
49
+ rails generate inertia_cable:install
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ### 1. Model — declare what broadcasts
55
+
56
+ ```ruby
57
+ class Message < ApplicationRecord
58
+ belongs_to :chat
59
+ broadcasts_to :chat
60
+ end
61
+ ```
62
+
63
+ ### 2. Controller — pass a signed stream token as a prop
64
+
65
+ ```ruby
66
+ class ChatsController < ApplicationController
67
+ def show
68
+ chat = Chat.find(params[:id])
69
+ render inertia: 'Chats/Show', props: {
70
+ chat: chat.as_json,
71
+ messages: -> { chat.messages.order(:created_at).as_json },
72
+ cable_stream: inertia_cable_stream(chat)
73
+ }
74
+ end
75
+ end
76
+ ```
77
+
78
+ ### 3. React — subscribe to the stream
79
+
80
+ ```tsx
81
+ import { useInertiaCable } from '@inertia-cable/react'
82
+
83
+ export default function ChatShow({ chat, messages, cable_stream }) {
84
+ useInertiaCable(cable_stream, { only: ['messages'] })
85
+
86
+ return (
87
+ <div>
88
+ <h1>{chat.name}</h1>
89
+ {messages.map(msg => <Message key={msg.id} message={msg} />)}
90
+ </div>
91
+ )
92
+ }
93
+ ```
94
+
95
+ That's it. When any user creates, updates, or deletes a message, all connected clients automatically reload the `messages` prop.
96
+
97
+ ---
98
+
99
+ ## Model DSL
100
+
101
+ ### `broadcasts_to`
102
+
103
+ Broadcasts a refresh signal to a named stream whenever the model is committed (via a single `after_commit` callback).
104
+
105
+ ```ruby
106
+ class Post < ApplicationRecord
107
+ belongs_to :board
108
+
109
+ broadcasts_to :board # stream to associated record
110
+ broadcasts_to ->(post) { [post.board, :posts] } # stream to a lambda
111
+ broadcasts_to "global_feed" # stream to a static string
112
+ end
113
+ ```
114
+
115
+ `broadcasts_refreshes_to` is available as a legacy alias.
116
+
117
+ #### Stream resolution
118
+
119
+ | Argument | Resolves to |
120
+ |----------|-------------|
121
+ | `:symbol` | Calls the method on the record (`post.board`) |
122
+ | `Proc` / `lambda` | Calls with the record (`->(post) { ... }`) |
123
+ | `String` | Used as-is |
124
+ | ActiveRecord model | GlobalID (`gid://app/Board/1`) |
125
+ | `Array` | Joins elements with `:` after resolving each |
126
+
127
+ #### Options
128
+
129
+ ```ruby
130
+ class Post < ApplicationRecord
131
+ # on: — limit which events trigger broadcasts (default: all)
132
+ broadcasts_to :board, on: [:create, :destroy]
133
+
134
+ # if: / unless: — standard Rails callback conditions
135
+ broadcasts_to :board, if: :published?
136
+ broadcasts_to :board, unless: -> { draft? }
137
+
138
+ # extra: — attach custom data to the payload (Hash or Proc)
139
+ broadcasts_to :board, extra: { priority: "high" }
140
+ broadcasts_to :board, extra: ->(post) { { category: post.category } }
141
+
142
+ # debounce: — coalesce rapid broadcasts server-side (requires shared cache store)
143
+ broadcasts_to :board, debounce: true # uses global InertiaCable.debounce_delay
144
+ broadcasts_to :board, debounce: 1.0 # custom delay in seconds
145
+
146
+ # Options compose
147
+ broadcasts_to :board, on: [:create, :destroy], if: :published?
148
+ end
149
+ ```
150
+
151
+ ### `broadcasts`
152
+
153
+ Convention-based version that broadcasts to `model_name.plural` (e.g., `"posts"`):
154
+
155
+ ```ruby
156
+ class Post < ApplicationRecord
157
+ broadcasts # broadcasts to "posts"
158
+ broadcasts on: [:create, :destroy] # with options
159
+ end
160
+ ```
161
+
162
+ `broadcasts_refreshes` is available as a legacy alias.
163
+
164
+ ### Instance methods
165
+
166
+ ```ruby
167
+ post = Post.find(1)
168
+
169
+ # Sync
170
+ post.broadcast_refresh_to(board)
171
+ post.broadcast_refresh_to(board, :posts) # compound stream
172
+ post.broadcast_refresh # to model_name.plural
173
+
174
+ # Async (via ActiveJob)
175
+ post.broadcast_refresh_later_to(board)
176
+ post.broadcast_refresh_later
177
+
178
+ # With extra payload or debounce
179
+ post.broadcast_refresh_to(board, extra: { priority: "high" })
180
+ post.broadcast_refresh_later_to(board, debounce: 2.0)
181
+
182
+ # With inline condition (block — skips broadcast if falsy)
183
+ post.broadcast_refresh_to(board) { published? }
184
+ ```
185
+
186
+ ### Broadcast payload
187
+
188
+ Every broadcast sends this JSON:
189
+
190
+ ```json
191
+ {
192
+ "type": "refresh",
193
+ "model": "Message",
194
+ "id": 42,
195
+ "action": "create",
196
+ "timestamp": "2026-02-02T18:15:19+00:00",
197
+ "extra": {}
198
+ }
199
+ ```
200
+
201
+ The `action` field is `"create"`, `"update"`, or `"destroy"`. The `extra` field contains data from the `extra:` option (empty object if not set).
202
+
203
+ ---
204
+
205
+ ## Controller Helper
206
+
207
+ `inertia_cable_stream` generates a cryptographically signed stream token. Pass it as an Inertia prop.
208
+
209
+ ```ruby
210
+ inertia_cable_stream(chat) # signed "gid://app/Chat/1"
211
+ inertia_cable_stream("posts") # signed "posts"
212
+ inertia_cable_stream(chat, :messages) # signed "gid://app/Chat/1:messages"
213
+ ```
214
+
215
+ Each element is resolved individually: objects that respond to `to_gid_param` use GlobalID, otherwise `to_param` is called. Nested arrays are flattened and `nil`/blank elements are stripped.
216
+
217
+ The token is verified server-side when the client subscribes — invalid or tampered tokens are rejected.
218
+
219
+ ---
220
+
221
+ ## React Hook
222
+
223
+ > Only the React adapter (`@inertia-cable/react`) is available. Vue and Svelte adapters are welcome as community contributions.
224
+
225
+ ### `useInertiaCable(signedStreamName, options?)`
226
+
227
+ Returns `{ connected }` — a boolean indicating whether the WebSocket subscription is active.
228
+
229
+ ```tsx
230
+ const { connected } = useInertiaCable(cable_stream, {
231
+ only: ['messages'], // only reload these props
232
+ except: ['metadata'], // reload all except these
233
+ onRefresh: (data) => { // callback before each reload
234
+ console.log(`${data.model} #${data.id} was ${data.action}`)
235
+ if (data.extra?.priority === 'high') toast.warn('Priority update!')
236
+ },
237
+ onConnected: () => {}, // subscription connected
238
+ onDisconnected: () => {}, // connection dropped
239
+ debounce: 200, // client-side debounce in ms (default: 100)
240
+ enabled: isVisible, // disable/enable subscription (default: true)
241
+ })
242
+ ```
243
+
244
+ | Option | Type | Default | Description |
245
+ |--------|------|---------|-------------|
246
+ | `only` | `string[]` | — | Only reload these props |
247
+ | `except` | `string[]` | — | Reload all props except these |
248
+ | `onRefresh` | `(data) => void` | — | Callback before each reload |
249
+ | `onConnected` | `() => void` | — | Called when subscription connects |
250
+ | `onDisconnected` | `() => void` | — | Called when connection drops |
251
+ | `debounce` | `number` | `100` | Debounce delay in ms |
252
+ | `enabled` | `boolean` | `true` | Enable/disable subscription |
253
+
254
+ **Automatic catch-up on reconnection:** When a WebSocket connection drops and reconnects (e.g., network interruption or backgrounded tab), the hook automatically triggers a `router.reload()` to fetch any changes missed while disconnected. This only fires on *re*connection — not the initial connect. ActionCable handles the reconnection itself (with exponential backoff); the hook just ensures your props are fresh when it comes back.
255
+
256
+ Use `connected` to show connection state in the UI:
257
+
258
+ ```tsx
259
+ const { connected } = useInertiaCable(cable_stream, { only: ['messages'] })
260
+
261
+ if (!connected) return <Banner>Reconnecting…</Banner>
262
+ ```
263
+
264
+ ### Multiple streams on one page
265
+
266
+ ```tsx
267
+ function Dashboard({ stats, notifications, stats_stream, notifications_stream }) {
268
+ useInertiaCable(stats_stream, { only: ['stats'] })
269
+ useInertiaCable(notifications_stream, { only: ['notifications'] })
270
+
271
+ return (
272
+ <>
273
+ <StatsPanel stats={stats} />
274
+ <NotificationList notifications={notifications} />
275
+ </>
276
+ )
277
+ }
278
+ ```
279
+
280
+ ### `InertiaCableProvider`
281
+
282
+ Optional context provider for a custom ActionCable URL. Without it, the hook connects to the default `/cable` endpoint.
283
+
284
+ ```tsx
285
+ import { InertiaCableProvider } from '@inertia-cable/react'
286
+
287
+ createInertiaApp({
288
+ setup({ el, App, props }) {
289
+ createRoot(el).render(
290
+ <InertiaCableProvider url="wss://cable.example.com/cable">
291
+ <App {...props} />
292
+ </InertiaCableProvider>
293
+ )
294
+ },
295
+ })
296
+ ```
297
+
298
+ `getConsumer()` and `setConsumer()` are also exported for low-level access to the ActionCable consumer singleton.
299
+
300
+ TypeScript types (`RefreshPayload`, `UseInertiaCableOptions`, `UseInertiaCableReturn`, `InertiaCableProviderProps`) are exported from `@inertia-cable/react`.
301
+
302
+ ---
303
+
304
+ ## Suppressing Broadcasts
305
+
306
+ Thread-safe and nestable:
307
+
308
+ ```ruby
309
+ # Global — suppress all models
310
+ InertiaCable.suppressing_broadcasts do
311
+ 1000.times { Post.create!(title: "Imported") }
312
+ end
313
+
314
+ # Class-level
315
+ Post.suppressing_broadcasts do
316
+ Post.create!(title: "Silent")
317
+ end
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Server-Side Debounce
323
+
324
+ Optionally coalesce rapid broadcasts using Rails cache. **Not used by default** — the client-side 100ms debounce handles most cases.
325
+
326
+ Requires a shared cache store (Redis, Memcached, or SolidCache) in multi-process deployments.
327
+
328
+ ```ruby
329
+ InertiaCable.debounce_delay = 0.5 # seconds (default)
330
+
331
+ InertiaCable::Debounce.broadcast("my_stream", payload)
332
+ InertiaCable::Debounce.broadcast("my_stream", payload, delay: 2.0)
333
+ ```
334
+
335
+ ---
336
+
337
+ ## Testing
338
+
339
+ InertiaCable ships a `TestHelper` module:
340
+
341
+ ```ruby
342
+ class MessageTest < ActiveSupport::TestCase
343
+ include InertiaCable::TestHelper
344
+
345
+ test "broadcasting on create" do
346
+ chat = chats(:general)
347
+
348
+ assert_broadcasts_on(chat) do
349
+ Message.create!(chat: chat, body: "hello")
350
+ end
351
+ end
352
+
353
+ test "no broadcasts when suppressed" do
354
+ chat = chats(:general)
355
+
356
+ assert_no_broadcasts_on(chat) do
357
+ Message.suppressing_broadcasts do
358
+ Message.create!(chat: chat, body: "silent")
359
+ end
360
+ end
361
+ end
362
+
363
+ test "inspect broadcast payloads" do
364
+ chat = chats(:general)
365
+
366
+ payloads = capture_broadcasts_on(chat) do
367
+ Message.create!(chat: chat, body: "hello")
368
+ end
369
+
370
+ assert_equal "create", payloads.first[:action]
371
+ end
372
+ end
373
+ ```
374
+
375
+ | Method | Description |
376
+ |--------|-------------|
377
+ | `assert_broadcasts_on(*streamables, count: nil) { }` | Assert broadcasts occurred |
378
+ | `assert_no_broadcasts_on(*streamables) { }` | Assert no broadcasts occurred |
379
+ | `capture_broadcasts_on(*streamables) { }` | Capture and return payload array |
380
+
381
+ All three accept splat streamables: `assert_broadcasts_on(chat, :messages) { ... }`
382
+
383
+ ---
384
+
385
+ ## Configuration
386
+
387
+ ```ruby
388
+ # config/initializers/inertia_cable.rb
389
+ InertiaCable.signed_stream_verifier_key = "custom_key" # default: secret_key_base + "inertia_cable"
390
+ InertiaCable.debounce_delay = 0.5 # server-side debounce (seconds)
391
+ ```
392
+
393
+ ---
394
+
395
+ ## Security
396
+
397
+ Stream tokens are HMAC-SHA256 signed using `secret_key_base` and verified server-side on subscription. Invalid tokens are rejected. No data travels over the WebSocket — actual data is fetched via Inertia's normal HTTP cycle, which runs through your controller and its authorization logic on every reload. Token rotation follows `secret_key_base` rotation.
398
+
399
+ ---
400
+
401
+ ## Troubleshooting
402
+
403
+ ### Stream token mismatch
404
+
405
+ The stream signed in the controller must match what the model broadcasts to:
406
+
407
+ ```ruby
408
+ # Model
409
+ broadcasts_to :board # broadcasts to gid://app/Board/1
410
+
411
+ # Controller — must sign the same object
412
+ inertia_cable_stream(@post.board) # ✓ signs gid://app/Board/1
413
+ inertia_cable_stream(@post) # ✗ signs gid://app/Post/1
414
+ ```
415
+
416
+ ### `only`/`except` crashes
417
+
418
+ Always pass arrays, never `undefined`:
419
+
420
+ ```tsx
421
+ // Bad
422
+ useInertiaCable(stream, { only: someCondition ? ['messages'] : undefined })
423
+
424
+ // Good
425
+ useInertiaCable(stream, { ...(someCondition ? { only: ['messages'] } : {}) })
426
+ ```
427
+
428
+ ### Server-side debounce not working across processes
429
+
430
+ `Rails.cache` defaults to `MemoryStore` (per-process). Use a shared store in production:
431
+
432
+ ```ruby
433
+ config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
434
+ ```
435
+
436
+ ---
437
+
438
+ ## Requirements
439
+
440
+ - Ruby >= 3.1
441
+ - Rails >= 7.0 (ActionCable, ActiveJob, ActiveSupport)
442
+ - Inertia.js >= 1.0 with React (`@inertiajs/react`)
443
+ - ActionCable configured with Redis or SolidCable (production) or async (development)
444
+
445
+ ## Development
446
+
447
+ ```bash
448
+ bundle install && bundle exec rspec # Ruby specs
449
+ cd frontend && npm install && npm test # Frontend
450
+ ```
451
+
452
+ ## License
453
+
454
+ MIT
@@ -0,0 +1,12 @@
1
+ module InertiaCable
2
+ class StreamChannel < ActionCable::Channel::Base
3
+ def subscribed
4
+ verified_stream = InertiaCable.signed_stream_verifier.verified(params[:signed_stream_name])
5
+ if verified_stream
6
+ stream_from Array(verified_stream).join(":")
7
+ else
8
+ reject
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+ module InertiaCable
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ desc "Install InertiaCable into your Rails application"
7
+
8
+ def copy_cable_setup
9
+ template "cable_setup.ts", "app/javascript/channels/inertia_cable.ts"
10
+ end
11
+
12
+ def show_instructions
13
+ say ""
14
+ say "InertiaCable installed!", :green
15
+ say ""
16
+ say "Next steps:"
17
+ say " 1. Add the npm package to your frontend:"
18
+ say " npm install @inertia-cable/react @rails/actioncable"
19
+ say ""
20
+ say " 2. Ensure ActionCable is configured in config/cable.yml"
21
+ say " (use redis adapter for production)"
22
+ say ""
23
+ say " 3. Add broadcasts_refreshes_to to your models:"
24
+ say " class Message < ApplicationRecord"
25
+ say " belongs_to :chat"
26
+ say " broadcasts_refreshes_to :chat"
27
+ say " end"
28
+ say ""
29
+ say " 4. Pass cable_stream prop from your controller:"
30
+ say " render inertia: 'Chats/Show', props: {"
31
+ say " cable_stream: inertia_cable_stream(chat)"
32
+ say " }"
33
+ say ""
34
+ say " 5. Use the hook in your React component:"
35
+ say " useInertiaCable(cable_stream, { only: ['messages'] })"
36
+ say ""
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,7 @@
1
+ // InertiaCable - ActionCable setup for Inertia.js
2
+ //
3
+ // This file is auto-generated by `rails g inertia_cable:install`.
4
+ // You can customize the cable URL below if needed.
5
+
6
+ export { useInertiaCable } from '@inertia-cable/react'
7
+ export { InertiaCableProvider } from '@inertia-cable/react'
@@ -0,0 +1,10 @@
1
+ module InertiaCable
2
+ class BroadcastJob < ActiveJob::Base
3
+ queue_as :default
4
+ discard_on ActiveJob::DeserializationError
5
+
6
+ def perform(stream_name, payload)
7
+ InertiaCable.broadcast(stream_name, payload)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,172 @@
1
+ module InertiaCable
2
+ module Broadcastable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ thread_mattr_accessor :suppressed_inertia_cable_broadcasts, instance_accessor: false, default: false
7
+ end
8
+
9
+ class_methods do
10
+ def suppressed_inertia_cable_broadcasts?
11
+ suppressed_inertia_cable_broadcasts
12
+ end
13
+
14
+ # Broadcast refresh signal to a named stream on commit.
15
+ #
16
+ # broadcasts_to :board
17
+ # broadcasts_to :board, on: [:create, :destroy]
18
+ # broadcasts_to :board, if: :published?
19
+ # broadcasts_to :board, unless: -> { draft? }
20
+ # broadcasts_to ->(post) { [post.board, :posts] }
21
+ # broadcasts_to :board, extra: { priority: "high" }
22
+ # broadcasts_to :board, extra: ->(post) { { category: post.category } }
23
+ # broadcasts_to :board, debounce: true
24
+ # broadcasts_to :board, debounce: 1.0
25
+ #
26
+ def broadcasts_to(stream, on: %i[create update destroy], if: nil, unless: nil, extra: nil, debounce: nil)
27
+ callback_condition = binding.local_variable_get(:if)
28
+ callback_unless = binding.local_variable_get(:unless)
29
+ events = Array(on)
30
+
31
+ callback_options = {}
32
+ callback_options[:if] = callback_condition if callback_condition
33
+ callback_options[:unless] = callback_unless if callback_unless
34
+
35
+ if events.sort == %i[create destroy update].sort
36
+ after_commit(**callback_options) do
37
+ broadcast_refresh_later_to(resolve_stream(stream), extra: resolve_extra(extra), debounce: debounce)
38
+ end
39
+ else
40
+ if events.include?(:create)
41
+ after_create_commit(**callback_options) do
42
+ broadcast_refresh_later_to(resolve_stream(stream), extra: resolve_extra(extra), debounce: debounce)
43
+ end
44
+ end
45
+
46
+ if events.include?(:update)
47
+ after_update_commit(**callback_options) do
48
+ broadcast_refresh_later_to(resolve_stream(stream), extra: resolve_extra(extra), debounce: debounce)
49
+ end
50
+ end
51
+
52
+ if events.include?(:destroy)
53
+ after_destroy_commit(**callback_options) do
54
+ broadcast_refresh_later_to(resolve_stream(stream), extra: resolve_extra(extra), debounce: debounce)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ # Legacy alias — kept for compatibility with Turbo-style naming.
61
+ alias_method :broadcasts_refreshes_to, :broadcasts_to
62
+
63
+ # Convention-based: broadcasts to model_name.plural stream.
64
+ #
65
+ # broadcasts
66
+ # broadcasts on: [:create, :destroy]
67
+ #
68
+ def broadcasts(**options)
69
+ broadcasts_to(model_name.plural, **options)
70
+ end
71
+
72
+ # Legacy alias — kept for compatibility with Turbo-style naming.
73
+ alias_method :broadcasts_refreshes, :broadcasts
74
+
75
+ def suppressing_broadcasts(&block)
76
+ original = suppressed_inertia_cable_broadcasts
77
+ self.suppressed_inertia_cable_broadcasts = true
78
+ yield
79
+ ensure
80
+ self.suppressed_inertia_cable_broadcasts = original
81
+ end
82
+ end
83
+
84
+ # Broadcast refresh synchronously to explicit stream(s).
85
+ #
86
+ # post.broadcast_refresh_to(board)
87
+ # post.broadcast_refresh_to(board, :posts)
88
+ # post.broadcast_refresh_to(board, extra: { priority: "high" })
89
+ # post.broadcast_refresh_to(board) { published? }
90
+ #
91
+ def broadcast_refresh_to(*streamables, extra: nil, &block)
92
+ return if self.class.suppressed_inertia_cable_broadcasts?
93
+ return if block && !instance_exec(&block)
94
+
95
+ InertiaCable.broadcast(streamables, refresh_payload(extra: extra))
96
+ end
97
+
98
+ # Broadcast refresh asynchronously to explicit stream(s).
99
+ #
100
+ # post.broadcast_refresh_later_to(board)
101
+ # post.broadcast_refresh_later_to(board, :posts)
102
+ # post.broadcast_refresh_later_to(board, extra: { priority: "high" })
103
+ # post.broadcast_refresh_later_to(board, debounce: true)
104
+ # post.broadcast_refresh_later_to(board) { published? }
105
+ #
106
+ def broadcast_refresh_later_to(*streamables, extra: nil, debounce: nil, &block)
107
+ return if self.class.suppressed_inertia_cable_broadcasts?
108
+ return if block && !instance_exec(&block)
109
+
110
+ resolved = InertiaCable::Streams::StreamName.stream_name_from(streamables)
111
+ payload = refresh_payload(extra: extra)
112
+
113
+ if debounce
114
+ delay = debounce == true ? nil : debounce
115
+ InertiaCable::Debounce.broadcast(resolved, payload, delay: delay)
116
+ else
117
+ InertiaCable::BroadcastJob.perform_later(resolved, payload)
118
+ end
119
+ end
120
+
121
+ # Broadcast refresh synchronously to model_name.plural.
122
+ def broadcast_refresh(&block)
123
+ broadcast_refresh_to(self.class.model_name.plural, &block)
124
+ end
125
+
126
+ # Broadcast refresh asynchronously to model_name.plural.
127
+ def broadcast_refresh_later(&block)
128
+ broadcast_refresh_later_to(self.class.model_name.plural, &block)
129
+ end
130
+
131
+ private
132
+
133
+ def resolve_stream(stream)
134
+ case stream
135
+ when Symbol then send(stream)
136
+ when Proc then stream.call(self)
137
+ when String then stream
138
+ else stream
139
+ end
140
+ end
141
+
142
+ def resolve_extra(extra)
143
+ case extra
144
+ when Proc then extra.call(self)
145
+ when Hash then extra
146
+ else nil
147
+ end
148
+ end
149
+
150
+ def inferred_action
151
+ if destroyed?
152
+ "destroy"
153
+ elsif previously_new_record?
154
+ "create"
155
+ else
156
+ "update"
157
+ end
158
+ end
159
+
160
+ def refresh_payload(extra: nil)
161
+ payload = {
162
+ type: "refresh",
163
+ model: self.class.name,
164
+ id: try(:id),
165
+ action: inferred_action,
166
+ timestamp: Time.current.iso8601
167
+ }
168
+ payload[:extra] = extra if extra.present?
169
+ payload
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,2 @@
1
+ require "action_cable"
2
+ require_relative "../../app/channels/inertia_cable/stream_channel"
@@ -0,0 +1,12 @@
1
+ module InertiaCable
2
+ module ControllerHelpers
3
+ # Returns a signed stream name to pass as an Inertia prop.
4
+ #
5
+ # inertia_cable_stream(chat)
6
+ # inertia_cable_stream(chat, :messages)
7
+ #
8
+ def inertia_cable_stream(*streamables)
9
+ InertiaCable::Streams::StreamName.signed_stream_name(*streamables)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module InertiaCable
2
+ module Debounce
3
+ def self.broadcast(stream_name, payload, delay: nil)
4
+ delay = delay || InertiaCable.debounce_delay
5
+ cache_key = "inertia_cable:debounce:#{stream_name}"
6
+ return if Rails.cache.exist?(cache_key)
7
+
8
+ Rails.cache.write(cache_key, true, expires_in: delay)
9
+ ActionCable.server.broadcast(stream_name, payload)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ module InertiaCable
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace InertiaCable
4
+
5
+ initializer "inertia_cable.broadcastable" do
6
+ ActiveSupport.on_load(:active_record) do
7
+ include InertiaCable::Broadcastable
8
+ end
9
+ end
10
+
11
+ initializer "inertia_cable.controller_helpers" do
12
+ ActiveSupport.on_load(:action_controller) do
13
+ include InertiaCable::ControllerHelpers
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ module InertiaCable
2
+ module Streams
3
+ module StreamName
4
+ extend self
5
+
6
+ def signed_stream_name(*streamables)
7
+ InertiaCable.signed_stream_verifier.generate(stream_name_from(streamables))
8
+ end
9
+
10
+ def stream_name_from(streamables)
11
+ streamables = Array(streamables).flatten
12
+ streamables.compact_blank!
13
+ streamables.map { |s| single_stream_name(s) }.join(":")
14
+ end
15
+
16
+ private
17
+
18
+ def single_stream_name(streamable)
19
+ if streamable.respond_to?(:to_gid_param)
20
+ streamable.to_gid_param
21
+ else
22
+ streamable.to_param
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ require "active_support/core_ext/module/attribute_accessors_per_thread"
2
+
3
+ module InertiaCable
4
+ module Suppressor
5
+ # Global suppression (across all models).
6
+ #
7
+ # InertiaCable.suppressing_broadcasts { ... }
8
+ #
9
+ thread_mattr_accessor :suppressed, default: false
10
+
11
+ def self.suppressing(&block)
12
+ previous = suppressed
13
+ self.suppressed = true
14
+ yield
15
+ ensure
16
+ self.suppressed = previous
17
+ end
18
+
19
+ def self.suppressed?
20
+ suppressed
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,95 @@
1
+ module InertiaCable
2
+ module TestHelper
3
+ # Assert that broadcasts were made to the given stream.
4
+ #
5
+ # assert_broadcasts_on(chat) { Message.create!(chat: chat) }
6
+ # assert_broadcasts_on(chat, count: 2) { ... }
7
+ # assert_broadcasts_on("posts") { post.broadcast_refresh }
8
+ #
9
+ def assert_broadcasts_on(*streamables, count: nil, &block)
10
+ payloads = capture_broadcasts_on(*streamables, &block)
11
+ stream = InertiaCable::Streams::StreamName.stream_name_from(streamables)
12
+
13
+ if count
14
+ _ic_assert_equal count, payloads.size,
15
+ "Expected #{count} broadcast(s) on #{stream.inspect}, but got #{payloads.size}"
16
+ else
17
+ _ic_assert !payloads.empty?,
18
+ "Expected at least one broadcast on #{stream.inspect}, but there were none"
19
+ end
20
+
21
+ payloads
22
+ end
23
+
24
+ # Assert that no broadcasts were made to the given stream.
25
+ #
26
+ # assert_no_broadcasts_on(chat) { Message.create!(chat: other_chat) }
27
+ #
28
+ def assert_no_broadcasts_on(*streamables, &block)
29
+ payloads = capture_broadcasts_on(*streamables, &block)
30
+ stream = InertiaCable::Streams::StreamName.stream_name_from(streamables)
31
+
32
+ _ic_assert payloads.empty?,
33
+ "Expected no broadcasts on #{stream.inspect}, but got #{payloads.size}"
34
+ end
35
+
36
+ # Capture all broadcasts to a stream within a block. Returns an array of payload hashes.
37
+ #
38
+ # Automatically performs enqueued BroadcastJobs inline so that both sync
39
+ # and async broadcasts are captured.
40
+ #
41
+ # payloads = capture_broadcasts_on(chat) { Message.create!(chat: chat) }
42
+ # payloads.first[:action] # => "create"
43
+ #
44
+ def capture_broadcasts_on(*streamables, &block)
45
+ stream = InertiaCable::Streams::StreamName.stream_name_from(streamables)
46
+ collected = []
47
+
48
+ callback = ->(name, payload) {
49
+ collected << payload if name == stream
50
+ }
51
+
52
+ InertiaCable.on_broadcast(&callback)
53
+
54
+ if defined?(ActiveJob::Base) && ActiveJob::Base.queue_adapter.respond_to?(:enqueued_jobs)
55
+ # Perform broadcast jobs inline to capture their broadcasts
56
+ original_adapter = ActiveJob::Base.queue_adapter
57
+ ActiveJob::Base.queue_adapter = :inline
58
+ begin
59
+ yield
60
+ ensure
61
+ ActiveJob::Base.queue_adapter = original_adapter
62
+ end
63
+ else
64
+ yield
65
+ end
66
+
67
+ InertiaCable.off_broadcast(&callback)
68
+
69
+ collected
70
+ end
71
+
72
+ private
73
+
74
+ # Framework-agnostic assertion — works in both Minitest and RSpec
75
+ def _ic_assert(condition, message = "Assertion failed")
76
+ if respond_to?(:assert)
77
+ assert(condition, message)
78
+ elsif condition
79
+ true
80
+ else
81
+ raise message
82
+ end
83
+ end
84
+
85
+ def _ic_assert_equal(expected, actual, message = nil)
86
+ if respond_to?(:assert_equal)
87
+ assert_equal(expected, actual, message)
88
+ elsif expected == actual
89
+ true
90
+ else
91
+ raise message || "Expected #{expected.inspect}, got #{actual.inspect}"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,3 @@
1
+ module InertiaCable
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,57 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/module/attribute_accessors"
3
+ require "active_support/core_ext/module/attribute_accessors_per_thread"
4
+
5
+ require "inertia_cable/version"
6
+
7
+ module InertiaCable
8
+ mattr_accessor :signed_stream_verifier_key
9
+ mattr_accessor :debounce_delay, default: 0.5
10
+
11
+ def self.signed_stream_verifier
12
+ @signed_stream_verifier ||= ActiveSupport::MessageVerifier.new(
13
+ signed_stream_verifier_key || Rails.application.secret_key_base + "inertia_cable",
14
+ digest: "SHA256",
15
+ serializer: JSON
16
+ )
17
+ end
18
+
19
+ def self.reset_signed_stream_verifier!
20
+ @signed_stream_verifier = nil
21
+ end
22
+
23
+ def self.broadcast(streamables, payload)
24
+ return if Suppressor.suppressed?
25
+
26
+ resolved = Streams::StreamName.stream_name_from(streamables)
27
+ broadcast_callbacks.each { |cb| cb.call(resolved, payload) }
28
+ ActionCable.server.broadcast(resolved, payload)
29
+ end
30
+
31
+ def self.suppressing_broadcasts(&block)
32
+ InertiaCable::Suppressor.suppressing(&block)
33
+ end
34
+
35
+ # Test instrumentation — used by InertiaCable::TestHelper
36
+ def self.on_broadcast(&callback)
37
+ broadcast_callbacks << callback
38
+ end
39
+
40
+ def self.off_broadcast(&callback)
41
+ broadcast_callbacks.delete(callback)
42
+ end
43
+
44
+ def self.broadcast_callbacks
45
+ @broadcast_callbacks ||= []
46
+ end
47
+ end
48
+
49
+ require "inertia_cable/streams/stream_name"
50
+ require "inertia_cable/broadcastable"
51
+ require "inertia_cable/channel"
52
+ require "inertia_cable/broadcast_job"
53
+ require "inertia_cable/debounce"
54
+ require "inertia_cable/suppressor"
55
+ require "inertia_cable/controller_helpers"
56
+ require "inertia_cable/test_helper"
57
+ require "inertia_cable/engine" if defined?(Rails::Engine)
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: inertia_cable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cole Robertson
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-02-03 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actioncable
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activejob
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activesupport
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: railties
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ description: Lightweight ActionCable integration for Inertia.js Rails apps. Broadcasts
69
+ refresh signals over WebSockets, triggering Inertia router.reload() on the client.
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - LICENSE
75
+ - README.md
76
+ - app/channels/inertia_cable/stream_channel.rb
77
+ - lib/generators/inertia_cable/install/install_generator.rb
78
+ - lib/generators/inertia_cable/install/templates/cable_setup.ts
79
+ - lib/inertia_cable.rb
80
+ - lib/inertia_cable/broadcast_job.rb
81
+ - lib/inertia_cable/broadcastable.rb
82
+ - lib/inertia_cable/channel.rb
83
+ - lib/inertia_cable/controller_helpers.rb
84
+ - lib/inertia_cable/debounce.rb
85
+ - lib/inertia_cable/engine.rb
86
+ - lib/inertia_cable/streams/stream_name.rb
87
+ - lib/inertia_cable/suppressor.rb
88
+ - lib/inertia_cable/test_helper.rb
89
+ - lib/inertia_cable/version.rb
90
+ homepage: https://github.com/cole-robertson/inertia-cable
91
+ licenses:
92
+ - MIT
93
+ metadata: {}
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.1'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.6.2
109
+ specification_version: 4
110
+ summary: ActionCable broadcast DSL for Inertia Rails
111
+ test_files: []