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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +36 -0
- data/{LICENSE.txt → LICENSE} +6 -6
- data/README.md +389 -139
- data/app/assets/javascripts/firehose.js +258 -0
- data/config/importmap.rb +1 -0
- data/db/migrate/20260214185244_create_firehose_events.rb +23 -0
- data/lib/commands/firehose_command.rb +119 -0
- data/lib/firehose/channel.rb +40 -0
- data/lib/firehose/cleanup_job.rb +21 -0
- data/lib/firehose/engine.rb +36 -0
- data/lib/firehose/helper.rb +40 -0
- data/lib/firehose/models/channel.rb +12 -0
- data/lib/firehose/models/message.rb +11 -0
- data/lib/firehose/queue.rb +44 -0
- data/lib/firehose/server.rb +931 -18
- data/lib/firehose/sse.rb +187 -0
- data/lib/firehose/stream.rb +32 -0
- data/lib/firehose/streamable.rb +25 -0
- data/lib/firehose/websocket.rb +242 -0
- data/lib/firehose.rb +67 -15
- data/lib/generators/firehose/install/install_generator.rb +43 -0
- data/lib/generators/firehose/install/templates/firehose_controller.rb +16 -0
- metadata +40 -428
- data/.env.sample +0 -17
- data/.gitignore +0 -12
- data/.rbenv-version +0 -1
- data/.rspec +0 -1
- data/.travis.yml +0 -16
- data/Gemfile +0 -4
- data/Guardfile +0 -31
- data/Procfile +0 -1
- data/Procfile.dev +0 -4
- data/Rakefile +0 -53
- data/bin/firehose +0 -9
- data/config/evergreen.rb +0 -9
- data/config/rainbows.rb +0 -17
- data/firehose.gemspec +0 -49
- data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
- data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -70
- data/lib/assets/javascripts/firehose/core.js.coffee +0 -5
- data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -149
- data/lib/assets/javascripts/firehose/transport.js.coffee +0 -42
- data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
- data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -102
- data/lib/assets/javascripts/firehose.js.coffee +0 -3
- data/lib/assets/javascripts/vendor/json2.js +0 -487
- data/lib/firehose/assets.rb +0 -56
- data/lib/firehose/cli.rb +0 -78
- data/lib/firehose/client/consumer.rb +0 -94
- data/lib/firehose/client/producer.rb +0 -108
- data/lib/firehose/client.rb +0 -10
- data/lib/firehose/logging.rb +0 -32
- data/lib/firehose/rack/app.rb +0 -49
- data/lib/firehose/rack/consumer/http_long_poll.rb +0 -86
- data/lib/firehose/rack/consumer/web_socket.rb +0 -95
- data/lib/firehose/rack/consumer.rb +0 -39
- data/lib/firehose/rack/ping.rb +0 -88
- data/lib/firehose/rack/publisher.rb +0 -53
- data/lib/firehose/rack.rb +0 -32
- data/lib/firehose/rails.rb +0 -7
- data/lib/firehose/server/app.rb +0 -52
- data/lib/firehose/server/channel.rb +0 -80
- data/lib/firehose/server/publisher.rb +0 -134
- data/lib/firehose/server/subscriber.rb +0 -51
- data/lib/firehose/version.rb +0 -4
- data/package.json +0 -5
- data/spec/integrations/integration_test_helper.rb +0 -22
- data/spec/integrations/rainbows_spec.rb +0 -7
- data/spec/integrations/shared_examples.rb +0 -120
- data/spec/integrations/thin_spec.rb +0 -7
- data/spec/javascripts/firehose/base_spec.coffee +0 -2
- data/spec/javascripts/firehose/consumer_spec.coffee +0 -45
- data/spec/javascripts/firehose/long_poll_spec.coffee +0 -44
- data/spec/javascripts/firehose/transport_spec.coffee +0 -32
- data/spec/javascripts/firehose/websocket_spec.coffee +0 -25
- data/spec/javascripts/helpers/spec_helper.js +0 -30
- data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
- data/spec/lib/assets_spec.rb +0 -35
- data/spec/lib/client/consumer_spec.rb +0 -13
- data/spec/lib/client/producer_spec.rb +0 -71
- data/spec/lib/firehose_spec.rb +0 -7
- data/spec/lib/rack/consumer/http_long_poll_spec.rb +0 -12
- data/spec/lib/rack/consumer_spec.rb +0 -19
- data/spec/lib/rack/ping_spec.rb +0 -28
- data/spec/lib/rack/publisher_spec.rb +0 -34
- data/spec/lib/server/app_spec.rb +0 -15
- data/spec/lib/server/channel_spec.rb +0 -164
- data/spec/lib/server/publisher_spec.rb +0 -84
- data/spec/lib/server/subscriber_spec.rb +0 -69
- data/spec/spec_helper.rb +0 -77
data/lib/firehose/sse.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
17
|
+
module Models
|
|
18
|
+
autoload :Channel, "firehose/models/channel"
|
|
19
|
+
autoload :Message, "firehose/models/message"
|
|
20
|
+
end
|
|
6
21
|
|
|
7
|
-
|
|
8
|
-
|
|
22
|
+
class << self
|
|
23
|
+
def channel(name)
|
|
24
|
+
Channel.new(name, server:)
|
|
25
|
+
end
|
|
9
26
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|