Skip to content

magland/notifyrelay

Repository files navigation

notifyrelay

A simple, Heroku-deployable Node.js pub/sub messaging service for topic-based message delivery with long polling support. Designed to enable Cloudflare Workers to publish messages and Python clients (behind firewalls) to receive them via long polling.

Features

  • Topic-based messaging: Dynamic topics with no pre-configuration needed
  • True long polling: Requests stay open up to 55 seconds waiting for messages
  • Smart batching: 200ms buffer after first message to batch multiple messages
  • Exactly-once delivery: Each message is delivered only once per subscriber
  • Message expiry: Messages expire after 10 seconds
  • In-memory storage: Fast, simple, no database required
  • Active subscriber tracking: See who's currently subscribed
  • Authentication: Separate keys for publishers and subscribers

API Endpoints

POST /publish

Publish a message to a topic.

Authentication: Requires PUBLISH_KEY via Authorization header.

Request Body:

{
  "topic": "example-topic",
  "message": "your-opaque-message-string"
}

Response:

{
  "success": true,
  "messageId": "unique-message-id"
}

POST /poll

Long-polling endpoint for subscribers to receive messages.

Authentication: Requires SUBSCRIBE_KEY via Authorization header.

Request Body:

{
  "subscriber_id": "unique-subscriber-id",
  "subscriber_name": "my-process-name",
  "topics": ["topic1", "topic2"],
  "timeout": 30000
}

Parameters:

  • subscriber_id (required): Unique identifier for this subscriber
  • subscriber_name (required): Human-readable name for this subscriber
  • topics (required): Array of topic strings to subscribe to
  • timeout (optional): Maximum long poll duration in milliseconds (default: 55000, max: 55000)

Response (after ~1 second wait):

{
  "messages": {
    "topic1": [
      {
        "message": "message-content",
        "timestamp": "2024-01-01T12:00:00.000Z"
      }
    ],
    "topic2": [
      {
        "message": "another-message",
        "timestamp": "2024-01-01T12:00:01.000Z"
      }
    ]
  },
  "count": 2
}

Behavior:

  • True long polling: Keeps connection open up to 55 seconds waiting for messages
  • When messages arrive, waits 200ms to batch additional messages before responding
  • Returns all undelivered, unexpired messages matching the subscriber's topics
  • Messages are delivered in per-topic order
  • Only one active poll per subscriber (new poll cancels previous)
  • Updates subscriber's last seen time and topic list
  • Responds immediately if messages are already available when poll starts

GET /subscribers

Public endpoint to list active subscribers.

Authentication: None required.

Response:

{
  "subscribers": [
    {
      "subscriber_id": "abc123",
      "subscriber_name": "my-process",
      "last_seen": "2024-01-01T12:00:00.000Z"
    }
  ]
}

Returns subscribers who have polled within the last 30 seconds.

GET /

Health check and status endpoint.

Response:

{
  "service": "notifyrelay",
  "status": "running",
  "uptime": 123.45,
  "stats": {
    "messages": 5,
    "subscribers": 2,
    "activePolls": 1
  }
}

Installation & Local Development

Prerequisites

  • Node.js >= 18.0.0
  • npm

Setup

  1. Clone the repository

  2. Install dependencies:

    npm install
  3. Set environment variables:

    export PUBLISH_KEY="your-secret-publish-key"
    export SUBSCRIBE_KEY="your-secret-subscribe-key"
    export PORT=3000  # optional, defaults to 3000
  4. Start the server:

    npm start

Heroku Deployment

Prerequisites

  • Heroku CLI installed
  • Git repository initialized (git init if not already done)
  • Heroku account created

Deployment Steps

  1. Initialize Git repository (if not already done):

    git init
    git add .
    git commit -m "Initial commit"
  2. Create Heroku app:

    heroku create your-app-name

    This creates a new Heroku app and adds a heroku remote to your git repository.

  3. Set required environment variables:

    heroku config:set PUBLISH_KEY="your-secret-publish-key"
    heroku config:set SUBSCRIBE_KEY="your-secret-subscribe-key"

    Important: Generate strong, random keys for production:

    # Generate random keys (example using openssl)
    PUBLISH_KEY=$(openssl rand -base64 32)
    SUBSCRIBE_KEY=$(openssl rand -base64 32)
    
    heroku config:set PUBLISH_KEY="$PUBLISH_KEY"
    heroku config:set SUBSCRIBE_KEY="$SUBSCRIBE_KEY"
    
    # Save these keys securely for your clients
    echo "PUBLISH_KEY=$PUBLISH_KEY"
    echo "SUBSCRIBE_KEY=$SUBSCRIBE_KEY"
  4. Deploy to Heroku:

    git push heroku main

    If your default branch is master:

    git push heroku master
  5. Verify deployment:

    # Check app status
    heroku ps
    
    # View logs
    heroku logs --tail
    
    # Open app in browser
    heroku open

Post-Deployment Verification

  1. Health check - Verify the service is running:

    curl https://your-app-name.herokuapp.com/

    Expected response:

    {
      "service": "notifyrelay",
      "status": "running",
      "uptime": 123.45,
      "stats": {
        "messages": 0,
        "subscribers": 0,
        "activePolls": 0
      }
    }
  2. Test publish - Send a test message:

    curl -X POST https://your-app-name.herokuapp.com/publish \
      -H "Authorization: your-publish-key" \
      -H "Content-Type: application/json" \
      -d '{"topic":"test","message":"Hello from Heroku!"}'

    Expected response:

    {
      "success": true,
      "messageId": "..."
    }
  3. Test subscribe - Poll for messages:

    curl -X POST https://your-app-name.herokuapp.com/poll \
      -H "Authorization: your-subscribe-key" \
      -H "Content-Type: application/json" \
      -d '{"subscriber_id":"test-sub","subscriber_name":"CLI Test","topics":["test"]}'

