firehose 1.2.20 → 2.1.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.
Files changed (91) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +36 -0
  3. data/{LICENSE.txt → LICENSE} +6 -6
  4. data/README.md +389 -139
  5. data/app/assets/javascripts/firehose.js +258 -0
  6. data/config/importmap.rb +1 -0
  7. data/db/migrate/20260214185244_create_firehose_events.rb +23 -0
  8. data/lib/commands/firehose_command.rb +119 -0
  9. data/lib/firehose/channel.rb +40 -0
  10. data/lib/firehose/cleanup_job.rb +21 -0
  11. data/lib/firehose/engine.rb +36 -0
  12. data/lib/firehose/helper.rb +40 -0
  13. data/lib/firehose/models/channel.rb +12 -0
  14. data/lib/firehose/models/message.rb +11 -0
  15. data/lib/firehose/queue.rb +44 -0
  16. data/lib/firehose/server.rb +931 -18
  17. data/lib/firehose/sse.rb +187 -0
  18. data/lib/firehose/stream.rb +32 -0
  19. data/lib/firehose/streamable.rb +25 -0
  20. data/lib/firehose/websocket.rb +242 -0
  21. data/lib/firehose.rb +67 -15
  22. data/lib/generators/firehose/install/install_generator.rb +43 -0
  23. data/lib/generators/firehose/install/templates/firehose_controller.rb +16 -0
  24. metadata +40 -428
  25. data/.env.sample +0 -17
  26. data/.gitignore +0 -12
  27. data/.rbenv-version +0 -1
  28. data/.rspec +0 -1
  29. data/.travis.yml +0 -16
  30. data/Gemfile +0 -4
  31. data/Guardfile +0 -31
  32. data/Procfile +0 -1
  33. data/Procfile.dev +0 -4
  34. data/Rakefile +0 -53
  35. data/bin/firehose +0 -9
  36. data/config/evergreen.rb +0 -9
  37. data/config/rainbows.rb +0 -17
  38. data/firehose.gemspec +0 -49
  39. data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
  40. data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -70
  41. data/lib/assets/javascripts/firehose/core.js.coffee +0 -5
  42. data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -149
  43. data/lib/assets/javascripts/firehose/transport.js.coffee +0 -42
  44. data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
  45. data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -102
  46. data/lib/assets/javascripts/firehose.js.coffee +0 -3
  47. data/lib/assets/javascripts/vendor/json2.js +0 -487
  48. data/lib/firehose/assets.rb +0 -56
  49. data/lib/firehose/cli.rb +0 -78
  50. data/lib/firehose/client/consumer.rb +0 -94
  51. data/lib/firehose/client/producer.rb +0 -108
  52. data/lib/firehose/client.rb +0 -10
  53. data/lib/firehose/logging.rb +0 -32
  54. data/lib/firehose/rack/app.rb +0 -49
  55. data/lib/firehose/rack/consumer/http_long_poll.rb +0 -86
  56. data/lib/firehose/rack/consumer/web_socket.rb +0 -95
  57. data/lib/firehose/rack/consumer.rb +0 -39
  58. data/lib/firehose/rack/ping.rb +0 -88
  59. data/lib/firehose/rack/publisher.rb +0 -53
  60. data/lib/firehose/rack.rb +0 -32
  61. data/lib/firehose/rails.rb +0 -7
  62. data/lib/firehose/server/app.rb +0 -52
  63. data/lib/firehose/server/channel.rb +0 -80
  64. data/lib/firehose/server/publisher.rb +0 -134
  65. data/lib/firehose/server/subscriber.rb +0 -51
  66. data/lib/firehose/version.rb +0 -4
  67. data/package.json +0 -5
  68. data/spec/integrations/integration_test_helper.rb +0 -22
  69. data/spec/integrations/rainbows_spec.rb +0 -7
  70. data/spec/integrations/shared_examples.rb +0 -120
  71. data/spec/integrations/thin_spec.rb +0 -7
  72. data/spec/javascripts/firehose/base_spec.coffee +0 -2
  73. data/spec/javascripts/firehose/consumer_spec.coffee +0 -45
  74. data/spec/javascripts/firehose/long_poll_spec.coffee +0 -44
  75. data/spec/javascripts/firehose/transport_spec.coffee +0 -32
  76. data/spec/javascripts/firehose/websocket_spec.coffee +0 -25
  77. data/spec/javascripts/helpers/spec_helper.js +0 -30
  78. data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
  79. data/spec/lib/assets_spec.rb +0 -35
  80. data/spec/lib/client/consumer_spec.rb +0 -13
  81. data/spec/lib/client/producer_spec.rb +0 -71
  82. data/spec/lib/firehose_spec.rb +0 -7
  83. data/spec/lib/rack/consumer/http_long_poll_spec.rb +0 -12
  84. data/spec/lib/rack/consumer_spec.rb +0 -19
  85. data/spec/lib/rack/ping_spec.rb +0 -28
  86. data/spec/lib/rack/publisher_spec.rb +0 -34
  87. data/spec/lib/server/app_spec.rb +0 -15
  88. data/spec/lib/server/channel_spec.rb +0 -164
  89. data/spec/lib/server/publisher_spec.rb +0 -84
  90. data/spec/lib/server/subscriber_spec.rb +0 -69
  91. data/spec/spec_helper.rb +0 -77
