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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Controller that provides custom WebSocket-based Turbo Streams support
|
|
4
|
+
// Automatically subscribes to streams marked with data-turbo-stream
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
connect() {
|
|
7
|
+
console.debug("Turbo Streams WebSocket controller connected")
|
|
8
|
+
|
|
9
|
+
// Find all turbo-stream markers in the document
|
|
10
|
+
const markers = document.querySelectorAll('[data-turbo-stream="true"]')
|
|
11
|
+
|
|
12
|
+
if (markers.length === 0) {
|
|
13
|
+
console.debug("No turbo-stream markers found")
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Collect all stream names
|
|
18
|
+
this.streams = new Set()
|
|
19
|
+
markers.forEach(marker => {
|
|
20
|
+
const streams = marker.dataset.streams
|
|
21
|
+
if (streams) {
|
|
22
|
+
streams.split(',').forEach(stream => this.streams.add(stream.trim()))
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (this.streams.size === 0) {
|
|
27
|
+
console.debug("No streams to subscribe to")
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.debug("Subscribing to streams:", Array.from(this.streams))
|
|
32
|
+
|
|
33
|
+
// Create WebSocket connection
|
|
34
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
35
|
+
this.ws = new WebSocket(`${protocol}//${window.location.host}/cable`)
|
|
36
|
+
this.subscribed = new Set()
|
|
37
|
+
|
|
38
|
+
this.ws.onopen = () => {
|
|
39
|
+
console.debug('WebSocket connected')
|
|
40
|
+
// Subscribe to all streams
|
|
41
|
+
this.streams.forEach(stream => {
|
|
42
|
+
this.ws.send(JSON.stringify({
|
|
43
|
+
type: 'subscribe',
|
|
44
|
+
stream: stream
|
|
45
|
+
}))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.ws.onmessage = (event) => {
|
|
50
|
+
const msg = JSON.parse(event.data)
|
|
51
|
+
|
|
52
|
+
switch (msg.type) {
|
|
53
|
+
case 'subscribed':
|
|
54
|
+
console.debug('Subscribed to stream:', msg.stream)
|
|
55
|
+
this.subscribed.add(msg.stream)
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
case 'message':
|
|
59
|
+
if (this.streams.has(msg.stream)) {
|
|
60
|
+
console.debug("Received update on stream:", msg.stream)
|
|
61
|
+
this.processTurboStream(msg.stream, msg.data)
|
|
62
|
+
}
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
case 'ping':
|
|
66
|
+
// Respond to ping
|
|
67
|
+
this.ws.send(JSON.stringify({ type: 'pong' }))
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.ws.onerror = (error) => {
|
|
73
|
+
console.error('WebSocket error:', error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.ws.onclose = () => {
|
|
77
|
+
console.debug('WebSocket disconnected')
|
|
78
|
+
|
|
79
|
+
// Auto-reconnect after 3 seconds if we had subscriptions
|
|
80
|
+
if (this.subscribed.size > 0) {
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
console.debug('Attempting to reconnect...')
|
|
83
|
+
this.connect()
|
|
84
|
+
}, 3000)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
disconnect() {
|
|
90
|
+
// Clean up subscription when controller is removed
|
|
91
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
92
|
+
this.streams.forEach(stream => {
|
|
93
|
+
this.ws.send(JSON.stringify({
|
|
94
|
+
type: 'unsubscribe',
|
|
95
|
+
stream: stream
|
|
96
|
+
}))
|
|
97
|
+
})
|
|
98
|
+
this.ws.close()
|
|
99
|
+
}
|
|
100
|
+
this.subscribed.clear()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
processTurboStream(stream, data) {
|
|
104
|
+
// Check if data is a string (Turbo Stream HTML) or object (custom JSON)
|
|
105
|
+
if (typeof data === 'string') {
|
|
106
|
+
// Process as Turbo Stream HTML
|
|
107
|
+
this.processTurboStreamHTML(data)
|
|
108
|
+
} else {
|
|
109
|
+
// Dispatch custom event for JSON data
|
|
110
|
+
const event = new CustomEvent('turbo:stream-message', {
|
|
111
|
+
detail: { stream: stream, data: data },
|
|
112
|
+
bubbles: true
|
|
113
|
+
})
|
|
114
|
+
document.dispatchEvent(event)
|
|
115
|
+
console.debug('Dispatched turbo:stream-message event for stream:', stream)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
processTurboStreamHTML(html) {
|
|
120
|
+
// Parse the Turbo Stream HTML and apply the action
|
|
121
|
+
const parser = new DOMParser()
|
|
122
|
+
const doc = parser.parseFromString(html, 'text/html')
|
|
123
|
+
const turboStream = doc.querySelector('turbo-stream')
|
|
124
|
+
|
|
125
|
+
if (!turboStream) {
|
|
126
|
+
console.warn('No turbo-stream element found in:', html)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const action = turboStream.getAttribute('action')
|
|
131
|
+
const target = turboStream.getAttribute('target')
|
|
132
|
+
const template = turboStream.querySelector('template')
|
|
133
|
+
|
|
134
|
+
if (!target) {
|
|
135
|
+
console.warn('No target specified in turbo-stream')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const targetElement = document.getElementById(target)
|
|
140
|
+
if (!targetElement) {
|
|
141
|
+
console.warn('Target element not found:', target)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
switch (action) {
|
|
146
|
+
case 'replace':
|
|
147
|
+
if (template && template.content) {
|
|
148
|
+
const newElement = template.content.firstElementChild.cloneNode(true)
|
|
149
|
+
targetElement.replaceWith(newElement)
|
|
150
|
+
console.debug('Replaced element:', target)
|
|
151
|
+
}
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
case 'update':
|
|
155
|
+
if (template && template.content) {
|
|
156
|
+
targetElement.innerHTML = template.innerHTML
|
|
157
|
+
console.debug('Updated element:', target)
|
|
158
|
+
}
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
case 'append':
|
|
162
|
+
if (template && template.content) {
|
|
163
|
+
targetElement.appendChild(template.content.cloneNode(true))
|
|
164
|
+
console.debug('Appended to element:', target)
|
|
165
|
+
}
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
case 'prepend':
|
|
169
|
+
if (template && template.content) {
|
|
170
|
+
targetElement.prepend(template.content.cloneNode(true))
|
|
171
|
+
console.debug('Prepended to element:', target)
|
|
172
|
+
}
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
case 'remove':
|
|
176
|
+
targetElement.remove()
|
|
177
|
+
console.debug('Removed element:', target)
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
console.warn('Unknown turbo-stream action:', action)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module TurboCable
|
|
5
|
+
# Provides Turbo Streams broadcasting methods for ActiveRecord models
|
|
6
|
+
# Drop-in replacement for Turbo::Streams::Broadcastable
|
|
7
|
+
#
|
|
8
|
+
# Hybrid async/sync approach:
|
|
9
|
+
# - _later_to methods: Use Active Job if available, otherwise synchronous
|
|
10
|
+
# - Non-_later_to methods: Always synchronous
|
|
11
|
+
module Broadcastable
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
# Module-level method for broadcasting custom JSON data
|
|
15
|
+
# Useful for progress updates, job status, or non-Turbo-Stream messages
|
|
16
|
+
def self.broadcast_json(stream_name, data)
|
|
17
|
+
# Get the actual Puma/Rails server port
|
|
18
|
+
# Priority: TURBO_CABLE_PORT > PORT > 3000
|
|
19
|
+
# Use TURBO_CABLE_PORT to override PORT when it's set to proxy/Thruster port
|
|
20
|
+
default_port = ENV["TURBO_CABLE_PORT"] || ENV["PORT"] || "3000"
|
|
21
|
+
uri = URI(ENV.fetch("TURBO_CABLE_BROADCAST_URL", "http://localhost:#{default_port}/_broadcast"))
|
|
22
|
+
|
|
23
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
24
|
+
http.open_timeout = 1
|
|
25
|
+
http.read_timeout = 1
|
|
26
|
+
|
|
27
|
+
request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
28
|
+
request.body = {
|
|
29
|
+
stream: stream_name,
|
|
30
|
+
data: data
|
|
31
|
+
}.to_json
|
|
32
|
+
|
|
33
|
+
http.request(request)
|
|
34
|
+
rescue => e
|
|
35
|
+
Rails.logger.error("TurboCable: JSON broadcast failed: #{e.class} - #{e.message}") if defined?(Rails)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Async broadcast methods (truly async if Active Job is configured)
|
|
39
|
+
def broadcast_replace_later_to(stream_name, **options)
|
|
40
|
+
if async_broadcast_available?
|
|
41
|
+
enqueue_broadcast_job(stream_name, action: :replace, **options)
|
|
42
|
+
else
|
|
43
|
+
broadcast_action_now(stream_name, action: :replace, **options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def broadcast_update_later_to(stream_name, **options)
|
|
48
|
+
if async_broadcast_available?
|
|
49
|
+
enqueue_broadcast_job(stream_name, action: :update, **options)
|
|
50
|
+
else
|
|
51
|
+
broadcast_action_now(stream_name, action: :update, **options)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def broadcast_append_later_to(stream_name, **options)
|
|
56
|
+
if async_broadcast_available?
|
|
57
|
+
enqueue_broadcast_job(stream_name, action: :append, **options)
|
|
58
|
+
else
|
|
59
|
+
broadcast_action_now(stream_name, action: :append, **options)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def broadcast_prepend_later_to(stream_name, **options)
|
|
64
|
+
if async_broadcast_available?
|
|
65
|
+
enqueue_broadcast_job(stream_name, action: :prepend, **options)
|
|
66
|
+
else
|
|
67
|
+
broadcast_action_now(stream_name, action: :prepend, **options)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Synchronous broadcast methods (always immediate)
|
|
72
|
+
def broadcast_replace_to(stream_name, **options)
|
|
73
|
+
broadcast_action_now(stream_name, action: :replace, **options)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def broadcast_update_to(stream_name, **options)
|
|
77
|
+
broadcast_action_now(stream_name, action: :update, **options)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def broadcast_append_to(stream_name, **options)
|
|
81
|
+
broadcast_action_now(stream_name, action: :append, **options)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def broadcast_prepend_to(stream_name, **options)
|
|
85
|
+
broadcast_action_now(stream_name, action: :prepend, **options)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def broadcast_remove_to(stream_name, target:)
|
|
89
|
+
turbo_stream_html = <<~HTML
|
|
90
|
+
<turbo-stream action="remove" target="#{target}">
|
|
91
|
+
</turbo-stream>
|
|
92
|
+
HTML
|
|
93
|
+
|
|
94
|
+
broadcast_turbo_stream(stream_name, turbo_stream_html)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Check if async broadcasting is available (Active Job with non-inline adapter)
|
|
100
|
+
def async_broadcast_available?
|
|
101
|
+
defined?(ActiveJob) &&
|
|
102
|
+
defined?(TurboCable::BroadcastJob) &&
|
|
103
|
+
ActiveJob::Base.queue_adapter_name != :inline
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Enqueue broadcast job for async processing
|
|
107
|
+
def enqueue_broadcast_job(stream_name, action:, target: nil, partial: nil, html: nil, locals: {})
|
|
108
|
+
TurboCable::BroadcastJob.perform_later(
|
|
109
|
+
stream_name,
|
|
110
|
+
action: action,
|
|
111
|
+
model_gid: to_global_id.to_s,
|
|
112
|
+
target: target,
|
|
113
|
+
partial: partial,
|
|
114
|
+
html: html,
|
|
115
|
+
locals: locals
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Broadcast immediately (synchronous)
|
|
120
|
+
def broadcast_action_now(stream_name, action:, target: nil, partial: nil, html: nil, locals: {})
|
|
121
|
+
# Determine target - use explicit target or derive from model
|
|
122
|
+
target ||= "#{self.class.name.underscore}_#{id}"
|
|
123
|
+
|
|
124
|
+
# Generate content HTML
|
|
125
|
+
content_html = if html
|
|
126
|
+
html
|
|
127
|
+
elsif partial
|
|
128
|
+
# Render partial with locals
|
|
129
|
+
ApplicationController.render(partial: partial, locals: locals.merge(self.class.name.underscore.to_sym => self))
|
|
130
|
+
else
|
|
131
|
+
# Render default partial for this model
|
|
132
|
+
ApplicationController.render(partial: self, locals: locals)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Generate Turbo Stream HTML
|
|
136
|
+
turbo_stream_html = <<~HTML
|
|
137
|
+
<turbo-stream action="#{action}" target="#{target}">
|
|
138
|
+
<template>
|
|
139
|
+
#{content_html}
|
|
140
|
+
</template>
|
|
141
|
+
</turbo-stream>
|
|
142
|
+
HTML
|
|
143
|
+
|
|
144
|
+
broadcast_turbo_stream(stream_name, turbo_stream_html)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def broadcast_turbo_stream(stream_name, html)
|
|
148
|
+
uri = URI(ENV.fetch("TURBO_CABLE_BROADCAST_URL", "http://localhost:#{ENV.fetch('PORT', 3000)}/_broadcast"))
|
|
149
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
150
|
+
request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
151
|
+
request.body = {
|
|
152
|
+
stream: stream_name,
|
|
153
|
+
data: html
|
|
154
|
+
}.to_json
|
|
155
|
+
|
|
156
|
+
http.request(request)
|
|
157
|
+
rescue => e
|
|
158
|
+
Rails.logger.error("Broadcast failed: #{e.message}") if defined?(Rails)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module TurboCable
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace TurboCable
|
|
4
|
+
|
|
5
|
+
# Add Rack middleware for WebSocket handling
|
|
6
|
+
initializer "turbo_cable.middleware" do |app|
|
|
7
|
+
app.middleware.use TurboCable::RackHandler
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Prepend Broadcastable in ApplicationRecord (overrides turbo-rails methods)
|
|
11
|
+
initializer "turbo_cable.active_record" do
|
|
12
|
+
ActiveSupport.on_load(:active_record) do
|
|
13
|
+
prepend TurboCable::Broadcastable
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Make helpers available to the host application
|
|
18
|
+
initializer "turbo_cable.helpers" do
|
|
19
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
20
|
+
helper TurboCable::StreamsHelper
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
require "digest/sha1"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TurboCable
|
|
6
|
+
# Rack middleware for handling WebSocket connections using Rack hijack
|
|
7
|
+
# Uses RFC 6455 WebSocket protocol (no dependencies required)
|
|
8
|
+
class RackHandler
|
|
9
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
10
|
+
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
@connections = {} # stream => [sockets]
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
# Handle WebSocket upgrade for /cable
|
|
19
|
+
if env["PATH_INFO"] == "/cable" && websocket_request?(env)
|
|
20
|
+
handle_websocket(env)
|
|
21
|
+
# Return -1 to indicate we've hijacked the connection
|
|
22
|
+
[ -1, {}, [] ]
|
|
23
|
+
elsif env["PATH_INFO"] == "/_broadcast" && env["REQUEST_METHOD"] == "POST"
|
|
24
|
+
handle_broadcast(env)
|
|
25
|
+
else
|
|
26
|
+
@app.call(env)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def websocket_request?(env)
|
|
33
|
+
env["HTTP_UPGRADE"]&.downcase == "websocket" &&
|
|
34
|
+
env["HTTP_CONNECTION"]&.downcase&.include?("upgrade")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_websocket(env)
|
|
38
|
+
# Hijack the TCP socket from Rack/Puma
|
|
39
|
+
io = env["rack.hijack"].call
|
|
40
|
+
|
|
41
|
+
# Perform WebSocket handshake (RFC 6455)
|
|
42
|
+
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
43
|
+
accept = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
|
|
44
|
+
|
|
45
|
+
io.write("HTTP/1.1 101 Switching Protocols\r\n")
|
|
46
|
+
io.write("Upgrade: websocket\r\n")
|
|
47
|
+
io.write("Connection: Upgrade\r\n")
|
|
48
|
+
io.write("Sec-WebSocket-Accept: #{accept}\r\n")
|
|
49
|
+
io.write("\r\n")
|
|
50
|
+
|
|
51
|
+
# Set read timeout to 60 seconds to detect half-dead connections
|
|
52
|
+
# This matches Navigator's timeout and prevents ghost connections from accumulating
|
|
53
|
+
io.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [ 60, 0 ].pack("l_2"))
|
|
54
|
+
|
|
55
|
+
# Track connection subscriptions
|
|
56
|
+
subscriptions = Set.new
|
|
57
|
+
|
|
58
|
+
# Handle WebSocket frames in a thread
|
|
59
|
+
Thread.new do
|
|
60
|
+
begin
|
|
61
|
+
loop do
|
|
62
|
+
frame = read_frame(io)
|
|
63
|
+
break if frame.nil? || frame[:opcode] == 8 # Close frame
|
|
64
|
+
|
|
65
|
+
if frame[:opcode] == 1 # Text frame
|
|
66
|
+
handle_message(io, frame[:payload], subscriptions)
|
|
67
|
+
elsif frame[:opcode] == 9 # Ping
|
|
68
|
+
send_frame(io, 10, frame[:payload]) # Pong
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
rescue => e
|
|
72
|
+
Rails.logger.error("WebSocket error: #{e}")
|
|
73
|
+
ensure
|
|
74
|
+
# Unsubscribe from all streams
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
subscriptions.each do |stream|
|
|
77
|
+
@connections[stream]&.delete(io)
|
|
78
|
+
@connections.delete(stream) if @connections[stream]&.empty?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
io.close rescue nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle_message(io, payload, subscriptions)
|
|
87
|
+
msg = JSON.parse(payload)
|
|
88
|
+
|
|
89
|
+
case msg["type"]
|
|
90
|
+
when "subscribe"
|
|
91
|
+
stream = msg["stream"]
|
|
92
|
+
|
|
93
|
+
# Add connection to stream
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
@connections[stream] ||= []
|
|
96
|
+
@connections[stream] << io
|
|
97
|
+
end
|
|
98
|
+
subscriptions.add(stream)
|
|
99
|
+
|
|
100
|
+
# Send confirmation
|
|
101
|
+
response = { type: "subscribed", stream: stream }
|
|
102
|
+
send_frame(io, 1, response.to_json)
|
|
103
|
+
|
|
104
|
+
when "unsubscribe"
|
|
105
|
+
stream = msg["stream"]
|
|
106
|
+
|
|
107
|
+
# Remove connection from stream
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@connections[stream]&.delete(io)
|
|
110
|
+
@connections.delete(stream) if @connections[stream]&.empty?
|
|
111
|
+
end
|
|
112
|
+
subscriptions.delete(stream)
|
|
113
|
+
|
|
114
|
+
when "pong"
|
|
115
|
+
# Client responding to ping
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_broadcast(env)
|
|
120
|
+
# Security: Only allow broadcasts from localhost
|
|
121
|
+
unless localhost_request?(env)
|
|
122
|
+
return [ 403, { "Content-Type" => "text/plain" }, [ "Forbidden: Broadcasts only allowed from localhost" ] ]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Read JSON body
|
|
126
|
+
input = env["rack.input"].read
|
|
127
|
+
data = JSON.parse(input)
|
|
128
|
+
|
|
129
|
+
stream = data["stream"]
|
|
130
|
+
message = { type: "message", stream: stream, data: data["data"] }
|
|
131
|
+
payload = message.to_json
|
|
132
|
+
|
|
133
|
+
# Broadcast to all connections on this stream
|
|
134
|
+
sockets = @mutex.synchronize { @connections[stream]&.dup || [] }
|
|
135
|
+
|
|
136
|
+
sockets.each do |io|
|
|
137
|
+
begin
|
|
138
|
+
send_frame(io, 1, payload)
|
|
139
|
+
rescue
|
|
140
|
+
# Connection died, will be cleaned up by read loop
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
[ 200, { "Content-Type" => "text/plain" }, [ "OK" ] ]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def localhost_request?(env)
|
|
148
|
+
remote_addr = env["REMOTE_ADDR"]
|
|
149
|
+
# Allow IPv4 localhost (127.0.0.1, 127.x.x.x) and IPv6 localhost (::1)
|
|
150
|
+
remote_addr =~ /^127\./ || remote_addr == "::1"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Read WebSocket frame (RFC 6455 format)
|
|
154
|
+
def read_frame(io)
|
|
155
|
+
byte1 = io.read(1)&.unpack1("C")
|
|
156
|
+
return nil if byte1.nil?
|
|
157
|
+
|
|
158
|
+
fin = (byte1 & 0x80) != 0
|
|
159
|
+
opcode = byte1 & 0x0F
|
|
160
|
+
|
|
161
|
+
byte2 = io.read(1)&.unpack1("C")
|
|
162
|
+
return nil if byte2.nil?
|
|
163
|
+
|
|
164
|
+
masked = (byte2 & 0x80) != 0
|
|
165
|
+
length = byte2 & 0x7F
|
|
166
|
+
|
|
167
|
+
if length == 126
|
|
168
|
+
length = io.read(2).unpack1("n")
|
|
169
|
+
elsif length == 127
|
|
170
|
+
length = io.read(8).unpack1("Q>")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
mask_key = masked ? io.read(4).unpack("C*") : nil
|
|
174
|
+
payload_data = io.read(length)
|
|
175
|
+
|
|
176
|
+
if masked && mask_key
|
|
177
|
+
payload_data = payload_data.bytes.map.with_index do |byte, i|
|
|
178
|
+
byte ^ mask_key[i % 4]
|
|
179
|
+
end.pack("C*")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
{ opcode: opcode, payload: payload_data, fin: fin }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Send WebSocket frame (RFC 6455 format)
|
|
186
|
+
def send_frame(io, opcode, payload)
|
|
187
|
+
payload = payload.b
|
|
188
|
+
length = payload.bytesize
|
|
189
|
+
|
|
190
|
+
frame = [ 0x80 | opcode ].pack("C") # FIN=1
|
|
191
|
+
|
|
192
|
+
if length < 126
|
|
193
|
+
frame << [ length ].pack("C")
|
|
194
|
+
elsif length < 65536
|
|
195
|
+
frame << [ 126, length ].pack("Cn")
|
|
196
|
+
else
|
|
197
|
+
frame << [ 127, length ].pack("CQ>")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
frame << payload
|
|
201
|
+
io.write(frame)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
data/lib/turbo_cable.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require "turbo_cable/version"
|
|
2
|
+
require "turbo_cable/engine"
|
|
3
|
+
require "turbo_cable/rack_handler"
|
|
4
|
+
require "turbo_cable/broadcastable"
|
|
5
|
+
|
|
6
|
+
module TurboCable
|
|
7
|
+
# Custom WebSocket-based Turbo Streams implementation
|
|
8
|
+
# Drop-in replacement for Action Cable with reduced memory footprint
|
|
9
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: turbo_cable
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sam Ruby
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2025-11-04 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
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
|
+
description: TurboCable replaces Action Cable with a custom WebSocket implementation
|
|
27
|
+
for Turbo Streams, providing 79-85% memory savings (134-144MB per process) while
|
|
28
|
+
maintaining full API compatibility. Designed for single-server deployments with
|
|
29
|
+
zero external dependencies beyond Ruby's standard library.
|
|
30
|
+
email:
|
|
31
|
+
- rubys@intertwingly.net
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- EXAMPLES.md
|
|
38
|
+
- MIT-LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- app/assets/stylesheets/turbo_cable/application.css
|
|
42
|
+
- app/controllers/turbo_cable/application_controller.rb
|
|
43
|
+
- app/helpers/turbo_cable/application_helper.rb
|
|
44
|
+
- app/helpers/turbo_cable/streams_helper.rb
|
|
45
|
+
- app/jobs/turbo_cable/application_job.rb
|
|
46
|
+
- app/jobs/turbo_cable/broadcast_job.rb
|
|
47
|
+
- app/mailers/turbo_cable/application_mailer.rb
|
|
48
|
+
- app/models/turbo_cable/application_record.rb
|
|
49
|
+
- app/views/layouts/turbo_cable/application.html.erb
|
|
50
|
+
- config/routes.rb
|
|
51
|
+
- lib/generators/turbo_cable/install/install_generator.rb
|
|
52
|
+
- lib/generators/turbo_cable/install/templates/turbo_streams_controller.js
|
|
53
|
+
- lib/tasks/turbo_cable_tasks.rake
|
|
54
|
+
- lib/turbo_cable.rb
|
|
55
|
+
- lib/turbo_cable/broadcastable.rb
|
|
56
|
+
- lib/turbo_cable/engine.rb
|
|
57
|
+
- lib/turbo_cable/rack_handler.rb
|
|
58
|
+
- lib/turbo_cable/version.rb
|
|
59
|
+
homepage: https://github.com/rubys/turbo_cable
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://github.com/rubys/turbo_cable
|
|
64
|
+
source_code_uri: https://github.com/rubys/turbo_cable
|
|
65
|
+
changelog_uri: https://github.com/rubys/turbo_cable/blob/main/CHANGELOG.md
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: 3.0.0
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '0'
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 3.6.3
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: Lightweight WebSocket-based Turbo Streams for single-server Rails deployments
|
|
83
|
+
test_files: []
|