Environment Variables

Variable Required Description
PORT No Port to listen on (Heroku sets this automatically)
PUBLISH_KEY Yes Secret key for publishing messages
SUBSCRIBE_KEY Yes Secret key for subscribing to messages

Troubleshooting

App crashes on startup:

# Check logs for errors
heroku logs --tail

# Verify environment variables are set
heroku config

# Restart the app
heroku restart

Common issues:

  • Missing environment variables: Ensure both PUBLISH_KEY and SUBSCRIBE_KEY are set
  • Build failures: Check package.json dependencies are correct
  • Port binding errors: Don't hardcode PORT; let Heroku set it via environment variable

View detailed logs:

# Last 100 lines
heroku logs -n 100

# Real-time logs
heroku logs --tail

# Filter by dyno/process
heroku logs --ps web

Scaling & Performance

Dyno recommendations:

  • Free tier: Sufficient for development/testing (sleeps after 30 min inactivity)
  • Hobby tier ($7/month): Recommended for production (no sleeping, SSL included)
  • Standard tier: For higher traffic needs

Note: This service is designed as a single-instance application due to in-memory storage. Horizontal scaling (multiple dynos) is not supported.

Upgrade dyno type:

heroku ps:type hobby

Updating Your Deployment

Push updates to Heroku:

git add .
git commit -m "Your update message"
git push heroku main

The app will automatically restart with the new code.

Python Client Library

A companion Python client library is available in the python-client/ directory. It provides a queue-based message retrieval system designed for easy integration into existing Python applications.

Features:

  • Queue-based message retrieval (no callbacks required)
  • Background polling thread with thread-safe operations
  • Optional automatic JSON deserialization per topic
  • Context manager support for clean resource management
  • Simple API for both publishing and subscribing

Quick Example:

from notifyrelay import NotifyRelayClient

# Create client and subscriber
client = NotifyRelayClient(
    base_url="https://your-app.herokuapp.com",
    subscribe_key="your-subscribe-key"
)
subscriber = client.create_subscriber("my-app-id", "My App")
subscriber.subscribe(["alerts", "logs"])
subscriber.start()

# In your main application loop
while running:
    # Do your application work...
    
    # Check for new messages (non-blocking)
    messages = subscriber.get_messages()
    for msg in messages:
        print(f"[{msg['topic']}] {msg['message']}")

See python-client/README.md for full documentation and examples.

Installation:

cd python-client
pip install -e .

Usage Examples

Publishing from Cloudflare Worker

async function publishMessage(topic, message) {
  const response = await fetch('https://your-app.herokuapp.com/publish', {
    method: 'POST',
    headers: {
      'Authorization': 'your-publish-key',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ topic, message })
  });
  return await response.json();
}

Subscribing from Python (using Python client library)

from notifyrelay import NotifyRelayClient

client = NotifyRelayClient(
    base_url="https://your-app.herokuapp.com",
    subscribe_key="your-subscribe-key"
)

subscriber = client.create_subscriber("my-unique-id", "my-python-client")
subscriber.subscribe(["topic1", "topic2"])
subscriber.start()

while True:
    # Check for messages (non-blocking)
    messages = subscriber.get_messages()
    
    if messages:
        print(f"Received {len(messages)} messages:")
        for msg in messages:
            print(f"  [{msg['topic']}] {msg['message']}")
    
    time.sleep(0.1)

Using the API directly (without the Python client library):

import requests
import time

SUBSCRIBE_KEY = "your-subscribe-key"
BASE_URL = "https://your-app.herokuapp.com"

subscriber_id = "my-unique-id"
subscriber_name = "my-python-client"
topics = ["topic1", "topic2"]

while True:
    try:
        response = requests.post(
            f"{BASE_URL}/poll",
            headers={"Authorization": SUBSCRIBE_KEY},
            json={
                "subscriber_id": subscriber_id,
                "subscriber_name": subscriber_name,
                "topics": topics
            },
            timeout=60
        )
        
        data = response.json()
        if data.get("count", 0) > 0:
            print(f"Received {data['count']} messages:")
            for topic, messages in data["messages"].items():
                for msg in messages:
                    print(f"  [{topic}] {msg['message']}")
        
    except Exception as e:
        print(f"Error: {e}")
        time.sleep(1)

Architecture

In-Memory Data Structures

  • Messages: Array of message objects with topic, content, timestamp, and delivery tracking
  • Subscribers: Map of subscriber info including ID, name, topics, and last seen time
  • Active Polls: Map tracking ongoing long-poll requests per subscriber

Message Lifecycle

  1. Message published to topic
  2. Stored in memory with timestamp
  3. Delivered to subscribers on their next poll
  4. Marked as delivered to each subscriber (exactly-once guarantee)
  5. Expired and removed after 10 seconds

Background Cleanup

  • Message cleanup: Runs every 5 seconds, removes messages older than 10 seconds
  • Subscriber cleanup: Runs every 60 seconds, removes inactive subscribers (>5 minutes)

Limitations

  • In-memory only: Messages and subscribers are lost on restart
  • Single instance: Not designed for horizontal scaling
  • No persistence: No database, all data in RAM
  • No rate limiting: Expects Cloudflare or other upstream rate limiting

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors