inertia_cable 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3eef5ebaf526671cbdfa7efd381c2487f9ec19633a4dd6478bd3f399f262c1c
4
- data.tar.gz: 064c84423d1c5742e146404d410581d5610223e916082e94c71c214336ab3247
3
+ metadata.gz: 25d0d8cfcadeb38db163745b520998196f61fa48865253227abe0e3ee22d5686
4
+ data.tar.gz: 7f7f8affa983fa0c0a1543d1118cf67eb3abd609606b1c16fac076b8e5767ad4
5
5
  SHA512:
6
- metadata.gz: b16694b36fc8c42c3661a66d6ef94329b306a4b40a183f866993ed43460d3538ae120b68b5ec5115d83523cb252204fc34721c52fd93ba93e376a3246287caeb
7
- data.tar.gz: '01828651d1f2f96acbe07e85c5b02cd64153a7f7089a99e77c8d054e6694bd71c8514e6a8a7f864735ca0fac42f7c0358681194c0f7de914206f9ed3577e0799'
6
+ metadata.gz: 504d3453823e7cf2887da87a0aa9d56839292e2d8fbe2504e7ca76648d0addafef076e2c655eb3d4a1a88d6f688c7709b60237c3c9ed320676dd9fc7a8544a9f
7
+ data.tar.gz: aba9941405aea7ca05100162c5f7555c0257a9aee672662614167b30d4d75b3e32dd418f8e24d1ce8d014e2e81c4638d3e7d2ab172714d14cc677b51f3bac3a3
data/README.md CHANGED
@@ -19,6 +19,7 @@ Inertia HTTP request → controller re-evaluates props → React re-renders
19
19
  - [Model DSL](#model-dsl)
20
20
  - [Controller Helper](#controller-helper)
21
21
  - [React Hook](#react-hook)
22
+ - [Direct Messages](#direct-messages)
22
23
  - [Suppressing Broadcasts](#suppressing-broadcasts)
23
24
  - [Server-Side Debounce](#server-side-debounce)
24
25
  - [Testing](#testing)
@@ -181,11 +182,16 @@ post.broadcast_refresh_later_to(board, debounce: 2.0)
181
182
 
182
183
  # With inline condition (block — skips broadcast if falsy)
183
184
  post.broadcast_refresh_to(board) { published? }
185
+
186
+ # Direct messages (ephemeral data, no prop reload)
187
+ post.broadcast_message_to(board, data: { progress: 50 })
188
+ post.broadcast_message_later_to(board, data: { progress: 50 })
189
+ post.broadcast_message_to(board, data: { progress: 50 }) { running? }
184
190
  ```
185
191
 
186
192
  ### Broadcast payload
187
193
 
188
- Every broadcast sends this JSON:
194
+ Refresh broadcasts send this JSON:
189
195
 
190
196
  ```json
191
197
  {
@@ -200,6 +206,17 @@ Every broadcast sends this JSON:
200
206
 
201
207
  The `action` field is `"create"`, `"update"`, or `"destroy"`. The `extra` field contains data from the `extra:` option (empty object if not set).
202
208
 
209
+ Message broadcasts send a minimal payload:
210
+
211
+ ```json
212
+ {
213
+ "type": "message",
214
+ "data": { "progress": 50, "total": 200 }
215
+ }
216
+ ```
217
+
218
+ Messages are ephemeral — no `model`, `id`, `action`, or `timestamp` fields.
219
+
203
220
  ---
204
221
 
205
222
  ## Controller Helper
@@ -234,6 +251,9 @@ const { connected } = useInertiaCable(cable_stream, {
234
251
  console.log(`${data.model} #${data.id} was ${data.action}`)
235
252
  if (data.extra?.priority === 'high') toast.warn('Priority update!')
236
253
  },
254
+ onMessage: (data) => { // receive direct messages (no reload)
255
+ setProgress(data.progress)
256
+ },
237
257
  onConnected: () => {}, // subscription connected
238
258
  onDisconnected: () => {}, // connection dropped
239
259
  debounce: 200, // client-side debounce in ms (default: 100)
@@ -246,6 +266,7 @@ const { connected } = useInertiaCable(cable_stream, {
246
266
  | `only` | `string[]` | — | Only reload these props |
247
267
  | `except` | `string[]` | — | Reload all props except these |
248
268
  | `onRefresh` | `(data) => void` | — | Callback before each reload |
269
+ | `onMessage` | `(data) => void` | — | Receive direct message data (no reload) |
249
270
  | `onConnected` | `() => void` | — | Called when subscription connects |
250
271
  | `onDisconnected` | `() => void` | — | Called when connection drops |
251
272
  | `debounce` | `number` | `100` | Debounce delay in ms |
@@ -297,7 +318,70 @@ createInertiaApp({
297
318
 
298
319
  `getConsumer()` and `setConsumer()` are also exported for low-level access to the ActionCable consumer singleton.
299
320
 
300
- TypeScript types (`RefreshPayload`, `UseInertiaCableOptions`, `UseInertiaCableReturn`, `InertiaCableProviderProps`) are exported from `@inertia-cable/react`.
321
+ TypeScript types (`RefreshPayload`, `MessagePayload`, `CablePayload`, `UseInertiaCableOptions`, `UseInertiaCableReturn`, `InertiaCableProviderProps`) are exported from `@inertia-cable/react`. `CablePayload` is a discriminated union of `RefreshPayload | MessagePayload` for type-safe handling of raw payloads.
322
+
323
+ ---
324
+
325
+ ## Direct Messages
326
+
327
+ Push ephemeral data directly into React state over the same signed stream — no prop reload, no extra hook.
328
+
329
+ ### Job progress example
330
+
331
+ ```ruby
332
+ # app/jobs/csv_import_job.rb
333
+ class CsvImportJob < ApplicationJob
334
+ def perform(import)
335
+ rows = CSV.read(import.file.path)
336
+ rows.each_with_index do |row, i|
337
+ process_row(row)
338
+ import.broadcast_message_to(import.user, data: { progress: i + 1, total: rows.size })
339
+ end
340
+ import.broadcast_refresh_to(import.user) # final reload with completed data
341
+ end
342
+ end
343
+ ```
344
+
345
+ ```tsx
346
+ import { useState } from 'react'
347
+ import { useInertiaCable } from '@inertia-cable/react'
348
+
349
+ export default function ImportShow({ import_record, cable_stream }) {
350
+ const [progress, setProgress] = useState<{ progress: number; total: number } | null>(null)
351
+
352
+ useInertiaCable(cable_stream, {
353
+ only: ['import_record'],
354
+ onMessage: (data) => setProgress({ progress: data.progress as number, total: data.total as number }),
355
+ })
356
+
357
+ return (
358
+ <div>
359
+ <h1>Import #{import_record.id}</h1>
360
+ {progress && <p>Processing {progress.progress} / {progress.total}</p>}
361
+ </div>
362
+ )
363
+ }
364
+ ```
365
+
366
+ ### Usage patterns
367
+
368
+ ```tsx
369
+ // Prop reload only (unchanged)
370
+ useInertiaCable(stream, { only: ['messages'] })
371
+
372
+ // Direct data only (no reload)
373
+ useInertiaCable(stream, {
374
+ onMessage: (data) => setProgress(data.progress)
375
+ })
376
+
377
+ // Both — progress during job, final reload on completion
378
+ useInertiaCable(stream, {
379
+ only: ['imports'],
380
+ onMessage: (data) => setProgress(data.progress)
381
+ })
382
+ ```
383
+
384
+ Messages are delivered immediately with no debouncing. Each `broadcast_message_to` call triggers exactly one `onMessage` callback.
301
385
 
302
386
  ---
303
387
 
@@ -1,40 +1,105 @@
1
1
  module InertiaCable
2
2
  module Generators
3
3
  class InstallGenerator < Rails::Generators::Base
4
- source_root File.expand_path("templates", __dir__)
5
-
6
4
  desc "Install InertiaCable into your Rails application"
7
5
 
8
- def copy_cable_setup
9
- template "cable_setup.ts", "app/javascript/channels/inertia_cable.ts"
6
+ def patch_inertia_entrypoint
7
+ entrypoint = detect_entrypoint
8
+ unless entrypoint
9
+ say "Could not find Inertia entrypoint — you'll need to add InertiaCableProvider manually.", :yellow
10
+ return
11
+ end
12
+
13
+ say "Patching #{entrypoint}...", :green
14
+
15
+ # Add import after the createInertiaApp import
16
+ if File.read(entrypoint).include?("@inertia-cable/react")
17
+ say " Import already present, skipping", :yellow
18
+ else
19
+ inject_into_file entrypoint, after: /^import \{ createInertiaApp \} from .+\n/ do
20
+ "import { InertiaCableProvider } from '@inertia-cable/react'\n"
21
+ end
22
+ end
23
+
24
+ content = File.read(entrypoint)
25
+
26
+ if content.include?("InertiaCableProvider")
27
+ say " InertiaCableProvider already present, skipping", :yellow
28
+ return
29
+ end
30
+
31
+ # Pattern 1: createElement style
32
+ # createRoot(el).render(createElement(App, props))
33
+ if content.match?(/createRoot\(el\)\.render\(\s*createElement\(App,\s*props\)\s*\)/)
34
+ gsub_file entrypoint,
35
+ /createRoot\(el\)\.render\(\s*createElement\(App,\s*props\)\s*\)/,
36
+ "createRoot(el).render(\n createElement(InertiaCableProvider, null, createElement(App, props)),\n )"
37
+ say " Wrapped render in InertiaCableProvider (createElement style)", :green
38
+
39
+ # Pattern 2: JSX with StrictMode
40
+ # createRoot(el).render(<StrictMode><App {...props} /></StrictMode>)
41
+ elsif content.match?(/createRoot\(el\)\.render\(\s*\n?\s*<StrictMode>\s*\n?\s*<App\s+\{\.\.\.props\}\s*\/>\s*\n?\s*<\/StrictMode>/)
42
+ gsub_file entrypoint,
43
+ /<StrictMode>\s*\n?\s*<App\s+\{\.\.\.props\}\s*\/>\s*\n?\s*<\/StrictMode>/,
44
+ "<StrictMode>\n <InertiaCableProvider>\n <App {...props} />\n </InertiaCableProvider>\n </StrictMode>"
45
+ say " Wrapped render in InertiaCableProvider (JSX + StrictMode style)", :green
46
+
47
+ # Pattern 3: JSX without StrictMode
48
+ # createRoot(el).render(<App {...props} />)
49
+ elsif content.match?(/createRoot\(el\)\.render\(\s*\n?\s*<App\s+\{\.\.\.props\}\s*\/>/)
50
+ gsub_file entrypoint,
51
+ /<App\s+\{\.\.\.props\}\s*\/>/,
52
+ "<InertiaCableProvider>\n <App {...props} />\n </InertiaCableProvider>"
53
+ say " Wrapped render in InertiaCableProvider (JSX style)", :green
54
+
55
+ else
56
+ say " Could not detect render pattern — add InertiaCableProvider manually.", :yellow
57
+ say " See: https://github.com/cole-robertson/inertia_cable#inertiaCableProvider"
58
+ end
10
59
  end
11
60
 
12
- def show_instructions
61
+ def show_next_steps
13
62
  say ""
14
63
  say "InertiaCable installed!", :green
15
64
  say ""
16
65
  say "Next steps:"
17
- say " 1. Add the npm package to your frontend:"
18
- say " npm install @inertia-cable/react @rails/actioncable"
19
66
  say ""
20
- say " 2. Ensure ActionCable is configured in config/cable.yml"
21
- say " (use redis adapter for production)"
67
+ say " 1. Install the npm package:"
68
+ say " npm install @inertia-cable/react @rails/actioncable"
22
69
  say ""
23
- say " 3. Add broadcasts_refreshes_to to your models:"
70
+ say " 2. Add broadcasts to your models:"
24
71
  say " class Message < ApplicationRecord"
25
72
  say " belongs_to :chat"
26
- say " broadcasts_refreshes_to :chat"
73
+ say " broadcasts_to :chat"
27
74
  say " end"
28
75
  say ""
29
- say " 4. Pass cable_stream prop from your controller:"
76
+ say " 3. Pass cable_stream prop from your controller:"
30
77
  say " render inertia: 'Chats/Show', props: {"
31
78
  say " cable_stream: inertia_cable_stream(chat)"
32
79
  say " }"
33
80
  say ""
34
- say " 5. Use the hook in your React component:"
35
- say " useInertiaCable(cable_stream, { only: ['messages'] })"
81
+ say " 4. Use the hook in your React component:"
82
+ say " import { useInertiaCable } from '@inertia-cable/react'"
83
+ say ""
84
+ say " const { connected } = useInertiaCable(cable_stream, { only: ['messages'] })"
36
85
  say ""
37
86
  end
87
+
88
+ private
89
+
90
+ def detect_entrypoint
91
+ candidates = %w[
92
+ app/frontend/entrypoints/inertia.ts
93
+ app/frontend/entrypoints/inertia.tsx
94
+ app/frontend/entrypoints/inertia.js
95
+ app/frontend/entrypoints/inertia.jsx
96
+ app/javascript/entrypoints/inertia.ts
97
+ app/javascript/entrypoints/inertia.tsx
98
+ app/javascript/entrypoints/inertia.js
99
+ app/javascript/entrypoints/inertia.jsx
100
+ ]
101
+ candidates.find { |f| File.exist?(Rails.root.join(f)) }
102
+ end
38
103
  end
39
104
  end
40
105
  end
@@ -128,6 +128,33 @@ module InertiaCable
128
128
  broadcast_refresh_later_to(self.class.model_name.plural, &block)
129
129
  end
130
130
 
131
+ # Broadcast a direct message synchronously to explicit stream(s).
132
+ #
133
+ # post.broadcast_message_to(board, data: { progress: 50 })
134
+ # post.broadcast_message_to(board, :posts, data: { progress: 50 })
135
+ # post.broadcast_message_to(board, data: { progress: 50 }) { running? }
136
+ #
137
+ def broadcast_message_to(*streamables, data:, &block)
138
+ return if self.class.suppressed_inertia_cable_broadcasts?
139
+ return if block && !instance_exec(&block)
140
+
141
+ InertiaCable.broadcast(streamables, message_payload(data: data))
142
+ end
143
+
144
+ # Broadcast a direct message asynchronously to explicit stream(s).
145
+ #
146
+ # post.broadcast_message_later_to(board, data: { progress: 50 })
147
+ # post.broadcast_message_later_to(board, :posts, data: { progress: 50 })
148
+ # post.broadcast_message_later_to(board, data: { progress: 50 }) { running? }
149
+ #
150
+ def broadcast_message_later_to(*streamables, data:, &block)
151
+ return if self.class.suppressed_inertia_cable_broadcasts?
152
+ return if block && !instance_exec(&block)
153
+
154
+ resolved = InertiaCable::Streams::StreamName.stream_name_from(streamables)
155
+ InertiaCable::BroadcastJob.perform_later(resolved, message_payload(data: data))
156
+ end
157
+
131
158
  private
132
159
 
133
160
  def resolve_stream(stream)
@@ -168,5 +195,9 @@ module InertiaCable
168
195
  payload[:extra] = extra if extra.present?
169
196
  payload
170
197
  end
198
+
199
+ def message_payload(data:)
200
+ { type: "message", data: data }
201
+ end
171
202
  end
172
203
  end
@@ -1,3 +1,3 @@
1
1
  module InertiaCable
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inertia_cable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole Robertson
@@ -75,7 +75,6 @@ files:
75
75
  - README.md
76
76
  - app/channels/inertia_cable/stream_channel.rb
77
77
  - lib/generators/inertia_cable/install/install_generator.rb
78
- - lib/generators/inertia_cable/install/templates/cable_setup.ts
79
78
  - lib/inertia_cable.rb
80
79
  - lib/inertia_cable/broadcast_job.rb
81
80
  - lib/inertia_cable/broadcastable.rb
@@ -1,7 +0,0 @@
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'