1.4.1

PubSub Adapters

PubSub adapters provide the messaging backend for WebSocket broadcasting and real-time communication. They enable features like chat rooms, live updates, and presence tracking.

PubSub Adapter Interface

All pub/sub adapters must implement the abstract PubSubAdapter class:

abstract class Amber::Adapters::PubSubAdapter
  abstract def publish(topic : String, sender_id : String, message : JSON::Any) : Nil
  abstract def subscribe(topic : String, &block : (String, JSON::Any) -> Nil) : Nil
  abstract def unsubscribe(topic : String) : Nil
end

Built-in Memory Adapter

The MemoryPubSubAdapter is the default:

# Automatically used when adapter: "memory"
class Amber::Adapters::MemoryPubSubAdapter < PubSubAdapter
  # Fiber-based async message delivery
  # Perfect for single-server deployments
end

When to Use Custom Adapters

Memory adapter works great for:

  • Development environments
  • Single-server deployments
  • Testing

Use custom adapters when:

  • Running multiple application servers
  • Need message persistence
  • Requiring exactly-once delivery
  • Scaling horizontally

Creating a Custom Adapter

Redis PubSub Adapter Example

# src/adapters/redis_pubsub_adapter.cr
require "redis"

class RedisPubSubAdapter < Amber::Adapters::PubSubAdapter
  @subscriptions = {} of String => Redis::Subscription

  def initialize(@redis : Redis::PooledClient)
    @pubsub_redis = Redis.new(url: ENV["REDIS_URL"])
  end

  def publish(topic : String, sender_id : String, message : JSON::Any) : Nil
    payload = {
      sender_id: sender_id,
      message: message,
      timestamp: Time.utc.to_unix
    }.to_json

    @redis.publish(channel(topic), payload)
  end

  def subscribe(topic : String, &block : (String, JSON::Any) -> Nil) : Nil
    spawn do
      @pubsub_redis.subscribe(channel(topic)) do |on|
        on.message do |channel, payload|
          data = JSON.parse(payload)
          sender_id = data["sender_id"].as_s
          message = data["message"]
          block.call(sender_id, message)
        end
      end
    end
  end

  def unsubscribe(topic : String) : Nil
    @pubsub_redis.unsubscribe(channel(topic))
  end

  private def channel(topic : String)
    "pubsub:#{topic}"
  end
end

PostgreSQL LISTEN/NOTIFY Adapter

# src/adapters/postgres_pubsub_adapter.cr

class PostgresPubSubAdapter < Amber::Adapters::PubSubAdapter
  @listeners = {} of String => Fiber

  def initialize(@connection : DB::Database)
    @notify_conn = DB.open(ENV["DATABASE_URL"])
  end

  def publish(topic : String, sender_id : String, message : JSON::Any) : Nil
    payload = {sender_id: sender_id, message: message}.to_json
    @connection.exec("SELECT pg_notify($1, $2)", topic, payload)
  end

  def subscribe(topic : String, &block : (String, JSON::Any) -> Nil) : Nil
    @listeners[topic] = spawn do
      @notify_conn.using_connection do |conn|
        conn.exec("LISTEN #{topic}")
        conn.on_notification do |notification|
          if notification.channel == topic
            data = JSON.parse(notification.payload)
            block.call(data["sender_id"].as_s, data["message"])
          end
        end
      end
    end
  end

  def unsubscribe(topic : String) : Nil
    @notify_conn.exec("UNLISTEN #{topic}")
    @listeners.delete(topic)
  end
end

Registering Custom Adapters

# config/initializers/adapters.cr
require "../src/adapters/redis_pubsub_adapter"

Amber::Adapters::AdapterFactory.register_pubsub_adapter("redis") do
  redis = Redis::PooledClient.new(url: ENV["REDIS_URL"])
  RedisPubSubAdapter.new(redis)
end

Configuration

# config/environments/production.yml
pubsub:
  adapter: "redis"

Using PubSub in WebSocket Channels

# src/channels/chat_channel.cr
class ChatChannel < Amber::WebSockets::Channel
  def subscribed
    stream_from "chat_room_#{params["room_id"]}"
  end

  def receive(message)
    # Broadcast to all subscribers via adapter
    broadcast("chat_room_#{params["room_id"]}", message)
  end

  def unsubscribed
    stop_streaming_from "chat_room_#{params["room_id"]}"
  end
end

Multi-Server Broadcasting

With a Redis or database adapter, broadcasts work across servers:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Server 1  │     │    Redis    │     │   Server 2  │
│  (Users A)  │────▶│   PubSub    │◀────│  (Users B)  │
└─────────────┘     └─────────────┘     └─────────────┘
       │                   │                   │
       ▼                   ▼                   ▼
   User A1             Broadcast           User B1
   User A2                                 User B2

When User A1 sends a message:

  1. Server 1 publishes to Redis
  2. Redis broadcasts to all subscribers
  3. Server 2 receives and delivers to Users B

Presence Tracking

Implement presence with your adapter:

class PresenceChannel < Amber::WebSockets::Channel
  def subscribed
    track_presence("room_#{params["room_id"]}", current_user.id)
    broadcast_presence
  end

  def unsubscribed
    untrack_presence("room_#{params["room_id"]}", current_user.id)
    broadcast_presence
  end

  private def broadcast_presence
    presence = get_presence("room_#{params["room_id"]}")
    broadcast("presence_#{params["room_id"]}", {users: presence})
  end
end

Testing

Use mock adapter for tests:

class MockPubSubAdapter < Amber::Adapters::PubSubAdapter
  property published = [] of {String, String, JSON::Any}

  def publish(topic : String, sender_id : String, message : JSON::Any) : Nil
    published << {topic, sender_id, message}
  end

  def subscribe(topic : String, &block : (String, JSON::Any) -> Nil) : Nil
    # No-op for tests
  end

  def unsubscribe(topic : String) : Nil
    # No-op for tests
  end
end

# In tests
it "broadcasts message" do
  adapter = MockPubSubAdapter.new
  channel = ChatChannel.new(adapter)

  channel.receive({"text" => "Hello"})

  adapter.published.size.should eq(1)
  adapter.published.first[2]["text"].should eq("Hello")
end