firehose 1.3.6 → 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 (104) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +31 -10
  3. data/{LICENSE.txt → LICENSE} +6 -6
  4. data/README.md +379 -187
  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 +930 -17
  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 +39 -366
  25. data/.codeclimate.yml +0 -29
  26. data/.dockerignore +0 -2
  27. data/.env.sample +0 -17
  28. data/.gitignore +0 -14
  29. data/.rspec +0 -1
  30. data/.rubocop.yml +0 -1156
  31. data/.ruby-version +0 -1
  32. data/.travis.yml +0 -12
  33. data/Dockerfile +0 -11
  34. data/Gemfile +0 -6
  35. data/Procfile +0 -1
  36. data/Procfile.dev +0 -3
  37. data/Rakefile +0 -64
  38. data/bin/firehose +0 -9
  39. data/coffeelint.json +0 -129
  40. data/config/rainbows.rb +0 -17
  41. data/docker-compose.yml +0 -17
  42. data/firehose.gemspec +0 -45
  43. data/karma.config.coffee +0 -89
  44. data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
  45. data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -86
  46. data/lib/assets/javascripts/firehose/core.js.coffee +0 -6
  47. data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -210
  48. data/lib/assets/javascripts/firehose/multiplexed_consumer.js.coffee +0 -74
  49. data/lib/assets/javascripts/firehose/transport.js.coffee +0 -44
  50. data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
  51. data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -148
  52. data/lib/assets/javascripts/firehose.js.coffee +0 -2
  53. data/lib/firehose/assets.rb +0 -56
  54. data/lib/firehose/cli.rb +0 -79
  55. data/lib/firehose/client/consumer.rb +0 -94
  56. data/lib/firehose/client/producer.rb +0 -114
  57. data/lib/firehose/client.rb +0 -10
  58. data/lib/firehose/logging.rb +0 -32
  59. data/lib/firehose/rack/app.rb +0 -49
  60. data/lib/firehose/rack/consumer/http_long_poll.rb +0 -159
  61. data/lib/firehose/rack/consumer/web_socket.rb +0 -200
  62. data/lib/firehose/rack/consumer.rb +0 -78
  63. data/lib/firehose/rack/ping.rb +0 -88
  64. data/lib/firehose/rack/publisher.rb +0 -59
  65. data/lib/firehose/rack.rb +0 -32
  66. data/lib/firehose/rails.rb +0 -7
  67. data/lib/firehose/server/app.rb +0 -52
  68. data/lib/firehose/server/channel.rb +0 -72
  69. data/lib/firehose/server/message_buffer.rb +0 -59
  70. data/lib/firehose/server/publisher.rb +0 -133
  71. data/lib/firehose/server/redis.rb +0 -32
  72. data/lib/firehose/server/subscriber.rb +0 -51
  73. data/lib/firehose/version.rb +0 -4
  74. data/package.json +0 -17
  75. data/spec/integrations/integration_test_helper.rb +0 -22
  76. data/spec/integrations/rainbows_spec.rb +0 -7
  77. data/spec/integrations/shared_examples.rb +0 -202
  78. data/spec/integrations/thin_spec.rb +0 -7
  79. data/spec/javascripts/firehose/base_spec.coffee +0 -2
  80. data/spec/javascripts/firehose/consumer_spec.coffee +0 -45
  81. data/spec/javascripts/firehose/long_poll_spec.coffee +0 -44
  82. data/spec/javascripts/firehose/multiplexed_consumer_spec.coffee +0 -72
  83. data/spec/javascripts/firehose/transport_spec.coffee +0 -30
  84. data/spec/javascripts/firehose/websocket_spec.coffee +0 -27
  85. data/spec/javascripts/helpers/spec_helper.js +0 -31
  86. data/spec/javascripts/support/jquery-1.11.1.js +0 -10308
  87. data/spec/javascripts/support/json2.js +0 -487
  88. data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
  89. data/spec/javascripts/support/spec_helper.coffee +0 -3
  90. data/spec/lib/assets_spec.rb +0 -35
  91. data/spec/lib/client/consumer_spec.rb +0 -13
  92. data/spec/lib/client/producer_spec.rb +0 -71
  93. data/spec/lib/firehose_spec.rb +0 -7
  94. data/spec/lib/rack/consumer/http_long_poll_spec.rb +0 -30
  95. data/spec/lib/rack/consumer_spec.rb +0 -19
  96. data/spec/lib/rack/ping_spec.rb +0 -28
  97. data/spec/lib/rack/publisher_spec.rb +0 -34
  98. data/spec/lib/server/app_spec.rb +0 -15
  99. data/spec/lib/server/channel_spec.rb +0 -178
  100. data/spec/lib/server/message_buffer_spec.rb +0 -148
  101. data/spec/lib/server/publisher_spec.rb +0 -91
  102. data/spec/lib/server/redis_spec.rb +0 -13
  103. data/spec/lib/server/subscriber_spec.rb +0 -70
  104. data/spec/spec_helper.rb +0 -84
