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.
data/README.md ADDED
@@ -0,0 +1,337 @@
1
+ # TurboCable
2
+
3
+ Custom WebSocket-based Turbo Streams implementation for Rails. Provides significant memory savings (79-85% reduction) for single-server deployments.
4
+
5
+ ## ⚠️ Important Limitations
6
+
7
+ **TurboCable is designed for specific use cases.** Read carefully before adopting:
8
+
9
+ ### ✅ When to Use TurboCable
10
+
11
+ - **Single-server applications** - All users connect to one Rails instance
12
+ - **Development environments** - Great for local dev with live reloading
13
+ - **Single-tenant deployments** - Each customer/event runs independently
14
+ - **Resource-constrained environments** - Memory savings matter (VPS, embedded)
15
+ - **Simple real-time needs** - Basic live updates within one process
16
+
17
+ ### ❌ When NOT to Use TurboCable
18
+
19
+ - **Horizontally scaled apps** - Multiple servers/dynos serving same application (Heroku, AWS ECS, Kubernetes with replicas)
20
+ - **Load-balanced production** - Multiple Rails instances behind a load balancer
21
+ - **Cross-server broadcasts** - Need to broadcast to users on different machines
22
+ - **High-availability setups** - Require Redis or Solid Cable backed pub/sub across instances
23
+ - **Bidirectional WebSocket communication** - Client→Server data flow over WebSockets (chat apps, collaborative editing, real-time drawing)
24
+ - **Action Cable channels** - Custom channels with server-side actions and the channels DSL
25
+
26
+ **If you need cross-server broadcasts or bidirectional WebSocket communication, stick with Action Cable + Redis/Solid Cable.** TurboCable only broadcasts within a single Rails process and only supports server→client Turbo Streams.
27
+
28
+ ## Why TurboCable?
29
+
30
+ For applications that fit the constraints above, Action Cable's memory overhead may be unnecessary. TurboCable provides the same Turbo Streams functionality using a lightweight WebSocket implementation built on Rack hijack and RFC 6455, with zero external dependencies beyond Ruby's standard library.
31
+
32
+ **Memory Savings (single server):**
33
+ - Action Cable: ~169MB per process
34
+ - TurboCable: ~25-35MB per process
35
+ - **Savings: 134-144MB (79-85% reduction)**
36
+
37
+ ## Features
38
+
39
+ For applications within the constraints above:
40
+
41
+ - **Turbo Streams API compatibility** - Same `turbo_stream_from` and `broadcast_*` methods
42
+ - **Zero dependencies** - Only Ruby stdlib (no Redis, no Solid Cable, no external services)
43
+ - **Hybrid async/sync** - Uses Active Job when available, otherwise synchronous (transparent)
44
+ - **Simple installation** - `rails generate turbo_cable:install`
45
+ - **All Turbo Stream actions** - replace, update, append, prepend, remove
46
+ - **Auto-reconnection** - Handles connection drops gracefully
47
+ - **Thread-safe** - Concurrent connections and broadcasts
48
+ - **RFC 6455 compliant** - Standard WebSocket protocol
49
+
50
+ ## Installation
51
+
52
+ Add this line to your application's Gemfile:
53
+
54
+ ```ruby
55
+ gem "turbo_cable"
56
+ ```
57
+
58
+ Install the gem:
59
+
60
+ ```bash
61
+ bundle install
62
+ ```
63
+
64
+ Run the installer:
65
+
66
+ ```bash
67
+ rails generate turbo_cable:install
68
+ ```
69
+
70
+ This will:
71
+ 1. Copy the Stimulus controller to `app/javascript/controllers/turbo_streams_controller.js`
72
+ 2. Add `data-controller="turbo-streams"` to your `<body>` tag
73
+
74
+ Restart your Rails server and you're done!
75
+
76
+ ## Usage
77
+
78
+ > **💡 Want real-world examples?** See [EXAMPLES.md](EXAMPLES.md) for patterns drawn from production applications: live scoring, progress tracking, background job output, and more.
79
+
80
+ ### In Your Views
81
+
82
+ Use `turbo_stream_from` exactly as you would with Action Cable:
83
+
84
+ ```erb
85
+ <div>
86
+ <%= turbo_stream_from "counter_updates" %>
87
+
88
+ <span id="counter-value"><%= @counter.value %></span>
89
+ </div>
90
+ ```
91
+
92
+ ### In Your Models
93
+
94
+ Use the same broadcast methods you're familiar with:
95
+
96
+ ```ruby
97
+ class Counter < ApplicationRecord
98
+ def broadcast_update
99
+ broadcast_replace_later_to "counter_updates",
100
+ target: "counter-value",
101
+ html: "<span id='counter-value'>#{value}</span>"
102
+ end
103
+ end
104
+ ```
105
+
106
+ **Available broadcast methods:**
107
+ - `broadcast_replace_later_to` / `broadcast_replace_to`
108
+ - `broadcast_update_later_to` / `broadcast_update_to`
109
+ - `broadcast_append_later_to` / `broadcast_append_to`
110
+ - `broadcast_prepend_later_to` / `broadcast_prepend_to`
111
+ - `broadcast_remove_to`
112
+
113
+ All methods support the same options as Turbo Streams:
114
+ - `target:` - DOM element ID
115
+ - `partial:` - Render a partial
116
+ - `html:` - Use raw HTML
117
+ - `locals:` - Pass locals to partial
118
+
119
+ ### Example with Partial
120
+
121
+ ```ruby
122
+ class Score < ApplicationRecord
123
+ after_save do
124
+ broadcast_replace_later_to "live-scores",
125
+ partial: "scores/score",
126
+ target: dom_id(self)
127
+ end
128
+ end
129
+ ```
130
+
131
+ ### Custom JSON Broadcasting
132
+
133
+ For use cases that need structured data instead of HTML (progress bars, charts, interactive widgets), use `TurboCable::Broadcastable.broadcast_json`:
134
+
135
+ ```ruby
136
+ class OfflinePlaylistJob < ApplicationJob
137
+ def perform(user_id)
138
+ stream_name = "playlist_progress_#{user_id}"
139
+
140
+ # Broadcast JSON updates
141
+ TurboCable::Broadcastable.broadcast_json(stream_name, {
142
+ status: 'processing',
143
+ progress: 50,
144
+ message: 'Processing files...'
145
+ })
146
+ end
147
+ end
148
+ ```
149
+
150
+ **JavaScript handling** (in a Stimulus controller):
151
+
152
+ ```javascript
153
+ connect() {
154
+ document.addEventListener('turbo:stream-message', this.handleMessage.bind(this))
155
+ }
156
+
157
+ handleMessage(event) {
158
+ const { stream, data } = event.detail
159
+ if (stream === 'playlist_progress_123') {
160
+ console.log(data.progress) // 50
161
+ this.updateProgressBar(data.progress, data.message)
162
+ }
163
+ }
164
+ ```
165
+
166
+ The Stimulus controller automatically dispatches `turbo:stream-message` CustomEvents when receiving JSON data (non-HTML strings). See [EXAMPLES.md](EXAMPLES.md#custom-json-broadcasting) for a complete working example with progress tracking.
167
+
168
+ ## Configuration
169
+
170
+ ### Broadcast URL (Optional)
171
+
172
+ By default, broadcasts use this port selection logic:
173
+ 1. `ENV['TURBO_CABLE_PORT']` - if set, always use this port
174
+ 2. `ENV['PORT']` - if set and TURBO_CABLE_PORT is not set, use this port
175
+ 3. `3000` - default fallback
176
+
177
+ Configure these environment variables if needed:
178
+
179
+ ```ruby
180
+ # config/application.rb or initializer
181
+
182
+ # Override PORT when it's set to a proxy/foreman port (e.g., Thruster, foreman defaults to 5000)
183
+ ENV['TURBO_CABLE_PORT'] = '3000'
184
+
185
+ # Or specify the complete URL (overrides all port detection)
186
+ ENV['TURBO_CABLE_BROADCAST_URL'] = 'http://localhost:3000/_broadcast'
187
+ ```
188
+
189
+ **When to set `TURBO_CABLE_PORT`:**
190
+ - **Foreman/Overmind**: These set `PORT=5000` by default, but Rails runs on a different port
191
+ - **Thruster/nginx proxy**: When `PORT` is set to the proxy port, not the Rails server port
192
+ - **Never needed**: When `PORT` correctly points to your Rails server (like with Navigator/configurator.rb)
193
+
194
+ ## Migration from Action Cable
195
+
196
+ **⚠️ First, verify your deployment architecture supports TurboCable.** If you have multiple Rails instances serving the same app (Heroku dynos, AWS containers, Kubernetes pods, load-balanced VPS), TurboCable won't work for you. See "When NOT to Use" above.
197
+
198
+ **If you're on a single server:**
199
+
200
+ **Views:** No changes needed! `turbo_stream_from` works identically.
201
+
202
+ **Models:** No changes needed! All `broadcast_*` methods work identically.
203
+
204
+ **Infrastructure:** Just add the gem and run the installer. Action Cable, Redis, and Solid Cable can be removed.
205
+
206
+ ## Protocol Specification
207
+
208
+ ### WebSocket Messages (JSON)
209
+
210
+ **Client → Server:**
211
+ ```json
212
+ {"type": "subscribe", "stream": "counter_updates"}
213
+ {"type": "unsubscribe", "stream": "counter_updates"}
214
+ {"type": "pong"}
215
+ ```
216
+
217
+ **Server → Client:**
218
+ ```json
219
+ {"type": "subscribed", "stream": "counter_updates"}
220
+ {"type": "message", "stream": "counter_updates", "data": "<turbo-stream...>"}
221
+ {"type": "message", "stream": "progress", "data": {"status": "processing", "progress": 50}}
222
+ {"type": "ping"}
223
+ ```
224
+
225
+ The `data` field can contain either:
226
+ - **String**: Turbo Stream HTML (automatically processed as DOM updates)
227
+ - **Object**: Custom JSON data (dispatched as `turbo:stream-message` event)
228
+
229
+ ### Broadcast Endpoint
230
+
231
+ ```bash
232
+ POST /_broadcast
233
+ Content-Type: application/json
234
+
235
+ # Turbo Stream HTML
236
+ {
237
+ "stream": "counter_updates",
238
+ "data": "<turbo-stream action=\"replace\" target=\"counter\">...</turbo-stream>"
239
+ }
240
+
241
+ # Custom JSON
242
+ {
243
+ "stream": "progress_updates",
244
+ "data": {"status": "processing", "progress": 50, "message": "Processing..."}
245
+ }
246
+ ```
247
+
248
+ ## How It Works
249
+
250
+ 1. **Rack Middleware**: Intercepts `/cable` requests and upgrades to WebSocket
251
+ 2. **Stimulus Controller**: Discovers `turbo_stream_from` markers and subscribes
252
+ 3. **Broadcast Endpoint**: Rails broadcasts via HTTP POST to `/_broadcast`
253
+ 4. **WebSocket Distribution**: Middleware forwards updates to subscribed clients
254
+
255
+ **Critical architectural constraint:** All components (WebSocket server, Rails app, broadcast endpoint) run in the same process. This is why cross-server broadcasting isn't supported.
256
+
257
+ ## Security
258
+
259
+ ### Broadcast Endpoint Protection
260
+
261
+ The `/_broadcast` endpoint is **restricted to localhost only** (127.0.0.0/8 and ::1). This prevents external attackers from broadcasting arbitrary HTML to connected clients.
262
+
263
+ **Why this matters:** An unprotected broadcast endpoint would allow XSS attacks - anyone who could POST to `/_broadcast` could inject malicious HTML into user browsers.
264
+
265
+ **Why localhost-only is safe:** Since TurboCable runs in-process with your Rails app, all broadcasts originate from the same machine. External access is never needed and would indicate an attack.
266
+
267
+ **Network configuration:** Ensure your firewall/reverse proxy doesn't forward external requests to `/_broadcast`. This endpoint should never be exposed through nginx, Apache, or any proxy.
268
+
269
+ ## Compatibility
270
+
271
+ - **Rails:** 7.0+ (tested with 8.0+)
272
+ - **Ruby:** 3.0+
273
+ - **Browsers:** All modern browsers with WebSocket support
274
+ - **Server:** Puma or any Rack server that supports `rack.hijack`
275
+
276
+ ## Technical Details
277
+
278
+ ### Action Cable Feature Differences
279
+
280
+ - **`stream_for` not supported** - Use `turbo_stream_from` instead
281
+ - **Client→Server communication** - Use standard HTTP requests (forms, fetch, Turbo Frames) instead of WebSocket channel actions
282
+ - **In-process WebSocket server** - Not a separate cable server; runs within Rails process
283
+
284
+ ### Hybrid Async/Sync Behavior
285
+
286
+ TurboCable intelligently chooses between async and sync broadcasting:
287
+
288
+ **Methods with `_later_to` suffix** (e.g., `broadcast_replace_later_to`):
289
+ - ✅ **Async** - If Active Job is configured with a non-inline adapter (Solid Queue, Sidekiq, etc.), broadcasts are enqueued as jobs
290
+ - 🔄 **Sync fallback** - If no job backend exists, broadcasts happen synchronously via HTTP POST
291
+
292
+ **Methods without `_later_to`** (e.g., `broadcast_replace_to`):
293
+ - 🔄 **Always sync** - Broadcasts happen immediately, useful for callbacks like `before_destroy`
294
+
295
+ **Why hybrid?**
296
+ - **Zero dependencies** - Works out of the box without requiring a job backend
297
+ - **Performance** - Async when available prevents blocking HTTP responses
298
+ - **Flexibility** - Automatically adapts to your infrastructure
299
+
300
+ **Example:**
301
+ ```ruby
302
+ # Development (no job backend) - synchronous
303
+ counter.broadcast_replace_later_to "updates" # HTTP POST happens now
304
+
305
+ # Production (with Solid Queue) - asynchronous
306
+ counter.broadcast_replace_later_to "updates" # Job enqueued, returns immediately
307
+ ```
308
+
309
+ ### What IS Supported
310
+
311
+ - ✅ All Turbo Streams actions (replace, update, append, prepend, remove)
312
+ - ✅ Multiple concurrent connections per process
313
+ - ✅ Multiple streams per connection
314
+ - ✅ Partial rendering with locals
315
+ - ✅ Auto-reconnection on connection loss
316
+ - ✅ Thread-safe subscription management
317
+
318
+ ## Development
319
+
320
+ After checking out the repo:
321
+
322
+ ```bash
323
+ bundle install
324
+ bundle exec rake test
325
+ ```
326
+
327
+ ## Contributing
328
+
329
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubys/turbo_cable.
330
+
331
+ ## License
332
+
333
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
334
+
335
+ ## Credits
336
+
337
+ Inspired by the memory optimization needs of multi-region Rails deployments. Built to prove that Action Cable's functionality can be achieved with minimal dependencies and maximum efficiency.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,4 @@
1
+ module TurboCable
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module TurboCable
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ module TurboCable
2
+ module StreamsHelper
3
+ # Custom turbo_stream_from that works with our WebSocket implementation
4
+ # Drop-in replacement for Turbo Stream's turbo_stream_from helper
5
+ def turbo_stream_from(*stream_names)
6
+ # Create a marker element that JavaScript will find and subscribe to
7
+ tag.div(
8
+ data: {
9
+ turbo_stream: true,
10
+ streams: stream_names.join(",")
11
+ },
12
+ style: "display: none;"
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,4 @@
1
+ module TurboCable
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,71 @@
1
+ module TurboCable
2
+ class BroadcastJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(stream_name, action:, model_gid: nil, target: nil, partial: nil, html: nil, locals: {})
6
+ # Resolve model from GlobalID if provided
7
+ model = GlobalID::Locator.locate(model_gid) if model_gid
8
+
9
+ # Determine target
10
+ target ||= "#{model.class.name.underscore}_#{model.id}" if model
11
+
12
+ # Generate content HTML
13
+ content_html = if html
14
+ html
15
+ elsif partial
16
+ # Render partial with locals
17
+ ApplicationController.render(partial: partial, locals: locals.merge(model ? { model.class.name.underscore.to_sym => model } : {}))
18
+ elsif model
19
+ # Render default partial for this model
20
+ ApplicationController.render(partial: model, locals: locals)
21
+ else
22
+ raise ArgumentError, "Must provide html, partial, or model"
23
+ end
24
+
25
+ # Generate Turbo Stream HTML
26
+ turbo_stream_html = if action.to_sym == :remove
27
+ <<~HTML
28
+ <turbo-stream action="remove" target="#{target}">
29
+ </turbo-stream>
30
+ HTML
31
+ else
32
+ <<~HTML
33
+ <turbo-stream action="#{action}" target="#{target}">
34
+ <template>
35
+ #{content_html}
36
+ </template>
37
+ </turbo-stream>
38
+ HTML
39
+ end
40
+
41
+ # Broadcast via HTTP POST
42
+ broadcast_turbo_stream(stream_name, turbo_stream_html)
43
+ end
44
+
45
+ private
46
+
47
+ def broadcast_turbo_stream(stream_name, html)
48
+ require "net/http"
49
+ require "json"
50
+
51
+ # Get the actual Puma/Rails server port
52
+ # Priority: TURBO_CABLE_PORT > PORT > 3000
53
+ # Use TURBO_CABLE_PORT to override PORT when it's set to proxy/Thruster port
54
+ default_port = ENV["TURBO_CABLE_PORT"] || ENV["PORT"] || "3000"
55
+ uri = URI(ENV.fetch("TURBO_CABLE_BROADCAST_URL", "http://localhost:#{default_port}/_broadcast"))
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.open_timeout = 1
58
+ http.read_timeout = 1
59
+
60
+ request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
61
+ request.body = {
62
+ stream: stream_name,
63
+ data: html
64
+ }.to_json
65
+
66
+ http.request(request)
67
+ rescue => e
68
+ Rails.logger.error("Broadcast failed: #{e.message}") if defined?(Rails)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,6 @@
1
+ module TurboCable
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module TurboCable
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Turbo cable</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "turbo_cable/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ TurboCable::Engine.routes.draw do
2
+ end
@@ -0,0 +1,47 @@
1
+ module TurboCable
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def copy_javascript_controller
7
+ copy_file "turbo_streams_controller.js",
8
+ "app/javascript/controllers/turbo_streams_controller.js"
9
+ end
10
+
11
+ def add_controller_to_layout
12
+ layout_file = "app/views/layouts/application.html.erb"
13
+
14
+ if File.exist?(layout_file)
15
+ # Check if already added
16
+ content = File.read(layout_file)
17
+ unless content.include?('data-controller="turbo-streams"')
18
+ inject_into_file layout_file,
19
+ ' data-controller="turbo-streams"',
20
+ after: "<body"
21
+ end
22
+ else
23
+ say "WARNING: Could not find #{layout_file}", :yellow
24
+ say "Please add data-controller=\"turbo-streams\" to your <body> tag manually", :yellow
25
+ end
26
+ end
27
+
28
+ def show_readme
29
+ say "\n" + "=" * 70
30
+ say "TurboCable Installation Complete!", :green
31
+ say "=" * 70
32
+ say "\nNext steps:"
33
+ say " 1. Restart your Rails server"
34
+ say " 2. Use turbo_stream_from in your views"
35
+ say " 3. Use broadcast_* methods in your models"
36
+ say "\nExample usage:"
37
+ say " # In your view"
38
+ say ' <%= turbo_stream_from "counter_updates" %>'
39
+ say "\n # In your model"
40
+ say ' broadcast_replace_later_to "counter_updates", target: "counter"'
41
+ say "\nFor more information, see the README at:"
42
+ say " https://github.com/rubys/turbo_cable"
43
+ say "\n" + "=" * 70 + "\n"
44
+ end
45
+ end
46
+ end
47
+ end