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.
@@ -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,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :turbo_cable do
3
+ # # Task goes here
4
+ # end
@@ -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
@@ -0,0 +1,3 @@
1
+ module TurboCable
2
+ VERSION = "1.0.0"
3
+ end
@@ -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: []