data/README.md CHANGED
@@ -1,286 +1,478 @@
1
- __ _ _
2
- / _(_) | |
3
- | |_ _ _ __ ___| |__ ___ ___ ___
4
- | _| | '__/ _ \ '_ \ / _ \/ __|/ _ \
5
- | | | | | | __/ | | | (_) \__ \ __/
6
- |_| |_|_| \___|_| |_|\___/|___/\___|
1
+ # Firehose 2.0
7
2
 
8
- Build realtime web applications in Ruby and JS
3
+ Real-time streaming for Rails with WebSocket and SSE transports, database-backed persistence, and automatic replay on reconnect.
9
4
 
10
- [![Build Status](https://travis-ci.org/firehoseio/firehose.svg?branch=master)](https://travis-ci.org/firehoseio/firehose) [![Code Climate](https://codeclimate.com/github/firehoseio/firehose/badges/gpa.svg)](https://codeclimate.com/github/firehoseio/firehose) [![Test Coverage](https://codeclimate.com/github/firehoseio/firehose/badges/coverage.svg)](https://codeclimate.com/github/firehoseio/firehose/coverage)
5
+ Built on a dedicated PostgreSQL LISTEN/NOTIFY connection — no ActionCable, no polling, no connection pool pressure.
11
6
 
12
- # What is Firehose?
7
+ ## Why not ActionCable?
13
8
 
14
- Firehose is both a Rack application and JavaScript library that makes building real-time web applications possible.
9
+ ActionCable is fire-and-forget. Messages are published into Redis pub/sub with no persistence — if a client is disconnected when a message arrives, that message is gone. There's no way to know what was missed, and no way to catch up.
15
10
 
16
- # Getting Started
11
+ This matters more than it sounds. Mobile users tunnel through subways. Laptops go to sleep. WiFi drops for a few seconds during a video call. Browser tabs get suspended by the OS to save memory. In every case, the WebSocket closes, messages fly by, and when the client reconnects it has no idea it missed anything. The UI is silently stale.
17
12
 
18
- First, you'll need to [install and run Redis 2.6](http://redis.io/download).
19
- Version 2.6 is required because Firehose uses [Lua/EVAL](http://redis.io/commands/eval) for its transactions, which is not available in earlier versions of Redis.
13
+ ActionCable also has no concept of message ordering. If you broadcast three updates to a stream, there's no sequence number, no monotonic ID, nothing for the client to compare against. You can't detect a gap because there's nothing to detect a gap *in*.
20
14
 
21
- Then install the gem.
15
+ Firehose fixes this:
22
16
 
23
- ```sh
24
- $ gem install firehose
25
- ```
17
+ - **Every message is persisted** in PostgreSQL with a monotonic sequence per stream and a global auto-incrementing ID. Messages are real database rows, not ephemeral pub/sub pings.
26
18
 
27
- ## The Server
19
+ - **Clients track their position** via `last_event_id`. The JavaScript client tracks the highest event ID it has seen. On reconnect, it sends this ID back to the server.
28
20
 
29
- Now fire up the server.
21
+ - **Replay on reconnect.** The server queries all messages with `id > last_event_id` across the client's subscribed streams and replays them in order before resuming live delivery. The client sees every message, in order, with no gaps.
30
22
 
31
- ```
32
- $ firehose server
33
- >> Thin web server (v1.3.1 codename Triple Espresso)
34
- >> Maximum connections set to 1024
35
- >> Listening on 127.0.0.1:7474, CTRL+C to stop
36
- ```
23
+ - **Sequence numbers detect channel resets.** Each stream has its own monotonic sequence counter. If a client sees sequence 5 followed by sequence 1, it knows the channel was reset (e.g., data was cleared, the stream was recreated). The client can react accordingly — full refresh, re-fetch state, whatever makes sense for the application.
24
+
25
+ - **The browser does most of the work.** The `<firehose-stream-source>` custom element handles subscribe, unsubscribe, reconnection with exponential backoff, and `last_event_id` tracking automatically. Drop the element in your HTML and forget about it.
26
+
27
+ - **No Redis.** Everything runs on PostgreSQL, which you already have. LISTEN/NOTIFY for real-time fan-out, regular tables for persistence. One fewer piece of infrastructure.
28
+
29
+ ## Prior Art
30
+
31
+ ### ActionCable + Redis
32
+
33
+ The default Rails real-time stack. Redis pub/sub distributes messages across processes — fast, simple, and well-documented. But Redis pub/sub is ephemeral: messages exist only in the moment they're published. No persistence, no replay, no sequence numbers. The JavaScript client reconnects automatically but starts from zero every time. Works fine when "best effort" delivery is acceptable and you don't mind the occasional stale UI after a reconnect.
34
+
35
+ ### SolidCable
36
+
37
+ 37signals' database-backed ActionCable adapter, shipping with Rails 8. Replaces Redis with a `solid_cable_messages` table — each process polls for new messages by ID. Eliminates Redis as a dependency, which is great. The messages *are* in the database with auto-incrementing IDs, so the raw material for replay is right there. But SolidCable doesn't expose it: the ActionCable client has no `last_event_id` concept, so reconnection still means missed messages. Polling also introduces a small latency floor (default 100ms) compared to LISTEN/NOTIFY which is near-instant.
38
+
39
+ ### AnyCable
40
+
41
+ Moves WebSocket connection handling to a Go or Rust server, calling back to Rails via gRPC for channel logic. Dramatically better connection scalability — tens of thousands of connections instead of hundreds. The commercial Pro version adds reliable streams with epoch+offset tracking and replay on reconnect, which solves the persistence gap. The open-source version is fire-and-forget like ActionCable. Requires deploying and operating a separate server process, plus Redis or NATS for pub/sub between the Go server and Rails.
37
42
 
38
- In case you're wondering, the Firehose application server runs the Rack app `Firehose::Rack::App.new` inside of Thin or Rainbows! `Firehose::Rack::App` consists of a bunch of smaller apps and a middleware, which is useful for hacking.
43
+ ### Turbo Streams / Broadcasts
39
44
 
40
- ## Publish a message to a bunch of subscribers
45
+ A presentation layer on top of ActionCable, not a transport. Broadcasts `<turbo-stream>` HTML fragments that surgically update the DOM. Turbo 8's page refresh (morphing) simplifies this — broadcast `"refresh"` and the client re-fetches the full page, sidestepping DOM-ID coupling. Page refresh partially mitigates missed messages since each refresh fetches complete state, but the refresh *signal* itself can still be lost during a disconnection window. Inherits all of ActionCable's delivery guarantees (none).
41
46
 
42
- Lets test it out! Open two terminal windows. In one window, curl:
47
+ ### Mercure
43
48
 
44
- ```sh
45
- $ curl "http://localhost:7474/hello"
49
+ A standalone SSE hub (written in Go) with built-in persistence and `Last-Event-ID` replay. Closest in philosophy to Firehose — messages are persisted, clients resume from where they left off, and SSE's native reconnection handles the transport. Not Rails-specific and requires deploying a separate server. SSE is unidirectional (server-to-client), so client-to-server communication needs separate HTTP requests.
50
+
51
+ ### How Firehose Compares
52
+
53
+ | | ActionCable | SolidCable | AnyCable Pro | Mercure | **Firehose** |
54
+ |---|---|---|---|---|---|
55
+ | **Replay on reconnect** | No | No | Yes | Yes | **Yes** |
56
+ | **Message persistence** | No | Yes (no replay API) | Yes | Yes | **Yes** |
57
+ | **Sequence numbers** | No | No | Epoch+offset | Event IDs | **Per-stream monotonic** |
58
+ | **Infrastructure** | Redis | Database | Go/Rust + Redis/NATS | Go hub | **PostgreSQL** |
59
+ | **Transport** | WebSocket | WebSocket | WebSocket | SSE | **WebSocket + SSE** |
60
+ | **Latency** | ~instant | ~100ms (polling) | ~instant | ~instant | **~instant (NOTIFY)** |
61
+ | **Extra processes** | No | No | Yes | Yes | **No** |
62
+
63
+ ## Quick Start
64
+
65
+ Add to your Gemfile:
66
+
67
+ ```ruby
68
+ gem "firehose"
46
69
  ```
47
70
 
48
- Then run the following in the other terminal:
71
+ Install:
49
72
 
50
- ```sh
51
- $ curl -X PUT -d "Greetings fellow human being..." "http://localhost:7474/hello"
73
+ ```bash
74
+ bundle install
75
+ bin/rails generate firehose:install
52
76
  ```
53
77
 
54
- and you should see the message in the other terminal.
78
+ This creates a `FirehoseController`, adds routes, wires up the JavaScript client, and runs migrations.
79
+
80
+ Publish from anywhere in your app:
55
81
 
56
- ```sh
57
- Greetings fellow human being...
82
+ ```ruby
83
+ Firehose.channel("dashboard").publish("refresh")
58
84
  ```
59
85
 
60
- ## Yeah, so?
86
+ Subscribe in your views:
61
87
 
62
- You have a dirt simple HTTP pub-sub feed. You could setup an `after_commit` hook on ActiveRecord to push JSON to an end-point. On the other side, you could have a Backbone.js application that picks up the changes and updates the client-side UI.
88
+ ```html
89
+ <firehose-stream-source streams="dashboard"></firehose-stream-source>
90
+ ```
63
91
 
64
- Holy mackerel! Its a nice, clean, RESTful way to build real-time web applications.
92
+ The page will automatically refresh via Turbo when an event arrives.
65
93
 
66
- # The JavaScript Consumer
94
+ ## Features
67
95
 
68
- Firehose doesn't just stop at curl; it has a full-featured JavaScript client that lets you subscribe to channels for live updates.
96
+ - **Dual transports**: WebSocket and Server-Sent Events (SSE)
97
+ - **Database persistence**: Messages stored in PostgreSQL for replay on reconnect
98
+ - **Automatic replay**: Missed events delivered via `last_event_id` (WebSocket) or `Last-Event-ID` (SSE)
99
+ - **Dedicated PG connection**: LISTEN/NOTIFY outside the ActiveRecord pool, PgBouncer-safe with direct connection
100
+ - **Inline delivery**: Small payloads sent inline via NOTIFY (no DB round-trip), large payloads fall back to DB fetch
101
+ - **Auto-cleanup**: Configurable threshold keeps the last N messages per stream
102
+ - **Falcon-compatible**: Built for async Ruby with async-websocket
69
103
 
70
- Still have the server running? Copy and paste the code below into Firebug or the WebKit console.
104
+ ## Channels
71
105
 
72
- ```javascript
106
+ Channels are the primary API for publishing and subscribing:
73
107
 
74
- new Firehose.Consumer({
75
- message: function(msg){
76
- console.log(msg);
77
- },
78
- connected: function(){
79
- console.log("Great Scotts!! We're connected!");
80
- },
81
- disconnected: function(){
82
- console.log("Well shucks, we're not connected anymore");
83
- },
84
- error: function(){
85
- console.log("Well then, something went horribly wrong.");
86
- },
87
- // Note that we do NOT specify a protocol here because we don't
88
- // know that yet.
89
- uri: '//localhost:7474/hello'
90
- }).connect();
108
+ ```ruby
109
+ # Get a channel
110
+ channel = Firehose.channel("dashboard")
111
+
112
+ # Publish a message (persists to DB + NOTIFY)
113
+ channel.publish("refresh")
114
+ channel.publish({ action: "update", id: 42 }.to_json)
115
+
116
+ # Subscribe to live events (returns a closeable subscription)
117
+ sub = channel.subscribe { |payload| puts payload }
118
+ sub.close
119
+
120
+ # From a model
121
+ class Comment < ApplicationRecord
122
+ after_commit :notify_post
123
+
124
+ def notify_post
125
+ Firehose.channel(post.to_gid_param).publish("refresh")
126
+ end
127
+ end
91
128
  ```
92
129
 
93
- There's also a Consumer that uses channel multiplexing.
94
- The multiplexed consumer is useful for scenarios where you want to subscribe
95
- to messages from many channels at once, without having to use one connection
96
- per channel. You can specify a list of channels to subscribe to, including a
97
- handler function per channel that gets called with all messages coming from that
98
- channel.
130
+ ## Queues
99
131
 
100
- Example:
132
+ Queues provide ephemeral push/pop signaling over PG LISTEN/NOTIFY — no database persistence, no replay. Use them for transient coordination like auth nonces, job completion signals, or request/response patterns between processes.
101
133
 
102
- ```javascript
103
- new Firehose.MultiplexedConsumer({
104
- connected: function(){
105
- console.log("Great Scotts!! We're connected!");
106
- },
107
- disconnected: function(){
108
- console.log("Well shucks, we're not connected anymore");
109
- },
110
- error: function(){
111
- console.log("Well then, something went horribly wrong.");
112
- },
113
- // Note that we don't specify a general message handler function
114
- // but instead define one per channel below
115
-
116
- // Note that we do NOT specify a protocol here because we don't
117
- // know that yet. We also don't specify a specific channel name as part of
118
- // the URI but instead pass in a list of subscriptions below
119
- uri: '//localhost:7474/',
120
-
121
- // List of channel subscriptions:
122
- channels: {
123
- "/my/channel/1": {
124
- last_sequence: 10, // defaults to 0 and can be ommitted
125
- message: function(msg) {
126
- console.log("got message on channel 1:");
127
- console.log(msg);
128
- }
129
- },
130
- "/my/channel/2": {
131
- message: function(msg) {
132
- console.log("got message on channel 2:");
133
- console.log(msg);
134
- }
135
- }
136
- }
137
- }).connect();
134
+ ```ruby
135
+ queue = Firehose.server.queue("my-queue")
136
+
137
+ # Producer
138
+ queue.push("hello")
139
+
140
+ # Consumer (blocks until a message arrives)
141
+ message = queue.pop
142
+ message = queue.pop(timeout: 5) # raises Firehose::Queue::TimeoutError
143
+
144
+ queue.close
138
145
  ```
139
146
 
140
- Then publish another message.
147
+ ## Controller
141
148
 
142
- ```sh
143
- $ curl -X PUT -d "\"This is almost magical\"" "http://localhost:7474/hello"
149
+ The install generator creates `app/controllers/firehose_controller.rb`:
150
+
151
+ ```ruby
152
+ class FirehoseController < ApplicationController
153
+ include Firehose::Stream # Includes both WebSocket and SSE
154
+
155
+ # Or include just one:
156
+ # include Firehose::WebSocket
157
+ # include Firehose::SSE
158
+ end
144
159
  ```
145
160
 
146
- # How is it different from socket.io?
161
+ Add authentication and stream authorization:
162
+
163
+ ```ruby
164
+ class FirehoseController < ApplicationController
165
+ include Firehose::Stream
147
166
 
148
- socket.io attempts to store connection state per node instance. Firehose makes no attempt to store connection state.
167
+ before_action :authenticate_user!
149
168
 
150
- Also, socket.io attempts to abstract a low-latency full-duplex port. Firehose assumes that its impossible to simulate this in older web browsers that don't support WebSockets. As such, Firehose focuses on low-latency server-to-client connections and encourages the use of existing HTTP transports, like POST and PUT, for client-to-server communications.
169
+ def authorize_streams(streams)
170
+ streams.select { |s| current_user.can_access?(s) }
171
+ end
172
+
173
+ def build_event(event)
174
+ # Transform events before sending, or return nil to skip
175
+ event
176
+ end
177
+ end
178
+ ```
151
179
 
152
- # The Ruby Publisher
180
+ ## Routes
153
181
 
154
- While you can certainly make your own PUT requests when publishing messages, Firehose includes a Ruby client for easy publishing.
182
+ The install generator adds these routes:
155
183
 
156
184
  ```ruby
157
- require 'firehose'
158
- require 'json'
159
- json = {'hello'=> 'world'}.to_json
160
- firehose = Firehose::Client::Producer::Http.new('//127.0.0.1:7474')
161
- firehose.publish(json).to("/my/messages/path")
185
+ match "firehose", to: "firehose#websocket", via: [:get, :connect]
186
+ get "firehose/sse", to: "firehose#sse"
162
187
  ```
163
188
 
164
- # Configuration
189
+ ## JavaScript Client
165
190
 
166
- Firehose can be configured via environmental variables. Take a look at the [`.env.sample`](./.env.sample) file for more info.
191
+ The install generator adds `import "firehose"` to your `application.js`. The importmap pin is set up automatically by the engine.
167
192
 
168
- ## Rack Configuration
193
+ ### Declarative (Custom Element)
169
194
 
170
- There are two rack applications that are included with Firehose: `Firehose::Rack::Producer` which a client can `PUT` HTTP request with message payloads to publish information on Firehose and the `Firehose::Rack::Consumer` application which a client connects to via HTTP long polling or WebSockets to consume a message.
195
+ ```html
196
+ <firehose-stream-source path="/firehose" streams="dashboard,user:42"></firehose-stream-source>
197
+ ```
198
+
199
+ When the element connects, it opens a WebSocket and subscribes to the listed streams. When it disconnects (page navigation, element removal), it unsubscribes. Reconnection with exponential backoff is automatic.
200
+
201
+ Multiple elements with the same `path` share a single WebSocket connection.
202
+
203
+ ### Programmatic
204
+
205
+ ```javascript
206
+ Firehose.subscribe("/firehose", ["dashboard", "user:42"])
207
+ Firehose.unsubscribe("/firehose", ["dashboard"])
171
208
 
172
- ### Consumer Configuration
209
+ // Listen for events
210
+ document.addEventListener("firehose:message", (e) => {
211
+ console.log(e.detail) // { id: 123, stream: "dashboard", data: "refresh" }
212
+ })
213
+ ```
214
+
215
+ ### Turbo Integration
216
+
217
+ When an event has `data: "refresh"`, the client automatically triggers a Turbo page refresh. No additional setup needed — just publish `"refresh"` as your data payload.
218
+
219
+ ## Phlex Helper
173
220
 
174
221
  ```ruby
175
- # Kitchen-sink rack configuration file example
176
- require 'firehose'
177
-
178
- consumer = Firehose::Rack::Consumer.new do |app|
179
- # Configure how long the server should wait before send the client a 204
180
- # with a request to reconnect. Typically browsers time-out the client connection
181
- # after 30 seconds, so we set the `Firehose.Consumer` JS client to 25, and the
182
- # server to 20 to make sure latency or timing doesn't cause any problems.
183
- app.http_long_poll.timeout = 20
222
+ class Components::Base < Phlex::HTML
223
+ include Firehose::Helper
184
224
  end
225
+ ```
185
226
 
186
- run consumer
227
+ ```ruby
228
+ firehose_stream_from @report
229
+ firehose_stream_from "dashboard", "user:#{current_user.id}"
230
+ firehose_stream_from @model, path: "/admin/firehose"
187
231
  ```
188
232
 
189
- ### Publisher Configuration
233
+ ## Configuration
190
234
 
191
- ```ruby
192
- # Kitchen-sink rack configuration file example
193
- require 'firehose'
235
+ Create `config/firehose.rb` for Ruby configuration, or `config/firehose.yml` for YAML. Ruby config takes precedence.
194
236
 
195
- # There's nothing to configure with the Publisher, but its possible that
196
- # you might include rack middleware authorization mechanisms here to control
197
- # who can publish to Firehose.
237
+ ### Ruby
198
238
 
199
- run Firehose::Rack::Publisher.new
239
+ ```ruby
240
+ # config/firehose.rb
241
+ Firehose.server.configure do |config|
242
+ config.database_url = ENV["FIREHOSE_DATABASE_URL"] # Direct PG connection (bypasses PgBouncer)
243
+ config.cleanup_threshold = 100 # Keep last N messages per stream (default: 100)
244
+ config.reconnect_attempts = nil # Max reconnect attempts (default: nil = unlimited)
245
+ config.reconnect_delay = 1 # Base delay in seconds, doubles each attempt (default: 1)
246
+ config.reconnect_max_delay = 30 # Cap on exponential backoff (default: 30)
247
+ config.notify_max_bytes = 7999 # PG NOTIFY payload limit (default: 7999)
248
+ config.watchdog_enabled = true # Detect wedged consumer thread (default: true)
249
+ config.watchdog_deadline = 60 # Seconds without heartbeat before forcing reconnect (default: 60)
250
+ config.watchdog_interval = 10 # Seconds between watchdog checks (default: 10)
251
+ config.tcp_keepalives_idle = 30 # Seconds idle before keepalive probes (default: 30)
252
+ config.tcp_keepalives_interval = 10 # Seconds between keepalive probes (default: 10)
253
+ config.tcp_keepalives_count = 3 # Failed probes before peer declared dead (default: 3)
254
+ config.replay_on_reconnect = true # Re-fetch missed messages after a reconnect (default: true)
255
+ config.metrics_interval = 15 # Seconds between metric emissions (default: 15)
256
+ config.max_command_queue_depth = nil # Cap before NOTIFY commands are dropped (default: nil = unbounded)
257
+ config.notify_pool_size = 4 # NOTIFY worker threads; 0 = run inline (default: 4)
258
+ end
200
259
  ```
201
260
 
202
- ## Sprockets
261
+ ### YAML
203
262
 
204
- Using Sprockets is the recommended method of including the included client-side assets in a web page.
263
+ ```yaml
264
+ # config/firehose.yml
265
+ development:
266
+ cleanup_threshold: 100
205
267
 
206
- 1. Add the firehose gem in your app's Gemfile.
268
+ production:
269
+ database_url: postgres://direct-db:5432/myapp
270
+ cleanup_threshold: 100
271
+ reconnect_attempts: 10
272
+ ```
207
273
 
208
- 2. Append the firehose gem's assets to the sprockets path. In a Rails app, this is usually done in an initializer.
274
+ ### Logger
209
275
 
210
276
  ```ruby
211
- # Add firehose to a custom sprockets configuration.
212
- my_sprockets_env = Sprockets::Environment.new
213
- Firehose::Assets::Sprockets.configure my_sprockets_env
277
+ Firehose.logger = Rails.logger # default in Rails
278
+ Firehose.logger = Logger.new($stdout) # outside Rails
279
+ Firehose.logger.level = Logger::DEBUG # verbose: LISTEN/UNLISTEN/NOTIFY
214
280
  ```
215
281
 
216
- 3. Require your config file and the firehose gem. This would look something like this:
282
+ ### Error reporting
283
+
284
+ Firehose reports prolonged connection losses and unexpected errors through
285
+ `Rails.error.report` (Rails 7+), which Sentry, Honeybadger, Appsignal,
286
+ Rollbar, and similar services already hook into. Errors are reported once
287
+ per outage (not once per failed retry) so you don't get paged for every
288
+ backoff tick. Reports include `source: "firehose.server"` and a context
289
+ hash with the current stage, reconnect count, subscriber count, and
290
+ command queue depth.
291
+
292
+ ### Metrics
293
+
294
+ Wire `Firehose.on_metrics` to any reporter:
217
295
 
218
296
  ```ruby
219
- #= require some/other/js/file
220
- #= require lib/firehose_config
221
- #= require firehose
222
- #= require some/more/js/files
297
+ # config/initializers/firehose.rb
298
+ Firehose.on_metrics = ->(metrics) {
299
+ metrics.each { |name, value| StatsD.gauge("firehose.#{name}", value) }
300
+ }
301
+ ```
302
+
303
+ The hook is called every `metrics_interval` seconds (default 15) with a
304
+ flat hash containing:
305
+
306
+ ```
307
+ command_queue_depth, subscribed_channels, seconds_since_heartbeat,
308
+ thread_alive, watchdog_alive, reconnects_current,
309
+ broadcasts_total, notifies_received, reconnects_total, watchdog_kicks,
310
+ commands_dropped, replay_messages_delivered
223
311
  ```
224
312
 
225
- It is important that your firehose config file comes first.
313
+ Mix of gauges and monotonic counters — pick based on the metric name
314
+ when you forward them. The emitter runs in its own thread, swallows any
315
+ exception raised by your reporter, and starts only when `on_metrics` is
316
+ set.
226
317
 
227
- ### Not using sprockets?
318
+ ### Replay gap
228
319
 
229
- If you don't intend to use the Firehose JavaScript client in a Ruby stack where Sprockets is available, you can grab the unminified source by running:
320
+ When a client reconnects with `last_event_id` older than the oldest
321
+ retained message on a stream, Firehose can't honor the replay. Instead
322
+ of silently delivering a partial replay, the server emits a typed error
323
+ event per stream:
230
324
 
231
- ```sh
232
- $ firehose javascript > firehose.js
325
+ ```json
326
+ { "error": "replay_gap", "stream": "foo",
327
+ "last_event_id": 123, "oldest_retained_id": 450, "current_id": 500 }
233
328
  ```
234
329
 
235
- Copy the firehose.js where needed in your project.
330
+ - WebSocket: delivered as a JSON text frame on the same connection.
331
+ - SSE: delivered as `event: firehose_replay_gap` so clients can listen
332
+ targeted: `eventSource.addEventListener("firehose_replay_gap", handler)`.
236
333
 
237
- # Web Server
334
+ Clients decide how to recover: full page reload, API refetch, or a
335
+ banner prompting the user. The gem's JS client dispatches a
336
+ `firehose:replay_gap` CustomEvent that apps can observe.
238
337
 
239
- Firehose currently supports Thin and Rainbows! (which is the default). Neither is listed as a dependency in the gemspec so that you don't need to install whichever one you aren't using. You can set which server to use via the `.env` file (recommended) or with the `-s` option to `bin/firehose`.
338
+ ### WebSocket heartbeat
240
339
 
241
- # Exception Notification
340
+ When a stream is idle, the server sends a protocol-level ping frame
341
+ every 30s. Browsers auto-respond with pong frames at the network layer
342
+ per RFC 6455 — no client code needed. If no pong is received within 90s,
343
+ the server closes the connection to reclaim resources held by half-open
344
+ clients. The heartbeat piggybacks on the writer fiber to avoid
345
+ concurrent-write races.
242
346
 
243
- If you'd like to be notified of exceptions, add something like this in your custom config.ru file.
347
+ SSE uses a comment-line keepalive (`: keepalive\n\n`) on the same 30s
348
+ cadence — dead clients surface via the write failing.
349
+
350
+ ### NOTIFY worker pool
351
+
352
+ By default Firehose runs `SELECT pg_notify(...)` calls on a pool of 4
353
+ worker threads, each with its own PG connection. This decouples NOTIFY
354
+ dispatch from the listener connection so `receive_notifications` can
355
+ interleave with dispatch instead of blocking behind a backlog of
356
+ pending NOTIFYs.
357
+
358
+ Set `notify_pool_size = 0` to disable the pool and run NOTIFYs inline
359
+ on the listener connection (original behavior).
360
+
361
+ Ordering across the pool is not preserved — clients that care about
362
+ event order rely on each message's `id` and `sequence` fields the same
363
+ way they already do for multi-writer broadcasts. The `firehose.js`
364
+ client tracks `lastEventId` monotonically, so out-of-order delivery is
365
+ handled naturally.
366
+
367
+ Failed NOTIFYs are counted as `notify_failures` in metrics. The
368
+ underlying message stays persisted in `firehose_messages`, so
369
+ reconnecting clients catch up via replay.
370
+
371
+ ### Per-connection backpressure
372
+
373
+ Each WebSocket handler bounds its outgoing event queue at 1000. A client
374
+ whose consumption falls that far behind is disconnected (not silently
375
+ buffered until OOM). On reconnect they may receive `replay_gap` if their
376
+ cursor has aged past retention.
377
+
378
+ The server's command queue (which fans out NOTIFYs to PG) can also be
379
+ capped via `max_command_queue_depth`. When exceeded, new NOTIFY commands
380
+ are dropped — messages remain persisted in `firehose_messages` so
381
+ reconnecting clients catch up via replay. `listen`/`unlisten`/`shutdown`
382
+ commands are never dropped.
383
+
384
+ ### Diagnostics
244
385
 
245
386
  ```ruby
246
- # Use exceptional to handle anything missed by Rack::Exceptional
247
- if exceptional_key = ENV['EXCEPTIONAL_KEY']
248
- require 'exceptional'
249
- EM.error_handler do |e|
250
- Firehose.logger.error "Unhandled exception: #{e.class} #{e.message}\n#{e.backtrace.join "\n"}"
251
- ::Exceptional.handle(e)
252
- end
253
- end
387
+ Firehose.server.diagnostics
388
+ # => {
389
+ # pid: 12345,
390
+ # running: true,
391
+ # started: true,
392
+ # thread_alive: true,
393
+ # thread_status: "sleep",
394
+ # watchdog_alive: true,
395
+ # seconds_since_heartbeat: 0.12,
396
+ # command_queue_depth: 0,
397
+ # reconnects: 0,
398
+ # subscribed_channels: 3
399
+ # }
254
400
  ```
255
401
 
256
- # Deployment
402
+ Use this from a Rails admin endpoint or console to verify the consumer
403
+ thread is alive and draining. `seconds_since_heartbeat > 60` or
404
+ `command_queue_depth` growing across samples is a sign the consumer is
405
+ stuck — the watchdog will normally force a reconnect before that becomes
406
+ serious.
407
+
408
+ ### Callback rule
257
409
 
258
- The recommended method ofGdeploying firehose is to deploy it separately from your main app.
410
+ Subscriber callbacks run **synchronously on the single consumer thread**.
411
+ A slow callback delays every other subscriber on every channel in that
412
+ process. The built-in WebSocket, SSE, and Queue handlers all push to
413
+ thread-safe queues in microseconds — safe. If you use
414
+ `Firehose.channel("x").subscribe { ... }` with custom code, keep the
415
+ block tight (push onto your own queue, enqueue a job, signal a fiber)
416
+ and do the heavy work elsewhere.
259
417
 
260
- 1. Create a new project with a Gemfile such as
418
+ ### Broadcast connection pool
419
+
420
+ `broadcast` writes through ActiveRecord, so it uses the app's AR pool.
421
+ Under heavy broadcast volume, that contends with request handling. Two
422
+ mitigations:
423
+
424
+ - Set `config.database_url` to a direct PG URL — this only affects the
425
+ dedicated LISTEN connection, not broadcast writes.
426
+ - Run Firehose writes against a separate AR role with its own pool using
427
+ `ActiveRecord::Base.connects_to(database: { writing: :primary, firehose: :firehose })`
428
+ and call `Firehose::Models::Channel.connected_to(role: :firehose)` in an
429
+ initializer. Out of scope for the gem itself, but worth setting up on
430
+ busy deployments.
431
+
432
+ ## Protocol
433
+
434
+ ### WebSocket
261
435
 
262
436
  ```
263
- gem "firehose"
264
- gem "airbrake"
265
- gem "rainbows", :require => false
266
- gem "rack", "~> 1.4.0" # if you're using Rainbows. See https://github.com/firehoseio/firehose/commit/dfe55fff
267
- gem "foreman", :require => false
268
- gem "capistrano", :require => false
437
+ Client → Server: { "command": "subscribe", "streams": ["a", "b"], "last_event_id": 123 }
438
+ Client → Server: { "command": "unsubscribe", "streams": ["a"] }
439
+ Server → Client: { "id": 456, "stream": "a", "data": "refresh", "sequence": 7, "channel_id": 3 }
269
440
  ```
270
441
 
271
- Of course, you could use `exceptional` instead of `airbrake` and `thin` instead of `rainbows`.
442
+ ### SSE
272
443
 
273
- 2. Set up `config/deploy.rb` to your liking. You can follow most directions for using Capistrano and Foreman to deploy Rack apps, such as https://gist.github.com/1027117
444
+ ```
445
+ GET /firehose/sse?streams=a,b
446
+ Last-Event-ID: 123
274
447
 
275
- 3. Set up `config/rainbows.rb` (if you are using Rainbows!). The gem includes example configurations scripts to get you started. There's also an example at https://gist.github.com/bradgessler/f2416efdbb1771e983b3.
448
+ id: 456
449
+ event: a
450
+ data: {"data":"refresh","channel_id":3,"sequence":7}
451
+ ```
452
+
453
+ ## Architecture
454
+
455
+ ```
456
+ Firehose.channel("stream").publish("refresh")
457
+ │
458
+ ├── INSERT into firehose_messages (persistence for replay)
459
+ │
460
+ └── PG NOTIFY with inline event JSON (or message ID if > 8KB)
461
+ │
462
+ ▼
463
+ Server background thread (IO.select on PG socket)
464
+ │
465
+ ├── WebSocket handler → browser → Turbo page refresh
466
+ ├── SSE handler → browser → Turbo page refresh
467
+ └── Firehose::Queue → Ruby consumer
468
+ ```
276
469
 
277
- # New releases & version bump
470
+ **Live path**: NOTIFY carries the full event inline — no database round-trip.
278
471
 
279
- For a new release of Firehose, bump the version number in `lib/firehose/version.rb` as well as `package.json`.
280
- Make sure, they have the same version number.
472
+ **Replay path**: On reconnect, missed messages fetched from database by ID.
281
473
 
282
- # Support
474
+ **Connection model**: One dedicated PG connection per process. LISTEN stays on this connection (PG requirement). NOTIFY commands are non-blocking (queued to background thread).
283
475
 
284
- ## Ruby version
476
+ ## License
285
477
 
286
- Firehose will support the latest minor 2.x revisions of Ruby that are officially supported by the Ruby community. More details at https://www.ruby-lang.org/.
478
+ MIT