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.
- 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
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"
}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 subscribersubscriber_name(required): Human-readable name for this subscribertopics(required): Array of topic strings to subscribe totimeout(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
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.
Health check and status endpoint.
Response:
{
"service": "notifyrelay",
"status": "running",
"uptime": 123.45,
"stats": {
"messages": 5,
"subscribers": 2,
"activePolls": 1
}
}- Node.js >= 18.0.0
- npm
-
Clone the repository
-
Install dependencies:
npm install
-
Set environment variables:
export PUBLISH_KEY="your-secret-publish-key" export SUBSCRIBE_KEY="your-secret-subscribe-key" export PORT=3000 # optional, defaults to 3000
-
Start the server:
npm start
- Heroku CLI installed
- Git repository initialized (
git initif not already done) - Heroku account created
-
Initialize Git repository (if not already done):
git init git add . git commit -m "Initial commit"
-
Create Heroku app:
heroku create your-app-name
This creates a new Heroku app and adds a
herokuremote to your git repository. -
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"
-
Deploy to Heroku:
git push heroku main
If your default branch is
master:git push heroku master
-
Verify deployment:
# Check app status heroku ps # View logs heroku logs --tail # Open app in browser heroku open
-
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 } } -
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": "..." } -
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"]}'
| 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 |
App crashes on startup:
# Check logs for errors
heroku logs --tail
# Verify environment variables are set
heroku config
# Restart the app
heroku restartCommon issues:
- Missing environment variables: Ensure both
PUBLISH_KEYandSUBSCRIBE_KEYare set - Build failures: Check
package.jsondependencies 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 webDyno 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 hobbyPush updates to Heroku:
git add .
git commit -m "Your update message"
git push heroku mainThe app will automatically restart with the new code.
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 .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();
}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)- 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 published to topic
- Stored in memory with timestamp
- Delivered to subscribers on their next poll
- Marked as delivered to each subscriber (exactly-once guarantee)
- Expired and removed after 10 seconds
- Message cleanup: Runs every 5 seconds, removes messages older than 10 seconds
- Subscriber cleanup: Runs every 60 seconds, removes inactive subscribers (>5 minutes)
- 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
MIT