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 +4 -4
- data/README.md +83 -14
- data/lib/generators/pulse_zero/install/install_generator.rb +37 -13
- data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +238 -285
- data/lib/pulse_zero/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e6a8b21ed27a36968fdafbc307f696a435bec0aa776f4d217e6f92c73a81b890
|
4
|
+
data.tar.gz: a68d614d85077616fa9187618242d4437f4dd27e09330ef563e703442b27c719
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
61
|
-
broadcasts_to ->(post) {
|
63
|
+
# Broadcast to a simple channel
|
64
|
+
broadcasts_to ->(post) { "posts" }
|
62
65
|
|
63
|
-
# Or broadcast to
|
64
|
-
|
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 =
|
74
|
-
|
75
|
-
|
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
|
-
|
88
|
-
|
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
|
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
|
-
|
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
|
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
|
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 "\
|
139
|
-
say "
|
140
|
-
say "
|
141
|
-
say "
|
142
|
-
say "
|
143
|
-
say "\
|
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
|
-
#
|
146
|
+
# 1. Enable broadcasting on a model:
|
146
147
|
class Post < ApplicationRecord
|
147
148
|
include Pulse::Broadcastable
|
148
|
-
broadcasts_to ->(post) {
|
149
|
+
broadcasts_to ->(post) { "posts" }
|
149
150
|
end
|
150
151
|
|
151
|
-
#
|
152
|
-
|
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
|
-
#
|
158
|
+
# 3. Subscribe in React component:
|
155
159
|
usePulse(pulseStream, (message) => {
|
156
|
-
|
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
|
1
|
+
# Pulse Zero Usage Guide
|
2
2
|
|
3
|
-
##
|
3
|
+
## What is Pulse Zero?
|
4
4
|
|
5
|
-
Pulse
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
45
|
-
broadcasts_to ->(post) {
|
46
|
-
|
47
|
-
# Or broadcast to
|
48
|
-
|
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 =
|
60
|
-
@pulse_stream = Pulse::Streams::StreamName
|
61
|
-
.signed_stream_name([Current.account, "posts"])
|
39
|
+
@posts = Post.all
|
62
40
|
|
63
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
78
|
-
|
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
|
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
|
-
//
|
90
|
-
|
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
|
120
|
+
// Full reload for refresh events
|
94
121
|
router.reload()
|
95
122
|
break
|
96
123
|
}
|
97
124
|
})
|
98
|
-
|
125
|
+
|
99
126
|
return (
|
100
|
-
|
101
|
-
{
|
102
|
-
|
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
|
-
|
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
|
-
##
|
152
|
-
|
153
|
-
### Scoped Broadcasting
|
183
|
+
## Advanced Usage
|
154
184
|
|
155
|
-
|
185
|
+
### Manual Broadcasting
|
156
186
|
|
157
187
|
```ruby
|
158
|
-
|
159
|
-
|
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
|
-
|
162
|
-
belongs_to :user
|
198
|
+
### Suppress Broadcasts During Bulk Operations
|
163
199
|
|
164
|
-
|
165
|
-
|
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
|
-
###
|
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
|
-
|
225
|
+
## Configuration
|
172
226
|
|
173
227
|
```ruby
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
194
|
-
|
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
|
-
#
|
265
|
+
# Suppress broadcasts in tests
|
197
266
|
Post.suppressing_pulse_broadcasts do
|
198
|
-
|
267
|
+
# Your test code
|
199
268
|
end
|
269
|
+
```
|
200
270
|
|
201
|
-
|
202
|
-
|
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
|
-
|
280
|
+
Check connection health:
|
206
281
|
|
207
|
-
|
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
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
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
|
-
|
307
|
+
### Direct Broadcasting (More Control)
|
225
308
|
|
226
|
-
|
309
|
+
For more control over what gets broadcast:
|
227
310
|
|
228
|
-
|
311
|
+
```ruby
|
312
|
+
class Post < ApplicationRecord
|
313
|
+
include Pulse::Broadcastable
|
229
314
|
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
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 => [
|
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
|
data/lib/pulse_zero/version.rb
CHANGED