@@ -0,0 +1,187 @@
1
+ require "async/queue"
2
+
3
+ module Firehose
4
+ # SSE (Server-Sent Events) transport for Firehose.
5
+ #
6
+ # Usage:
7
+ # class CableController < ApplicationController
8
+ # include Firehose::SSE
9
+ #
10
+ # before_action :authenticate_user!
11
+ #
12
+ # def authorize_streams(streams)
13
+ # streams.select { |s| current_user.can_access?(s) }
14
+ # end
15
+ # end
16
+ #
17
+ # Route:
18
+ # get "cable/sse", to: "cable#sse"
19
+ #
20
+ # Client:
21
+ # const source = new EventSource("/cable/sse?streams=foo,bar")
22
+ # source.addEventListener("foo", (e) => console.log(e.data))
23
+ #
24
+ # Replay:
25
+ # Browser automatically sends Last-Event-ID header on reconnect.
26
+ #
27
+ module SSE
28
+ extend ActiveSupport::Concern
29
+ include Streamable
30
+
31
+ # Seconds between SSE comment-line keepalives. Dead clients surface
32
+ # via the write failing; intermediaries that time out idle HTTP
33
+ # connections are kept happy by the steady trickle.
34
+ KEEPALIVE_INTERVAL = 30
35
+
36
+ def sse
37
+ streams = parse_sse_streams
38
+ streams = authorize_streams(streams)
39
+
40
+ if streams.empty?
41
+ head :forbidden
42
+ return
43
+ end
44
+
45
+ response.headers["Content-Type"] = "text/event-stream"
46
+ response.headers["Cache-Control"] = "no-cache, no-store"
47
+ response.headers["X-Accel-Buffering"] = "no"
48
+ response.headers["Connection"] = "keep-alive"
49
+
50
+ handler = SSEHandler.new(request, response, streams:, controller: self)
51
+ handler.run
52
+ end
53
+
54
+ private
55
+
56
+ def parse_sse_streams
57
+ streams_param = params[:streams]
58
+ return [] unless streams_param
59
+
60
+ streams_param.split(",").map(&:strip).reject(&:empty?)
61
+ end
62
+
63
+ class SSEHandler
64
+ def initialize(request, response, streams:, controller:)
65
+ @request = request
66
+ @response = response
67
+ @streams = streams
68
+ @controller = controller
69
+ @subscriptions = Firehose.server.subscriptions
70
+ @queue = Async::Queue.new
71
+ end
72
+
73
+ def run
74
+ task = Async::Task.current
75
+
76
+ writer_task = task.async do
77
+ write_messages
78
+ end
79
+
80
+ replay_events
81
+ subscribe_to_streams
82
+ writer_task.wait
83
+ rescue Protocol::HTTP::Error, EOFError, Async::Stop, IOError, Errno::EPIPE
84
+ # Client disconnected
85
+ ensure
86
+ writer_task&.stop
87
+ @subscriptions.close
88
+ @queue.close
89
+ end
90
+
91
+ private
92
+
93
+ def last_event_id
94
+ @request.headers["Last-Event-ID"].to_i
95
+ end
96
+
97
+ # See WebSocketHandler#replay_events — same gap-detection logic
98
+ # for SSE. On replay_gap the client sees an event with the magic
99
+ # "firehose:replay_gap" event name plus details; EventSource
100
+ # dispatches it like any custom event.
101
+ def replay_events
102
+ return unless last_event_id > 0
103
+
104
+ channels = Models::Channel.where(name: @streams).to_a
105
+ channels.each do |ch|
106
+ oldest = ch.messages.minimum(:id)
107
+ next unless oldest
108
+
109
+ if oldest > last_event_id
110
+ current = ch.messages.maximum(:id)
111
+ write_event(
112
+ error: "replay_gap",
113
+ stream: ch.name,
114
+ last_event_id: last_event_id,
115
+ oldest_retained_id: oldest,
116
+ current_id: current
117
+ )
118
+ next
119
+ end
120
+
121
+ ch.messages
122
+ .where("id > ?", last_event_id)
123
+ .order(:id)
124
+ .find_each { |msg| write_event(id: msg.id, channel_id: msg.channel_id, sequence: msg.sequence, stream: ch.name, data: msg.data) }
125
+ end
126
+ end
127
+
128
+ def subscribe_to_streams
129
+ @streams.each do |stream|
130
+ @subscriptions.add(stream) { |payload| @queue.enqueue(payload) }
131
+ end
132
+
133
+ # Keep connection open waiting for events
134
+ sleep
135
+ rescue Async::Stop
136
+ # Task stopped
137
+ end
138
+
139
+ def write_messages
140
+ loop do
141
+ payload = @queue.dequeue(timeout: KEEPALIVE_INTERVAL)
142
+
143
+ if payload.nil?
144
+ # Idle — write an SSE comment line. Clients ignore it; dead
145
+ # clients surface via write failure propagating as IOError.
146
+ @response.body.write(": keepalive\n\n")
147
+ next
148
+ end
149
+
150
+ event = JSON.parse(payload, symbolize_names: true)
151
+ event = resolve_event(event) unless event.key?(:data)
152
+ write_event(event) if event
153
+ end
154
+ rescue Async::Stop
155
+ # Task stopped
156
+ end
157
+
158
+ def resolve_event(event)
159
+ msg = Models::Message.includes(:channel).find_by(id: event[:id])
160
+ return unless msg
161
+ { id: msg.id, channel_id: msg.channel_id, sequence: msg.sequence, stream: msg.channel.name, data: msg.data }
162
+ end
163
+
164
+ def write_event(event)
165
+ event = event.transform_keys(&:to_sym)
166
+ event = @controller.build_event(event)
167
+ return unless event
168
+
169
+ body = @response.body
170
+
171
+ if event[:error]
172
+ # Error events (e.g., replay_gap) use a dedicated event name
173
+ # so clients can register targeted listeners:
174
+ # eventSource.addEventListener("firehose_replay_gap", handler)
175
+ body.write("event: firehose_#{event[:error]}\n")
176
+ body.write("data: #{event.except(:error).to_json}\n\n")
177
+ return
178
+ end
179
+
180
+ body.write("id: #{event[:id]}\n")
181
+ body.write("event: #{event[:stream]}\n")
182
+ data = { data: event[:data], channel_id: event[:channel_id], sequence: event[:sequence] }.to_json
183
+ body.write("data: #{data}\n\n")
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,32 @@
1
+ module Firehose
2
+ # Include both WebSocket and SSE transports.
3
+ #
4
+ # Usage:
5
+ # class CableController < ApplicationController
6
+ # include Firehose::Stream
7
+ #
8
+ # before_action :authenticate_user!
9
+ #
10
+ # def authorize_streams(streams)
11
+ # streams.select { |s| current_user.can_access?(s) }
12
+ # end
13
+ #
14
+ # def build_event(event)
15
+ # event # or transform/filter
16
+ # end
17
+ # end
18
+ #
19
+ # Routes:
20
+ # match "cable", to: "cable#websocket", via: [:get, :connect]
21
+ # get "cable/sse", to: "cable#sse"
22
+ #
23
+ # Or include just one transport:
24
+ # include Firehose::WebSocket
25
+ # include Firehose::SSE
26
+ #
27
+ module Stream
28
+ extend ActiveSupport::Concern
29
+ include WebSocket
30
+ include SSE
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # Shared base for WebSocket and SSE transports.
2
+ # Include this module to get the callback interface without a transport.
3
+ #
4
+ # Callbacks (override in controller):
5
+ # authorize_streams(streams) - Filter which streams user can subscribe to
6
+ # build_event(event) - Transform event hash before sending, or nil to skip
7
+ #
8
+ module Firehose
9
+ module Streamable
10
+ extend ActiveSupport::Concern
11
+
12
+ # Override to authorize stream access.
13
+ # Return only the streams the user is allowed to subscribe to.
14
+ def authorize_streams(streams)
15
+ streams
16
+ end
17
+
18
+ # Override to transform events before sending.
19
+ # Return nil to skip sending the event.
20
+ # Event is a hash: { id:, stream:, data: }
21
+ def build_event(event)
22
+ event
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,242 @@
1
+ require "async/websocket/adapters/rails"
2
+ require "async/queue"
3
+
4
+ module Firehose
5
+ # WebSocket transport for Firehose.
6
+ #
7
+ # Usage:
8
+ # class CableController < ApplicationController
9
+ # include Firehose::WebSocket
10
+ #
11
+ # before_action :authenticate_user!
12
+ #
13
+ # def authorize_streams(streams)
14
+ # streams.select { |s| current_user.can_access?(s) }
15
+ # end
16
+ # end
17
+ #
18
+ # Route:
19
+ # match "cable", to: "cable#websocket", via: [:get, :connect]
20
+ #
21
+ # Protocol:
22
+ # Client sends: { "command": "subscribe", "streams": ["a", "b"], "last_event_id": 123 }
23
+ # Client sends: { "command": "unsubscribe", "streams": ["a"] }
24
+ # Server sends: { "id": 456, "stream": "a", "data": "refresh" }
25
+ #
26
+ module WebSocket
27
+ extend ActiveSupport::Concern
28
+ include Streamable
29
+
30
+ # Seconds between server-initiated WS ping frames when the stream is
31
+ # idle. Browsers auto-respond with pong frames at the protocol level
32
+ # (RFC 6455), so no client code is needed.
33
+ PING_INTERVAL = 30
34
+
35
+ # Deadline after which a lack of pong is treated as a dead client.
36
+ # Two missed pings — generous enough to tolerate jittery networks,
37
+ # tight enough to reclaim leaked half-open connections promptly.
38
+ PONG_TIMEOUT = 90
39
+
40
+ # Upper bound on per-connection outgoing queue depth. A healthy client
41
+ # drains events as fast as the server produces them; if the queue
42
+ # fills, the client is too slow (bad network, backgrounded tab that
43
+ # stopped reading). We disconnect rather than buffering without bound.
44
+ # Clients reconnect and receive replay_gap if their cursor is stale.
45
+ QUEUE_LIMIT = 1000
46
+
47
+ def websocket
48
+ self.response = Async::WebSocket::Adapters::Rails.open(request) do |connection|
49
+ handler = WebSocketHandler.new(connection, controller: self)
50
+ handler.run
51
+ end
52
+ end
53
+
54
+ class WebSocketHandler
55
+ def initialize(connection, controller:)
56
+ @connection = connection
57
+ @controller = controller
58
+ @streams = Set.new
59
+ @subscriptions = Firehose.server.subscriptions
60
+ @queue = Async::Queue.new
61
+ @queue_limit = QUEUE_LIMIT
62
+ @overflow = false
63
+ @last_pong_at = monotonic_now
64
+ install_pong_tracker
65
+ end
66
+
67
+ def run
68
+ task = Async::Task.current
69
+
70
+ writer_task = task.async do
71
+ write_messages
72
+ end
73
+
74
+ read_messages
75
+ writer_task.stop
76
+ rescue Protocol::WebSocket::ClosedError, EOFError, Async::Stop
77
+ # Client disconnected
78
+ ensure
79
+ @subscriptions.close
80
+ @streams.clear
81
+ @queue.close
82
+ end
83
+
84
+ private
85
+
86
+ # Override the connection's pong handler so we can track liveness.
87
+ # Pings are sent from write_messages below; pongs arrive via the
88
+ # read fiber, which calls into the connection's receive_pong.
89
+ def install_pong_tracker
90
+ tracker = self
91
+ @connection.define_singleton_method(:receive_pong) do |_frame|
92
+ tracker.send(:record_pong)
93
+ end
94
+ end
95
+
96
+ def record_pong
97
+ @last_pong_at = monotonic_now
98
+ end
99
+
100
+ def monotonic_now
101
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ end
103
+
104
+ def read_messages
105
+ while (message = @connection.read)
106
+ handle_message(JSON.parse(message.to_str))
107
+ end
108
+ rescue JSON::ParserError
109
+ # Ignore malformed messages
110
+ end
111
+
112
+ # Owns the only write path to the connection: event frames AND
113
+ # heartbeat pings. Piggybacking pings here avoids concurrent-write
114
+ # races with a separate heartbeat fiber.
115
+ #
116
+ # When idle (dequeue times out), send a ping if we're overdue.
117
+ # If no pong has been seen within PONG_TIMEOUT, close the connection
118
+ # — read_messages will exit cleanly via ClosedError.
119
+ def write_messages
120
+ loop do
121
+ payload = @queue.dequeue(timeout: PING_INTERVAL)
122
+
123
+ if payload.nil?
124
+ if monotonic_now - @last_pong_at > PONG_TIMEOUT
125
+ Firehose.logger.warn {
126
+ "[Firehose] WebSocket pong timeout (> #{PONG_TIMEOUT}s), closing connection"
127
+ }
128
+ @connection.close
129
+ return
130
+ end
131
+ @connection.send_ping
132
+ next
133
+ end
134
+
135
+ event = JSON.parse(payload, symbolize_names: true)
136
+ event = resolve_event(event) unless event.key?(:data)
137
+ send_event(event) if event
138
+ end
139
+ rescue Async::Stop
140
+ # Task stopped
141
+ end
142
+
143
+ def resolve_event(event)
144
+ msg = Models::Message.includes(:channel).find_by(id: event[:id])
145
+ return unless msg
146
+ { id: msg.id, channel_id: msg.channel_id, sequence: msg.sequence, stream: msg.channel.name, data: msg.data }
147
+ end
148
+
149
+ def handle_message(msg)
150
+ case msg["command"]
151
+ when "subscribe"
152
+ subscribe(msg["streams"] || [], msg["last_event_id"].to_i)
153
+ when "unsubscribe"
154
+ unsubscribe(msg["streams"] || [])
155
+ end
156
+ end
157
+
158
+ def subscribe(streams, last_event_id)
159
+ streams = Array(streams).map(&:to_s)
160
+ streams = @controller.authorize_streams(streams)
161
+
162
+ new_streams = streams - @streams.to_a
163
+ @streams.merge(new_streams)
164
+
165
+ replay_events(new_streams, last_event_id) if new_streams.any? && last_event_id > 0
166
+
167
+ new_streams.each do |stream|
168
+ @subscriptions.add(stream) { |payload| enqueue_or_disconnect(payload) }
169
+ end
170
+ end
171
+
172
+ # Called from the Firehose consumer thread — must return quickly so
173
+ # we don't block other subscribers. If our queue is full, the client
174
+ # is draining too slowly: close the connection (cleanup runs via the
175
+ # rescue in #run) and stop accepting more events. The client will
176
+ # reconnect and hit replay_gap if its cursor has aged past retention.
177
+ def enqueue_or_disconnect(payload)
178
+ if @queue.size >= @queue_limit
179
+ return if @overflow
180
+ @overflow = true
181
+ Firehose.logger.warn {
182
+ "[Firehose] WS queue depth #{@queue.size} >= #{@queue_limit}; disconnecting slow client"
183
+ }
184
+ begin
185
+ @connection.close
186
+ rescue => e
187
+ Firehose.logger.debug { "[Firehose] Error closing overflow client: #{e.message}" }
188
+ end
189
+ else
190
+ @queue.enqueue(payload)
191
+ end
192
+ end
193
+
194
+ def unsubscribe(streams)
195
+ Array(streams).map(&:to_s).each do |stream|
196
+ @streams.delete(stream)
197
+ @subscriptions.remove(stream)
198
+ end
199
+ end
200
+
201
+ # Replay messages the client missed, per stream. If a stream's
202
+ # oldest retained message is newer than last_event_id, the gap
203
+ # exceeds our retention window — emit a replay_gap error for that
204
+ # stream so the client can decide how to recover (reload, API
205
+ # refetch, surface to user). Streams with an intact window get
206
+ # normal replay.
207
+ def replay_events(streams, since_id)
208
+ channels = Models::Channel.where(name: streams).to_a
209
+ channels.each do |ch|
210
+ oldest = ch.messages.minimum(:id)
211
+ next unless oldest # no messages at all — nothing to replay
212
+
213
+ if oldest > since_id
214
+ current = ch.messages.maximum(:id)
215
+ send_event(
216
+ error: "replay_gap",
217
+ stream: ch.name,
218
+ last_event_id: since_id,
219
+ oldest_retained_id: oldest,
220
+ current_id: current
221
+ )
222
+ next
223
+ end
224
+
225
+ ch.messages
226
+ .where("id > ?", since_id)
227
+ .order(:id)
228
+ .find_each { |msg| send_event(id: msg.id, channel_id: msg.channel_id, sequence: msg.sequence, stream: ch.name, data: msg.data) }
229
+ end
230
+ end
231
+
232
+ def send_event(event)
233
+ event = @controller.build_event(event.transform_keys(&:to_sym))
234
+ return unless event
235
+
236
+ message = Protocol::WebSocket::TextMessage.generate(event)
237
+ @connection.write(message)
238
+ @connection.flush
239
+ end
240
+ end
241
+ end
242
+ end
data/lib/firehose.rb CHANGED
@@ -1,19 +1,71 @@
1
- ENV['RACK_ENV'] ||= 'development' # TODO - Lets not rock out envs like its 1999.
1
+ # Firehose: WebSocket and SSE streaming with database-backed replay
2
+ #
3
+ # Usage:
4
+ # Firehose.channel("dashboard").publish("refresh")
5
+ #
6
+ # sub = Firehose.channel("dashboard").subscribe { |event| puts event }
7
+ # sub.close
8
+ #
9
+ # Controller:
10
+ # class FirehoseController < ApplicationController
11
+ # include Firehose::Stream
12
+ # end
13
+ #
14
+ module Firehose
15
+ VERSION = "2.1.0"
2
16
 
