pulse_zero 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d243fa7e75252ee44f79f39ca3b2c94718a541745662db23950d654f4b781ae9
4
- data.tar.gz: f40ad0d09e0f3befd5ac36d4826ec49c87ef6de99299b580537f66ccb13c7dea
3
+ metadata.gz: e6a8b21ed27a36968fdafbc307f696a435bec0aa776f4d217e6f92c73a81b890
4
+ data.tar.gz: a68d614d85077616fa9187618242d4437f4dd27e09330ef563e703442b27c719
5
5
  SHA512:
6
- metadata.gz: b2d0f0a24c3df60801a5a90298100ae52ed4ff5172f2ba5d0f3b1aa59f7d2f140b10e0fa65f394997ccb92dd93f8a81ea413ad84d44946b7de3d2ddc47173c96
7
- data.tar.gz: 0f03276d631c1ef157db668eb8dae7e86f875c7e7c5f87b72fe559890f83fbc753eabae8fbff11869c4ab690d0b040cacd8c114ed0bef91ecaf4bc4c839deaec
6
+ metadata.gz: ff16c5c80bc52b4c739fd308e4c79f177b0ea9d4d6a2eacd7cb4501e622bd0a9a91b4992ece15d827a77b9ee789d4ad1e5a502aecb89a67fb693af78f19d50ce
7
+ data.tar.gz: ca85a9f358c257668e05fdd569f3f4a1ffc78cfa54cd7096928081b87c78da25290640eeea1b307510aacd1225d47c7d60853ed5f7cacacee9d9f545b94c5576
data/README.md CHANGED
@@ -6,6 +6,8 @@ Real-time broadcasting generator for Rails + Inertia.js applications. Generate a
6
6
 
7
7
  Pulse Zero generates a complete real-time broadcasting system directly into your Rails application. Unlike traditional gems, all code is copied into your project, giving you full ownership and the ability to customize everything.
8
8
 
9
+ Inspired by Turbo Rails, Pulse Zero brings familiar broadcasting patterns to Inertia.js applications. If you've used `broadcasts_to` in Turbo Rails, you'll feel right at home with Pulse Zero's API.
10
+
9
11
  Features:
10
12
  - 🚀 WebSocket broadcasting via ActionCable
11
13
  - 🔒 Secure signed streams
@@ -13,6 +15,7 @@ Features:
13
15
  - 🔄 Automatic reconnection with exponential backoff
14
16
  - 📦 TypeScript support for Inertia + React
15
17
  - 🎯 Zero runtime dependencies
18
+ - 🏗️ Turbo Rails-inspired API design
16
19
 
17
20
  ## Installation
18
21
 
@@ -57,11 +60,11 @@ rails generate pulse_zero:install
57
60
  class Post < ApplicationRecord
58
61
  include Pulse::Broadcastable
59
62
 
60
- # Broadcast to account-scoped channel
61
- broadcasts_to ->(post) { [post.account, "posts"] }
63
+ # Broadcast to a simple channel
64
+ broadcasts_to ->(post) { "posts" }
62
65
 
63
- # Or broadcast to a simple channel
64
- broadcasts "posts"
66
+ # Or broadcast to account-scoped channel
67
+ # broadcasts_to ->(post) { [post.account, "posts"] }
65
68
  end
66
69
  ```
67
70
 
@@ -70,41 +73,106 @@ end
70
73
  ```ruby
71
74
  class PostsController < ApplicationController
72
75
  def index
