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.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +91 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +281 -0
  5. data/lib/generators/pulse_zero/install/install_generator.rb +186 -0
  6. data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/channel.rb.tt +6 -0
  7. data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/connection.rb.tt +59 -0
  8. data/lib/generators/pulse_zero/install/templates/backend/app/channels/pulse/channel.rb.tt +15 -0
  9. data/lib/generators/pulse_zero/install/templates/backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt +17 -0
  10. data/lib/generators/pulse_zero/install/templates/backend/app/jobs/pulse/broadcast_job.rb.tt +28 -0
  11. data/lib/generators/pulse_zero/install/templates/backend/app/models/concerns/pulse/broadcastable.rb.tt +85 -0
  12. data/lib/generators/pulse_zero/install/templates/backend/app/models/current.rb.tt +9 -0
  13. data/lib/generators/pulse_zero/install/templates/backend/config/initializers/pulse.rb.tt +43 -0
  14. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/engine.rb.tt +43 -0
  15. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/broadcasts.rb.tt +80 -0
  16. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/stream_name.rb.tt +34 -0
  17. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/thread_debouncer.rb.tt +31 -0
  18. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse.rb.tt +38 -0
  19. data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +532 -0
  20. data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-pulse.ts.tt +66 -0
  21. data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-visibility-refresh.ts.tt +61 -0
  22. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-connection.ts.tt +169 -0
  23. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-recovery-strategy.ts.tt +156 -0
  24. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-visibility-manager.ts.tt +143 -0
  25. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse.ts.tt +130 -0
  26. data/lib/pulse_zero/engine.rb +10 -0
  27. data/lib/pulse_zero/version.rb +5 -0
  28. data/lib/pulse_zero.rb +13 -0
  29. data/pulse_zero.gemspec +35 -0
  30. 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
+ }