hotsock-turbo 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: acb980991920b0cf9f918c2eb1415f7236cd68a1b7d32ef32fb23e61ef123ec1
4
+ data.tar.gz: 501175af602730032c80b888b419050d3dd9f95258e9c6d3b96c850c91b583e9
5
+ SHA512:
6
+ metadata.gz: d4f52b687ea9e85db973edad805dac02761ec3f0c1ec602c36473c13a7c38dafd51f6b864004cad85f9db5dd6230d11d0236bf3445e97f6b5ba7b20a74405017
7
+ data.tar.gz: 509e133c9a296026acaf7ed40ed161075c688c08da13a9a6379208dbe5317c18cb9932fcf4a561d6feff8285e5f2958f15a55c6f0895dda5f2895236890b3dca
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 3.2
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rails", "~> 8.1"
6
+ gem "turbo-rails", "~> 2.0"
7
+
8
+ group :development, :test do
9
+ gem "rake"
10
+ gem "standard", require: false
11
+ end
12
+
13
+ group :test do
14
+ gem "maxitest"
15
+ gem "ostruct"
16
+ end
data/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # Hotsock Turbo
2
+
3
+ Turbo Streams integration for [Hotsock](https://www.hotsock.io), enabling real-time updates in Rails applications using Hotsock's WebSocket infrastructure.
4
+
5
+ ## Overview
6
+
7
+ Hotsock Turbo provides a seamless way to broadcast Turbo Stream updates to your Rails application using Hotsock instead of Action Cable. It offers:
8
+
9
+ - Real-time page updates via WebSockets
10
+ - Model callbacks for automatic broadcasting on create, update, and destroy
11
+ - Support for Turbo Stream refresh (morphing) broadcasts
12
+ - Drop-in replacement mode for existing `Turbo::Broadcastable` usage
13
+ - Both synchronous and asynchronous (ActiveJob) broadcasting
14
+
15
+ ## Requirements
16
+
17
+ - Ruby >= 3.2
18
+ - Rails with [Turbo](https://github.com/hotwired/turbo-rails)
19
+ - [Hotsock](https://rubygems.org/gems/hotsock) gem (>= 1.0)
20
+ - [@hotsock/hotsock-js](https://www.npmjs.com/package/@hotsock/hotsock-js) npm package
21
+
22
+ ## Installation
23
+
24
+ Add the gem to your Gemfile:
25
+
26
+ ```ruby
27
+ gem "turbo-rails"
28
+ gem "hotsock-turbo"
29
+ ```
30
+
31
+ Then run:
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ ### JavaScript Setup
38
+
39
+ #### Using Importmap
40
+
41
+ ```bash
42
+ bin/importmap pin @hotsock/hotsock-js
43
+ ```
44
+
45
+ Then in your `application.js`:
46
+
47
+ ```javascript
48
+ import "@hotsock/hotsock-js"
49
+ import "hotsock-turbo"
50
+ ```
51
+
52
+ #### Using npm/yarn
53
+
54
+ ```bash
55
+ npm install @hotsock/hotsock-js
56
+ # or
57
+ yarn add @hotsock/hotsock-js
58
+ ```
59
+
60
+ Then in your JavaScript entry point:
61
+
62
+ ```javascript
63
+ import "@hotsock/hotsock-js"
64
+ import "hotsock-turbo"
65
+ ```
66
+
67
+ ## Configuration
68
+
69
+ Create an initializer at `config/initializers/hotsock_turbo.rb`:
70
+
71
+ ```ruby
72
+ Hotsock::Turbo.configure do |config|
73
+ # Required: Path to an endpoint that returns Hotsock connection tokens
74
+ config.connect_token_path = "/hotsock/connect_token"
75
+
76
+ # Required: Your Hotsock WebSocket URL
77
+ config.wss_url = "wss://your-hotsock-instance.example.com"
78
+
79
+ # Optional: Log level for the JavaScript client (default: "warn")
80
+ config.log_level = "warn"
81
+
82
+ # Optional: Parent controller for any generated routes (default: "ApplicationController")
83
+ config.parent_controller = "ApplicationController"
84
+
85
+ # Optional: Enable drop-in replacement for Turbo::Broadcastable (default: false)
86
+ config.override_turbo_broadcastable = false
87
+ end
88
+ ```
89
+
90
+ ### Configuration Options
91
+
92
+ | Option | Default | Description |
93
+ | ------------------------------ | ------------------------- | ---------------------------------------------------------------------- |
94
+ | `connect_token_path` | `nil` | Path to your endpoint that issues Hotsock connection tokens |
95
+ | `wss_url` | `nil` | Your Hotsock WebSocket server URL |
96
+ | `log_level` | `"warn"` | JavaScript client log level (`"debug"`, `"info"`, `"warn"`, `"error"`) |
97
+ | `parent_controller` | `"ApplicationController"` | Base controller for generated routes |
98
+ | `override_turbo_broadcastable` | `false` | When `true`, overrides standard Turbo broadcast methods to use Hotsock |
99
+
100
+ ## Usage
101
+
102
+ ### View Helpers
103
+
104
+ #### Meta Tags
105
+
106
+ Add the required meta tags to your layout (typically in `<head>`):
107
+
108
+ ```erb
109
+ <%= hotsock_turbo_meta_tags %>
110
+ ```
111
+
112
+ This renders the configuration needed by the JavaScript client:
113
+
114
+ ```html
115
+ <meta name="hotsock:connect-token-path" content="/hotsock/connect_token" />
116
+ <meta name="hotsock:log-level" content="warn" />
117
+ <meta
118
+ name="hotsock:wss-url"
119
+ content="wss://your-hotsock-instance.example.com"
120
+ />
121
+ ```
122
+
123
+ You can override configuration per-page:
124
+
125
+ ```erb
126
+ <%= hotsock_turbo_meta_tags wss_url: "wss://other.example.com", log_level: "debug" %>
127
+ ```
128
+
129
+ #### Stream Subscriptions
130
+
131
+ Subscribe to streams in your views:
132
+
133
+ ```erb
134
+ <%= hotsock_turbo_stream_from @board %>
135
+ <%= hotsock_turbo_stream_from @board, :messages %>
136
+ <%= hotsock_turbo_stream_from "custom_stream_name" %>
137
+ ```
138
+
139
+ This renders a custom element that manages the WebSocket subscription:
140
+
141
+ ```html
142
+ <hotsock-turbo-stream-source
143
+ data-channel="..."
144
+ data-token="..."
145
+ data-user-id="..."
146
+ >
147
+ </hotsock-turbo-stream-source>
148
+ ```
149
+
150
+ ### Model Broadcasting
151
+
152
+ Hotsock Turbo automatically includes broadcasting methods in all ActiveRecord models.
153
+
154
+ #### Broadcasting Refreshes
155
+
156
+ For models that should trigger page refreshes (works great with Turbo's morphing):
157
+
158
+ ```ruby
159
+ class Board < ApplicationRecord
160
+ # Broadcasts refresh to "boards" stream on create/update/destroy
161
+ hotsock_broadcasts_refreshes
162
+ end
163
+
164
+ class Column < ApplicationRecord
165
+ belongs_to :board
166
+
167
+ # Broadcasts refresh to the board's stream on create/update/destroy
168
+ hotsock_broadcasts_refreshes_to :board
169
+ end
170
+ ```
171
+
172
+ #### Broadcasting DOM Updates
173
+
174
+ For fine-grained DOM updates (append, replace, remove):
175
+
176
+ ```ruby
177
+ class Message < ApplicationRecord
178
+ belongs_to :board
179
+
180
+ # Broadcasts append on create, replace on update, remove on destroy
181
+ hotsock_broadcasts_to :board
182
+ end
183
+
184
+ class Comment < ApplicationRecord
185
+ # Broadcasts to "comments" stream by default
186
+ hotsock_broadcasts
187
+ end
188
+ ```
189
+
190
+ #### Options
191
+
192
+ ```ruby
193
+ class Message < ApplicationRecord
194
+ belongs_to :board
195
+
196
+ # Customize the insert action and target
197
+ hotsock_broadcasts_to :board,
198
+ inserts_by: :prepend, # :append (default), :prepend
199
+ target: "board_messages", # DOM ID to target
200
+ partial: "messages/card" # Custom partial
201
+ end
202
+ ```
203
+
204
+ ### Instance Methods
205
+
206
+ Broadcast from anywhere in your application:
207
+
208
+ ```ruby
209
+ message = Message.find(1)
210
+
211
+ # Sync broadcasts (immediate)
212
+ message.hotsock_broadcast_refresh
213
+ message.hotsock_broadcast_refresh_to(board)
214
+ message.hotsock_broadcast_replace
215
+ message.hotsock_broadcast_replace_to(board)
216
+ message.hotsock_broadcast_append_to(board, target: "messages")
217
+ message.hotsock_broadcast_prepend_to(board, target: "messages")
218
+ message.hotsock_broadcast_remove
219
+ message.hotsock_broadcast_remove_to(board)
220
+
221
+ # Async broadcasts (via ActiveJob)
222
+ message.hotsock_broadcast_refresh_later
223
+ message.hotsock_broadcast_refresh_later_to(board)
224
+ message.hotsock_broadcast_replace_later
225
+ message.hotsock_broadcast_replace_later_to(board)
226
+ message.hotsock_broadcast_append_later_to(board, target: "messages")
227
+ message.hotsock_broadcast_prepend_later_to(board, target: "messages")
228
+ ```
229
+
230
+ ### Direct Channel Broadcasting
231
+
232
+ Broadcast without a model instance:
233
+
234
+ ```ruby
235
+ Hotsock::Turbo::StreamsChannel.broadcast_refresh_to(board)
236
+ Hotsock::Turbo::StreamsChannel.broadcast_append_to(
237
+ board,
238
+ target: "messages",
239
+ partial: "messages/message",
240
+ locals: { message: message }
241
+ )
242
+ Hotsock::Turbo::StreamsChannel.broadcast_remove_to(board, target: "message_123")
243
+ ```
244
+
245
+ ### Drop-in Turbo Replacement
246
+
247
+ If you have existing code using `Turbo::Broadcastable` methods, you can enable drop-in replacement mode:
248
+
249
+ ```ruby
250
+ # config/initializers/hotsock_turbo.rb
251
+ Hotsock::Turbo.configure do |config|
252
+ config.override_turbo_broadcastable = true
253
+ end
254
+ ```
255
+
256
+ Now standard Turbo method names work with Hotsock:
257
+
258
+ ```ruby
259
+ class Message < ApplicationRecord
260
+ belongs_to :board
261
+
262
+ # These now use Hotsock instead of Action Cable
263
+ broadcasts_refreshes_to :board
264
+ broadcasts_to :board
265
+ broadcasts
266
+ broadcasts_refreshes
267
+ end
268
+
269
+ # Instance methods also work
270
+ message.broadcast_refresh
271
+ message.broadcast_replace_later_to(board)
272
+ ```
273
+
274
+ ### Suppressing Broadcasts
275
+
276
+ Temporarily disable broadcasts within a block:
277
+
278
+ ```ruby
279
+ Message.suppressing_turbo_broadcasts do
280
+ # No broadcasts will be sent
281
+ Message.create!(content: "Silent message", board: board)
282
+ message.update!(content: "Silent update")
283
+ end
284
+ ```
285
+
286
+ Check suppression status:
287
+
288
+ ```ruby
289
+ Message.suppressed_turbo_broadcasts? # => false
290
+
291
+ Message.suppressing_turbo_broadcasts do
292
+ Message.suppressed_turbo_broadcasts? # => true
293
+ end
294
+ ```
295
+
296
+ ## Customization
297
+
298
+ ### Custom User Identification
299
+
300
+ By default, subscriptions use `session.id` as the user identifier. Override this by defining `hotsock_uid` in your helper or controller:
301
+
302
+ ```ruby
303
+ # app/helpers/application_helper.rb
304
+ module ApplicationHelper
305
+ private
306
+
307
+ def hotsock_uid
308
+ current_user&.id&.to_s || session.id.to_s
309
+ end
310
+ end
311
+ ```
312
+
313
+ ## How It Works
314
+
315
+ 1. **Meta tags** provide configuration to the JavaScript client
316
+ 2. **`<hotsock-turbo-stream-source>`** elements connect to Hotsock via WebSocket
317
+ 3. **Model callbacks** or direct calls trigger broadcasts via `Hotsock::Turbo::StreamsChannel`
318
+ 4. **Hotsock** delivers messages to subscribed clients
319
+ 5. **JavaScript client** receives messages and calls `Turbo.renderStreamMessage()` to update the DOM
320
+
321
+ ## License
322
+
323
+ This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ require "standard/rake"
4
+
5
+ Rake::TestTask.new(:test) do |test|
6
+ test.warning = true
7
+ test.pattern = "test/**/*.rb"
8
+ end
9
+
10
+ task default: ["standard:fix", :test]
@@ -0,0 +1,142 @@
1
+ import { HotsockClient } from "@hotsock/hotsock-js"
2
+
3
+ function createHotsockClient() {
4
+ return new HotsockClient(
5
+ document
6
+ .querySelector('meta[name="hotsock:wss-url"]')
7
+ ?.getAttribute("content"),
8
+ {
9
+ connectTokenFn: async () => {
10
+ const connectTokenPath = document.querySelector(
11
+ 'meta[name="hotsock:connect-token-path"]'
12
+ ).content
13
+ const csrfToken = document.querySelector(
14
+ 'meta[name="csrf-token"]'
15
+ ).content
16
+ const response = await fetch(connectTokenPath, {
17
+ method: "POST",
18
+ headers: {
19
+ "x-csrf-token": csrfToken,
20
+ },
21
+ })
22
+ const data = await response.json()
23
+ return data.token
24
+ },
25
+ lazyConnection: true,
26
+ logLevel: document.querySelector('meta[name="hotsock:log-level"]')
27
+ ?.content,
28
+ }
29
+ )
30
+ }
31
+
32
+ const hotsockClient = window.Hotsock || createHotsockClient()
33
+ window.Hotsock = hotsockClient
34
+
35
+ // Track active subscriptions by channel
36
+ const subscriptions = new Map() // channel -> { binding, elements: Set, unsubscribeTimer }
37
+ const UNSUBSCRIBE_DELAY_MS = 250
38
+
39
+ class HotsockTurboStreamSourceElement extends HTMLElement {
40
+ #channel = null
41
+
42
+ connectedCallback() {
43
+ this.#subscribe()
44
+ }
45
+
46
+ disconnectedCallback() {
47
+ this.#unsubscribe()
48
+ }
49
+
50
+ #subscribe() {
51
+ const { channel, token } = this.dataset
52
+
53
+ if (!channel || !token) {
54
+ return
55
+ }
56
+
57
+ this.#channel = channel
58
+
59
+ if (typeof window === "undefined" || !window.Turbo) {
60
+ console.warn("hotsock-turbo-stream-source: Turbo is not available")
61
+ return
62
+ }
63
+
64
+ let sub = subscriptions.get(channel)
65
+
66
+ if (sub) {
67
+ // Cancel unsubscribe if pending
68
+ if (sub.unsubscribeTimer) {
69
+ clearTimeout(sub.unsubscribeTimer)
70
+ sub.unsubscribeTimer = null
71
+ }
72
+ // Add this element to the subscription's element set
73
+ sub.elements.add(this)
74
+ } else {
75
+ const { Turbo } = window
76
+ const binding = hotsockClient.bind(
77
+ "turbo_stream",
78
+ ({ data }) => {
79
+ if (!data?.html) return
80
+ try {
81
+ Turbo.renderStreamMessage(data.html)
82
+ } catch (error) {
83
+ console.error("Failed to render Turbo Stream message:", error)
84
+ }
85
+ },
86
+ { channel, subscribeTokenFn: () => token }
87
+ )
88
+
89
+ sub = { binding, elements: new Set([this]), unsubscribeTimer: null }
90
+ subscriptions.set(channel, sub)
91
+ }
92
+
93
+ this.subscriptionConnected()
94
+ }
95
+
96
+ #unsubscribe() {
97
+ const channel = this.#channel
98
+ if (!channel) {
99
+ return
100
+ }
101
+
102
+ const sub = subscriptions.get(channel)
103
+ if (!sub) {
104
+ this.subscriptionDisconnected()
105
+ return
106
+ }
107
+
108
+ // Remove this element from the subscription
109
+ sub.elements.delete(this)
110
+
111
+ // If no elements remain, schedule delayed unsubscribe
112
+ if (sub.elements.size === 0 && !sub.unsubscribeTimer) {
113
+ sub.unsubscribeTimer = setTimeout(() => {
114
+ // Double-check no elements have reconnected
115
+ if (sub.elements.size === 0) {
116
+ sub.binding.unbind()
117
+ subscriptions.delete(channel)
118
+ }
119
+ }, UNSUBSCRIBE_DELAY_MS)
120
+ }
121
+
122
+ this.subscriptionDisconnected()
123
+ this.#channel = null
124
+ }
125
+
126
+ subscriptionConnected() {
127
+ this.setAttribute("connected", "")
128
+ }
129
+
130
+ subscriptionDisconnected() {
131
+ this.removeAttribute("connected")
132
+ }
133
+ }
134
+
135
+ if (customElements.get("hotsock-turbo-stream-source") === undefined) {
136
+ customElements.define(
137
+ "hotsock-turbo-stream-source",
138
+ HotsockTurboStreamSourceElement
139
+ )
140
+ }
141
+
142
+ export { hotsockClient }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotsock
4
+ module Turbo
5
+ class TokensController < Hotsock::Turbo.config.parent_controller.constantize
6
+ def connect
7
+ render json: {token: connect_token}
8
+ end
9
+
10
+ private
11
+
12
+ def connect_token
13
+ uid = respond_to?(:hotsock_uid, true) ? hotsock_uid : session.id.to_s
14
+ umd = respond_to?(:hotsock_umd, true) ? hotsock_umd : nil
15
+
16
+ claims = {scope: "connect", keepAlive: true, uid:, umd:}
17
+ Hotsock.issue_token(claims)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Hotsock
6
+ module Turbo
7
+ # The job that powers all the broadcast_$action_later broadcasts.
8
+ class ActionBroadcastJob < ActiveJob::Base
9
+ discard_on ActiveJob::DeserializationError
10
+
11
+ def perform(stream, action:, target:, targets: nil, attributes: {}, **rendering)
12
+ Hotsock::Turbo::StreamsChannel.broadcast_action_to(
13
+ stream,
14
+ action:,
15
+ target:,
16
+ targets:,
17
+ attributes:,
18
+ **rendering
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Hotsock
6
+ module Turbo
7
+ # The job that powers broadcast_render_later_to for rendering turbo stream templates.
8
+ class BroadcastJob < ActiveJob::Base
9
+ discard_on ActiveJob::DeserializationError
10
+
11
+ def perform(stream, **rendering)
12
+ Hotsock::Turbo::StreamsChannel.broadcast_render_to(stream, **rendering)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Hotsock
6
+ module Turbo
7
+ class BroadcastRefreshJob < ActiveJob::Base
8
+ discard_on ActiveJob::DeserializationError
9
+
10
+ def perform(stream, request_id: nil)
11
+ Hotsock::Turbo::StreamsChannel.broadcast_refresh_to(stream, request_id:)
12
+ end
13
+ end
14
+ end
15
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Hotsock::Turbo::Engine.routes.draw do
4
+ post "connect", to: "tokens#connect"
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(::File.join(::File.dirname(__FILE__), "lib"))
4
+
5
+ require "hotsock/turbo/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "hotsock-turbo"
9
+ spec.version = Hotsock::Turbo::VERSION
10
+ spec.authors = ["James Miller"]
11
+ spec.email = ["support@hotsock.io"]
12
+ spec.homepage = "https://www.hotsock.io"
13
+ spec.summary = "Turbo Streams integration for Hotsock"
14
+ spec.description = "Provides Turbo Streams integration for Hotsock, enabling real-time updates in Rails applications using Hotsock's WebSocket infrastructure."
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.2"
17
+
18
+ spec.metadata = {
19
+ "homepage_uri" => "https://www.hotsock.io",
20
+ "bug_tracker_uri" => "https://github.com/hotsock/hotsock-turbo/issues",
21
+ "documentation_uri" => "https://github.com/hotsock/hotsock-turbo/blob/main/README.md",
22
+ "changelog_uri" => "https://github.com/hotsock/hotsock-turbo/releases",
23
+ "source_code_uri" => "https://github.com/hotsock/hotsock-turbo"
24
+ }
25
+
26
+ ignored = Regexp.union(
27
+ /\A\.git/,
28
+ /\Atest/
29
+ )
30
+ spec.files = `git ls-files`.split("\n").reject { |f| ignored.match(f) }
31
+
32
+ spec.add_dependency "hotsock", "~> 1.0"
33
+ end