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 +7 -0
- data/LICENSE +21 -0
- data/README.md +454 -0
- data/app/channels/inertia_cable/stream_channel.rb +12 -0
- data/lib/generators/inertia_cable/install/install_generator.rb +40 -0
- data/lib/generators/inertia_cable/install/templates/cable_setup.ts +7 -0
- data/lib/inertia_cable/broadcast_job.rb +10 -0
- data/lib/inertia_cable/broadcastable.rb +172 -0
- data/lib/inertia_cable/channel.rb +2 -0
- data/lib/inertia_cable/controller_helpers.rb +12 -0
- data/lib/inertia_cable/debounce.rb +12 -0
- data/lib/inertia_cable/engine.rb +17 -0
- data/lib/inertia_cable/streams/stream_name.rb +27 -0
- data/lib/inertia_cable/suppressor.rb +23 -0
- data/lib/inertia_cable/test_helper.rb +95 -0
- data/lib/inertia_cable/version.rb +3 -0
- data/lib/inertia_cable.rb +57 -0
- metadata +111 -0
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,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,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,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: []
|