Action Cable and WebSockets¶
Configure Navigator to support Rails Action Cable for real-time WebSocket connections, including standalone cable servers and multi-tenant WebSocket routing.
Use Cases¶
- Real-time chat applications
- Live notifications and updates
- Collaborative editing
- Live dashboards and analytics
- Real-time gaming features
- Live streaming data
Basic Action Cable Setup¶
navigator.yml
server:
listen: 3000
public_dir: ./public
# Redis for Action Cable
managed_processes:
- name: redis
command: redis-server
args: [--port, "6379"]
auto_restart: true
# Static files
static:
directories:
- path: /assets/
root: public/assets/
cache: 86400
extensions: [css, js, png, jpg, gif]
# Applications
applications:
global_env:
RAILS_ENV: production
REDIS_URL: redis://localhost:6379/0
CABLE_REDIS_URL: redis://localhost:6379/5 # Separate DB for Cable
tenants:
# Main Rails application
- name: main
path: /
working_dir: /var/www/app
# Action Cable server (WebSocket connections)
- name: cable
path: /cable
working_dir: /var/www/app
force_max_concurrent_requests: 0 # Unlimited for WebSockets
Standalone Action Cable Server¶
Run Action Cable as a separate process for better performance:
navigator.yml
server:
listen: 3000
managed_processes:
- name: redis
command: redis-server
auto_restart: true
# Standalone Action Cable server
- name: action-cable
command: bundle
args: [exec, puma, -p, "28080", cable/config.ru]
working_dir: /var/www/app
env:
RAILS_ENV: production
REDIS_URL: redis://localhost:6379/5
PORT: "28080"
auto_restart: true
start_delay: 2
applications:
global_env:
RAILS_ENV: production
ACTION_CABLE_URL: ws://localhost:3000/cable
tenants:
# Main Rails app
- name: main
path: /
working_dir: /var/www/app
# Proxy /cable to standalone server
- name: cable
path: /cable
standalone_server: "localhost:28080"
Standalone Cable Configuration¶
cable/config.ru
# Standalone Action Cable server
require_relative '../config/environment'
# Configure Action Cable
ActionCable.server.config.cable = {
adapter: 'redis',
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/5'),
channel_prefix: Rails.application.class.module_parent_name.underscore
}
# Disable request forgery protection for WebSocket connections
ActionCable.server.config.disable_request_forgery_protection = true
# Allow connections from any origin in development
if Rails.env.development?
ActionCable.server.config.allowed_request_origins = [/.*/]
end
run ActionCable.server
Rails Action Cable Configuration¶
1. Configure Action Cable¶
config/cable.yml
development:
adapter: redis
url: <%= ENV.fetch('CABLE_REDIS_URL', 'redis://localhost:6379/5') %>
channel_prefix: myapp_development
production:
adapter: redis
url: <%= ENV.fetch('CABLE_REDIS_URL', 'redis://localhost:6379/5') %>
channel_prefix: myapp_production
test:
adapter: test
2. Configure Routes¶
config/routes.rb
Rails.application.routes.draw do
# Mount Action Cable server
mount ActionCable.server => '/cable'
# Other routes...
end
3. Configure Connection¶
app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# Get user from session or token
if verified_user = User.find_by(id: session[:user_id])
verified_user
else
# For API authentication, check Authorization header
if token = request.headers['Authorization']&.remove('Bearer ')
User.find_by(api_token: token)
else
reject_unauthorized_connection
end
end
end
end
end
WebSocket Channel Examples¶
Chat Channel¶
app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
# Subscribe to room-specific stream
stream_from "chat_#{params[:room]}"
# Track user presence
Redis.current.sadd("chat_users_#{params[:room]}", current_user.id)
broadcast_user_joined
end
def unsubscribed
# Remove user from presence tracking
Redis.current.srem("chat_users_#{params[:room]}", current_user.id)
broadcast_user_left
end
def speak(data)
# Validate and save message
message = Message.create!(
user: current_user,
room: params[:room],
content: data['message']
)
# Broadcast to all room subscribers
ActionCable.server.broadcast(
"chat_#{params[:room]}",
{
type: 'message',
message: message.content,
user: current_user.name,
timestamp: message.created_at.iso8601
}
)
end
private
def broadcast_user_joined
ActionCable.server.broadcast(
"chat_#{params[:room]}",
{
type: 'user_joined',
user: current_user.name,
users_count: Redis.current.scard("chat_users_#{params[:room]}")
}
)
end
def broadcast_user_left
ActionCable.server.broadcast(
"chat_#{params[:room]}",
{
type: 'user_left',
user: current_user.name,
users_count: Redis.current.scard("chat_users_#{params[:room]}")
}
)
end
end
Notification Channel¶
app/channels/notification_channel.rb
class NotificationChannel < ApplicationCable::Channel
def subscribed
# Subscribe to user-specific notifications
stream_from "notifications_#{current_user.id}"
end
def mark_read(data)
notification = current_user.notifications.find(data['id'])
notification.update(read_at: Time.current)
end
end
Live Updates Channel¶
app/channels/live_updates_channel.rb
class LiveUpdatesChannel < ApplicationCable::Channel
def subscribed
# Subscribe to model updates
stream_from "updates_#{params[:model]}_#{params[:id]}"
end
end
# Trigger updates from models
class Post < ApplicationRecord
after_update_commit do
ActionCable.server.broadcast(
"updates_post_#{id}",
{
type: 'update',
model: 'post',
id: id,
attributes: slice(:title, :content, :updated_at)
}
)
end
end
JavaScript Client Setup¶
Basic Connection¶
// app/assets/javascripts/cable.js
import { createConsumer } from "@rails/actioncable"
// Create WebSocket connection
const cable = createConsumer("/cable")
// Subscribe to chat channel
const chatChannel = cable.subscriptions.create(
{ channel: "ChatChannel", room: "general" },
{
connected() {
console.log("Connected to chat channel")
},
disconnected() {
console.log("Disconnected from chat channel")
},
received(data) {
switch(data.type) {
case 'message':
addMessageToDOM(data)
break
case 'user_joined':
updateUserCount(data.users_count)
showNotification(`${data.user} joined`)
break
case 'user_left':
updateUserCount(data.users_count)
showNotification(`${data.user} left`)
break
}
},
speak(message) {
this.perform('speak', { message: message })
}
}
)
// Send message
function sendMessage() {
const input = document.getElementById('message-input')
const message = input.value.trim()
if (message) {
chatChannel.speak(message)
input.value = ''
}
}
Authentication Token¶
// For API-based authentication
import { createConsumer } from "@rails/actioncable"
const token = localStorage.getItem('auth_token')
const cable = createConsumer(`/cable?token=${token}`)
React Integration¶
// React hook for Action Cable
import { useEffect, useState } from 'react'
import { createConsumer } from '@rails/actioncable'
export function useActionCable(channelName, params = {}) {
const [cable] = useState(() => createConsumer('/cable'))
const [connected, setConnected] = useState(false)
const [subscription, setSubscription] = useState(null)
useEffect(() => {
const sub = cable.subscriptions.create(
{ channel: channelName, ...params },
{
connected: () => setConnected(true),
disconnected: () => setConnected(false),
received: (data) => {
// Handle received data
console.log('Received:', data)
}
}
)
setSubscription(sub)
return () => {
sub.unsubscribe()
}
}, [cable, channelName, params])
return { connected, subscription }
}
// Usage in component
function ChatRoom({ roomId }) {
const { connected, subscription } = useActionCable('ChatChannel', { room: roomId })
const sendMessage = (message) => {
subscription?.perform('speak', { message })
}
return (
<div>
<div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
{/* Chat UI */}
</div>
)
}
Multi-Tenant WebSocket Configuration¶
Tenant-Specific Channels¶
navigator.yml
applications:
tenants:
# Tenant-specific cable endpoints
- name: tenant1-cable
path: /tenant1/cable
match_pattern: "/tenant1/cable"
working_dir: /var/www/app
env:
TENANT_ID: tenant1
CABLE_REDIS_DB: "6"
force_max_concurrent_requests: 0
- name: tenant2-cable
path: /tenant2/cable
match_pattern: "/tenant2/cable"
working_dir: /var/www/app
env:
TENANT_ID: tenant2
CABLE_REDIS_DB: "7"
force_max_concurrent_requests: 0
Dynamic Channel Routing¶
app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user, :tenant_id
def connect
self.current_user = find_verified_user
self.tenant_id = extract_tenant_id
end
private
def extract_tenant_id
# Extract tenant from path: /tenant1/cable
path_segments = request.path.split('/')
tenant_segment = path_segments[1]
if Tenant.exists?(slug: tenant_segment)
tenant_segment
else
reject_unauthorized_connection
end
end
end
end
Performance Optimization¶
Connection Limits¶
navigator.yml
applications:
tenants:
- name: cable
path: /cable
# Unlimited concurrent connections for WebSockets
force_max_concurrent_requests: 0
# But limit Rails processes to prevent resource exhaustion
max_processes: 2
idle_timeout: 300
Redis Optimization¶
managed_processes:
- name: redis
command: redis-server
args:
# Optimize for pub/sub workload
- --maxclients 10000
- --timeout 0
- --tcp-keepalive 60
- --tcp-backlog 511
# Pub/sub specific optimizations
- --client-output-buffer-limit "pubsub 32mb 8mb 60"
- --hz 10
Action Cable Configuration¶
config/environments/production.rb
Rails.application.configure do
# Action Cable configuration
config.action_cable.url = ENV.fetch('ACTION_CABLE_URL', 'ws://localhost:3000/cable')
config.action_cable.allowed_request_origins = [
ENV.fetch('ALLOWED_ORIGINS', 'https://example.com').split(',')
].flatten
# Disable request forgery protection for API usage
config.action_cable.disable_request_forgery_protection = ENV['DISABLE_CABLE_CSRF'] == 'true'
# Connection pool settings
config.action_cable.connection_class = -> { ApplicationCable::Connection }
config.action_cable.worker_pool_size = ENV.fetch('CABLE_WORKER_POOL_SIZE', 4).to_i
end
Monitoring and Debugging¶
WebSocket Health Check¶
app/controllers/health_controller.rb
class HealthController < ApplicationController
def cable
# Test Action Cable connection
begin
ActionCable.server.broadcast('health_check', { timestamp: Time.current })
render json: { status: 'healthy', cable: 'connected' }
rescue => e
render json: {
status: 'unhealthy',
cable: 'disconnected',
error: e.message
}, status: :service_unavailable
end
end
end
Connection Monitoring¶
app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
def connect
Rails.logger.info "WebSocket connection attempt from #{request.remote_ip}"
self.current_user = find_verified_user
Rails.logger.info "WebSocket connected for user #{current_user.id}"
end
def disconnect
Rails.logger.info "WebSocket disconnected for user #{current_user&.id}"
end
end
end
Redis Monitoring¶
# Monitor Redis pub/sub activity
redis-cli monitor | grep -E "(PUBLISH|SUBSCRIBE)"
# Check Action Cable activity
redis-cli pubsub channels "*"
redis-cli pubsub numsub "chat_general"
Security Considerations¶
Origin Validation¶
config/environments/production.rb
Rails.application.configure do
config.action_cable.allowed_request_origins = [
'https://myapp.com',
'https://www.myapp.com',
/https:\/\/.*\.myapp\.com/
]
end
Token Authentication¶
app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# Check for token in query params or headers
token = request.params[:token] || request.headers['Authorization']&.remove('Bearer ')
if token && (user = User.find_by(api_token: token))
user
else
reject_unauthorized_connection
end
end
end
end
Rate Limiting¶
app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def speak(data)
# Rate limiting: max 10 messages per minute per user
rate_limit_key = "chat_rate_limit:#{current_user.id}"
current_count = Redis.current.get(rate_limit_key).to_i
if current_count >= 10
# Send error to client
transmit({ error: 'Rate limit exceeded. Please slow down.' })
return
end
# Increment counter with expiration
Redis.current.multi do |multi|
multi.incr(rate_limit_key)
multi.expire(rate_limit_key, 60)
end
# Process message...
end
end
Troubleshooting¶
Connection Issues¶
-
Check WebSocket upgrade:
-
Verify Redis pub/sub:
-
Check Navigator logs:
Performance Issues¶
-
Monitor connection count:
-
Check Redis memory usage:
-
Monitor message throughput:
Testing¶
Test WebSocket Connection¶
test/channels/chat_channel_test.rb
require 'test_helper'
class ChatChannelTest < ActionCable::Channel::TestCase
test "subscribes to stream" do
user = users(:alice)
subscribe(room: "general")
assert subscription.confirmed?
assert_has_stream "chat_general"
end
test "speaks message" do
user = users(:alice)
subscribe(room: "general")
perform :speak, message: "Hello world"
assert_broadcast_on("chat_general", {
type: 'message',
message: "Hello world",
user: user.name
})
end
end
Integration Testing¶
test/integration/action_cable_test.rb
require 'test_helper'
class ActionCableTest < ActionDispatch::IntegrationTest
test "cable server is accessible" do
get "/cable"
assert_response :success
end
end