3
- require 'uri'
4
- require 'firehose/version'
5
- require 'firehose/logging'
17
+ module Models
18
+ autoload :Channel, "firehose/models/channel"
19
+ autoload :Message, "firehose/models/message"
20
+ end
6
21
 
7
- # TODO - Figure if we need to have an if/else for Rails::Engine loading and Firehose::Assets::Sprockets.auto_detect
8
- require 'firehose/rails' if defined?(::Rails::Engine)
22
+ class << self
23
+ def channel(name)
24
+ Channel.new(name, server:)
25
+ end
9
26
 
10
- module Firehose
11
- autoload :Server, 'firehose/server'
12
- autoload :Client, 'firehose/client'
13
- autoload :Assets, 'firehose/assets'
14
- autoload :Rack, 'firehose/rack'
15
- autoload :CLI, 'firehose/cli'
16
-
17
- # Default URI for the Firehose server. Consider the port "well-known" and bindable from other apps.
18
- URI = URI.parse("//0.0.0.0:7474").freeze
27
+ def server
28
+ @server || raise("Firehose::Server not started. Ensure the Firehose engine has initialized.")
29
+ end
30
+
31
+ def server=(server)
32
+ @server = server
33
+ end
34
+
35
+ def logger
36
+ @logger ||= defined?(Rails) ? Rails.logger : Logger.new($stdout, progname: "Firehose")
37
+ end
38
+
39
+ def logger=(logger)
40
+ @logger = logger
41
+ end
42
+
43
+ # Optional metrics hook. Called periodically from a supervised thread
44
+ # with a flat hash of current gauges and counters:
45
+ #
46
+ # Firehose.on_metrics = ->(metrics) {
47
+ # metrics.each { |k, v| StatsD.gauge("firehose.#{k}", v) }
48
+ # }
49
+ #
50
+ # Set to nil (default) to disable emission. Gem never raises from a
51
+ # metrics call — if your reporter throws, the error is swallowed.
52
+ def on_metrics
53
+ @on_metrics
54
+ end
55
+
56
+ def on_metrics=(callable)
57
+ @on_metrics = callable
58
+ end
59
+ end
19
60
  end
