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
data/README.md CHANGED
@@ -1,228 +1,478 @@
1
- __ _ _
2
- / _(_) | |
3
- | |_ _ _ __ ___| |__ ___ ___ ___
4
- | _| | '__/ _ \ '_ \ / _ \/ __|/ _ \
5
- | | | | | | __/ | | | (_) \__ \ __/
6
- |_| |_|_| \___|_| |_|\___/|___/\___|
7
-
8
- Build realtime web applications in Ruby and JS
1
+ # Firehose 2.0
9
2
 
10
- [![Build Status](https://travis-ci.org/polleverywhere/firehose.png)](https://travis-ci.org/polleverywhere/firehose)
3
+ Real-time streaming for Rails with WebSocket and SSE transports, database-backed persistence, and automatic replay on reconnect.
11
4
 
12
- # What is Firehose?
5
+ Built on a dedicated PostgreSQL LISTEN/NOTIFY connection — no ActionCable, no polling, no connection pool pressure.
13
6
 
14
- Firehose is both a Rack application and JavaScript library that makes building real-time web applications possible.
7
+ ## Why not ActionCable?
15
8
 
16
- # Getting Started
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.
17
10
 
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.
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.
20
12
 
21
- Then install the gem.
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*.
22
14
 
23
- ```sh
24
- $ gem install firehose
25
- ```
15
+ Firehose fixes this:
26
16
 
27
- ## The Server
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.
28
18
 
29
- Now fire up 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.
30
20
 
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
- ```
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.
22
+
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.
55
79
 
56
- ```sh
57
- Greetings fellow human being...
80
+ Publish from anywhere in your app:
81
+
82
+ ```ruby
83
+ Firehose.channel("dashboard").publish("refresh")
58
84
  ```
59
85
 
60
- ## Yeah, so?
86
+ Subscribe in your views:
87
+
88
+ ```html
89
+ <firehose-stream-source streams="dashboard"></firehose-stream-source>
90
+ ```
61
91
 
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.
92
+ The page will automatically refresh via Turbo when an event arrives.
63
93
 
64
- Holy mackerel! Its a nice, clean, RESTful way to build real-time web applications.
94
+ ## Features
65
95
 
66
- # The JavaScript Consumer
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
67
103
 
68
- Firehose doesn't just stop at curl; it has a full-featured JavaScript client that lets you subscribe to channels for live updates.
104
+ ## Channels
69
105
 
70
- Still have the server running? Copy and paste the code below into Firebug or the WebKit console.
106
+ Channels are the primary API for publishing and subscribing:
71
107
 
72
- ```javascript
108
+ ```ruby
109
+ # Get a channel
110
+ channel = Firehose.channel("dashboard")
73
111
 
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();
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
- Then publish another message.
130
+ ## Queues
131
+
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.
94
133
 
95
- ```sh
96
- $ curl -X PUT -d "\"This is almost magical\"" "http://localhost:7474/hello"
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
97
145
  ```
98
146
 
99
- # How is it different from socket.io?
147
+ ## Controller
100
148
 
101
- socket.io attempts to store connection state per node instance. Firehose makes no attempt to store connection state.
149
+ The install generator creates `app/controllers/firehose_controller.rb`:
102
150
 
103
- 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.
151
+ ```ruby
152
+ class FirehoseController < ApplicationController
153
+ include Firehose::Stream # Includes both WebSocket and SSE
104
154
 
105
- # The Ruby Publisher
155
+ # Or include just one:
156
+ # include Firehose::WebSocket
157
+ # include Firehose::SSE
158
+ end
159
+ ```
106
160
 
107
- While you can certainly make your own PUT requests when publishing messages, Firehose includes a Ruby client for easy publishing.
161
+ Add authentication and stream authorization:
108
162
 
109
163
  ```ruby
110
- require 'firehose'
111
- require 'json'
112
- json = {'hello'=> 'world'}.to_json
113
- firehose = Firehose::Client::Producer::Http.new('//127.0.0.1:7474')
114
- firehose.publish(json).to("/my/messages/path")
164
+ class FirehoseController < ApplicationController
165
+ include Firehose::Stream
166
+
167
+ before_action :authenticate_user!
168
+
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
115
178
  ```
116
179
 
117
- # Configuration
180
+ ## Routes
118
181
 
119
- Firehose can be configured via environmental variables. Take a look at the [`.env.sample`](./.env.sample) file for more info.
182
+ The install generator adds these routes:
120
183
 
121
- ## Rack Configuration
184
+ ```ruby
185
+ match "firehose", to: "firehose#websocket", via: [:get, :connect]
186
+ get "firehose/sse", to: "firehose#sse"
187
+ ```
188
+
189
+ ## JavaScript Client
122
190
 
123
- 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 HTT long polling or WebSockets to consume a message.
191
+ The install generator adds `import "firehose"` to your `application.js`. The importmap pin is set up automatically by the engine.
192
+
193
+ ### Declarative (Custom Element)
194
+
195
+ ```html
196
+ <firehose-stream-source path="/firehose" streams="dashboard,user:42"></firehose-stream-source>
197
+ ```
124
198
 
125
- ### Consumer Configuration
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"])
208
+
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
126
220
 
127
221
  ```ruby
128
- # Kitchen-sink rack configuration file example
129
- require 'firehose'
130
-
131
- consumer = Firehose::Rack::Consumer.new do |app|
132
- # Configure how long the server should wait before send the client a 204
133
- # with a request to reconnect. Typically browsers time-out the client connection
134
- # after 30 seconds, so we set the `Firehose.Consumer` JS client to 25, and the
135
- # server to 20 to make sure latency or timing doesn't cause any problems.
136
- app.http_long_poll.timeout = 20
222
+ class Components::Base < Phlex::HTML
223
+ include Firehose::Helper
137
224
  end
225
+ ```
138
226
 
139
- 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"
140
231
  ```
141
232
 
142
- ### Publisher Configuration
233
+ ## Configuration
143
234
 
144
- ```ruby
145
- # Kitchen-sink rack configuration file example
146
- require 'firehose'
235
+ Create `config/firehose.rb` for Ruby configuration, or `config/firehose.yml` for YAML. Ruby config takes precedence.
147
236
 
148
- # There's nothing to configure with the Publisher, but its possible that
149
- # you might include rack middleware authorization mechanisms here to control
150
- # who can publish to Firehose.
237
+ ### Ruby
151
238
 
152
- 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
153
259
  ```
154
260
 
155
- ## Sprockets
261
+ ### YAML
156
262
 
157
- 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
158
267
 
159
- 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
+ ```
160
273
 
161
- 2. Append the firehose gem's assets to the sprockets path. In a Rails app, this is usually done in an initializer.
274
+ ### Logger
162
275
 
163
276
  ```ruby
164
- # Add firehose to a custom sprockets configuration.
165
- my_sprockets_env = Sprockets::Environment.new
166
- 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
167
280
  ```
168
281
 
169
- 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:
170
295
 
171
296
  ```ruby
172
- #= require some/other/js/file
173
- #= require lib/firehose_config
174
- #= require firehose
175
- #= 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
176
311
  ```
177
312
 
178
- 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.
179
317
 
180
- ### Not using sprockets?
318
+ ### Replay gap
181
319
 
182
- 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:
183
324
 
184
- ```sh
185
- $ firehose javascript > firehose.js
325
+ ```json
326
+ { "error": "replay_gap", "stream": "foo",
327
+ "last_event_id": 123, "oldest_retained_id": 450, "current_id": 500 }
186
328
  ```
187
329
 
188
- 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)`.
333
+
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.
337
+
338
+ ### WebSocket heartbeat
339
+
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.
189
346
 
190
- # Web Server
347
+ SSE uses a comment-line keepalive (`: keepalive\n\n`) on the same 30s
348
+ cadence — dead clients surface via the write failing.
191
349
 
192
- 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`.
350
+ ### NOTIFY worker pool
193
351
 
194
- # Exception Notification
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.
195
357
 
196
- If you'd like to be notified of exceptions, add something like this in your custom config.ru file.
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
197
385
 
198
386
  ```ruby
199
- # Use exceptional to handle anything missed by Rack::Exceptional
200
- if exceptional_key = ENV['EXCEPTIONAL_KEY']
201
- require 'exceptional'
202
- EM.error_handler do |e|
203
- Firehose.logger.error "Unhandled exception: #{e.class} #{e.message}\n#{e.backtrace.join "\n"}"
204
- ::Exceptional.handle(e)
205
- end
206
- 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
+ # }
207
400
  ```
208
401
 
209
- # 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
409
+
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.
417
+
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.
210
431
 
211
- The recommended method of deploying firehose is to deploy it separately from your main app.
432
+ ## Protocol
212
433
 
213
- 1. Create a new project with a Gemfile such as
434
+ ### WebSocket
214
435
 
215
436
  ```
216
- gem "firehose"
217
- gem "airbrake"
218
- gem "rainbows", :require => false
219
- gem "rack", "~> 1.4.0" # if you're using Rainbows. See https://github.com/polleverywhere/firehose/commit/dfe55fff
220
- gem "foreman", :require => false
221
- 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 }
440
+ ```
441
+
442
+ ### SSE
443
+
444
+ ```
445
+ GET /firehose/sse?streams=a,b
446
+ Last-Event-ID: 123
447
+
448
+ id: 456
449
+ event: a
450
+ data: {"data":"refresh","channel_id":3,"sequence":7}
222
451
  ```
223
452
 
224
- Of course, you could use `exceptional` instead of `airbrake` and `thin` instead of `rainbows`.
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
+ ```
469
+
470
+ **Live path**: NOTIFY carries the full event inline — no database round-trip.
471
+
472
+ **Replay path**: On reconnect, missed messages fetched from database by ID.
473
+
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).
225
475
 
226
- 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
476
+ ## License
227
477
 
228
- 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.
478
+ MIT