Project

pulse_zero

0.0
No release in over 3 years
Generate a complete real-time broadcasting system for Rails applications using Inertia.js with React. All code is generated into your project with zero runtime dependencies.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

>= 7.0, < 9
~> 1.0
 Project Readme

Pulse Zero

Real-time broadcasting generator for Rails + Inertia.js applications. Generate a complete WebSocket-based real-time system with zero runtime dependencies.

What is Pulse Zero?

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.

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.

Features:

  • 🚀 WebSocket broadcasting via ActionCable
  • 🔒 Secure signed streams
  • 📱 Browser tab suspension handling
  • 🔄 Automatic reconnection with exponential backoff
  • 📦 TypeScript support for Inertia + React
  • 🎯 Zero runtime dependencies
  • 🏗️ Turbo Rails-inspired API design

Installation

Add this gem to your application's Gemfile:

group :development do
  gem 'pulse_zero'
end

Then run:

bundle install
rails generate pulse_zero:install

What Gets Generated?

Backend (Ruby)

  • lib/pulse/ - Core broadcasting system
  • app/models/concerns/pulse/broadcastable.rb - Model broadcasting DSL
  • app/controllers/concerns/pulse/request_id_tracking.rb - Request tracking
  • app/channels/pulse/channel.rb - WebSocket channel
  • app/jobs/pulse/broadcast_job.rb - Async broadcasting
  • config/initializers/pulse.rb - Configuration

Frontend (TypeScript)

  • app/frontend/lib/pulse.ts - Subscription manager
  • app/frontend/lib/pulse-connection.ts - Connection monitoring
  • app/frontend/lib/pulse-recovery-strategy.ts - Recovery logic
  • app/frontend/lib/pulse-visibility-manager.ts - Tab visibility handling
  • app/frontend/hooks/use-pulse.ts - React subscription hook
  • app/frontend/hooks/use-visibility-refresh.ts - Tab refresh hook

Quick Start

1. Enable Broadcasting on a Model

class Post < ApplicationRecord
  include Pulse::Broadcastable
  
  # Broadcast to a simple channel
  broadcasts_to ->(post) { "posts" }
  
  # Or broadcast to account-scoped channel
  # broadcasts_to ->(post) { [post.account, "posts"] }
end

2. Pass Stream Token to Frontend

class PostsController < ApplicationController
  def index
    @posts = Post.all

    render inertia: "Post/Index", props: {
      posts: @posts.map do |post|
        serialize_post(post)
      end,
      pulseStream: Pulse::Streams::StreamName.signed_stream_name("posts")
    }
  end
  
  private
  
  def serialize_post(post)
    {
      id: post.id,
      title: post.title,
      content: post.content,
      created_at: post.created_at
    }
  end
end

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:

  • Maintains security through cryptographically signed tokens
  • Works naturally with Inertia's prop system
  • Keeps the API simple and explicit

3. Subscribe in React Component

import { useState } from 'react'
import { usePulse } from '@/hooks/use-pulse'
import { useVisibilityRefresh } from '@/hooks/use-visibility-refresh'
import { router } from '@inertiajs/react'

interface IndexProps {
  posts: Array<{
    id: number
    title: string
    content: string
    created_at: string
  }>
  pulseStream: string
  flash: {
    success?: string
    error?: string
  }
}

export default function Index({ posts: initialPosts, flash, pulseStream }: IndexProps) {
  // Use local state for posts to enable real-time updates
  const [posts, setPosts] = useState(initialPosts)
  
  // Automatically refresh data when returning to the tab after 30+ seconds
  // This ensures users see fresh data after being away, handling cases where
  // WebSocket messages might have been missed during browser suspension
  useVisibilityRefresh(30, () => {
    router.reload({ only: ['posts'] })
  })

  // Subscribe to Pulse updates for real-time changes
  usePulse(pulseStream, (message) => {
    switch (message.event) {
      case 'created':
        // Add the new post to the beginning of the list
        setPosts(prev => [message.payload, ...prev])
        break
      case 'updated':
        // Replace the updated post in the list
        setPosts(prev => 
          prev.map(post => post.id === message.payload.id ? message.payload : post)
        )
        break
      case 'deleted':
        // Remove the deleted post from the list
        setPosts(prev => 
          prev.filter(post => post.id !== message.payload.id)
        )
        break
      case 'refresh':
        // Full reload for refresh events
        router.reload()
        break
    }
  })
  
  return (
    <>
      {flash.success && <div className="alert-success">{flash.success}</div>}
      <PostsList posts={posts} />
    </>
  )
}

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.

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.