61
+
62
+ require_relative "firehose/engine"
63
+ require_relative "firehose/channel"
64
+ require_relative "firehose/server"
65
+ require_relative "firehose/cleanup_job"
66
+ require_relative "firehose/streamable"
67
+ require_relative "firehose/websocket"
68
+ require_relative "firehose/sse"
69
+ require_relative "firehose/queue"
70
+ require_relative "firehose/stream"
71
+ require_relative "firehose/helper" if defined?(Phlex)
@@ -0,0 +1,43 @@
1
+ module Firehose
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def create_controller
7
+ template "firehose_controller.rb", "app/controllers/firehose_controller.rb"
8
+ end
9
+
10
+ def add_routes
11
+ route <<~RUBY
12
+ match "firehose", to: "firehose#websocket", via: [:get, :connect]
13
+ get "firehose/sse", to: "firehose#sse"
14
+ RUBY
15
+ end
16
+
17
+ def add_javascript_import
18
+ js_file = "app/javascript/application.js"
19
+ return unless File.exist?(js_file)
20
+
21
+ import_line = 'import "firehose"'
22
+ return if File.read(js_file).include?(import_line)
23
+
24
+ append_to_file js_file, "\n#{import_line}\n"
25
+ end
26
+
27
+ def run_migrations
28
+ rails_command "db:migrate"
29
+ end
30
+
31
+ def print_next_steps
32
+ say ""
33
+ say "Firehose installed!", :green
34
+ say ""
35
+ say " Broadcast: Firehose.channel(\"my-stream\").publish(\"refresh\")"
36
+ say " Subscribe: <firehose-stream-source streams=\"my-stream\"></firehose-stream-source>"
37
+ say ""
38
+ say " Edit app/controllers/firehose_controller.rb to add authentication."
39
+ say ""
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,16 @@
1
+ class FirehoseController < ApplicationController
2
+ include Firehose::Stream
3
+
4
+ # Public endpoint - no auth required.
5
+ # Uncomment below to add authentication and stream authorization.
6
+
7
+ # before_action :authenticate_user!
8
+ #
9
+ # def authorize_streams(streams)
10
+ # streams.select { |s| current_user.can_access?(s) }
11
+ # end
12
+ #
13
+ # def build_event(event)
14
+ # event # transform or return nil to skip
15
+ # end
16
+ end