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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Firehose: WebSocket client with automatic reconnection and replay
|
|
2
|
+
//
|
|
3
|
+
// Usage (declarative):
|
|
4
|
+
// <firehose-stream-source path="/firehose" streams="dashboard,user:42"></firehose-stream-source>
|
|
5
|
+
//
|
|
6
|
+
// Usage (imperative):
|
|
7
|
+
// Firehose.subscribe("/firehose", ["dashboard", "user:42"])
|
|
8
|
+
// Firehose.unsubscribe("/firehose", ["dashboard"])
|
|
9
|
+
//
|
|
10
|
+
// Each unique path gets its own WebSocket connection.
|
|
11
|
+
// Streams are subscribed/unsubscribed within each connection.
|
|
12
|
+
//
|
|
13
|
+
const Firehose = (function() {
|
|
14
|
+
// Connection pool: path -> Connection instance
|
|
15
|
+
const connections = new Map()
|
|
16
|
+
|
|
17
|
+
class Connection {
|
|
18
|
+
constructor(path) {
|
|
19
|
+
this.path = path
|
|
20
|
+
this.socket = null
|
|
21
|
+
this.streams = new Set()
|
|
22
|
+
this.lastEventId = 0
|
|
23
|
+
this.reconnectTimer = null
|
|
24
|
+
this.reconnectDelay = 1000
|
|
25
|
+
this.intentionalDisconnect = false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
connect() {
|
|
29
|
+
// Don't create new connection if socket exists in any state except CLOSED
|
|
30
|
+
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
|
|
31
|
+
console.debug(`[Firehose:${this.path}] Socket exists, state:`, this.socket.readyState)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.socket = null
|
|
36
|
+
|
|
37
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
|
38
|
+
const url = `${protocol}//${window.location.host}${this.path}`
|
|
39
|
+
|
|
40
|
+
console.debug(`[Firehose:${this.path}] Creating connection to`, url)
|
|
41
|
+
this.intentionalDisconnect = false
|
|
42
|
+
this.socket = new WebSocket(url)
|
|
43
|
+
|
|
44
|
+
this.socket.onopen = () => {
|
|
45
|
+
console.debug(`[Firehose:${this.path}] Connected`)
|
|
46
|
+
this.reconnectDelay = 1000
|
|
47
|
+
|
|
48
|
+
if (this.streams.size > 0) {
|
|
49
|
+
console.debug(`[Firehose:${this.path}] Subscribing to`, Array.from(this.streams), "from event", this.lastEventId)
|
|
50
|
+
this.send({
|
|
51
|
+
command: "subscribe",
|
|
52
|
+
streams: Array.from(this.streams),
|
|
53
|
+
last_event_id: this.lastEventId
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.socket.onmessage = (event) => {
|
|
59
|
+
const data = JSON.parse(event.data)
|
|
60
|
+
this.handleEvent(data)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.socket.onclose = () => {
|
|
64
|
+
console.debug(`[Firehose:${this.path}] Closed, intentional:`, this.intentionalDisconnect)
|
|
65
|
+
this.socket = null
|
|
66
|
+
|
|
67
|
+
if (!this.intentionalDisconnect && this.streams.size > 0) {
|
|
68
|
+
this.scheduleReconnect()
|
|
69
|
+
} else if (this.streams.size === 0) {
|
|
70
|
+
// Remove from pool if no streams
|
|
71
|
+
connections.delete(this.path)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.socket.onerror = (error) => {
|
|
76
|
+
console.debug(`[Firehose:${this.path}] Error`, error)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
disconnect() {
|
|
81
|
+
console.debug(`[Firehose:${this.path}] Disconnect called`)
|
|
82
|
+
clearTimeout(this.reconnectTimer)
|
|
83
|
+
this.intentionalDisconnect = true
|
|
84
|
+
if (this.socket) {
|
|
85
|
+
this.socket.close()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
scheduleReconnect() {
|
|
90
|
+
clearTimeout(this.reconnectTimer)
|
|
91
|
+
if (this.streams.size === 0) return
|
|
92
|
+
console.debug(`[Firehose:${this.path}] Reconnecting in`, this.reconnectDelay, "ms")
|
|
93
|
+
this.reconnectTimer = setTimeout(() => {
|
|
94
|
+
this.connect()
|
|
95
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
|
|
96
|
+
}, this.reconnectDelay)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
send(message) {
|
|
100
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
101
|
+
this.socket.send(JSON.stringify(message))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
subscribe(streamList) {
|
|
106
|
+
const newStreams = streamList.filter(s => !this.streams.has(s))
|
|
107
|
+
console.debug(`[Firehose:${this.path}] Subscribe:`, streamList, "new:", newStreams)
|
|
108
|
+
|
|
109
|
+
if (newStreams.length === 0) return
|
|
110
|
+
|
|
111
|
+
newStreams.forEach(s => this.streams.add(s))
|
|
112
|
+
|
|
113
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
114
|
+
this.send({
|
|
115
|
+
command: "subscribe",
|
|
116
|
+
streams: newStreams,
|
|
117
|
+
last_event_id: this.lastEventId
|
|
118
|
+
})
|
|
119
|
+
} else if (!this.socket || this.socket.readyState === WebSocket.CLOSED) {
|
|
120
|
+
this.connect()
|
|
121
|
+
} else if (this.socket.readyState === WebSocket.CLOSING) {
|
|
122
|
+
// Socket is closing - ensure onclose will reconnect
|
|
123
|
+
console.debug(`[Firehose:${this.path}] Socket closing, will reconnect`)
|
|
124
|
+
this.intentionalDisconnect = false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
unsubscribe(streamList) {
|
|
129
|
+
const toRemove = streamList.filter(s => this.streams.has(s))
|
|
130
|
+
console.debug(`[Firehose:${this.path}] Unsubscribe:`, streamList, "removing:", toRemove)
|
|
131
|
+
|
|
132
|
+
if (toRemove.length === 0) return
|
|
133
|
+
|
|
134
|
+
toRemove.forEach(s => this.streams.delete(s))
|
|
135
|
+
|
|
136
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
137
|
+
this.send({
|
|
138
|
+
command: "unsubscribe",
|
|
139
|
+
streams: toRemove
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.streams.size === 0) {
|
|
144
|
+
this.disconnect()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
handleEvent(event) {
|
|
149
|
+
console.debug(`[Firehose:${this.path}] Event:`, event)
|
|
150
|
+
|
|
151
|
+
if (event.id) {
|
|
152
|
+
this.lastEventId = Math.max(this.lastEventId, event.id)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const customEvent = new CustomEvent("firehose:message", {
|
|
156
|
+
bubbles: true,
|
|
157
|
+
detail: { ...event, path: this.path }
|
|
158
|
+
})
|
|
159
|
+
document.dispatchEvent(customEvent)
|
|
160
|
+
|
|
161
|
+
if (event.data === "refresh") {
|
|
162
|
+
refresh()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getConnection(path) {
|
|
168
|
+
if (!connections.has(path)) {
|
|
169
|
+
connections.set(path, new Connection(path))
|
|
170
|
+
}
|
|
171
|
+
return connections.get(path)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function subscribe(path, streamList) {
|
|
175
|
+
getConnection(path).subscribe(streamList)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function unsubscribe(path, streamList) {
|
|
179
|
+
const conn = connections.get(path)
|
|
180
|
+
if (conn) {
|
|
181
|
+
conn.unsubscribe(streamList)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function refresh() {
|
|
186
|
+
console.debug("[Firehose] Triggering page refresh")
|
|
187
|
+
if (typeof Turbo !== "undefined") {
|
|
188
|
+
if (Turbo.session && typeof Turbo.session.refresh === "function") {
|
|
189
|
+
Turbo.session.refresh(document.baseURI)
|
|
190
|
+
} else {
|
|
191
|
+
Turbo.visit(window.location.href, { action: "replace" })
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
window.location.reload()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
subscribe,
|
|
200
|
+
unsubscribe,
|
|
201
|
+
getConnection,
|
|
202
|
+
get connections() { return connections }
|
|
203
|
+
}
|
|
204
|
+
})()
|
|
205
|
+
|
|
206
|
+
// Custom element for declarative stream subscriptions
|
|
207
|
+
class FirehoseStreamSource extends HTMLElement {
|
|
208
|
+
connectedCallback() {
|
|
209
|
+
this._path = this.getAttribute("path") || "/firehose"
|
|
210
|
+
const streams = this.getAttribute("streams")
|
|
211
|
+
console.debug("[Firehose] Element connected, path:", this._path, "streams:", streams)
|
|
212
|
+
|
|
213
|
+
if (streams) {
|
|
214
|
+
this._streams = streams.split(",").map(s => s.trim()).filter(Boolean)
|
|
215
|
+
Firehose.subscribe(this._path, this._streams)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
disconnectedCallback() {
|
|
220
|
+
console.debug("[Firehose] Element disconnected, path:", this._path, "streams:", this._streams)
|
|
221
|
+
if (this._streams && this._path) {
|
|
222
|
+
Firehose.unsubscribe(this._path, this._streams)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
static get observedAttributes() {
|
|
227
|
+
return ["streams", "path"]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
231
|
+
// Only handle changes after initial connection
|
|
232
|
+
if (oldValue === null) return
|
|
233
|
+
|
|
234
|
+
if (name === "streams") {
|
|
235
|
+
const oldStreams = oldValue.split(",").map(s => s.trim()).filter(Boolean)
|
|
236
|
+
const newStreams = newValue.split(",").map(s => s.trim()).filter(Boolean)
|
|
237
|
+
|
|
238
|
+
const toRemove = oldStreams.filter(s => !newStreams.includes(s))
|
|
239
|
+
const toAdd = newStreams.filter(s => !oldStreams.includes(s))
|
|
240
|
+
|
|
241
|
+
if (toRemove.length) Firehose.unsubscribe(this._path, toRemove)
|
|
242
|
+
if (toAdd.length) Firehose.subscribe(this._path, toAdd)
|
|
243
|
+
|
|
244
|
+
this._streams = newStreams
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!customElements.get("firehose-stream-source")) {
|
|
250
|
+
customElements.define("firehose-stream-source", FirehoseStreamSource)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Export for module systems
|
|
254
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
255
|
+
module.exports = Firehose
|
|
256
|
+
} else if (typeof window !== "undefined") {
|
|
257
|
+
window.Firehose = Firehose
|
|
258
|
+
}
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pin "firehose", to: "firehose.js"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class CreateFirehoseEvents < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :firehose_channels do |t|
|
|
4
|
+
t.text :name, null: false
|
|
5
|
+
t.integer :sequence, null: false, default: 0
|
|
6
|
+
t.integer :messages_count, null: false, default: 0
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
add_index :firehose_channels, :name, unique: true
|
|
11
|
+
|
|
12
|
+
create_table :firehose_messages do |t|
|
|
13
|
+
t.references :channel,
|
|
14
|
+
null: false,
|
|
15
|
+
foreign_key: { to_table: :firehose_channels, on_delete: :cascade }
|
|
16
|
+
t.integer :sequence, null: false
|
|
17
|
+
t.text :data, null: false
|
|
18
|
+
t.datetime :created_at, null: false, default: -> { "now()" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
add_index :firehose_messages, [:channel_id, :sequence], unique: true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Firehose
|
|
2
|
+
module Command
|
|
3
|
+
class FirehoseCommand < Rails::Command::Base
|
|
4
|
+
namespace "firehose"
|
|
5
|
+
|
|
6
|
+
desc "status", "Show Firehose stats (--watch to refresh)"
|
|
7
|
+
option :watch, type: :numeric, default: nil, aliases: "-w", lazy_default: 1,
|
|
8
|
+
desc: "Refresh every N seconds (default: 1)"
|
|
9
|
+
def status
|
|
10
|
+
boot_application!
|
|
11
|
+
|
|
12
|
+
if options[:watch]
|
|
13
|
+
trap("INT") { puts; exit }
|
|
14
|
+
loop do
|
|
15
|
+
print "\e[2J\e[H"
|
|
16
|
+
print_stats
|
|
17
|
+
sleep options[:watch]
|
|
18
|
+
end
|
|
19
|
+
else
|
|
20
|
+
print_stats
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
default_command :status
|
|
25
|
+
|
|
26
|
+
desc "channels", "List Firehose channels"
|
|
27
|
+
option :limit, type: :numeric, default: 50, aliases: "-n",
|
|
28
|
+
desc: "Number of channels to show"
|
|
29
|
+
def channels
|
|
30
|
+
boot_application!
|
|
31
|
+
|
|
32
|
+
total = Models::Channel.count
|
|
33
|
+
channels = Models::Channel.order(sequence: :desc).limit(options[:limit])
|
|
34
|
+
|
|
35
|
+
if channels.empty?
|
|
36
|
+
puts "No channels."
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
name_width = [channels.map { |c| c.name.length }.max, 40].min
|
|
41
|
+
|
|
42
|
+
puts "%-#{name_width}s %8s %8s %s" % ["NAME", "SEQUENCE", "MESSAGES", "LATEST MESSAGE"]
|
|
43
|
+
puts "-" * (name_width + 50)
|
|
44
|
+
|
|
45
|
+
channels.each do |ch|
|
|
46
|
+
latest = ch.messages.order(id: :desc).limit(1).pick(:created_at)
|
|
47
|
+
latest_at = latest ? latest.strftime("%Y-%m-%d %H:%M:%S") : "-"
|
|
48
|
+
puts "%-#{name_width}s %8d %8d %s" % [ch.name.truncate(name_width), ch.sequence, ch.messages_count, latest_at]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
puts
|
|
52
|
+
puts "#{channels.size} of #{total} channel(s)"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
desc "messages", "List recent messages (use --tail to follow live)"
|
|
56
|
+
option :tail, type: :boolean, default: false, aliases: "-f",
|
|
57
|
+
desc: "Follow new messages as they arrive"
|
|
58
|
+
option :channel, type: :string, aliases: "-c",
|
|
59
|
+
desc: "Filter to a specific channel"
|
|
60
|
+
option :limit, type: :numeric, default: 20, aliases: "-n",
|
|
61
|
+
desc: "Number of recent messages to show"
|
|
62
|
+
def messages
|
|
63
|
+
boot_application!
|
|
64
|
+
|
|
65
|
+
scope = Models::Message.includes(:channel).order(id: :desc)
|
|
66
|
+
scope = scope.joins(:channel).where(firehose_channels: { name: options[:channel] }) if options[:channel]
|
|
67
|
+
|
|
68
|
+
messages = scope.limit(options[:limit]).to_a.reverse
|
|
69
|
+
|
|
70
|
+
if messages.any?
|
|
71
|
+
messages.each { |msg| print_message(msg) }
|
|
72
|
+
else
|
|
73
|
+
puts "No messages."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return unless options[:tail]
|
|
77
|
+
|
|
78
|
+
trap("INT") { puts; exit }
|
|
79
|
+
|
|
80
|
+
last_id = Models::Message.maximum(:id) || 0
|
|
81
|
+
|
|
82
|
+
loop do
|
|
83
|
+
new_scope = Models::Message.where("id > ?", last_id).includes(:channel).order(:id).limit(100)
|
|
84
|
+
new_scope = new_scope.joins(:channel).where(firehose_channels: { name: options[:channel] }) if options[:channel]
|
|
85
|
+
|
|
86
|
+
new_scope.each do |msg|
|
|
87
|
+
print_message(msg)
|
|
88
|
+
last_id = msg.id
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sleep 0.5
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
desc "install", "Install Firehose (creates controller, routes, JS import, runs migrations)"
|
|
96
|
+
def install
|
|
97
|
+
boot_application!
|
|
98
|
+
Rails::Generators.invoke("firehose:install", [], behavior: :invoke)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def print_stats
|
|
104
|
+
puts "channels: #{Models::Channel.count}"
|
|
105
|
+
puts "messages:"
|
|
106
|
+
puts " total: #{Models::Message.count}"
|
|
107
|
+
puts " last_minute: #{Models::Message.where("created_at > ?", 1.minute.ago).count}"
|
|
108
|
+
puts " last_hour: #{Models::Message.where("created_at > ?", 1.hour.ago).count}"
|
|
109
|
+
puts " last_day: #{Models::Message.where("created_at > ?", 1.day.ago).count}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def print_message(msg)
|
|
113
|
+
time = msg.created_at.strftime("%H:%M:%S.%L")
|
|
114
|
+
data = msg.data.truncate(80)
|
|
115
|
+
puts "[#{time}] ##{msg.id} #{msg.channel.name} seq:#{msg.sequence} #{data}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Firehose
|
|
2
|
+
# Public API for interacting with a named stream.
|
|
3
|
+
#
|
|
4
|
+
# channel = Firehose.channel("dashboard")
|
|
5
|
+
# channel.publish("refresh")
|
|
6
|
+
#
|
|
7
|
+
# sub = channel.subscribe { |payload| puts payload }
|
|
8
|
+
# sub.close
|
|
9
|
+
#
|
|
10
|
+
class Channel
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(name, server: Firehose.server)
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
@server = server
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def publish(data)
|
|
19
|
+
@server.broadcast(@name, data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def subscribe(&callback)
|
|
23
|
+
pg_channel = @server.channel_name(@name)
|
|
24
|
+
@server.subscribe(pg_channel, callback)
|
|
25
|
+
Subscription.new(@server, pg_channel, callback)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Subscription
|
|
29
|
+
def initialize(server, channel, callback)
|
|
30
|
+
@server = server
|
|
31
|
+
@channel = channel
|
|
32
|
+
@callback = callback
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close
|
|
36
|
+
@server.unsubscribe(@channel, @callback)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Firehose
|
|
2
|
+
class CleanupJob < ActiveJob::Base
|
|
3
|
+
def perform(stream)
|
|
4
|
+
channel = Models::Channel.find_by(name: stream)
|
|
5
|
+
return unless channel
|
|
6
|
+
|
|
7
|
+
cutoff_id = channel.messages
|
|
8
|
+
.order(sequence: :desc)
|
|
9
|
+
.offset(Firehose.server.cleanup_threshold)
|
|
10
|
+
.limit(1)
|
|
11
|
+
.pick(:id)
|
|
12
|
+
|
|
13
|
+
return unless cutoff_id
|
|
14
|
+
|
|
15
|
+
channel.messages.where("id <= ?", cutoff_id).delete_all
|
|
16
|
+
channel.update_columns(messages_count: channel.messages.count)
|
|
17
|
+
|
|
18
|
+
channel.destroy if channel.messages_count == 0
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Firehose
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
initializer "firehose.assets" do |app|
|
|
4
|
+
if app.config.respond_to?(:assets)
|
|
5
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
if defined?(Importmap)
|
|
9
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "firehose.migrations" do |app|
|
|
14
|
+
app.config.paths["db/migrate"].concat(config.paths["db/migrate"].expanded)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "firehose.server" do |app|
|
|
18
|
+
Firehose.server = Firehose::Server.new
|
|
19
|
+
|
|
20
|
+
rb_config = app.root.join("config/firehose.rb")
|
|
21
|
+
yml_config = app.root.join("config/firehose.yml")
|
|
22
|
+
|
|
23
|
+
if rb_config.exist?
|
|
24
|
+
load rb_config
|
|
25
|
+
elsif yml_config.exist?
|
|
26
|
+
Firehose.server.configure_from_yaml(yml_config)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Firehose.server.start
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
at_exit do
|
|
33
|
+
Firehose.server.shutdown rescue nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Firehose
|
|
2
|
+
module Helper
|
|
3
|
+
# Renders a <firehose-stream-source> element for the given streamables.
|
|
4
|
+
#
|
|
5
|
+
# Usage in Phlex:
|
|
6
|
+
# firehose_stream_from @report
|
|
7
|
+
# firehose_stream_from "dashboard", "user:#{current_user.id}"
|
|
8
|
+
# firehose_stream_from @report, path: "/my-cable"
|
|
9
|
+
#
|
|
10
|
+
def firehose_stream_from(*streamables, path: "/firehose")
|
|
11
|
+
streams = streamables.map { |s| stream_name_for(s) }.join(",")
|
|
12
|
+
render FirehoseStreamSource.new(streams:, path:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def stream_name_for(streamable)
|
|
18
|
+
case streamable
|
|
19
|
+
when String, Symbol
|
|
20
|
+
streamable.to_s
|
|
21
|
+
else
|
|
22
|
+
# ActiveRecord model - use global ID or model name + id
|
|
23
|
+
streamable.to_gid_param
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class FirehoseStreamSource < Phlex::HTML
|
|
28
|
+
register_element :firehose_stream_source
|
|
29
|
+
|
|
30
|
+
def initialize(streams:, path:)
|
|
31
|
+
@streams = streams
|
|
32
|
+
@path = path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def view_template
|
|
36
|
+
firehose_stream_source(streams: @streams, path: @path)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Firehose
|
|
2
|
+
# Raw push/pop queue over PG LISTEN/NOTIFY.
|
|
3
|
+
#
|
|
4
|
+
# For ephemeral signaling (auth nonces, job completion, etc.) where
|
|
5
|
+
# messages don't need database persistence or replay.
|
|
6
|
+
#
|
|
7
|
+
# For database-backed stream consumption with replay and ordering,
|
|
8
|
+
# use Server::Subscriptions with the SSE or WebSocket transports.
|
|
9
|
+
class Queue
|
|
10
|
+
class TimeoutError < StandardError; end
|
|
11
|
+
|
|
12
|
+
def initialize(channel, server: Firehose.server)
|
|
13
|
+
@channel = channel
|
|
14
|
+
@server = server
|
|
15
|
+
@queue = ::Queue.new
|
|
16
|
+
@callback = ->(payload) { @queue << payload }
|
|
17
|
+
@subscribed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subscribe
|
|
21
|
+
return self if @subscribed
|
|
22
|
+
@subscribed = true
|
|
23
|
+
@server.subscribe(@channel, @callback)
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def push(message)
|
|
28
|
+
@server.notify(@channel, message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pop(timeout: nil)
|
|
32
|
+
subscribe
|
|
33
|
+
payload = @queue.pop(timeout:)
|
|
34
|
+
raise TimeoutError, "No message received on #{@channel} within #{timeout}s" if payload.nil? && timeout
|
|
35
|
+
payload
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
@server.unsubscribe(@channel, @callback) if @subscribed
|
|
40
|
+
@subscribed = false
|
|
41
|
+
@queue.close
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|