hibira-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec8dd25bdad390a25709965fcd822ac5882f32a09ba94ae218a61f0f3bf88463
4
+ data.tar.gz: 0f75219446b55e2a1385861a14e69bc33a7f77eefc6642099ba33f9a38326345
5
+ SHA512:
6
+ metadata.gz: 90e64f04f5b36d52f038dce3c950730b3b85a182981969dd41764bb9626f4e9f03dc2d1f6190959175d294dc4e9c1c7fd05cbe38b13ea63a3a11800da49ef89d
7
+ data.tar.gz: 0743615164b04d789f48c40eac56507c92a8462bdc212f9d2638698f916833ee25083527858103b007b4ec5f6faf8dae85fcd9bc9dc370e710d0ab5b25bc9b9b
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Turbo Streams over Hibira: every Turbo broadcast (`broadcasts_to`,
6
+ `broadcast_*_to`, `broadcasts_refreshes`, the `_later` jobs) goes to a
7
+ private Hibira channel, and `turbo_stream_from` subscribes through Hibira.
8
+ No Action Cable server or Redis needed.
9
+ - Broadcasts over the app's event size limit are sent as a Turbo page refresh
10
+ (`config.oversize`).
11
+ - Channel auth for your own private and presence channels
12
+ (`config.authorize_channel`, `POST /hibira/auth`).
13
+ - Verified webhooks, including the previous secret during rotation
14
+ (`Hibira.on_webhook`, `POST /hibira/webhooks`).
15
+ - `Hibira.push` and push user tokens (`GET /hibira/push/token`).
16
+ - `bin/rails generate hibira:install` and `bin/rails hibira:check`.
17
+ - Rails 7.0 to 8.1.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hibira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # hibira-rails
2
+
3
+ [![ci](https://github.com/hibira-io/rails/actions/workflows/ci.yml/badge.svg)](https://github.com/hibira-io/rails/actions/workflows/ci.yml)
4
+
5
+ Hotwire Turbo Streams over [Hibira](https://hibira.io), with no Action Cable
6
+ server, Redis or Solid Cable to run.
7
+
8
+ Hibira is a hosted realtime service that speaks the Pusher Channels protocol.
9
+ This gem sends every Turbo Stream broadcast your app already makes through
10
+ Hibira, and makes `turbo_stream_from` subscribe through it. Your models and
11
+ views stay as they are. It also signs your own private and presence
12
+ channels, verifies webhooks and serves push notification tokens.
13
+
14
+ Supports Rails 7.0 to 8.1 (Ruby 3.1+) and turbo-rails 1.5 to 2.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ bundle add hibira-rails
20
+ bin/rails generate hibira:install
21
+ ```
22
+
23
+ The generator writes `config/initializers/hibira.rb`, pins `pusher-js` (or
24
+ installs it with your package manager in a jsbundling app) and imports the
25
+ `hibira/turbo` module after `@hotwired/turbo-rails` in
26
+ `app/javascript/application.js`.
27
+
28
+ Copy the app ID, key and secret from your app page on
29
+ [hibira.io/portal](https://hibira.io/portal) (the free plan needs no card)
30
+ into the environment:
31
+
32
+ ```sh
33
+ HIBIRA_APP_ID=your-app-id
34
+ HIBIRA_APP_KEY=your-app-key
35
+ HIBIRA_APP_SECRET=your-app-secret
36
+ ```
37
+
38
+ or into `bin/rails credentials:edit`:
39
+
40
+ ```yaml
41
+ hibira:
42
+ app_id: your-app-id
43
+ key: your-app-key
44
+ secret: your-app-secret
45
+ ```
46
+
47
+ Then check them:
48
+
49
+ ```sh
50
+ bin/rails hibira:check
51
+ ```
52
+
53
+ ## Turbo Streams
54
+
55
+ Nothing changes in your code. This model and view, straight from the Turbo
56
+ handbook, now update every open page through Hibira:
57
+
58
+ ```ruby
59
+ class Message < ApplicationRecord
60
+ belongs_to :room
61
+ broadcasts_to :room
62
+ end
63
+ ```
64
+
65
+ ```erb
66
+ <%= turbo_stream_from @room %>
67
+ <div id="messages"><%= render @room.messages %></div>
68
+ ```
69
+
70
+ `broadcast_append_to`, `broadcast_replace_later_to`, `broadcasts_refreshes`
71
+ and the rest all go through Hibira, from requests and from Active Job alike.
72
+
73
+ Each Turbo stream maps to one private channel. `turbo_stream_from` renders a
74
+ `<hibira-turbo-stream-source>` element holding the signed stream name, and the
75
+ browser can only subscribe with it (checked at `POST /hibira/turbo/auth`), the
76
+ same rule Action Cable applies. The app secret never leaves the server.
77
+
78
+ Turbo goes through Hibira whenever the credentials are set, except in the
79
+ test environment, where Turbo's `assert_turbo_stream_broadcasts` keeps working
80
+ on Action Cable. Force it either way:
81
+
82
+ ```ruby
83
+ Hibira.configure do |config|
84
+ config.turbo = true # or false
85
+ end
86
+ ```
87
+
88
+ A `turbo_stream_from` with a custom Action Cable channel
89
+ (`turbo_stream_from @room, channel: RoomChannel`) still uses Action Cable.
90
+
91
+ ### Large broadcasts
92
+
93
+ Each plan has an event size limit (4 KB on Free). When a Turbo Stream is
94
+ larger, the gem sends a Turbo 8 page refresh instead, so open pages still
95
+ catch up. For pages with long lists, `broadcasts_refreshes` with
96
+ `turbo_refreshes_with method: :morph` keeps every broadcast tiny:
97
+
98
+ ```ruby
99
+ class Room < ApplicationRecord
100
+ broadcasts_refreshes
101
+ end
102
+ ```
103
+
104
+ Set `config.oversize = :log` to drop large broadcasts instead, or `:raise`
105
+ to raise `Hibira::PayloadTooLarge`.
106
+
107
+ ## Your own channels
108
+
109
+ `Hibira.trigger` is the pusher-http-ruby client, already pointed at Hibira:
110
+
111
+ ```ruby
112
+ Hibira.trigger("orders", "order-shipped", { id: 1024 })
113
+ Hibira.client.channel_info("presence-room.1", info: "user_count")
114
+ ```
115
+
116
+ To sign private and presence channels, set `authorize_channel`. It receives
117
+ the channel name and the controller, which inherits from your
118
+ `ApplicationController`, so `current_user` and CSRF protection work as usual:
119
+
120
+ ```ruby
121
+ Hibira.configure do |config|
122
+ config.authorize_channel = lambda do |channel, controller|
123
+ user = controller.send(:current_user)
124
+ case channel
125
+ when /\Aprivate-user\.(\d+)\z/ then user&.id == $1.to_i
126
+ when /\Apresence-room\.(\d+)\z/ then user && { user_id: user.id, user_info: { name: user.name } }
127
+ end
128
+ end
129
+ end
130
+ ```
131
+
132
+ In the browser, reuse the client the Turbo module created:
133
+
134
+ ```erb
135
+ <%# in your layout, for pages without turbo_stream_from %>
136
+ <%= hibira_meta_tags %>
137
+ ```
138
+
139
+ ```js
140
+ import { hibira } from "hibira/turbo"
141
+
142
+ hibira().subscribe("private-user.42").bind("order-shipped", (order) => console.log(order))
143
+ ```
144
+
145
+ It authorizes at `POST /hibira/auth` with the page's CSRF token. For presence
146
+ rooms with thousands of members, use a `presence-large-*` channel and
147
+ [`@hibira-io/large-presence`](https://github.com/hibira-io/large-presence-js).
148
+
149
+ ## Webhooks
150
+
151
+ Add `https://your-app.example.com/hibira/webhooks` on your app page →
152
+ Webhooks, then handle events in an initializer:
153
+
154
+ ```ruby
155
+ Hibira.on_webhook("channel_vacated") do |event|
156
+ Room.find_by(channel: event["channel"])&.close!
157
+ end
158
+
159
+ Hibira.on_webhook { |event| Rails.logger.info("Hibira: #{event['name']}") } # every event
160
+ ```
161
+
162
+ The signature is checked against your app secret. While a rotated-out secret
163
+ is in its grace period, Hibira also signs with the old one
164
+ (`X-Hibira-Previous-Signature`), so webhooks keep arriving until you
165
+ redeploy. Each event is also instrumented as `webhook.hibira` with
166
+ `ActiveSupport::Notifications`.
167
+
168
+ ## Push notifications
169
+
170
+ Enable push on your app page (Push notifications), upload your APNs key or
171
+ FCM service account there, add `gem "pusher-push-notifications"` and set:
172
+
173
+ ```sh
174
+ HIBIRA_PUSH_INSTANCE_ID=your-instance-id
175
+ HIBIRA_PUSH_SECRET_KEY=your-secret-key
176
+ ```
177
+
178
+ ```ruby
179
+ Hibira.push.publish_to_users(
180
+ users: [order.user_id.to_s],
181
+ payload: {
182
+ apns: { aps: { alert: { title: "Order shipped", body: "Order ##{order.id} is on its way." } } },
183
+ fcm: { notification: { title: "Order shipped", body: "Order ##{order.id} is on its way." } },
184
+ web: { notification: { title: "Order shipped", body: "Order ##{order.id} is on its way.", deep_link: order_url(order) } }
185
+ }
186
+ )
187
+ ```
188
+
189
+ `GET /hibira/push/token?user_id=…` returns a user token for `setUserId` in the
190
+ web, Android and iOS SDKs. It answers only for the signed-in user
191
+ (`current_user` or `Current.user`; change it with `config.push_user_id`).
192
+
193
+ ## Settings
194
+
195
+ | Setting | Environment | Default |
196
+ | --- | --- | --- |
197
+ | `app_id`, `key`, `secret` | `HIBIRA_APP_ID`, `HIBIRA_APP_KEY`, `HIBIRA_APP_SECRET` | |
198
+ | `host` | `HIBIRA_HOST` | `ws.hibira.io` (or `ws-<cluster>.hibira.io`, as shown on your app page) |
199
+ | `port`, `use_tls` | `HIBIRA_PORT`, `HIBIRA_USE_TLS` | `443`, `true` |
200
+ | `turbo` | | `:auto` |
201
+ | `oversize` | | `:refresh` |
202
+ | `authorize_channel` | | refuses all |
203
+ | `push_instance_id`, `push_secret_key` | `HIBIRA_PUSH_INSTANCE_ID`, `HIBIRA_PUSH_SECRET_KEY` | |
204
+ | `push_endpoint` | `HIBIRA_PUSH_ENDPOINT` | `https://<host>` |
205
+ | `push_user_id` | | `current_user&.id` or `Current.user&.id` |
206
+ | `parent_controller` | | `"ApplicationController"` |
207
+ | `draw_routes`, `route_prefix` | | `true`, `"hibira"` |
208
+ | `client_options` | | `{}` (passed to `Pusher::Client.new`) |
209
+
210
+ Values set in `Hibira.configure` win over the environment, which wins over
211
+ credentials.
212
+
213
+ ## License
214
+
215
+ MIT
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ # Signs subscriptions to your own private- and presence- channels through
5
+ # Hibira.config.authorize_channel. Inherits from your ApplicationController
6
+ # (Hibira.config.parent_controller), so current_user, the session and CSRF
7
+ # protection work as in the rest of your app.
8
+ class ChannelAuthController < Hibira.config.parent_controller.constantize
9
+ def create
10
+ channel_name = params[:channel_name].to_s
11
+ socket_id = params[:socket_id].to_s
12
+ authorize = Hibira.config.authorize_channel
13
+
14
+ return head(:forbidden) if authorize.nil? || Hibira::Turbo.turbo_channel?(channel_name)
15
+ return head(:forbidden) unless channel_name.start_with?("private-", "presence-")
16
+
17
+ result = authorize.call(channel_name, self)
18
+ return head(:forbidden) unless result
19
+
20
+ if channel_name.start_with?("presence-")
21
+ return head(:forbidden) unless result.is_a?(Hash) && result.with_indifferent_access[:user_id].present?
22
+
23
+ render json: Hibira.client.authenticate(channel_name, socket_id, presence_data(result))
24
+ else
25
+ render json: Hibira.client.authenticate(channel_name, socket_id)
26
+ end
27
+ rescue ::Pusher::Error
28
+ head :unprocessable_entity
29
+ end
30
+
31
+ private
32
+
33
+ def presence_data(result)
34
+ result = result.with_indifferent_access
35
+ { user_id: result[:user_id].to_s, user_info: result[:user_info] || {} }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ # Returns a Beams user token for the signed-in user, for setUserId in the
5
+ # web, Android and iOS SDKs, at GET /hibira/push/token. The SDKs pass
6
+ # ?user_id=, which must be the signed-in user's push user ID
7
+ # (Hibira.config.push_user_id).
8
+ class PushTokensController < Hibira.config.parent_controller.constantize
9
+ def show
10
+ user_id = Hibira.config.push_user_id.call(self)&.to_s
11
+ return head(:unauthorized) if user_id.blank?
12
+ return head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(user_id, params[:user_id].to_s)
13
+
14
+ render json: Hibira.push.generate_token(user: user_id)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ # Signs subscriptions to Turbo stream channels. The signed stream name that
5
+ # turbo_stream_from rendered is the credential, as it is for Action Cable,
6
+ # so this needs no session and no CSRF token.
7
+ class TurboAuthController < ActionController::Base
8
+ skip_forgery_protection
9
+
10
+ def create
11
+ stream_name = ::Turbo::StreamsChannel.verified_stream_name(params[:signed_stream_name].to_s)
12
+ channel_name = params[:channel_name].to_s
13
+
14
+ if stream_name && channel_name == Hibira::Turbo.channel_name(stream_name)
15
+ render json: Hibira.client.authenticate(channel_name, params[:socket_id].to_s)
16
+ else
17
+ head :forbidden
18
+ end
19
+ rescue ::Pusher::Error
20
+ head :unprocessable_entity
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ # Receives Hibira webhooks at POST /hibira/webhooks. Add that URL on your
5
+ # app page → Webhooks. Each verified event runs the Hibira.on_webhook
6
+ # handlers for its name and is instrumented as "webhook.hibira".
7
+ class WebhooksController < ActionController::Base
8
+ skip_forgery_protection
9
+
10
+ def create
11
+ webhook = Hibira::Webhook.new(request.env)
12
+ return head(:unauthorized) unless webhook.valid?
13
+
14
+ events = begin
15
+ webhook.events
16
+ rescue JSON::ParserError
17
+ return head(:bad_request)
18
+ end
19
+
20
+ events.each do |event|
21
+ ActiveSupport::Notifications.instrument("webhook.hibira", event: event, time_ms: webhook.time_ms) do
22
+ (Hibira.webhook_handlers[event["name"]] + Hibira.webhook_handlers["*"]).each do |handler|
23
+ handler.call(event)
24
+ end
25
+ end
26
+ end
27
+
28
+ head :ok
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,144 @@
1
+ // Turbo Streams over Hibira.
2
+ //
3
+ // Defines <hibira-turbo-stream-source>, which turbo_stream_from renders when
4
+ // the hibira-rails gem is set up. Each element subscribes to its stream's
5
+ // private channel and hands every "turbo-stream" event to Turbo, the way
6
+ // <turbo-cable-stream-source> does for Action Cable.
7
+ //
8
+ // import "@hotwired/turbo-rails"
9
+ // import "hibira/turbo"
10
+
11
+ import { Turbo } from "@hotwired/turbo-rails"
12
+ import Pusher from "pusher-js"
13
+
14
+ const EVENT = "turbo-stream"
15
+ const UNSUBSCRIBE_DELAY = 1000 // keeps the channel across a Turbo visit to a page with the same stream
16
+
17
+ let client = null
18
+ const streams = new Map() // channel name → { channel, elements, signedStreamName, authEndpoint, timer }
19
+
20
+ const META = { Key: "hibira-key", Host: "hibira-host", Port: "hibira-port", Tls: "hibira-tls", AuthEndpoint: "hibira-auth-endpoint" }
21
+
22
+ // A connection setting from the element's data-hibira-* attributes, falling
23
+ // back to the hibira_meta_tags meta tags.
24
+ function setting(element, name) {
25
+ const value = element?.dataset?.[`hibira${name}`]
26
+ if (value != null && value !== "") return value
27
+ return document.querySelector(`meta[name="${META[name]}"]`)?.content
28
+ }
29
+
30
+ function csrfToken() {
31
+ return document.querySelector('meta[name="csrf-token"]')?.content
32
+ }
33
+
34
+ async function authorize({ socketId, channelName }, callback) {
35
+ const stream = streams.get(channelName)
36
+ const endpoint = stream ? stream.authEndpoint : setting(null, "AuthEndpoint") || "/hibira/auth"
37
+ const body = new URLSearchParams({ socket_id: socketId, channel_name: channelName })
38
+ if (stream) body.set("signed_stream_name", stream.signedStreamName)
39
+
40
+ const headers = { Accept: "application/json" }
41
+ const token = csrfToken()
42
+ if (token) headers["X-CSRF-Token"] = token
43
+
44
+ try {
45
+ const response = await fetch(endpoint, { method: "POST", headers, body, credentials: "same-origin" })
46
+ if (!response.ok) throw new Error(`Hibira auth for ${channelName} failed with ${response.status}`)
47
+ callback(null, await response.json())
48
+ } catch (error) {
49
+ callback(error, null)
50
+ }
51
+ }
52
+
53
+ // The shared pusher-js client, created from the first stream source element
54
+ // or, without one, from the hibira_meta_tags meta tags. Use it for your own
55
+ // channels too: hibira().subscribe("presence-room.1").
56
+ export function hibira(element = null) {
57
+ if (client) return client
58
+
59
+ const key = setting(element, "Key")
60
+ if (!key) throw new Error("Hibira: no app key. Set HIBIRA_APP_KEY, or add <%= hibira_meta_tags %> to your layout.")
61
+
62
+ const tls = setting(element, "Tls") !== "false"
63
+ const port = Number(setting(element, "Port") || (tls ? 443 : 80))
64
+ client = new Pusher(key, {
65
+ cluster: "hkg", // required by pusher-js, unused once wsHost is set
66
+ wsHost: setting(element, "Host") || "ws.hibira.io",
67
+ wsPort: port,
68
+ wssPort: port,
69
+ forceTLS: tls,
70
+ enabledTransports: ["ws", "wss"],
71
+ channelAuthorization: { customHandler: authorize },
72
+ })
73
+ return client
74
+ }
75
+
76
+ class HibiraTurboStreamSourceElement extends HTMLElement {
77
+ static observedAttributes = ["channel-name", "signed-stream-name"]
78
+
79
+ connectedCallback() {
80
+ Turbo.connectStreamSource(this)
81
+ this.subscribe()
82
+ }
83
+
84
+ disconnectedCallback() {
85
+ Turbo.disconnectStreamSource(this)
86
+ this.unsubscribe()
87
+ }
88
+
89
+ attributeChangedCallback() {
90
+ if (this.subscribedTo) {
91
+ this.unsubscribe()
92
+ this.subscribe()
93
+ }
94
+ }
95
+
96
+ subscribe() {
97
+ const name = this.getAttribute("channel-name")
98
+ if (!name) return
99
+
100
+ let stream = streams.get(name)
101
+ if (!stream) {
102
+ stream = { elements: new Set(), timer: null }
103
+ streams.set(name, stream)
104
+ }
105
+ stream.signedStreamName = this.getAttribute("signed-stream-name")
106
+ stream.authEndpoint = setting(this, "AuthEndpoint")
107
+ clearTimeout(stream.timer)
108
+ stream.channel ||= hibira(this).subscribe(name)
109
+
110
+ this.receive = (data) => this.dispatchEvent(new MessageEvent("message", { data }))
111
+ this.connected = () => this.setAttribute("connected", "")
112
+ stream.channel.bind(EVENT, this.receive)
113
+ stream.channel.bind("pusher:subscription_succeeded", this.connected)
114
+ if (stream.channel.subscribed) this.connected()
115
+
116
+ stream.elements.add(this)
117
+ this.subscribedTo = name
118
+ }
119
+
120
+ unsubscribe() {
121
+ const name = this.subscribedTo
122
+ const stream = name && streams.get(name)
123
+ this.subscribedTo = null
124
+ this.removeAttribute("connected")
125
+ if (!stream) return
126
+
127
+ stream.channel.unbind(EVENT, this.receive)
128
+ stream.channel.unbind("pusher:subscription_succeeded", this.connected)
129
+ stream.elements.delete(this)
130
+
131
+ if (stream.elements.size === 0) {
132
+ clearTimeout(stream.timer)
133
+ stream.timer = setTimeout(() => {
134
+ if (stream.elements.size > 0) return
135
+ client?.unsubscribe(name)
136
+ streams.delete(name)
137
+ }, UNSUBSCRIBE_DELAY)
138
+ }
139
+ }
140
+ }
141
+
142
+ if (customElements.get("hibira-turbo-stream-source") === undefined) {
143
+ customElements.define("hibira-turbo-stream-source", HibiraTurboStreamSourceElement)
144
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "hibira/turbo", to: "hibira/turbo.js"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Hibira
6
+ module Generators
7
+ # bin/rails generate hibira:install
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Sets up Hibira: an initializer, pusher-js and the hibira/turbo JavaScript module."
12
+
13
+ class_option :skip_install, type: :boolean, default: false, desc: "Don't install pusher-js"
14
+
15
+ def create_initializer
16
+ template "hibira.rb", "config/initializers/hibira.rb"
17
+ end
18
+
19
+ def install_javascript
20
+ if importmap?
21
+ run "bin/importmap pin pusher-js" unless options[:skip_install]
22
+ import_in_application_js 'import "hibira/turbo"'
23
+ elsif File.exist?(File.join(destination_root, "package.json"))
24
+ copy_file File.expand_path("../../../../app/javascript/hibira/turbo.js", __dir__), "app/javascript/hibira/turbo.js"
25
+ run "#{package_manager} pusher-js" unless options[:skip_install]
26
+ import_in_application_js 'import "./hibira/turbo"'
27
+ else
28
+ say "No importmap or package.json found. Load pusher-js and the hibira/turbo module " \
29
+ "(#{File.expand_path('../../../../app/javascript/hibira/turbo.js', __dir__)}) yourself.", :yellow
30
+ end
31
+ end
32
+
33
+ def show_next_steps
34
+ say <<~TEXT
35
+
36
+ Hibira is installed. Copy the app ID, key and secret from your app page on
37
+ https://hibira.io/portal into the environment:
38
+
39
+ HIBIRA_APP_ID=your-app-id
40
+ HIBIRA_APP_KEY=your-app-key
41
+ HIBIRA_APP_SECRET=your-app-secret
42
+
43
+ or into bin/rails credentials:edit under hibira: (app_id, key, secret).
44
+ Then check them with:
45
+
46
+ bin/rails hibira:check
47
+ TEXT
48
+ end
49
+
50
+ private
51
+
52
+ def importmap?
53
+ File.exist?(File.join(destination_root, "config/importmap.rb"))
54
+ end
55
+
56
+ def package_manager
57
+ if File.exist?(File.join(destination_root, "bun.lockb")) || File.exist?(File.join(destination_root, "bun.lock"))
58
+ "bun add"
59
+ elsif File.exist?(File.join(destination_root, "yarn.lock"))
60
+ "yarn add"
61
+ elsif File.exist?(File.join(destination_root, "pnpm-lock.yaml"))
62
+ "pnpm add"
63
+ else
64
+ "npm install"
65
+ end
66
+ end
67
+
68
+ def import_in_application_js(line)
69
+ path = "app/javascript/application.js"
70
+ full = File.join(destination_root, path)
71
+ unless File.exist?(full)
72
+ say "Add #{line} to your JavaScript entry point after @hotwired/turbo-rails.", :yellow
73
+ return
74
+ end
75
+ return if File.read(full).include?(line)
76
+
77
+ if File.read(full).match?(%r{^import ["']@hotwired/turbo-rails["'].*$})
78
+ inject_into_file path, "\n#{line}", after: %r{^import ["']@hotwired/turbo-rails["'].*$}
79
+ else
80
+ append_to_file path, "#{line}\n"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hibira reads HIBIRA_APP_ID, HIBIRA_APP_KEY, HIBIRA_APP_SECRET and HIBIRA_HOST
4
+ # from the environment, then hibira: in your credentials. Anything set here
5
+ # wins. See https://github.com/hibira-io/rails for every option.
6
+ Hibira.configure do |config|
7
+ # Turbo Streams go through Hibira whenever the credentials are set (except
8
+ # in tests). Set true or false to force it.
9
+ # config.turbo = :auto
10
+
11
+ # A broadcast over your plan's event size limit is sent as a Turbo page
12
+ # refresh instead. Or :log to drop it, :raise to raise.
13
+ # config.oversize = :refresh
14
+
15
+ # Authorize your own private- and presence- channels (POST /hibira/auth).
16
+ # config.authorize_channel = lambda do |channel, controller|
17
+ # user = controller.send(:current_user)
18
+ # case channel
19
+ # when /\Aprivate-user\.(\d+)\z/ then user&.id == $1.to_i
20
+ # when /\Apresence-room\.(\d+)\z/ then user && { user_id: user.id, user_info: { name: user.name } }
21
+ # end
22
+ # end
23
+ end
24
+
25
+ # Hibira.on_webhook("channel_vacated") do |event|
26
+ # Rails.logger.info "#{event['channel']} is empty"
27
+ # end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ # Checks the settings and credentials, for bin/rails hibira:check.
5
+ module Check
6
+ module_function
7
+
8
+ # Returns [ok, lines].
9
+ def run(config = Hibira.config)
10
+ lines = []
11
+ ok = true
12
+
13
+ if config.configured?
14
+ lines << "ok App #{config.app_id} on #{config.host}:#{config.port}"
15
+ begin
16
+ Hibira.client.channels
17
+ lines << "ok Credentials accepted by the REST API"
18
+ rescue ::Pusher::AuthenticationError
19
+ ok = false
20
+ lines << "fail The app key or secret was refused. Copy them again from your app page on hibira.io/portal."
21
+ rescue ::Pusher::Error => e
22
+ ok = false
23
+ lines << "fail Could not reach #{config.host}: #{e.message}"
24
+ end
25
+ else
26
+ ok = false
27
+ lines << "fail Missing #{config.missing.join(', ')} (environment or credentials hibira:)"
28
+ end
29
+
30
+ lines << if config.turbo?
31
+ "ok Turbo Streams go through Hibira"
32
+ else
33
+ "info Turbo Streams use Action Cable (turbo: #{config.turbo.inspect})"
34
+ end
35
+
36
+ lines << if config.push_configured?
37
+ "ok Push notifications set up (#{config.push_endpoint})"
38
+ else
39
+ "info Push notifications off (set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY)"
40
+ end
41
+
42
+ [ok, lines]
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ # Settings for Hibira. Each one falls back to an environment variable and
5
+ # then to `hibira:` in Rails credentials, so an app that sets HIBIRA_APP_ID,
6
+ # HIBIRA_APP_KEY and HIBIRA_APP_SECRET needs no initializer at all.
7
+ class Configuration
8
+ # The app ID, key and secret from your app page on https://hibira.io.
9
+ attr_writer :app_id, :key, :secret
10
+
11
+ # The WebSocket host shown on the app page: ws.hibira.io for the default
12
+ # cluster, or ws-<cluster>.hibira.io for apps in another region. The
13
+ # REST API is served on the same host.
14
+ attr_writer :host, :port, :use_tls
15
+
16
+ # Send Turbo Stream broadcasts (broadcasts_to, broadcast_append_to,
17
+ # broadcasts_refreshes, ...) through Hibira instead of Action Cable, and
18
+ # make turbo_stream_from subscribe through Hibira. The default, :auto,
19
+ # does so whenever the credentials are set, except in the test
20
+ # environment, where Turbo's own broadcast assertions keep working on
21
+ # Action Cable. true forces it on, false off.
22
+ attr_accessor :turbo
23
+
24
+ # What to do when a Turbo Stream broadcast is larger than the app's event
25
+ # size limit (Hibira answers 413): :refresh sends a Turbo 8 page refresh
26
+ # instead, :log logs and drops it, :raise raises Hibira::PayloadTooLarge.
27
+ attr_accessor :oversize
28
+
29
+ # Called by the channel auth endpoint (POST /hibira/auth) for private-
30
+ # and presence- channels that are not Turbo streams. Receives the channel
31
+ # name and the controller (for current_user, session, params). Return
32
+ # false or nil to refuse, true to allow a private channel, or a hash such
33
+ # as { user_id: 1, user_info: { name: "Aiko" } } for a presence channel.
34
+ attr_accessor :authorize_channel
35
+
36
+ # Push notifications: from the app page → Push notifications.
37
+ attr_writer :push_instance_id, :push_secret_key, :push_endpoint
38
+
39
+ # Resolves the signed-in user's push user ID for GET /hibira/push/token
40
+ # (setUserId in the web, Android and iOS SDKs). Receives the controller;
41
+ # return nil when nobody is signed in. Defaults to the ID of current_user
42
+ # (Devise and most auth gems) or Current.user (the Rails 8 generator).
43
+ attr_accessor :push_user_id
44
+
45
+ # The controller the channel auth and push token endpoints inherit from,
46
+ # for current_user and your sign-in filters. Defaults to
47
+ # "ApplicationController".
48
+ attr_accessor :parent_controller
49
+
50
+ # Extra options passed to Pusher::Client.new (timeouts, http_proxy, ...).
51
+ attr_accessor :client_options
52
+
53
+ # The routes for Turbo and channel auth, webhooks and push tokens are drawn
54
+ # under /hibira. Set draw_routes to false to draw your own.
55
+ attr_accessor :draw_routes, :route_prefix
56
+
57
+ def initialize
58
+ @turbo = :auto
59
+ @oversize = :refresh
60
+ @client_options = {}
61
+ @parent_controller = "ApplicationController"
62
+ @draw_routes = true
63
+ @route_prefix = "hibira"
64
+ @push_user_id = lambda do |controller|
65
+ user = if controller.respond_to?(:current_user, true)
66
+ controller.send(:current_user)
67
+ elsif defined?(::Current) && ::Current.respond_to?(:user)
68
+ ::Current.user
69
+ end
70
+ user&.id
71
+ end
72
+ end
73
+
74
+ def app_id = setting(:@app_id, "HIBIRA_APP_ID", :app_id)
75
+ def key = setting(:@key, "HIBIRA_APP_KEY", :key)
76
+ def secret = setting(:@secret, "HIBIRA_APP_SECRET", :secret)
77
+ def host = setting(:@host, "HIBIRA_HOST", :host) || "ws.hibira.io"
78
+ def port = Integer(setting(:@port, "HIBIRA_PORT", :port) || (use_tls ? 443 : 80))
79
+
80
+ def use_tls
81
+ value = setting(:@use_tls, "HIBIRA_USE_TLS", :use_tls)
82
+ value.nil? || !%w[false 0 no off].include?(value.to_s.downcase)
83
+ end
84
+
85
+ def push_instance_id = setting(:@push_instance_id, "HIBIRA_PUSH_INSTANCE_ID", :push_instance_id)
86
+ def push_secret_key = setting(:@push_secret_key, "HIBIRA_PUSH_SECRET_KEY", :push_secret_key)
87
+
88
+ def push_endpoint
89
+ setting(:@push_endpoint, "HIBIRA_PUSH_ENDPOINT", :push_endpoint) || "https://#{host}"
90
+ end
91
+
92
+ # Whether Turbo Streams go through Hibira right now (see #turbo).
93
+ def turbo?
94
+ return turbo == true unless turbo == :auto
95
+
96
+ configured? && !(defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?)
97
+ end
98
+
99
+ # True when the app ID, key and secret are all set.
100
+ def configured?
101
+ [app_id, key, secret].none? { |v| v.nil? || v.to_s.empty? }
102
+ end
103
+
104
+ def push_configured?
105
+ [push_instance_id, push_secret_key].none? { |v| v.nil? || v.to_s.empty? }
106
+ end
107
+
108
+ # Names of the realtime settings that are still blank.
109
+ def missing
110
+ { "HIBIRA_APP_ID" => app_id, "HIBIRA_APP_KEY" => key, "HIBIRA_APP_SECRET" => secret }
111
+ .select { |_, v| v.nil? || v.to_s.empty? }.keys
112
+ end
113
+
114
+ private
115
+
116
+ def setting(ivar, env, credential)
117
+ value = instance_variable_get(ivar)
118
+ return value unless value.nil?
119
+
120
+ value = ENV[env]
121
+ return value unless value.nil? || value.empty?
122
+
123
+ credentials&.dig(credential)&.then { |v| v.to_s.empty? ? nil : v }
124
+ end
125
+
126
+ def credentials
127
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
128
+
129
+ ::Rails.application.credentials.hibira
130
+ rescue StandardError
131
+ nil
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ # Turbo::StreamsChannel subclasses ActionCable::Channel::Base, so the
5
+ # constants must load even in apps generated with --skip-action-cable. No
6
+ # Action Cable server is mounted or needed.
7
+ require "action_cable"
8
+
9
+ module Hibira
10
+ class Engine < ::Rails::Engine
11
+ initializer "hibira.assets" do |app|
12
+ if app.config.respond_to?(:assets)
13
+ app.config.assets.paths << root.join("app/javascript")
14
+ app.config.assets.precompile += %w[hibira/turbo.js] if app.config.assets.respond_to?(:precompile)
15
+ end
16
+ end
17
+
18
+ initializer "hibira.importmap", before: "importmap" do |app|
19
+ if app.config.respond_to?(:importmap)
20
+ app.config.importmap.paths << root.join("config/importmap.rb")
21
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
22
+ end
23
+ end
24
+
25
+ initializer "hibira.helpers" do
26
+ ActiveSupport.on_load(:action_view) do
27
+ include Hibira::StreamsHelper
28
+ end
29
+ end
30
+
31
+ # The Hibira routes are drawn straight into the app's routes (like Active
32
+ # Storage's), under Hibira.config.route_prefix.
33
+ initializer "hibira.routes" do |app|
34
+ app.routes.prepend do
35
+ next unless Hibira.config.draw_routes
36
+
37
+ scope Hibira.config.route_prefix, module: "hibira", as: "hibira" do
38
+ post "turbo/auth" => "turbo_auth#create", as: :turbo_auth
39
+ post "auth" => "channel_auth#create", as: :channel_auth
40
+ post "webhooks" => "webhooks#create", as: :webhooks
41
+ get "push/token" => "push_tokens#show", as: :push_token
42
+ end
43
+ end
44
+ end
45
+
46
+ config.to_prepare do
47
+ if defined?(::Turbo::StreamsChannel)
48
+ singleton = ::Turbo::StreamsChannel.singleton_class
49
+ singleton.prepend(Hibira::Turbo::Broadcasts) unless singleton < Hibira::Turbo::Broadcasts
50
+ end
51
+ if defined?(::Turbo::StreamsHelper) && !(::Turbo::StreamsHelper < Hibira::TurboStreamFrom)
52
+ ::Turbo::StreamsHelper.prepend(Hibira::TurboStreamFrom)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ module StreamsHelper
5
+ # Like turbo_stream_from, but subscribes through Hibira:
6
+ #
7
+ # <%= hibira_turbo_stream_from @room %>
8
+ #
9
+ # Renders a <hibira-turbo-stream-source> element, which the hibira/turbo
10
+ # JavaScript module turns into a subscription to the stream's private
11
+ # channel. While Turbo goes through Hibira (config.turbo, on whenever the
12
+ # credentials are set), turbo_stream_from renders this too, so existing
13
+ # views need no change.
14
+ def hibira_turbo_stream_from(*streamables, **attributes)
15
+ raise ArgumentError, "streamables can't be blank" unless streamables.any?(&:present?)
16
+
17
+ stream_name = ::Turbo::StreamsChannel.send(:stream_name_from, streamables)
18
+ attributes.delete(:channel)
19
+ attributes[:"signed-stream-name"] = ::Turbo::StreamsChannel.signed_stream_name(streamables)
20
+ attributes[:"channel-name"] = Hibira::Turbo.channel_name(stream_name)
21
+
22
+ tag.hibira_turbo_stream_source(**hibira_client_attributes, **attributes)
23
+ end
24
+
25
+ # Connection settings for the browser, as data attributes. The key is the
26
+ # public app key; the secret never leaves the server.
27
+ def hibira_client_attributes
28
+ config = Hibira.config
29
+ {
30
+ "data-hibira-key": config.key,
31
+ "data-hibira-host": config.host,
32
+ "data-hibira-port": config.port,
33
+ "data-hibira-tls": config.use_tls.to_s,
34
+ "data-hibira-auth-endpoint": hibira_route_path(:hibira_turbo_auth_path, "turbo/auth")
35
+ }
36
+ end
37
+
38
+ # Meta tags with the same settings, for your own pusher-js code:
39
+ #
40
+ # <%= hibira_meta_tags %>
41
+ def hibira_meta_tags
42
+ config = Hibira.config
43
+ safe_join([
44
+ tag.meta(name: "hibira-key", content: config.key),
45
+ tag.meta(name: "hibira-host", content: config.host),
46
+ tag.meta(name: "hibira-port", content: config.port),
47
+ tag.meta(name: "hibira-tls", content: config.use_tls.to_s),
48
+ tag.meta(name: "hibira-auth-endpoint", content: hibira_route_path(:hibira_channel_auth_path, "auth"))
49
+ ], "\n")
50
+ end
51
+
52
+ private
53
+
54
+ def hibira_route_path(helper, fallback)
55
+ respond_to?(helper) ? public_send(helper) : "/#{Hibira.config.route_prefix}/#{fallback}"
56
+ end
57
+ end
58
+
59
+ # Prepended to Turbo::StreamsHelper so turbo_stream_from goes through
60
+ # Hibira while Hibira.config.turbo? is true. A custom Action Cable channel
61
+ # (turbo_stream_from @room, channel: RoomChannel) still uses Action Cable.
62
+ module TurboStreamFrom
63
+ def turbo_stream_from(*streamables, **attributes)
64
+ channel = attributes[:channel]&.to_s
65
+ if Hibira.config.turbo? && (channel.nil? || channel == "Turbo::StreamsChannel")
66
+ hibira_turbo_stream_from(*streamables, **attributes)
67
+ else
68
+ super
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Hibira
6
+ # Turbo Streams over Hibira. Each Turbo stream name maps to one private
7
+ # channel, so a browser can only subscribe with the signed stream name that
8
+ # turbo_stream_from rendered for it, the same rule Action Cable applies.
9
+ module Turbo
10
+ EVENT = "turbo-stream"
11
+ CHANNEL_PREFIX = "private-turbo-"
12
+ REFRESH = '<turbo-stream action="refresh"></turbo-stream>'
13
+
14
+ class << self
15
+ # Stream names hold characters channel names cannot ("gid://app/Room/1"),
16
+ # so the channel is named after a digest of the stream name.
17
+ def channel_name(stream_name)
18
+ "#{CHANNEL_PREFIX}#{Digest::SHA256.hexdigest(stream_name.to_s)}"
19
+ end
20
+
21
+ def turbo_channel?(channel_name)
22
+ channel_name.to_s.start_with?(CHANNEL_PREFIX)
23
+ end
24
+
25
+ def broadcast(stream_name, content)
26
+ Hibira.trigger(channel_name(stream_name), EVENT, content.to_s)
27
+ rescue ::Pusher::Error => e
28
+ raise unless e.message.include?("Payload Too Large")
29
+
30
+ oversized(stream_name, content, e)
31
+ end
32
+
33
+ private
34
+
35
+ def oversized(stream_name, content, error)
36
+ message = "[Hibira] Turbo Stream for #{stream_name.inspect} is #{content.to_s.bytesize} bytes, " \
37
+ "over the app's event size limit"
38
+
39
+ case Hibira.config.oversize
40
+ when :raise
41
+ raise PayloadTooLarge, message
42
+ when :refresh
43
+ if content.to_s.include?('action="refresh"')
44
+ logger&.warn("#{message}; dropped")
45
+ else
46
+ logger&.warn("#{message}; sent a page refresh instead")
47
+ Hibira.trigger(channel_name(stream_name), EVENT, REFRESH)
48
+ end
49
+ else
50
+ logger&.warn("#{message}; dropped")
51
+ end
52
+ nil
53
+ end
54
+
55
+ def logger
56
+ defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
57
+ end
58
+ end
59
+
60
+ # Prepended to Turbo::StreamsChannel's singleton class. Every Turbo
61
+ # broadcast (broadcast_*_to, broadcasts_to, broadcasts_refreshes and the
62
+ # _later jobs) ends in broadcast_stream_to, so this one override sends all
63
+ # of them to Hibira.
64
+ module Broadcasts
65
+ def broadcast_stream_to(*streamables, content:)
66
+ return super unless Hibira.config.turbo?
67
+
68
+ streamables = streamables.flatten.compact_blank
69
+ return if streamables.empty?
70
+
71
+ Hibira::Turbo.broadcast(stream_name_from(streamables), content)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibira
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "active_support/security_utils"
6
+
7
+ module Hibira
8
+ # A webhook post from Hibira. X-Pusher-Signature is the hex HMAC-SHA256 of
9
+ # the body made with the app's current secret. While a rotated-out secret
10
+ # is in its grace period, X-Hibira-Previous-Signature carries the same HMAC
11
+ # made with the old one, so an app that still has the old secret keeps
12
+ # accepting webhooks until it is redeployed.
13
+ class Webhook
14
+ SIGNATURE_HEADERS = %w[HTTP_X_PUSHER_SIGNATURE HTTP_X_HIBIRA_PREVIOUS_SIGNATURE].freeze
15
+
16
+ attr_reader :body
17
+
18
+ def initialize(env, key: Hibira.config.key, secret: Hibira.config.secret)
19
+ @env = env
20
+ @key = key.to_s
21
+ @secret = secret.to_s
22
+ input = env["rack.input"]
23
+ input.rewind if input.respond_to?(:rewind)
24
+ @body = input ? input.read.to_s : ""
25
+ input.rewind if input.respond_to?(:rewind)
26
+ end
27
+
28
+ def valid?
29
+ return false if @key.empty? || @secret.empty?
30
+ return false unless secure_compare(@key, @env["HTTP_X_PUSHER_KEY"].to_s)
31
+
32
+ expected = OpenSSL::HMAC.hexdigest("SHA256", @secret, body)
33
+ SIGNATURE_HEADERS.any? { |h| secure_compare(expected, @env[h].to_s) }
34
+ end
35
+
36
+ def payload
37
+ @payload ||= JSON.parse(body)
38
+ end
39
+
40
+ def events
41
+ Array(payload["events"]).select { |e| e.is_a?(Hash) && e["name"].is_a?(String) }
42
+ end
43
+
44
+ def time_ms
45
+ payload["time_ms"].to_i
46
+ end
47
+
48
+ private
49
+
50
+ def secure_compare(a, b)
51
+ a.bytesize == b.bytesize && ActiveSupport::SecurityUtils.secure_compare(a, b)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hibira"
data/lib/hibira.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pusher"
4
+ require "hibira/version"
5
+ require "hibira/configuration"
6
+ require "hibira/webhook"
7
+ require "hibira/turbo"
8
+ require "hibira/streams_helper"
9
+ require "hibira/check"
10
+
11
+ # Rails integration for Hibira (https://hibira.io): Turbo Streams, private and
12
+ # presence channel auth, webhooks and push notifications over the Pusher
13
+ # Channels protocol.
14
+ module Hibira
15
+ class Error < StandardError; end
16
+ class MissingCredentials < Error; end
17
+ class PayloadTooLarge < Error; end
18
+
19
+ class << self
20
+ def config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield config
26
+ reset!
27
+ config
28
+ end
29
+
30
+ # The pusher-http-ruby client, pointed at Hibira.
31
+ def client
32
+ @client ||= begin
33
+ unless config.configured?
34
+ raise MissingCredentials, "Hibira is not configured: set #{config.missing.join(', ')} " \
35
+ "(run bin/rails hibira:check)"
36
+ end
37
+
38
+ ::Pusher::Client.new(
39
+ app_id: config.app_id.to_s,
40
+ key: config.key,
41
+ secret: config.secret,
42
+ host: config.host,
43
+ port: config.port,
44
+ use_tls: config.use_tls,
45
+ **config.client_options
46
+ )
47
+ end
48
+ end
49
+
50
+ # Hibira.trigger("orders", "order-shipped", { id: 1024 })
51
+ def trigger(channels, event, data, params = {})
52
+ client.trigger(channels, event, data, params)
53
+ end
54
+
55
+ # The Beams-compatible push client, pointed at Hibira. Needs the
56
+ # pusher-push-notifications gem in your Gemfile.
57
+ def push
58
+ begin
59
+ require "pusher/push_notifications"
60
+ rescue LoadError
61
+ raise Error, "Add gem \"pusher-push-notifications\" to your Gemfile to send push notifications"
62
+ end
63
+ raise MissingCredentials, "Set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY" unless config.push_configured?
64
+
65
+ push = ::Pusher::PushNotifications
66
+ push.configure do |c|
67
+ c.instance_id = config.push_instance_id
68
+ c.secret_key = config.push_secret_key
69
+ c.endpoint = config.push_endpoint
70
+ end
71
+ push
72
+ end
73
+
74
+ # Runs the block for each verified webhook event with that name ("*" for
75
+ # all). Events are also instrumented as "webhook.hibira".
76
+ #
77
+ # Hibira.on_webhook("channel_vacated") { |event| Room.close(event["channel"]) }
78
+ def on_webhook(name = "*", &block)
79
+ webhook_handlers[name.to_s] << block
80
+ block
81
+ end
82
+
83
+ def webhook_handlers
84
+ @webhook_handlers ||= Hash.new { |h, k| h[k] = [] }
85
+ end
86
+
87
+ def reset!
88
+ @client = nil
89
+ end
90
+ end
91
+ end
92
+
93
+ require "hibira/engine" if defined?(Rails::Engine)
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :hibira do
4
+ desc "Check the Hibira settings and credentials"
5
+ task check: :environment do
6
+ ok, lines = Hibira::Check.run
7
+ puts lines
8
+ exit 1 unless ok
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hibira-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hibira
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actioncable
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: pusher
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '2.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3'
52
+ - !ruby/object:Gem::Dependency
53
+ name: railties
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9'
72
+ - !ruby/object:Gem::Dependency
73
+ name: turbo-rails
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '1.5'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '3'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.5'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '3'
92
+ description: Sends Hotwire Turbo Stream broadcasts through Hibira, a hosted realtime
93
+ service that speaks the Pusher Channels protocol, with no Action Cable server or
94
+ Redis to run. Also signs private and presence channels, verifies webhooks and serves
95
+ push tokens.
96
+ email:
97
+ - github@hibira.io
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - CHANGELOG.md
103
+ - LICENSE
104
+ - README.md
105
+ - app/controllers/hibira/channel_auth_controller.rb
106
+ - app/controllers/hibira/push_tokens_controller.rb
107
+ - app/controllers/hibira/turbo_auth_controller.rb
108
+ - app/controllers/hibira/webhooks_controller.rb
109
+ - app/javascript/hibira/turbo.js
110
+ - config/importmap.rb
111
+ - lib/generators/hibira/install/install_generator.rb
112
+ - lib/generators/hibira/install/templates/hibira.rb
113
+ - lib/hibira-rails.rb
114
+ - lib/hibira.rb
115
+ - lib/hibira/check.rb
116
+ - lib/hibira/configuration.rb
117
+ - lib/hibira/engine.rb
118
+ - lib/hibira/streams_helper.rb
119
+ - lib/hibira/turbo.rb
120
+ - lib/hibira/version.rb
121
+ - lib/hibira/webhook.rb
122
+ - lib/tasks/hibira.rake
123
+ homepage: https://hibira.io
124
+ licenses:
125
+ - MIT
126
+ metadata:
127
+ homepage_uri: https://hibira.io
128
+ source_code_uri: https://github.com/hibira-io/rails
129
+ changelog_uri: https://github.com/hibira-io/rails/blob/main/CHANGELOG.md
130
+ bug_tracker_uri: https://github.com/hibira-io/rails/issues
131
+ rubygems_mfa_required: 'true'
132
+ rdoc_options: []
133
+ require_paths:
134
+ - lib
135
+ required_ruby_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '3.1'
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ requirements: []
146
+ rubygems_version: 3.6.9
147
+ specification_version: 4
148
+ summary: Turbo Streams, channel auth, webhooks and push notifications over Hibira
149
+ for Rails
150
+ test_files: []