73
- @posts = Current.account.posts
74
- @pulse_stream = Pulse::Streams::StreamName
75
- .signed_stream_name([Current.account, "posts"])
76
+ @posts = Post.all
77
+
78
+ render inertia: "Post/Index", props: {
79
+ posts: @posts.map do |post|
80
+ serialize_post(post)
81
+ end,
82
+ pulseStream: Pulse::Streams::StreamName.signed_stream_name("posts")
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def serialize_post(post)
89
+ {
90
+ id: post.id,
91
+ title: post.title,
92
+ content: post.content,
93
+ created_at: post.created_at
94
+ }
76
95
  end
77
96
  end
78
97
  ```
79
98
 
99
+ **Why pulseStream?** Following Turbo Rails' security model, Pulse uses signed stream names to prevent unauthorized access to WebSocket channels. Since Inertia.js doesn't have a built-in way to access streams like Turbo does, we pass the signed stream name as a prop. This approach:
100
+ - Maintains security through cryptographically signed tokens
101
+ - Works naturally with Inertia's prop system
102
+ - Keeps the API simple and explicit
103
+
80
104
  ### 3. Subscribe in React Component
81
105
 
82
106
  ```tsx
107
+ import { useState } from 'react'
83
108
  import { usePulse } from '@/hooks/use-pulse'
84
109
  import { useVisibilityRefresh } from '@/hooks/use-visibility-refresh'
85
110
  import { router } from '@inertiajs/react'
86
111
 
87
- export default function Posts({ posts, pulseStream }) {
88
- // Handle tab visibility
112
+ interface IndexProps {
113
+ posts: Array<{
114
+ id: number
115
+ title: string
116
+ content: string
117
+ created_at: string
118
+ }>
119
+ pulseStream: string
120
+ flash: {
121
+ success?: string
122
+ error?: string
123
+ }
124
+ }
125
+
126
+ export default function Index({ posts: initialPosts, flash, pulseStream }: IndexProps) {
127
+ // Use local state for posts to enable real-time updates
128
+ const [posts, setPosts] = useState(initialPosts)
129
+
130
+ // Automatically refresh data when returning to the tab after 30+ seconds
131
+ // This ensures users see fresh data after being away, handling cases where
132
+ // WebSocket messages might have been missed during browser suspension
89
133
  useVisibilityRefresh(30, () => {
90
134
  router.reload({ only: ['posts'] })
91
135
  })
92
-
93
- // Subscribe to real-time updates
136
+
137
+ // Subscribe to Pulse updates for real-time changes
94
138
  usePulse(pulseStream, (message) => {
95
139
  switch (message.event) {
96
140
  case 'created':
141
+ // Add the new post to the beginning of the list
142
+ setPosts(prev => [message.payload, ...prev])
143
+ break
97
144
  case 'updated':
145
+ // Replace the updated post in the list
146
+ setPosts(prev =>
147
+ prev.map(post => post.id === message.payload.id ? message.payload : post)
148
+ )
149
+ break
98
150
  case 'deleted':
99
- router.reload({ only: ['posts'] })
151
+ // Remove the deleted post from the list
152
+ setPosts(prev =>
153
+ prev.filter(post => post.id !== message.payload.id)
154
+ )
155
+ break
156
+ case 'refresh':
157
+ // Full reload for refresh events
158
+ router.reload()
100
159
  break
101
160
  }
102
161
  })
103
162
 
104
- return <PostsList posts={posts} />
163
+ return (
164
+ <>
165
+ {flash.success && <div className="alert-success">{flash.success}</div>}
166
+ <PostsList posts={posts} />
167
+ </>
168
+ )
105
169
  }
106
170
  ```
107
171
 
172
+ **Note:** This example shows optimistic UI updates using local state. Alternatively, you can use `router.reload({ only: ['posts'] })` for all events to fetch fresh data from the server, which ensures consistency but may feel less responsive.
173
+
174
+ **Why useVisibilityRefresh?** When users switch tabs or minimize their browser, WebSocket connections can be suspended and messages may be lost. The `useVisibilityRefresh` hook detects when users return to your app and automatically refreshes the data if they've been away for more than the specified threshold (30 seconds in this example). This ensures users always see up-to-date information without manual refreshing.
175
+
108
176
  ## Broadcasting Events
109
177
 
110
178
  Pulse broadcasts four types of events:
@@ -265,12 +333,13 @@ console.log(stats)
265
333
 
266
334
  ## Philosophy
267
335
 
268
- Pulse Zero follows the same philosophy as [authentication-zero](https://github.com/lazaronixon/authentication-zero):
336
+ Pulse Zero follows the same philosophy as [authentication-zero](https://github.com/lazaronixon/authentication-zero), and is heavily inspired by [Turbo Rails](https://github.com/hotwired/turbo-rails). The API design closely mirrors Turbo Rails patterns, making it intuitive for developers already familiar with the Hotwire ecosystem.
269
337
 
270
338
  - **Own your code**: All code is generated into your project
271
339
  - **No runtime dependencies**: The gem is only needed during generation
272
340
  - **Customizable**: Modify any generated code to fit your needs
273
341
  - **Production-ready**: Includes battle-tested patterns from real applications
342
+ - **Familiar API**: Inspired by Turbo Rails, uses similar broadcasting patterns and conventions
274
343
 
275
344
  ## Contributing
276
345
 
@@ -131,31 +131,55 @@ module PulseZero
131
131
  def create_documentation
132
132
  template "docs/PULSE_USAGE.md.tt", "docs/PULSE_USAGE.md"
133
133
 
134
- say "\n✅ Pulse real-time broadcasting has been installed!", :green
134
+ say "\n✅ Pulse Zero has been installed!", :green
135
+ say "🚀 Real-time broadcasting system generated with zero runtime dependencies", :blue
135
136
  say "\n⚠️ IMPORTANT: Configure authentication!", :yellow
136
137
  say "The default ApplicationCable connection accepts all connections."
137
138
  say "Edit app/channels/application_cable/connection.rb to add your authentication logic."
138
- say "\nNext steps:", :yellow
139
- say "1. Configure authentication in app/channels/application_cable/connection.rb"
140
- say "2. Read docs/PULSE_USAGE.md for complete setup instructions"
141
- say "3. Add 'include Pulse::Broadcastable' to models that need broadcasting"
142
- say "4. Use 'usePulse' hook in your React components"
143
- say "\nExample:", :blue
139
+ say "\nWhat was generated:", :yellow
140
+ say " Backend: Broadcasting system in lib/pulse/, models, controllers, channels, and jobs"
141
+ say " Frontend: TypeScript WebSocket management and React hooks"
142
+ say " Security: Signed streams for secure channel subscriptions"
143
+ say " Features: Auto-reconnection, tab suspension handling, and more"
144
+ say "\nQuick start:", :blue
144
145
  say <<~EXAMPLE
145
- # In your model:
146
+ # 1. Enable broadcasting on a model:
146
147
  class Post < ApplicationRecord
147
148
  include Pulse::Broadcastable
148
- broadcasts_to ->(post) { [post.account, "posts"] }
149
+ broadcasts_to ->(post) { "posts" }
149
150
  end
150
151
 
151
- # In your controller:
152
- @pulse_stream = Pulse::Streams::StreamName.signed_stream_name([Current.account, "posts"])
152
+ # 2. Pass stream token to frontend:
153
+ render inertia: "Post/Index", props: {
154
+ posts: @posts,
155
+ pulseStream: Pulse::Streams::StreamName.signed_stream_name("posts")
156
+ }
153
157
 
154
- # In your React component:
158
+ # 3. Subscribe in React component:
155
159
  usePulse(pulseStream, (message) => {
156
- router.reload({ only: ['posts'] })
160
+ switch (message.event) {
161
+ case 'created':
162
+ setPosts(prev => [message.payload, ...prev])
163
+ break
164
+ case 'updated':
165
+ setPosts(prev =>
166
+ prev.map(post => post.id === message.payload.id ? message.payload : post)
167
+ )
168
+ break
169
+ case 'deleted':
170
+ setPosts(prev => prev.filter(post => post.id !== message.payload.id))
171
+ break
172
+ case 'refresh':
173
+ router.reload()
174
+ break
175
+ }
157
176
  })
158
177
  EXAMPLE
178
+ say "\nNext steps:", :yellow
179
+ say "1. Configure authentication in app/channels/application_cable/connection.rb"
180
+ say "2. Read docs/PULSE_USAGE.md for complete documentation"
181
+ say "3. Enable debug logging: localStorage.setItem('PULSE_DEBUG', 'true')"
182
+ say "\nInspired by Turbo Rails • Full ownership of generated code • Customize everything!"
159
183
  end
160
184
 
161
185
  private
@@ -1,110 +1,142 @@
1
- # Pulse Real-time Broadcasting Guide
1
+ # Pulse Zero Usage Guide
2
2
 
3
- ## Overview
3
+ ## What is Pulse Zero?
4
4
 
5
- Pulse is a real-time broadcasting system that keeps your UI in sync with database changes. It's designed for Inertia.js + React applications, broadcasting JSON messages through WebSockets.
5
+ Pulse Zero generated a complete real-time broadcasting system directly into your Rails application. Unlike traditional gems, all code is now part of your project, giving you full ownership and the ability to customize everything.
6
6
 
7
- ## Quick Start
8
-
9
- ### 1. Enable Broadcasting on a Model
7
+ Inspired by Turbo Rails, Pulse brings familiar broadcasting patterns to Inertia.js applications. If you've used `broadcasts_to` in Turbo Rails, you'll feel right at home with Pulse's API.
10
8
 
11
- Add `include Pulse::Broadcastable` to your model. You have two approaches:
9
+ Features:
10
+ - 🚀 WebSocket broadcasting via ActionCable
11
+ - 🔒 Secure signed streams
12
+ - 📱 Browser tab suspension handling
13
+ - 🔄 Automatic reconnection with exponential backoff
14
+ - 📦 TypeScript support for Inertia + React
15
+ - 🎯 Zero runtime dependencies
16
+ - 🏗️ Turbo Rails-inspired API design
12
17
 
13
- #### Option A: Direct Broadcasting (More Control)
14
-
15
- ```ruby
16
- class Post < ApplicationRecord
17
- include Pulse::Broadcastable
18
-
19
- # Direct broadcasts with custom payloads
20
- after_create_commit -> { broadcast_created_later_to([account, "posts"], payload: to_inertia_json) }
21
- after_update_commit -> { broadcast_updated_later_to([account, "posts"], payload: to_inertia_json) }
22
- after_destroy_commit -> { broadcast_deleted_to([account, "posts"], payload: { id: id.to_s }) }
23
-
24
- private
25
-
26
- def to_inertia_json
27
- {
28
- id: id,
29
- content: content,
30
- state: state,
31
- created_at: created_at.iso8601,
32
- # ... other fields
33
- }
34
- end
35
- end
36
- ```
18
+ ## Quick Start
37
19
 
38
- #### Option B: DSL Broadcasting (Simpler)
20
+ ### 1. Enable Broadcasting on a Model
39
21
 
40
22
  ```ruby
41
23
  class Post < ApplicationRecord
42
24
  include Pulse::Broadcastable
43
-
44
- # Broadcast to account-scoped channel
45
- broadcasts_to ->(post) { [post.account, "posts"] }
46
-
47
- # Or broadcast to a simple channel
48
- broadcasts "posts"
25
+
26
+ # Broadcast to a simple channel
27
+ broadcasts_to ->(post) { "posts" }
28
+
29
+ # Or broadcast to account-scoped channel
30
+ # broadcasts_to ->(post) { [post.account, "posts"] }
49
31
  end
50
32
  ```
51
33
 
52
34
  ### 2. Pass Stream Token to Frontend
53
35
 
54
- In your controller, generate a signed stream token:
55
-
56
36
  ```ruby
57
37
  class PostsController < ApplicationController
58
38
  def index
59
- @posts = Current.account.posts
60
- @pulse_stream = Pulse::Streams::StreamName
61
- .signed_stream_name([Current.account, "posts"])
39
+ @posts = Post.all
62
40
 
63
- # The stream token is automatically available in your React component
41
+ render inertia: "Post/Index", props: {
42
+ posts: @posts.map do |post|
43
+ serialize_post(post)
44
+ end,
45
+ pulseStream: Pulse::Streams::StreamName.signed_stream_name("posts")
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def serialize_post(post)
52
+ {
53
+ id: post.id,
54
+ title: post.title,
55
+ content: post.content,
56
+ created_at: post.created_at
57
+ }
64
58
  end
65
59
  end
66
60
  ```
67
61
 
68
- ### 3. Subscribe in React Component
62
+ **Why pulseStream?** Following Turbo Rails' security model, Pulse uses signed stream names to prevent unauthorized access to WebSocket channels. Since Inertia.js doesn't have a built-in way to access streams like Turbo does, we pass the signed stream name as a prop. This approach:
63
+ - Maintains security through cryptographically signed tokens
64
+ - Works naturally with Inertia's prop system
65
+ - Keeps the API simple and explicit
69
66
 
70
- Use the `usePulse` hook to receive real-time updates:
67
+ ### 3. Subscribe in React Component
71
68
 
72
69
  ```tsx
70
+ import { useState } from 'react'
73
71
  import { usePulse } from '@/hooks/use-pulse'
74
72
  import { useVisibilityRefresh } from '@/hooks/use-visibility-refresh'
75
73
  import { router } from '@inertiajs/react'
76
74
 
77
- export default function Posts({ posts, pulseStream }) {
78
- // Handle tab visibility (refreshes data when returning to tab)
75
+ interface IndexProps {
76
+ posts: Array<{
77
+ id: number
78
+ title: string
79
+ content: string
80
+ created_at: string
81
+ }>
82
+ pulseStream: string
83
+ flash: {
84
+ success?: string
85
+ error?: string
86
+ }
87
+ }
88
+
89
+ export default function Index({ posts: initialPosts, flash, pulseStream }: IndexProps) {
90
+ // Use local state for posts to enable real-time updates
91
+ const [posts, setPosts] = useState(initialPosts)
92
+
93
+ // Automatically refresh data when returning to the tab after 30+ seconds
94
+ // This ensures users see fresh data after being away, handling cases where
95
+ // WebSocket messages might have been missed during browser suspension
79
96
  useVisibilityRefresh(30, () => {
80
97
  router.reload({ only: ['posts'] })
81
98
  })
82
99
 
83
- // Subscribe to real-time updates
100
+ // Subscribe to Pulse updates for real-time changes
84
101
  usePulse(pulseStream, (message) => {
85
102
  switch (message.event) {
86
103
  case 'created':
104
+ // Add the new post to the beginning of the list
105
+ setPosts(prev => [message.payload, ...prev])
106
+ break
87
107
  case 'updated':
108
+ // Replace the updated post in the list
109
+ setPosts(prev =>
110
+ prev.map(post => post.id === message.payload.id ? message.payload : post)
111
+ )
112
+ break
88
113
  case 'deleted':
89
- // Reload posts from server
90
- router.reload({ only: ['posts'] })
114
+ // Remove the deleted post from the list
115
+ setPosts(prev =>
116
+ prev.filter(post => post.id !== message.payload.id)
117
+ )
91
118
  break
92
119
  case 'refresh':
93
- // Full page refresh
120
+ // Full reload for refresh events
94
121
  router.reload()
95
122
  break
96
123
  }
97
124
  })
98
-
125
+
99
126
  return (
100
- <div>
101
- {posts.map(post => <PostCard key={post.id} post={post} />)}
102
- </div>
127
+ <>
128
+ {flash.success && <div className="alert-success">{flash.success}</div>}
129
+ <PostsList posts={posts} />
130
+ </>
103
131
  )
104
132
  }
105
133
  ```
106
134
 
107
- ## Message Events
135
+ **Note:** This example shows optimistic UI updates using local state. Alternatively, you can use `router.reload({ only: ['posts'] })` for all events to fetch fresh data from the server, which ensures consistency but may feel less responsive.
136
+
137
+ **Why useVisibilityRefresh?** When users switch tabs or minimize their browser, WebSocket connections can be suspended and messages may be lost. The `useVisibilityRefresh` hook detects when users return to your app and automatically refreshes the data if they've been away for more than the specified threshold (30 seconds in this example). This ensures users always see up-to-date information without manual refreshing.
138
+
139
+ ## Broadcasting Events
108
140
 
109
141
  Pulse broadcasts four types of events:
110
142
 
@@ -112,7 +144,7 @@ Pulse broadcasts four types of events:
112
144
  ```json
113
145
  {
114
146
  "event": "created",
115
- "payload": { "id": 123, "content": "New post", ... },
147
+ "payload": { "id": 123, "content": "New post" },
116
148
  "requestId": "uuid-123",
117
149
  "at": 1234567890.123
118
150
  }
@@ -122,7 +154,7 @@ Pulse broadcasts four types of events:
122
154
  ```json
123
155
  {
124
156
  "event": "updated",
125
- "payload": { "id": 123, "content": "Updated post", ... },
157
+ "payload": { "id": 123, "content": "Updated post" },
126
158
  "requestId": "uuid-456",
127
159
  "at": 1234567891.456
128
160
  }
@@ -148,90 +180,155 @@ Pulse broadcasts four types of events:
148
180
  }
149
181
  ```
150
182
 
151
- ## Common Patterns
152
-
153
- ### Scoped Broadcasting
183
+ ## Advanced Usage
154
184
 
155
- Always scope broadcasts to prevent users from seeing each other's data:
185
+ ### Manual Broadcasting
156
186
 
157
187
  ```ruby
158
- class Comment < ApplicationRecord
159
- include Pulse::Broadcastable
188
+ # Broadcast with custom payload
189
+ post.broadcast_updated_to(
190
+ [Current.account, "posts"],
191
+ payload: { id: post.id, featured: true }
192
+ )
193
+
194
+ # Async broadcasting
195
+ post.broadcast_updated_later_to([Current.account, "posts"])
196
+ ```
160
197
 
161
- belongs_to :post
162
- belongs_to :user
198
+ ### Suppress Broadcasts During Bulk Operations
163
199
 
164
- # Scope to the post's account and specific post
165
- broadcasts_to ->(comment) { [comment.post.account, "posts", comment.post_id, "comments"] }
200
+ ```ruby
201
+ Post.suppressing_pulse_broadcasts do
202
+ Post.where(account: account).update_all(featured: true)
166
203
  end
204
+
205
+ # Then send one refresh broadcast
206
+ Post.new.broadcast_refresh_to([account, "posts"])
167
207
  ```
168
208
 
169
- ### Manual Broadcasting
209
+ ### Custom Serialization
210
+
211
+ ```ruby
212
+ # config/initializers/pulse.rb
213
+ Rails.application.configure do
214
+ config.pulse.serializer = ->(record) {
215
+ case record
216
+ when Post
217
+ record.as_json(only: [:id, :title, :state])
218
+ else
219
+ record.as_json
220
+ end
221
+ }
222
+ end
223
+ ```
170
224
 
171
- Sometimes you need to broadcast manually:
225
+ ## Configuration
172
226
 
173
227
  ```ruby
174
- class PostsController < ApplicationController
175
- def approve
176
- @post = Post.find(params[:id])
177
- @post.approve!
178
-
179
- # Manual broadcast with custom payload
180
- @post.broadcast_updated_to(
181
- [Current.account, "posts"],
182
- payload: { id: @post.id, approved: true }
183
- )
184
- end
228
+ # config/initializers/pulse.rb
229
+ Rails.application.configure do
230
+ # Debounce window in milliseconds (default: 300)
231
+ config.pulse.debounce_ms = 300
232
+
233
+ # Background job queue (default: :default)
234
+ config.pulse.queue_name = :low
235
+
236
+ # Custom serializer
237
+ config.pulse.serializer = ->(record) { record.as_json }
185
238
  end
186
239
  ```
187
240
 
188
- ### Bulk Operations
241
+ ## Browser Tab Handling
242
+
243
+ Pulse includes sophisticated handling for browser tab suspension:
244
+
245
+ - **Quick switches (<30s)**: Just ensures connection is alive
246
+ - **Medium absence (30s-5min)**: Reconnects and syncs data
247
+ - **Long absence (>5min)**: Full page refresh for consistency
189
248
 
190
- Suppress broadcasts during bulk operations to avoid flooding:
249
+ Platform-aware thresholds:
250
+ - Desktop Chrome/Firefox: 30 seconds
251
+ - Safari/Mobile: 15 seconds (more aggressive)
252
+
253
+ ## Testing
191
254
 
192
255
  ```ruby
193
- # Bad - sends 1000 broadcasts
194
- Post.where(account: account).update_all(featured: true)
256
+ # In your test files
257
+ test "broadcasts on update" do
258
+ post = posts(:one)
259
+
260
+ assert_broadcast_on([post.account, "posts"]) do
261
+ post.update!(title: "New Title")
262
+ end
263
+ end
195
264
 
196
- # Good - suppresses broadcasts
265
+ # Suppress broadcasts in tests
197
266
  Post.suppressing_pulse_broadcasts do
198
- Post.where(account: account).update_all(featured: true)
267
+ # Your test code
199
268
  end
269
+ ```
200
270
 
201
- # Then send one refresh broadcast
202
- Post.new.broadcast_refresh_to([account, "posts"])
271
+ ## Debugging
272
+
273
+ Enable debug logging:
274
+
275
+ ```javascript
276
+ // In browser console
277
+ localStorage.setItem('PULSE_DEBUG', 'true')
203
278
  ```
204
279
 
205
- ### Async Broadcasting
280
+ Check connection health:
206
281
 
207
- For non-critical updates or heavy operations, use async broadcasting:
282
+ ```javascript
283
+ import { getPulseMonitorStats } from '@/lib/pulse-connection'
284
+
285
+ const stats = getPulseMonitorStats()
286
+ console.log(stats)
287
+ ```
288
+
289
+ ## Common Patterns
290
+
291
+ ### Scoped Broadcasting
292
+
293
+ Always scope broadcasts to prevent users from seeing each other's data:
208
294
 
209
295
  ```ruby
210
- class ProcessHeavyDataJob < ApplicationJob
211
- def perform(post)
212
- # Do heavy processing...
213
- result = process_data(post)
214
-
215
- # Broadcast asynchronously (via job queue)
216
- post.broadcast_updated_later_to(
217
- [post.account, "posts"],
218
- payload: { id: post.id, processing_complete: true, result: result }
219
- )
220
- end
296
+ class Comment < ApplicationRecord
297
+ include Pulse::Broadcastable
298
+
299
+ belongs_to :post
300
+ belongs_to :user
301
+
302
+ # Scope to the post's account and specific post
303
+ broadcasts_to ->(comment) { [comment.post.account, "posts", comment.post_id, "comments"] }
221
304
  end
222
305
  ```
223
306
 
224
- ## Frontend Patterns
307
+ ### Direct Broadcasting (More Control)
225
308
 
226
- ### Simple Reload
309
+ For more control over what gets broadcast:
227
310
 
228
- The simplest approach - just reload the data:
311
+ ```ruby
312
+ class Post < ApplicationRecord
313
+ include Pulse::Broadcastable
229
314
 
230
- ```tsx
231
- usePulse(pulseStream, (message) => {
232
- // Reload posts array from server
233
- router.reload({ only: ['posts'] })
234
- })
315
+ # Direct broadcasts with custom payloads
316
+ after_create_commit -> { broadcast_created_later_to([account, "posts"], payload: to_inertia_json) }
317
+ after_update_commit -> { broadcast_updated_later_to([account, "posts"], payload: to_inertia_json) }
318
+ after_destroy_commit -> { broadcast_deleted_to([account, "posts"], payload: { id: id.to_s }) }
319
+
320
+ private
321
+
322
+ def to_inertia_json
323
+ {
324
+ id: id,
325
+ title: title,
326
+ content: content,
327
+ state: state,
328
+ created_at: created_at.iso8601
329
+ }
330
+ end
331
+ end
235
332
  ```
236
333
 
237
334
  ### Optimistic Updates
@@ -247,7 +344,7 @@ export default function PostsList({ posts: initialPosts, pulseStream }) {
247
344
  usePulse(pulseStream, (message) => {
248
345
  switch (message.event) {
249
346
  case 'created':
250
- setPosts(prev => [...prev, message.payload])
347
+ setPosts(prev => [message.payload, ...prev])
251
348
  break
252
349
  case 'updated':
253
350
  setPosts(prev => prev.map(post =>
@@ -291,160 +388,6 @@ usePulse(pulseStream, (message) => {
291
388
  })
292
389
  ```
293
390
 
294
- ## Browser Tab Handling
295
-
296
- Pulse includes sophisticated handling for browser tab suspension. Modern browsers suspend WebSocket connections in background tabs to save battery.
297
-
298
- ### Automatic Recovery
299
-
300
- Use the `useVisibilityRefresh` hook to handle tab suspension:
301
-
302
- ```tsx
303
- // Default: refresh after 30 seconds hidden
304
- useVisibilityRefresh(30, () => {
305
- router.reload({ only: ['posts'] })
306
- })
307
-
308
- // Aggressive: refresh after 15 seconds (for critical data)
309
- useVisibilityRefresh(15, () => {
310
- router.reload()
311
- })
312
-
313
- // Relaxed: refresh after 2 minutes
314
- useVisibilityRefresh(120, () => {
315
- router.reload({ only: ['posts'] })
316
- })
317
- ```
318
-
319
- ### Platform-Specific Behavior
320
-
321
- - **Desktop Chrome/Firefox**: 30 second default threshold
322
- - **Safari/Mobile**: 15 second default (more aggressive suspension)
323
-
324
- ## Configuration
325
-
326
- ### Debounce Window
327
-
328
- Adjust how long Pulse waits to coalesce rapid updates:
329
-
330
- ```ruby
331
- # config/initializers/pulse.rb
332
- Rails.application.configure do
333
- # Default is 300ms
334
- config.pulse.debounce_ms = 500
335
- end
336
- ```
337
-
338
- ### Job Queue
339
-
340
- Configure which queue processes async broadcasts:
341
-
342
- ```ruby
343
- Rails.application.configure do
344
- # Use low priority queue for broadcasts
345
- config.pulse.queue_name = :low
346
- end
347
- ```
348
-
349
- ### Custom Serialization
350
-
351
- Control what data is sent in broadcasts:
352
-
353
- ```ruby
354
- # config/initializers/pulse.rb
355
- Rails.application.configure do
356
- config.pulse.serializer = ->(record) {
357
- case record
358
- when Post
359
- {
360
- id: record.id,
361
- title: record.title,
362
- state: record.state,
363
- author: record.user.name,
364
- updatedAt: record.updated_at.iso8601
365
- }
366
- when Comment
367
- {
368
- id: record.id,
369
- content: record.content.truncate(100),
370
- authorAvatar: record.user.avatar_url
371
- }
372
- else
373
- record.as_json
374
- end
375
- }
376
- end
377
- ```
378
-
379
- ## Testing
380
-
381
- ### Testing Model Broadcasts
382
-
383
- ```ruby
384
- class PostTest < ActiveSupport::TestCase
385
- test "broadcasts when published" do
386
- post = posts(:draft)
387
- account = accounts(:one)
388
-
389
- # Create the expected stream name
390
- stream = Pulse::Streams::StreamName.signed_stream_name([account, "posts"])
391
-
392
- # Assert broadcast happens
393
- assert_broadcast_on(stream) do
394
- post.publish!
395
- end
396
- end
397
-
398
- test "suppresses broadcasts in bulk operations" do
399
- Post.suppressing_pulse_broadcasts do
400
- # No broadcasts should happen here
401
- Post.import(large_dataset)
402
- end
403
- end
404
- end
405
- ```
406
-
407
- ### Disable in Tests
408
-
409
- Broadcasts are automatically disabled in the test environment via:
410
-
411
- ```ruby
412
- # config/initializers/pulse.rb
413
- if Rails.env.test?
414
- ENV["PULSE_DISABLED"] = "true"
415
- end
416
- ```
417
-
418
- ## Debugging
419
-
420
- Enable debug logging:
421
-
422
- ```javascript
423
- // In browser console
424
- localStorage.setItem('PULSE_DEBUG', 'true')
425
-
426
- // Now you'll see logs like:
427
- // [Pulse] Subscription connected: posts:123
428
- // [Pulse] Message received: {event: "updated", ...}
429
- // [Pulse] Connection lost, retrying in 2s...
430
- ```
431
-
432
- Check connection health:
433
-
434
- ```javascript
435
- import { getPulseMonitorStats } from '@/lib/pulse/pulse-connection'
436
-
437
- const stats = getPulseMonitorStats()
438
- console.log(stats)
439
- // {
440
- // isRunning: true,
441
- // isConnecting: false,
442
- // reconnectAttempts: 0,
443
- // secondsSinceActivity: 2.5,
444
- // secondsSinceMessage: 5.1
445
- // }
446
- ```
447
-
448
391
  ## Security Notes
449
392
 
450
393
  1. **Always use scoped streams** - Never broadcast to global channels
@@ -453,24 +396,6 @@ console.log(stats)
453
396
  4. **Sanitize payloads** - Don't include sensitive data in broadcasts
454
397
  5. **Use SSL in production** - WebSockets should run over WSS
455
398
 
456
- ## Common Issues
457
-
458
- ### Not receiving updates
459
- - Check WebSocket connection in Network tab
460
- - Verify stream name matches between backend and frontend
461
- - Ensure ActionCable is mounted in routes.rb
462
- - Check for authorization failures in Rails logs
463
-
464
- ### Too many updates
465
- - Use debouncing (automatic within 300ms)
466
- - Implement conditional broadcasting
467
- - Use `suppressing_pulse_broadcasts` for bulk operations
468
-
469
- ### Connection drops
470
- - Pulse automatically reconnects with exponential backoff
471
- - Check for SSL/proxy issues in production
472
- - Monitor server logs for WebSocket errors
473
-
474
399
  ## Authentication Setup
475
400
 
476
401
  By default, Pulse accepts all WebSocket connections in development. You need to configure authentication based on your setup:
@@ -523,10 +448,38 @@ rescue JWT::DecodeError
523
448
  end
524
449
  ```
525
450
 
451
+ ## Common Issues
452
+
453
+ ### Not receiving updates
454
+ - Check WebSocket connection in Network tab
455
+ - Verify stream name matches between backend and frontend
456
+ - Ensure ActionCable is mounted in routes.rb
457
+ - Check for authorization failures in Rails logs
458
+
459
+ ### Too many updates
460
+ - Use debouncing (automatic within 300ms)
461
+ - Implement conditional broadcasting
462
+ - Use `suppressing_pulse_broadcasts` for bulk operations
463
+
464
+ ### Connection drops
465
+ - Pulse automatically reconnects with exponential backoff
466
+ - Check for SSL/proxy issues in production
467
+ - Monitor server logs for WebSocket errors
468
+
469
+ ## Philosophy
470
+
471
+ Pulse Zero follows the same philosophy as [authentication-zero](https://github.com/lazaronixon/authentication-zero), and is heavily inspired by [Turbo Rails](https://github.com/hotwired/turbo-rails). The API design closely mirrors Turbo Rails patterns, making it intuitive for developers already familiar with the Hotwire ecosystem.
472
+
473
+ - **Own your code**: All code is generated into your project
474
+ - **No runtime dependencies**: The gem is only needed during generation
475
+ - **Customizable**: Modify any generated code to fit your needs
476
+ - **Production-ready**: Includes battle-tested patterns from real applications
477
+ - **Familiar API**: Inspired by Turbo Rails, uses similar broadcasting patterns and conventions
478
+
526
479
  ## Next Steps
527
480
 
528
481
  - Configure authentication in `app/channels/application_cable/connection.rb`
529
482
  - Try the example in a model: `include Pulse::Broadcastable`
530
483
  - Set up your first broadcast stream
531
484
  - Customize the serializer for your needs
532
- - Read about advanced patterns in the main documentation
485
+ - Read about advanced patterns in the main documentation
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PulseZero
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pulse_zero
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - darkamenosa