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,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
+ }
@@ -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,12 @@
1
+ module Firehose
2
+ module Models
3
+ class Channel < ActiveRecord::Base
4
+ self.table_name = "firehose_channels"
5
+
6
+ has_many :messages,
7
+ class_name: "Firehose::Models::Message",
8
+ foreign_key: :channel_id,
9
+ dependent: :delete_all
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Firehose
2
+ module Models
3
+ class Message < ActiveRecord::Base
4
+ self.table_name = "firehose_messages"
5
+
6
+ belongs_to :channel,
7
+ class_name: "Firehose::Models::Channel",
8
+ counter_cache: :messages_count
9
+ end
10
+ end
11
+ 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