Broadcasting Events

Pulse broadcasts four types of events:

created - When a record is created

{
  "event": "created",
  "payload": { "id": 123, "content": "New post" },
  "requestId": "uuid-123",
  "at": 1234567890.123
}

updated - When a record is updated

{
  "event": "updated",
  "payload": { "id": 123, "content": "Updated post" },
  "requestId": "uuid-456",
  "at": 1234567891.456
}

deleted - When a record is destroyed

{
  "event": "deleted",
  "payload": { "id": 123 },
  "requestId": "uuid-789",
  "at": 1234567892.789
}

refresh - Force a full refresh

{
  "event": "refresh",
  "payload": {},
  "requestId": "uuid-012",
  "at": 1234567893.012
}

Advanced Usage

Manual Broadcasting

# Broadcast with custom payload
post.broadcast_updated_to(
  [Current.account, "posts"],
  payload: { id: post.id, featured: true }
)

# Async broadcasting
post.broadcast_updated_later_to([Current.account, "posts"])

Suppress Broadcasts During Bulk Operations

Post.suppressing_pulse_broadcasts do
  Post.where(account: account).update_all(featured: true)
end

# Then send one refresh broadcast
Post.new.broadcast_refresh_to([account, "posts"])

Custom Serialization

# config/initializers/pulse.rb
Rails.application.configure do
  config.pulse.serializer = ->(record) {
    case record
    when Post
      record.as_json(only: [:id, :title, :state])
    else
      record.as_json
    end
  }
end

Configuration

# config/initializers/pulse.rb
Rails.application.configure do
  # Debounce window in milliseconds (default: 300)
  config.pulse.debounce_ms = 300
  
  # Background job queue (default: :default)
  config.pulse.queue_name = :low
  
  # Custom serializer
  config.pulse.serializer = ->(record) { record.as_json }
end

Browser Tab Handling

Pulse includes sophisticated handling for browser tab suspension:

  • Quick switches (<30s): Just ensures connection is alive
  • Medium absence (30s-5min): Reconnects and syncs data
  • Long absence (>5min): Full page refresh for consistency

Platform-aware thresholds:

  • Desktop Chrome/Firefox: 30 seconds
  • Safari/Mobile: 15 seconds (more aggressive)

Testing

# In your test files
test "broadcasts on update" do
  post = posts(:one)
  
  assert_broadcast_on([post.account, "posts"]) do
    post.update!(title: "New Title")
  end
end

# Suppress broadcasts in tests
Post.suppressing_pulse_broadcasts do
  # Your test code
end

Debugging

Enable debug logging:

// In browser console
localStorage.setItem('PULSE_DEBUG', 'true')

Check connection health:

import { getPulseMonitorStats } from '@/lib/pulse-connection'

const stats = getPulseMonitorStats()
console.log(stats)

Requirements

  • Rails 7.0+
  • ActionCable
  • Inertia.js
  • React (Vue/Svelte support coming soon)
  • TypeScript

Philosophy

Pulse Zero follows the same philosophy as authentication-zero, and is heavily inspired by Turbo Rails. The API design closely mirrors Turbo Rails patterns, making it intuitive for developers already familiar with the Hotwire ecosystem.

  • Own your code: All code is generated into your project
  • No runtime dependencies: The gem is only needed during generation
  • Customizable: Modify any generated code to fit your needs
  • Production-ready: Includes battle-tested patterns from real applications
  • Familiar API: Inspired by Turbo Rails, uses similar broadcasting patterns and conventions

Contributing

Bug reports and pull requests are welcome on GitHub.

License

The gem is available as open source under the terms of the MIT License.