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 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.