pulse_zero 0.3.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 +7 -0
- data/CHANGELOG.md +91 -0
- data/LICENSE.txt +21 -0
- data/README.md +281 -0
- data/lib/generators/pulse_zero/install/install_generator.rb +186 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/channel.rb.tt +6 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/connection.rb.tt +59 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/pulse/channel.rb.tt +15 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt +17 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/jobs/pulse/broadcast_job.rb.tt +28 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/models/concerns/pulse/broadcastable.rb.tt +85 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/models/current.rb.tt +9 -0
- data/lib/generators/pulse_zero/install/templates/backend/config/initializers/pulse.rb.tt +43 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/engine.rb.tt +43 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/broadcasts.rb.tt +80 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/stream_name.rb.tt +34 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/thread_debouncer.rb.tt +31 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse.rb.tt +38 -0
- data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +532 -0
- data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-pulse.ts.tt +66 -0
- data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-visibility-refresh.ts.tt +61 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-connection.ts.tt +169 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-recovery-strategy.ts.tt +156 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-visibility-manager.ts.tt +143 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse.ts.tt +130 -0
- data/lib/pulse_zero/engine.rb +10 -0
- data/lib/pulse_zero/version.rb +5 -0
- data/lib/pulse_zero.rb +13 -0
- data/pulse_zero.gemspec +35 -0
- metadata +109 -0
@@ -0,0 +1,532 @@
|
|
1
|
+
# Pulse Real-time Broadcasting Guide
|
2
|
+
|
3
|
+
## Overview
|
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.
|
6
|
+
|
7
|
+
## Quick Start
|
8
|
+
|
9
|
+
### 1. Enable Broadcasting on a Model
|
10
|
+
|
11
|
+
Add `include Pulse::Broadcastable` to your model. You have two approaches:
|
12
|
+
|
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
|
+
```
|
37
|
+
|
38
|
+
#### Option B: DSL Broadcasting (Simpler)
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
class Post < ApplicationRecord
|
42
|
+
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"
|
49
|
+
end
|
50
|
+
```
|
51
|
+
|
52
|
+
### 2. Pass Stream Token to Frontend
|
53
|
+
|
54
|
+
In your controller, generate a signed stream token:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class PostsController < ApplicationController
|
58
|
+
def index
|
59
|
+
@posts = Current.account.posts
|
60
|
+
@pulse_stream = Pulse::Streams::StreamName
|
61
|
+
.signed_stream_name([Current.account, "posts"])
|
62
|
+
|
63
|
+
# The stream token is automatically available in your React component
|
64
|
+
end
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
### 3. Subscribe in React Component
|
69
|
+
|
70
|
+
Use the `usePulse` hook to receive real-time updates:
|
71
|
+
|
72
|
+
```tsx
|
73
|
+
import { usePulse } from '@/hooks/use-pulse'
|
74
|
+
import { useVisibilityRefresh } from '@/hooks/use-visibility-refresh'
|
75
|
+
import { router } from '@inertiajs/react'
|
76
|
+
|
77
|
+
export default function Posts({ posts, pulseStream }) {
|
78
|
+
// Handle tab visibility (refreshes data when returning to tab)
|
79
|
+
useVisibilityRefresh(30, () => {
|
80
|
+
router.reload({ only: ['posts'] })
|
81
|
+
})
|
82
|
+
|
83
|
+
// Subscribe to real-time updates
|
84
|
+
usePulse(pulseStream, (message) => {
|
85
|
+
switch (message.event) {
|
86
|
+
case 'created':
|
87
|
+
case 'updated':
|
88
|
+
case 'deleted':
|
89
|
+
// Reload posts from server
|
90
|
+
router.reload({ only: ['posts'] })
|
91
|
+
break
|
92
|
+
case 'refresh':
|
93
|
+
// Full page refresh
|
94
|
+
router.reload()
|
95
|
+
break
|
96
|
+
}
|
97
|
+
})
|
98
|
+
|
99
|
+
return (
|
100
|
+
<div>
|
101
|
+
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
102
|
+
</div>
|
103
|
+
)
|
104
|
+
}
|
105
|
+
```
|
106
|
+
|
107
|
+
## Message Events
|
108
|
+
|
109
|
+
Pulse broadcasts four types of events:
|
110
|
+
|
111
|
+
### `created` - When a record is created
|
112
|
+
```json
|
113
|
+
{
|
114
|
+
"event": "created",
|
115
|
+
"payload": { "id": 123, "content": "New post", ... },
|
116
|
+
"requestId": "uuid-123",
|
117
|
+
"at": 1234567890.123
|
118
|
+
}
|
119
|
+
```
|
120
|
+
|
121
|
+
### `updated` - When a record is updated
|
122
|
+
```json
|
123
|
+
{
|
124
|
+
"event": "updated",
|
125
|
+
"payload": { "id": 123, "content": "Updated post", ... },
|
126
|
+
"requestId": "uuid-456",
|
127
|
+
"at": 1234567891.456
|
128
|
+
}
|
129
|
+
```
|
130
|
+
|
131
|
+
### `deleted` - When a record is destroyed
|
132
|
+
```json
|
133
|
+
{
|
134
|
+
"event": "deleted",
|
135
|
+
"payload": { "id": 123 },
|
136
|
+
"requestId": "uuid-789",
|
137
|
+
"at": 1234567892.789
|
138
|
+
}
|
139
|
+
```
|
140
|
+
|
141
|
+
### `refresh` - Force a full refresh
|
142
|
+
```json
|
143
|
+
{
|
144
|
+
"event": "refresh",
|
145
|
+
"payload": {},
|
146
|
+
"requestId": "uuid-012",
|
147
|
+
"at": 1234567893.012
|
148
|
+
}
|
149
|
+
```
|
150
|
+
|
151
|
+
## Common Patterns
|
152
|
+
|
153
|
+
### Scoped Broadcasting
|
154
|
+
|
155
|
+
Always scope broadcasts to prevent users from seeing each other's data:
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
class Comment < ApplicationRecord
|
159
|
+
include Pulse::Broadcastable
|
160
|
+
|
161
|
+
belongs_to :post
|
162
|
+
belongs_to :user
|
163
|
+
|
164
|
+
# Scope to the post's account and specific post
|
165
|
+
broadcasts_to ->(comment) { [comment.post.account, "posts", comment.post_id, "comments"] }
|
166
|
+
end
|
167
|
+
```
|
168
|
+
|
169
|
+
### Manual Broadcasting
|
170
|
+
|
171
|
+
Sometimes you need to broadcast manually:
|
172
|
+
|
173
|
+
```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
|
185
|
+
end
|
186
|
+
```
|
187
|
+
|
188
|
+
### Bulk Operations
|
189
|
+
|
190
|
+
Suppress broadcasts during bulk operations to avoid flooding:
|
191
|
+
|
192
|
+
```ruby
|
193
|
+
# Bad - sends 1000 broadcasts
|
194
|
+
Post.where(account: account).update_all(featured: true)
|
195
|
+
|
196
|
+
# Good - suppresses broadcasts
|
197
|
+
Post.suppressing_pulse_broadcasts do
|
198
|
+
Post.where(account: account).update_all(featured: true)
|
199
|
+
end
|
200
|
+
|
201
|
+
# Then send one refresh broadcast
|
202
|
+
Post.new.broadcast_refresh_to([account, "posts"])
|
203
|
+
```
|
204
|
+
|
205
|
+
### Async Broadcasting
|
206
|
+
|
207
|
+
For non-critical updates or heavy operations, use async broadcasting:
|
208
|
+
|
209
|
+
```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
|
221
|
+
end
|
222
|
+
```
|
223
|
+
|
224
|
+
## Frontend Patterns
|
225
|
+
|
226
|
+
### Simple Reload
|
227
|
+
|
228
|
+
The simplest approach - just reload the data:
|
229
|
+
|
230
|
+
```tsx
|
231
|
+
usePulse(pulseStream, (message) => {
|
232
|
+
// Reload posts array from server
|
233
|
+
router.reload({ only: ['posts'] })
|
234
|
+
})
|
235
|
+
```
|
236
|
+
|
237
|
+
### Optimistic Updates
|
238
|
+
|
239
|
+
Update local state immediately for better UX:
|
240
|
+
|
241
|
+
```tsx
|
242
|
+
import { useState } from 'react'
|
243
|
+
|
244
|
+
export default function PostsList({ posts: initialPosts, pulseStream }) {
|
245
|
+
const [posts, setPosts] = useState(initialPosts)
|
246
|
+
|
247
|
+
usePulse(pulseStream, (message) => {
|
248
|
+
switch (message.event) {
|
249
|
+
case 'created':
|
250
|
+
setPosts(prev => [...prev, message.payload])
|
251
|
+
break
|
252
|
+
case 'updated':
|
253
|
+
setPosts(prev => prev.map(post =>
|
254
|
+
post.id === message.payload.id ? message.payload : post
|
255
|
+
))
|
256
|
+
break
|
257
|
+
case 'deleted':
|
258
|
+
setPosts(prev => prev.filter(post => post.id !== message.payload.id))
|
259
|
+
break
|
260
|
+
case 'refresh':
|
261
|
+
router.reload()
|
262
|
+
break
|
263
|
+
}
|
264
|
+
})
|
265
|
+
|
266
|
+
return <PostsGrid posts={posts} />
|
267
|
+
}
|
268
|
+
```
|
269
|
+
|
270
|
+
### Notification Pattern
|
271
|
+
|
272
|
+
Show notifications for background updates:
|
273
|
+
|
274
|
+
```tsx
|
275
|
+
import { toast } from 'sonner'
|
276
|
+
|
277
|
+
usePulse(pulseStream, (message) => {
|
278
|
+
// Skip updates from current user to avoid duplicate notifications
|
279
|
+
if (message.requestId === getCurrentRequestId()) return
|
280
|
+
|
281
|
+
switch (message.event) {
|
282
|
+
case 'created':
|
283
|
+
toast.info('New post added')
|
284
|
+
router.reload({ only: ['posts'] })
|
285
|
+
break
|
286
|
+
case 'updated':
|
287
|
+
toast.info(`Post "${message.payload.title}" was updated`)
|
288
|
+
router.reload({ only: ['posts'] })
|
289
|
+
break
|
290
|
+
}
|
291
|
+
})
|
292
|
+
```
|
293
|
+
|
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
|
+
## Security Notes
|
449
|
+
|
450
|
+
1. **Always use scoped streams** - Never broadcast to global channels
|
451
|
+
2. **Stream names are signed** - Users can't subscribe to arbitrary streams
|
452
|
+
3. **Verify authorization** - The channel uses your authentication setup
|
453
|
+
4. **Sanitize payloads** - Don't include sensitive data in broadcasts
|
454
|
+
5. **Use SSL in production** - WebSockets should run over WSS
|
455
|
+
|
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
|
+
## Authentication Setup
|
475
|
+
|
476
|
+
By default, Pulse accepts all WebSocket connections in development. You need to configure authentication based on your setup:
|
477
|
+
|
478
|
+
### Devise Authentication
|
479
|
+
```ruby
|
480
|
+
# app/channels/application_cable/connection.rb
|
481
|
+
def find_verified_user
|
482
|
+
if verified_user = env["warden"]&.user
|
483
|
+
verified_user
|
484
|
+
else
|
485
|
+
reject_unauthorized_connection
|
486
|
+
end
|
487
|
+
end
|
488
|
+
```
|
489
|
+
|
490
|
+
### Session-based Authentication
|
491
|
+
```ruby
|
492
|
+
# app/channels/application_cable/connection.rb
|
493
|
+
def find_verified_user
|
494
|
+
if session[:user_id] && verified_user = User.find_by(id: session[:user_id])
|
495
|
+
verified_user
|
496
|
+
else
|
497
|
+
reject_unauthorized_connection
|
498
|
+
end
|
499
|
+
end
|
500
|
+
```
|
501
|
+
|
502
|
+
### JWT Authentication
|
503
|
+
```ruby
|
504
|
+
# app/channels/application_cable/connection.rb
|
505
|
+
def find_verified_user
|
506
|
+
if verified_user = User.find_by(id: decoded_jwt_user_id)
|
507
|
+
verified_user
|
508
|
+
else
|
509
|
+
reject_unauthorized_connection
|
510
|
+
end
|
511
|
+
end
|
512
|
+
|
513
|
+
private
|
514
|
+
|
515
|
+
def decoded_jwt_user_id
|
516
|
+
token = request.params[:token] || request.headers["Authorization"]&.split(" ")&.last
|
517
|
+
return unless token
|
518
|
+
|
519
|
+
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: "HS256")
|
520
|
+
decoded.first["user_id"]
|
521
|
+
rescue JWT::DecodeError
|
522
|
+
nil
|
523
|
+
end
|
524
|
+
```
|
525
|
+
|
526
|
+
## Next Steps
|
527
|
+
|
528
|
+
- Configure authentication in `app/channels/application_cable/connection.rb`
|
529
|
+
- Try the example in a model: `include Pulse::Broadcastable`
|
530
|
+
- Set up your first broadcast stream
|
531
|
+
- Customize the serializer for your needs
|
532
|
+
- Read about advanced patterns in the main documentation
|
@@ -0,0 +1,66 @@
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
2
|
+
import { subscribeToPulse, PulseMessage, PulseSubscription } from '@/lib/pulse/pulse'
|
3
|
+
import { startPulseMonitor, stopPulseMonitor } from '@/lib/pulse/pulse-connection'
|
4
|
+
|
5
|
+
/**
|
6
|
+
* React hook for subscribing to Pulse real-time updates
|
7
|
+
*
|
8
|
+
* @param signedStreamName - The signed stream name from the backend
|
9
|
+
* @param onMessage - Callback function to handle incoming messages
|
10
|
+
*
|
11
|
+
* @example
|
12
|
+
* ```tsx
|
13
|
+
* usePulse(pulseStream, (message) => {
|
14
|
+
* switch (message.event) {
|
15
|
+
* case 'created':
|
16
|
+
* case 'updated':
|
17
|
+
* case 'deleted':
|
18
|
+
* router.reload({ only: ['posts'] })
|
19
|
+
* break
|
20
|
+
* }
|
21
|
+
* })
|
22
|
+
* ```
|
23
|
+
*/
|
24
|
+
export function usePulse(
|
25
|
+
signedStreamName: string | null | undefined,
|
26
|
+
onMessage: (message: PulseMessage) => void
|
27
|
+
) {
|
28
|
+
const subscriptionRef = useRef<PulseSubscription | null>(null)
|
29
|
+
const callbackRef = useRef(onMessage)
|
30
|
+
|
31
|
+
// Update callback ref to avoid stale closures
|
32
|
+
useEffect(() => {
|
33
|
+
callbackRef.current = onMessage
|
34
|
+
})
|
35
|
+
|
36
|
+
useEffect(() => {
|
37
|
+
// Skip if no stream name
|
38
|
+
if (!signedStreamName) {
|
39
|
+
return
|
40
|
+
}
|
41
|
+
|
42
|
+
// Start connection monitor on first subscription
|
43
|
+
startPulseMonitor()
|
44
|
+
|
45
|
+
// Subscribe to the stream
|
46
|
+
subscriptionRef.current = subscribeToPulse(signedStreamName, (message) => {
|
47
|
+
callbackRef.current(message)
|
48
|
+
})
|
49
|
+
|
50
|
+
// Cleanup function
|
51
|
+
return () => {
|
52
|
+
if (subscriptionRef.current) {
|
53
|
+
subscriptionRef.current.unsubscribe()
|
54
|
+
subscriptionRef.current = null
|
55
|
+
}
|
56
|
+
}
|
57
|
+
}, [signedStreamName])
|
58
|
+
|
59
|
+
// Stop monitor when component unmounts
|
60
|
+
useEffect(() => {
|
61
|
+
return () => {
|
62
|
+
// In a real app, you might want to keep the monitor running
|
63
|
+
// if other components are still using Pulse
|
64
|
+
}
|
65
|
+
}, [])
|
66
|
+
}
|
@@ -0,0 +1,61 @@
|
|
1
|
+
import { useEffect, useRef } from "react"
|
2
|
+
import { createPulseVisibilityManager } from "@/lib/pulse/pulse-visibility-manager"
|
3
|
+
|
4
|
+
/**
|
5
|
+
* Refreshes the page when the browser tab becomes visible after being hidden
|
6
|
+
* for longer than the threshold duration. This prevents unnecessary refreshes
|
7
|
+
* when quickly switching between tabs.
|
8
|
+
*
|
9
|
+
* Uses the same strategies as Facebook/Twitter to handle browser tab suspension:
|
10
|
+
* - Tracks actual hidden duration (not just WebSocket staleness)
|
11
|
+
* - Handles both Page Visibility API and focus/blur events
|
12
|
+
* - Cleans up properly to prevent memory leaks
|
13
|
+
*
|
14
|
+
* @param thresholdSeconds Number of seconds the tab must be hidden before
|
15
|
+
* triggering a refresh on return (default: 30).
|
16
|
+
* @param refresh Callback executed when the page should be refreshed.
|
17
|
+
*
|
18
|
+
* @example
|
19
|
+
* ```tsx
|
20
|
+
* // In your page component
|
21
|
+
* useVisibilityRefresh(30, () => {
|
22
|
+
* router.reload({ only: ['posts'] })
|
23
|
+
* })
|
24
|
+
*
|
25
|
+
* // With custom threshold for critical data
|
26
|
+
* useVisibilityRefresh(15, () => {
|
27
|
+
* router.reload()
|
28
|
+
* })
|
29
|
+
* ```
|
30
|
+
*/
|
31
|
+
export function useVisibilityRefresh(
|
32
|
+
thresholdSeconds: number = 30,
|
33
|
+
refresh: () => void
|
34
|
+
) {
|
35
|
+
const managerRef = useRef<ReturnType<typeof createPulseVisibilityManager> | null>(null)
|
36
|
+
|
37
|
+
useEffect(() => {
|
38
|
+
// Create visibility manager with our config
|
39
|
+
managerRef.current = createPulseVisibilityManager({
|
40
|
+
onVisible: () => {
|
41
|
+
// Tab became visible - manager will check if refresh needed
|
42
|
+
},
|
43
|
+
onHidden: () => {
|
44
|
+
// Tab became hidden - manager tracks this
|
45
|
+
},
|
46
|
+
onStale: () => {
|
47
|
+
// Data is stale, trigger refresh
|
48
|
+
refresh()
|
49
|
+
},
|
50
|
+
staleThreshold: thresholdSeconds
|
51
|
+
})
|
52
|
+
|
53
|
+
// Cleanup on unmount
|
54
|
+
return () => {
|
55
|
+
if (managerRef.current) {
|
56
|
+
managerRef.current.cleanup()
|
57
|
+
managerRef.current = null
|
58
|
+
}
|
59
|
+
}
|
60
|
+
}, [thresholdSeconds, refresh])
|
61
|
+
}
|