turbo_cable 1.0.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/CHANGELOG.md +56 -0
- data/EXAMPLES.md +480 -0
- data/MIT-LICENSE +20 -0
- data/README.md +337 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/turbo_cable/application.css +15 -0
- data/app/controllers/turbo_cable/application_controller.rb +4 -0
- data/app/helpers/turbo_cable/application_helper.rb +4 -0
- data/app/helpers/turbo_cable/streams_helper.rb +16 -0
- data/app/jobs/turbo_cable/application_job.rb +4 -0
- data/app/jobs/turbo_cable/broadcast_job.rb +71 -0
- data/app/mailers/turbo_cable/application_mailer.rb +6 -0
- data/app/models/turbo_cable/application_record.rb +5 -0
- data/app/views/layouts/turbo_cable/application.html.erb +17 -0
- data/config/routes.rb +2 -0
- data/lib/generators/turbo_cable/install/install_generator.rb +47 -0
- data/lib/generators/turbo_cable/install/templates/turbo_streams_controller.js +184 -0
- data/lib/tasks/turbo_cable_tasks.rake +4 -0
- data/lib/turbo_cable/broadcastable.rb +161 -0
- data/lib/turbo_cable/engine.rb +24 -0
- data/lib/turbo_cable/rack_handler.rb +204 -0
- data/lib/turbo_cable/version.rb +3 -0
- data/lib/turbo_cable.rb +9 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 30c119c76d7a8582b017756899eeee0fcdd1ffddbb2e2b1e521c65a1f62dbe52
|
|
4
|
+
data.tar.gz: 1fd9541c95cd5e00c770e827b3ada328052d8e5a988322d22f1bb4577d1db7f4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ac0e5d7562e5bc03e2d8bf1d396ed0c16d894cf70aeb91573be29c19ed91e82fb90f28fbc5e584386cd10915362bb5640e605c2401a8a7b4d23912f785d6c993
|
|
7
|
+
data.tar.gz: 9ad4dbe3b6dd0b7b68c6de10ad3e38fc45480974b1fd26de5dd9db158a5ad1c9d10d3a079ba5a14a4b9c04e873612c76cf1939a7a27d7f0e14752f1a878ef00a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2025-01-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial public release of TurboCable
|
|
13
|
+
- WebSocket-based Turbo Streams implementation with 79-85% memory savings vs Action Cable
|
|
14
|
+
- Full API compatibility with Action Cable's Turbo Streams integration
|
|
15
|
+
- RFC 6455 compliant WebSocket protocol implementation via Rack hijack
|
|
16
|
+
- All Turbo Stream broadcast methods (`broadcast_replace_to`, `broadcast_update_to`, `broadcast_append_to`, `broadcast_prepend_to`, `broadcast_remove_to`)
|
|
17
|
+
- Async variants (`broadcast_*_later_to`) with hybrid Active Job support
|
|
18
|
+
- Custom JSON broadcasting via `broadcast_json` for structured data
|
|
19
|
+
- `turbo_stream_from` helper for subscribing to streams
|
|
20
|
+
- Stimulus controller for client-side WebSocket management
|
|
21
|
+
- Auto-reconnection with 3-second delay on disconnect
|
|
22
|
+
- 60-second read timeout for dead connection cleanup
|
|
23
|
+
- Thread-safe subscription management
|
|
24
|
+
- Generator for one-command installation (`rails generate turbo_cable:install`)
|
|
25
|
+
- Comprehensive test suite (rack_handler, broadcastable, streams_helper, integration tests)
|
|
26
|
+
- Support for Rails 7.0+
|
|
27
|
+
- Support for Ruby 3.0+
|
|
28
|
+
- Zero external dependencies beyond Ruby stdlib
|
|
29
|
+
|
|
30
|
+
### Architecture
|
|
31
|
+
|
|
32
|
+
- Rack middleware for WebSocket upgrade handling (`/cable`)
|
|
33
|
+
- HTTP POST endpoint for broadcasts (`/_broadcast`, localhost-only)
|
|
34
|
+
- In-process connection management (single-server deployments only)
|
|
35
|
+
- Hybrid async/sync broadcast delivery based on Active Job configuration
|
|
36
|
+
|
|
37
|
+
### Security
|
|
38
|
+
|
|
39
|
+
- Localhost-only broadcast endpoint (127.0.0.0/8 and ::1) prevents XSS attacks
|
|
40
|
+
- No authentication on WebSocket connections (suitable for public broadcasts)
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
|
|
44
|
+
- Comprehensive README with installation, usage, and migration guide
|
|
45
|
+
- EXAMPLES.md with production patterns (live scoring, progress tracking, custom JSON)
|
|
46
|
+
- CLAUDE.md with detailed architecture, protocol specification, and development workflow
|
|
47
|
+
- Clear warnings about single-server constraint and when NOT to use TurboCable
|
|
48
|
+
|
|
49
|
+
### Known Limitations
|
|
50
|
+
|
|
51
|
+
- Single-server deployments only (no multi-server/horizontal scaling)
|
|
52
|
+
- No `stream_for` helper support
|
|
53
|
+
- No bidirectional WebSocket communication (server→client only)
|
|
54
|
+
- No Action Cable channels DSL support
|
|
55
|
+
|
|
56
|
+
[1.0.0]: https://github.com/rubys/turbo_cable/releases/tag/v1.0.0
|
data/EXAMPLES.md
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# Real-World Examples
|
|
2
|
+
|
|
3
|
+
This document shows real-world patterns for using TurboCable, drawn from production applications.
|
|
4
|
+
|
|
5
|
+
## Live Updates
|
|
6
|
+
|
|
7
|
+
### Live Scoring Display
|
|
8
|
+
|
|
9
|
+
**Use Case**: Multiple judges entering scores during a competition. As each score is entered, all connected clients see the update in real-time.
|
|
10
|
+
|
|
11
|
+
**View** (`app/views/scores/index.html.erb`):
|
|
12
|
+
```erb
|
|
13
|
+
<div id="scores-board">
|
|
14
|
+
<%= turbo_stream_from "live-scores-#{@event.id}" %>
|
|
15
|
+
|
|
16
|
+
<%= render @scores %>
|
|
17
|
+
</div>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Model** (`app/models/score.rb`):
|
|
21
|
+
```ruby
|
|
22
|
+
class Score < ApplicationRecord
|
|
23
|
+
after_save do
|
|
24
|
+
broadcast_replace_later_to "live-scores-#{event_id}",
|
|
25
|
+
partial: "scores/score",
|
|
26
|
+
target: dom_id(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
after_destroy do
|
|
30
|
+
broadcast_remove_to "live-scores-#{event_id}",
|
|
31
|
+
target: dom_id(self)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Controller** (optional - client updates via form, server broadcasts):
|
|
37
|
+
```ruby
|
|
38
|
+
class ScoresController < ApplicationController
|
|
39
|
+
def update
|
|
40
|
+
@score.update(score_params)
|
|
41
|
+
# Turbo Stream broadcast happens automatically via after_save callback
|
|
42
|
+
respond_to do |format|
|
|
43
|
+
format.turbo_stream # Return empty response or specific update
|
|
44
|
+
format.html { redirect_to scores_path }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Pattern**:
|
|
51
|
+
- Client → Server: HTTP POST/PATCH (form submission, Turbo)
|
|
52
|
+
- Server → All Clients: WebSocket broadcast (Turbo Stream)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### Current Status Display
|
|
57
|
+
|
|
58
|
+
**Use Case**: Display which heat/round is currently active. When organizers advance to the next heat, all connected displays update automatically.
|
|
59
|
+
|
|
60
|
+
**View** (`app/views/events/show.html.erb`):
|
|
61
|
+
```erb
|
|
62
|
+
<div id="current-heat-display">
|
|
63
|
+
<%= turbo_stream_from "current-heat-#{@event.id}" %>
|
|
64
|
+
|
|
65
|
+
<h2>Now on Floor</h2>
|
|
66
|
+
<%= turbo_frame_tag "current-heat" do %>
|
|
67
|
+
<%= render "current_heat", heat: @event.current_heat %>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Model** (`app/models/event.rb`):
|
|
73
|
+
```ruby
|
|
74
|
+
class Event < ApplicationRecord
|
|
75
|
+
def advance_to_next_heat!
|
|
76
|
+
self.current_heat_number += 1
|
|
77
|
+
save!
|
|
78
|
+
|
|
79
|
+
broadcast_replace_to "current-heat-#{id}",
|
|
80
|
+
partial: "events/current_heat",
|
|
81
|
+
target: "current-heat",
|
|
82
|
+
locals: { heat: current_heat }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Pattern**: Admin action triggers broadcast to all passive viewers.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Progress Tracking
|
|
92
|
+
|
|
93
|
+
**Use Case**: User initiates a large file upload or batch operation. Progress bar updates in real-time without polling.
|
|
94
|
+
|
|
95
|
+
**View** (`app/views/playlists/new.html.erb`):
|
|
96
|
+
```erb
|
|
97
|
+
<div id="download-progress">
|
|
98
|
+
<%= turbo_stream_from "offline_playlist_#{current_user.id}" %>
|
|
99
|
+
|
|
100
|
+
<%= turbo_frame_tag "progress-bar" do %>
|
|
101
|
+
<div class="progress">
|
|
102
|
+
<div class="progress-bar" style="width: 0%">0%</div>
|
|
103
|
+
</div>
|
|
104
|
+
<% end %>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<%= button_to "Download Playlist", offline_playlist_path, method: :post %>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Controller** (`app/controllers/playlists_controller.rb`):
|
|
111
|
+
```ruby
|
|
112
|
+
class PlaylistsController < ApplicationController
|
|
113
|
+
def offline_playlist
|
|
114
|
+
OfflinePlaylistJob.perform_later(current_user.id, params[:playlist_id])
|
|
115
|
+
|
|
116
|
+
respond_to do |format|
|
|
117
|
+
format.turbo_stream # Show initial progress UI
|
|
118
|
+
format.html { redirect_to playlists_path, notice: "Download started..." }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Job** (`app/jobs/offline_playlist_job.rb`):
|
|
125
|
+
```ruby
|
|
126
|
+
class OfflinePlaylistJob < ApplicationJob
|
|
127
|
+
def perform(user_id, playlist_id)
|
|
128
|
+
playlist = Playlist.find(playlist_id)
|
|
129
|
+
total = playlist.songs.count
|
|
130
|
+
|
|
131
|
+
playlist.songs.each_with_index do |song, index|
|
|
132
|
+
# Process song...
|
|
133
|
+
|
|
134
|
+
# Broadcast progress
|
|
135
|
+
progress = ((index + 1).to_f / total * 100).round
|
|
136
|
+
broadcast_replace_to "offline_playlist_#{user_id}",
|
|
137
|
+
partial: "playlists/progress",
|
|
138
|
+
target: "progress-bar",
|
|
139
|
+
locals: { progress: progress }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Broadcast completion
|
|
143
|
+
broadcast_replace_to "offline_playlist_#{user_id}",
|
|
144
|
+
partial: "playlists/complete",
|
|
145
|
+
target: "progress-bar",
|
|
146
|
+
locals: { download_url: playlist.zip_url }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Pattern**:
|
|
152
|
+
- User-specific stream ensures progress only goes to requesting user
|
|
153
|
+
- Job broadcasts updates as work progresses
|
|
154
|
+
- Final broadcast includes download link
|
|
155
|
+
|
|
156
|
+
**Variation - Multiple Items**: For tracking multiple parallel operations, use different target IDs:
|
|
157
|
+
```ruby
|
|
158
|
+
servers.each do |server|
|
|
159
|
+
broadcast_update_to "update_progress_#{user_id}",
|
|
160
|
+
target: "server-#{server.id}", # Different target per item
|
|
161
|
+
locals: { server: server, status: "Updating..." }
|
|
162
|
+
|
|
163
|
+
server.update_configuration!
|
|
164
|
+
|
|
165
|
+
broadcast_update_to "update_progress_#{user_id}",
|
|
166
|
+
target: "server-#{server.id}",
|
|
167
|
+
locals: { server: server, status: "Complete" }
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Custom JSON Broadcasting
|
|
174
|
+
|
|
175
|
+
**Use Case**: Send structured JSON data instead of HTML when you need custom client-side handling, such as updating a progress bar, chart, or any interactive component.
|
|
176
|
+
|
|
177
|
+
### Progress Bar with JSON Events
|
|
178
|
+
|
|
179
|
+
**View** (`app/views/playlists/show.html.erb`):
|
|
180
|
+
```erb
|
|
181
|
+
<div data-controller="offline-playlist"
|
|
182
|
+
data-offline-playlist-stream-value="offline_playlist_<%= ENV['RAILS_APP_DB'] %>_<%= current_user.id %>">
|
|
183
|
+
<%= turbo_stream_from "offline_playlist_#{ENV['RAILS_APP_DB']}_#{current_user.id}" %>
|
|
184
|
+
|
|
185
|
+
<button data-action="click->offline-playlist#generate">
|
|
186
|
+
Prepare Offline Version
|
|
187
|
+
</button>
|
|
188
|
+
|
|
189
|
+
<div data-offline-playlist-target="progress" class="hidden">
|
|
190
|
+
<div class="progress-bar">
|
|
191
|
+
<div data-offline-playlist-target="progressBar" style="width: 0%">0%</div>
|
|
192
|
+
</div>
|
|
193
|
+
<p data-offline-playlist-target="message">Starting...</p>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Stimulus Controller** (`app/javascript/controllers/offline_playlist_controller.js`):
|
|
199
|
+
```javascript
|
|
200
|
+
import { Controller } from "@hotwired/stimulus"
|
|
201
|
+
|
|
202
|
+
export default class extends Controller {
|
|
203
|
+
static targets = ["button", "progress", "message", "progressBar"]
|
|
204
|
+
static values = { stream: String }
|
|
205
|
+
|
|
206
|
+
connect() {
|
|
207
|
+
// Listen for custom JSON events from TurboCable
|
|
208
|
+
this.boundHandleMessage = this.handleMessage.bind(this)
|
|
209
|
+
document.addEventListener('turbo:stream-message', this.boundHandleMessage)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
disconnect() {
|
|
213
|
+
document.removeEventListener('turbo:stream-message', this.boundHandleMessage)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
handleMessage(event) {
|
|
217
|
+
const { stream, data } = event.detail
|
|
218
|
+
|
|
219
|
+
// Only handle events for our stream
|
|
220
|
+
if (stream !== this.streamValue) return
|
|
221
|
+
|
|
222
|
+
// Handle different message types
|
|
223
|
+
switch (data.status) {
|
|
224
|
+
case 'processing':
|
|
225
|
+
this.updateProgress(data.progress, data.message)
|
|
226
|
+
break
|
|
227
|
+
case 'completed':
|
|
228
|
+
this.showDownloadLink(data.download_key)
|
|
229
|
+
break
|
|
230
|
+
case 'error':
|
|
231
|
+
this.showError(data.message)
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
generate() {
|
|
237
|
+
this.buttonTarget.disabled = true
|
|
238
|
+
this.progressTarget.classList.remove("hidden")
|
|
239
|
+
|
|
240
|
+
// Trigger job via HTTP
|
|
241
|
+
fetch(window.location.pathname, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: {
|
|
244
|
+
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
updateProgress(percent, message) {
|
|
250
|
+
this.progressBarTarget.style.width = `${percent}%`
|
|
251
|
+
this.progressBarTarget.textContent = `${percent}%`
|
|
252
|
+
this.messageTarget.textContent = message
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
showDownloadLink(cacheKey) {
|
|
256
|
+
const downloadUrl = `${window.location.pathname}.zip?cache_key=${cacheKey}`
|
|
257
|
+
this.messageTarget.innerHTML = `
|
|
258
|
+
<a href="${downloadUrl}" class="download-button">Download Playlist</a>
|
|
259
|
+
`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
showError(message) {
|
|
263
|
+
this.messageTarget.textContent = message
|
|
264
|
+
this.messageTarget.classList.add("error")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Job** (`app/jobs/offline_playlist_job.rb`):
|
|
270
|
+
```ruby
|
|
271
|
+
class OfflinePlaylistJob < ApplicationJob
|
|
272
|
+
def perform(event_id, user_id)
|
|
273
|
+
database = ENV['RAILS_APP_DB']
|
|
274
|
+
stream_name = "offline_playlist_#{database}_#{user_id}"
|
|
275
|
+
|
|
276
|
+
# Broadcast initial status
|
|
277
|
+
TurboCable::Broadcastable.broadcast_json(stream_name, {
|
|
278
|
+
status: 'processing',
|
|
279
|
+
progress: 0,
|
|
280
|
+
message: 'Starting playlist generation...'
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
total_heats = Solo.joins(:heat).where(heats: { number: 1.. }).count
|
|
284
|
+
|
|
285
|
+
if total_heats == 0
|
|
286
|
+
TurboCable::Broadcastable.broadcast_json(stream_name, {
|
|
287
|
+
status: 'error',
|
|
288
|
+
message: 'No solos found'
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Process items and broadcast progress
|
|
294
|
+
processed = 0
|
|
295
|
+
Solo.joins(:heat).where(heats: { number: 1.. }).find_each do |solo|
|
|
296
|
+
# ... do processing ...
|
|
297
|
+
|
|
298
|
+
processed += 1
|
|
299
|
+
progress = (processed.to_f / total_heats * 100).to_i
|
|
300
|
+
|
|
301
|
+
# Broadcast progress update
|
|
302
|
+
TurboCable::Broadcastable.broadcast_json(stream_name, {
|
|
303
|
+
status: 'processing',
|
|
304
|
+
progress: progress,
|
|
305
|
+
message: "Processing heat #{processed} of #{total_heats}..."
|
|
306
|
+
})
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Generate final file
|
|
310
|
+
cache_key = generate_zip_file(event_id, user_id)
|
|
311
|
+
|
|
312
|
+
# Broadcast completion
|
|
313
|
+
TurboCable::Broadcastable.broadcast_json(stream_name, {
|
|
314
|
+
status: 'completed',
|
|
315
|
+
progress: 100,
|
|
316
|
+
message: 'Playlist ready for download',
|
|
317
|
+
download_key: cache_key
|
|
318
|
+
})
|
|
319
|
+
rescue => e
|
|
320
|
+
Rails.logger.error("Playlist generation failed: #{e.message}")
|
|
321
|
+
TurboCable::Broadcastable.broadcast_json(stream_name, {
|
|
322
|
+
status: 'error',
|
|
323
|
+
message: "Failed to generate playlist: #{e.message}"
|
|
324
|
+
})
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Pattern**:
|
|
330
|
+
- Use `TurboCable::Broadcastable.broadcast_json()` to send structured data
|
|
331
|
+
- JavaScript receives `turbo:stream-message` CustomEvent with `{ stream, data }`
|
|
332
|
+
- Stimulus controller filters events by stream name
|
|
333
|
+
- Full control over client-side behavior (animations, state management, etc.)
|
|
334
|
+
|
|
335
|
+
**When to use JSON vs HTML**:
|
|
336
|
+
- ✅ **JSON**: Progress bars, charts, interactive widgets, state machines
|
|
337
|
+
- ✅ **HTML**: Simple DOM updates, lists, forms, standard content
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Background Command Output
|
|
342
|
+
|
|
343
|
+
**Use Case**: Admin runs a long-running command. Output streams to browser as it's generated.
|
|
344
|
+
|
|
345
|
+
**View** (`app/views/admin/commands/show.html.erb`):
|
|
346
|
+
```erb
|
|
347
|
+
<div id="command-output">
|
|
348
|
+
<%= turbo_stream_from "command_output_#{current_user.id}_#{@job_id}" %>
|
|
349
|
+
|
|
350
|
+
<%= turbo_frame_tag "output-log" do %>
|
|
351
|
+
<pre><code></code></pre>
|
|
352
|
+
<% end %>
|
|
353
|
+
</div>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Job** (`app/jobs/command_execution_job.rb`):
|
|
357
|
+
```ruby
|
|
358
|
+
class CommandExecutionJob < ApplicationJob
|
|
359
|
+
def perform(user_id, command, job_id)
|
|
360
|
+
stream_name = "command_output_#{user_id}_#{job_id}"
|
|
361
|
+
|
|
362
|
+
Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
|
|
363
|
+
stdout.each_line do |line|
|
|
364
|
+
broadcast_append_to stream_name,
|
|
365
|
+
partial: "admin/commands/line",
|
|
366
|
+
target: "output-log code",
|
|
367
|
+
locals: { line: line }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
exit_status = wait_thr.value
|
|
371
|
+
broadcast_append_to stream_name,
|
|
372
|
+
partial: "admin/commands/status",
|
|
373
|
+
target: "output-log",
|
|
374
|
+
locals: { status: exit_status }
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Pattern**:
|
|
381
|
+
- Job-specific stream (includes job_id) for multiple concurrent commands
|
|
382
|
+
- `append` action adds new lines without replacing previous output
|
|
383
|
+
- Works like `tail -f` in browser
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Key Patterns
|
|
388
|
+
|
|
389
|
+
### Stream Naming Conventions
|
|
390
|
+
|
|
391
|
+
**Global streams** (all users see same data):
|
|
392
|
+
```ruby
|
|
393
|
+
"live-scores-#{event.id}"
|
|
394
|
+
"current-heat-#{event.id}"
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**User-specific streams** (only one user sees data):
|
|
398
|
+
```ruby
|
|
399
|
+
"progress_#{user_id}"
|
|
400
|
+
"notifications_#{user_id}"
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**Job-specific streams** (multiple concurrent operations):
|
|
404
|
+
```ruby
|
|
405
|
+
"job_#{job_id}_#{user_id}"
|
|
406
|
+
"upload_#{upload_id}"
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Multi-tenant streams** (isolated by tenant/database):
|
|
410
|
+
```ruby
|
|
411
|
+
"updates_#{tenant_id}_#{resource_id}"
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Broadcast Methods
|
|
415
|
+
|
|
416
|
+
**Replace entire element**:
|
|
417
|
+
```ruby
|
|
418
|
+
broadcast_replace_to stream, target: "element-id", partial: "path/to/partial"
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**Update element contents**:
|
|
422
|
+
```ruby
|
|
423
|
+
broadcast_update_to stream, target: "element-id", partial: "path/to/partial"
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
**Append to list**:
|
|
427
|
+
```ruby
|
|
428
|
+
broadcast_append_to stream, target: "list-id", partial: "path/to/item"
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Prepend to list**:
|
|
432
|
+
```ruby
|
|
433
|
+
broadcast_prepend_to stream, target: "list-id", partial: "path/to/item"
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Remove element**:
|
|
437
|
+
```ruby
|
|
438
|
+
broadcast_remove_to stream, target: "element-id"
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### When TurboCable Works Well
|
|
442
|
+
|
|
443
|
+
✅ **Real-time dashboards** - Live metrics, status displays
|
|
444
|
+
✅ **Progress indicators** - Upload/download/processing progress
|
|
445
|
+
✅ **Live updates** - Scores, votes, counts, status changes
|
|
446
|
+
✅ **Notifications** - User-specific alerts and messages
|
|
447
|
+
✅ **Background job feedback** - Show what's happening in async jobs
|
|
448
|
+
✅ **Multi-user coordination** - Everyone sees same current state
|
|
449
|
+
|
|
450
|
+
### When You Need Action Cable Instead
|
|
451
|
+
|
|
452
|
+
❌ **Chat applications** - Requires client→server messages over WebSocket
|
|
453
|
+
❌ **Collaborative editing** - Needs operational transforms over WebSocket
|
|
454
|
+
❌ **Real-time drawing** - Continuous client→server data stream
|
|
455
|
+
❌ **Gaming** - Low-latency bidirectional communication
|
|
456
|
+
❌ **WebRTC signaling** - Peer coordination requires bidirectional channels
|
|
457
|
+
|
|
458
|
+
**Rule of thumb**: If you can describe your feature as "when X happens on the server, update Y on all clients", TurboCable works. If you need "when user does X in browser, send message to server over WebSocket", use Action Cable.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Migration from Action Cable
|
|
463
|
+
|
|
464
|
+
All of showcase's 5 Action Cable channels were pure `stream_from` channels with no custom actions. They migrated to TurboCable with **zero code changes** to views or models:
|
|
465
|
+
|
|
466
|
+
**Before** (`app/channels/scores_channel.rb`):
|
|
467
|
+
```ruby
|
|
468
|
+
class ScoresChannel < ApplicationCable::Channel
|
|
469
|
+
def subscribed
|
|
470
|
+
stream_from "live-scores-#{ENV['RAILS_APP_DB']}"
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**After**: Delete the channel file entirely. Use `turbo_stream_from` in views:
|
|
476
|
+
```erb
|
|
477
|
+
<%= turbo_stream_from "live-scores-#{@event.id}" %>
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
**If your Action Cable channel has custom action methods**, it won't migrate cleanly - you'll need to convert those to HTTP endpoints.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Sam Ruby
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|