This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of the app directory
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 3000 8080
|
||||||
|
|
||||||
|
# Set default environment variables
|
||||||
|
ENV WS_PORT=3000
|
||||||
|
ENV API_PORT=8080
|
||||||
|
ENV API_KEY=default-api-key-change-me
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:8080/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
# Socket Broker
|
||||||
|
|
||||||
|
A WebSocket broker application that handles topic-based messaging between frontend clients and backend services with API key authentication.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **WebSocket Server** (Port 3000): Handles client connections and topic subscriptions
|
||||||
|
- **HTTP API** (Port 8080): Allows backend services to publish messages to topics
|
||||||
|
- **API Key Authentication**: Secure access control for publishing messages
|
||||||
|
- **Topic-based messaging**: Clients subscribe to topics and receive messages when published
|
||||||
|
- **Real-time communication**: Instant message delivery to all subscribed clients
|
||||||
|
- **Health monitoring**: Built-in health checks and status endpoints
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend Clients ←→ WebSocket Server (Port 3000)
|
||||||
|
↕
|
||||||
|
Backend Services ←→ HTTP API (Port 8080) [🔐 API Key Required]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT**: The HTTP API endpoints that publish messages require a valid API key for authentication. This ensures that only authorized backend services can send messages to your WebSocket clients.
|
||||||
|
|
||||||
|
### API Key Configuration
|
||||||
|
|
||||||
|
Set the `API_KEY` environment variable to secure your application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set a strong, random API key
|
||||||
|
export API_KEY="your-secure-api-key-here"
|
||||||
|
|
||||||
|
# Or use in docker-compose
|
||||||
|
API_KEY=your-secure-api-key-here docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Methods
|
||||||
|
|
||||||
|
The API accepts the API key in three ways:
|
||||||
|
|
||||||
|
1. **Authorization Header** (Recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Authorization: Bearer your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **X-API-Key Header**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
X-API-Key: your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Query Parameter**:
|
||||||
|
```bash
|
||||||
|
POST /publish?api_key=your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker build -t socket-broker .
|
||||||
|
|
||||||
|
# Run the container with API key
|
||||||
|
docker run -d \
|
||||||
|
--name socket-broker \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e API_KEY="your-secure-api-key" \
|
||||||
|
socket-broker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy environment file
|
||||||
|
cp env.example .env
|
||||||
|
|
||||||
|
# Edit .env and set your API key
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Node.js directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set API key
|
||||||
|
export API_KEY="your-secure-api-key"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ---------- | --------------------------- | ---------------------------------------- |
|
||||||
|
| `WS_PORT` | `3000` | WebSocket server port |
|
||||||
|
| `API_PORT` | `8080` | HTTP API server port |
|
||||||
|
| `API_KEY` | `default-api-key-change-me` | **REQUIRED**: API key for authentication |
|
||||||
|
|
||||||
|
## WebSocket API (Port 3000)
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
|
||||||
|
Connect to `ws://localhost:3000` using Socket.IO client.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
#### Subscribe to a topic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
socket.emit("subscribe", "user-notifications");
|
||||||
|
socket.on("subscribed", (data) => {
|
||||||
|
console.log(`Subscribed to ${data.topic}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Unsubscribe from a topic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
socket.emit("unsubscribe", "user-notifications");
|
||||||
|
socket.on("unsubscribed", (data) => {
|
||||||
|
console.log(`Unsubscribed from ${data.topic}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Receive messages
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
socket.on("message", (data) => {
|
||||||
|
console.log(`Message from topic ${data.topic}:`, data.message);
|
||||||
|
console.log("Data:", data.data);
|
||||||
|
console.log("Timestamp:", data.timestamp);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error handling
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
console.error("Socket error:", error.message);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP API (Port 8080)
|
||||||
|
|
||||||
|
### Public Endpoints (No Authentication Required)
|
||||||
|
|
||||||
|
#### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"connections": 5,
|
||||||
|
"topics": 3,
|
||||||
|
"timestamp": "2024-01-01T12:00:00.000Z",
|
||||||
|
"apiKeyConfigured": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Topics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /topics
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topics": [
|
||||||
|
{
|
||||||
|
"topic": "user-notifications",
|
||||||
|
"subscribers": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": "system-alerts",
|
||||||
|
"subscribers": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Topic Info
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /topics/{topic}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "user-notifications",
|
||||||
|
"subscribers": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protected Endpoints (API Key Required)
|
||||||
|
|
||||||
|
#### Response Codes
|
||||||
|
|
||||||
|
- **200 OK**: Message published successfully to subscribers
|
||||||
|
- **204 No Content**: Message published successfully but no subscribers for the topic
|
||||||
|
- **400 Bad Request**: Invalid request (missing topic, message, or data)
|
||||||
|
- **401 Unauthorized**: Invalid or missing API key
|
||||||
|
- **500 Internal Server Error**: Server error
|
||||||
|
|
||||||
|
#### Publish Message
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /publish
|
||||||
|
Authorization: Bearer your-api-key-here
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"topic": "user-notifications",
|
||||||
|
"message": "New message received",
|
||||||
|
"data": { "userId": 123, "type": "email" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /publish/{topic}
|
||||||
|
Authorization: Bearer your-api-key-here
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "New message received",
|
||||||
|
"data": { "userId": 123, "type": "email" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Message published to topic 'user-notifications'",
|
||||||
|
"subscribers": 2,
|
||||||
|
"payload": {
|
||||||
|
"topic": "user-notifications",
|
||||||
|
"message": "New message received",
|
||||||
|
"data": { "userId": 123, "type": "email" },
|
||||||
|
"timestamp": "2024-01-01T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: If there are no subscribers for the topic, the API returns `204 No Content` instead of an error. This indicates that the message was published successfully, but there were no recipients to receive it.
|
||||||
|
|
||||||
|
### Authentication Errors
|
||||||
|
|
||||||
|
If you don't provide a valid API key, you'll get:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": "API key is required. Use Authorization header or x-api-key header or api_key query parameter."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Example (JavaScript)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Socket Broker Client</title>
|
||||||
|
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Socket Broker Client</h1>
|
||||||
|
<div id="status">Connecting...</div>
|
||||||
|
<div id="messages"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const socket = io("http://localhost:3000");
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
document.getElementById("status").textContent = "Connected";
|
||||||
|
|
||||||
|
// Subscribe to a topic
|
||||||
|
socket.emit("subscribe", "user-notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("subscribed", (data) => {
|
||||||
|
console.log("Subscribed:", data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("message", (data) => {
|
||||||
|
const messagesDiv = document.getElementById("messages");
|
||||||
|
const messageElement = document.createElement("div");
|
||||||
|
messageElement.innerHTML = `
|
||||||
|
<strong>${data.topic}:</strong> ${data.message}
|
||||||
|
<br><small>${data.timestamp}</small>
|
||||||
|
`;
|
||||||
|
messagesDiv.appendChild(messageElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
document.getElementById("status").textContent = "Disconnected";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Example (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Get API key from environment variable
|
||||||
|
API_KEY = os.getenv('API_KEY', 'your-api-key-here')
|
||||||
|
|
||||||
|
def publish_message(topic, message, data=None):
|
||||||
|
url = "http://localhost:8080/publish"
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {API_KEY}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"topic": topic,
|
||||||
|
"message": message,
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=payload, headers=headers)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
result = publish_message(
|
||||||
|
topic="user-notifications",
|
||||||
|
message="New email received",
|
||||||
|
data={"userId": 123, "emailId": "abc123"}
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Example (Node.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
const API_KEY = process.env.API_KEY || "your-api-key-here";
|
||||||
|
|
||||||
|
async function publishMessage(topic, message, data = null) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
"http://localhost:8080/publish",
|
||||||
|
{
|
||||||
|
topic,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Error publishing message:",
|
||||||
|
error.response?.data || error.message
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage
|
||||||
|
publishMessage("user-notifications", "New notification", { userId: 123 })
|
||||||
|
.then((result) => console.log("Message published:", result))
|
||||||
|
.catch((error) => console.error("Failed to publish:", error));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
socket-broker:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- WS_PORT=3000
|
||||||
|
- API_PORT=8080
|
||||||
|
- API_KEY=${API_KEY:-default-api-key-change-me}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"require('http').get('http://localhost:8080/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
The application provides several monitoring endpoints:
|
||||||
|
|
||||||
|
- `/health` - Overall health status and API key configuration status
|
||||||
|
- `/topics` - List of active topics and subscriber counts
|
||||||
|
- `/topics/{topic}` - Specific topic information
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Generate Strong API Keys**: Use cryptographically secure random strings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure API key
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment Variables**: Never hardcode API keys in your code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Good
|
||||||
|
export API_KEY="$(openssl rand -base64 32)"
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
export API_KEY="my-secret-key-123"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Rotate Keys Regularly**: Change your API keys periodically
|
||||||
|
|
||||||
|
4. **Limit Access**: Only share API keys with trusted backend services
|
||||||
|
|
||||||
|
5. **HTTPS in Production**: Use HTTPS/WSS in production environments
|
||||||
|
|
||||||
|
6. **Network Security**: Consider using internal networks for backend communication
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **401 Unauthorized**: Check that you're providing the correct API key
|
||||||
|
2. **Port conflicts**: Ensure ports 3000 and 8080 are available
|
||||||
|
3. **CORS errors**: Check that the frontend origin is allowed
|
||||||
|
4. **Connection refused**: Verify the container is running and ports are exposed
|
||||||
|
|
||||||
|
### Testing Authentication
|
||||||
|
|
||||||
|
Test your API key authentication:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test without API key (should fail with 401)
|
||||||
|
curl -X POST http://localhost:8080/publish \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic":"test","message":"hello"}'
|
||||||
|
|
||||||
|
# Test with correct API key but no subscribers (should succeed with 204)
|
||||||
|
curl -X POST http://localhost:8080/publish \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic":"test","message":"hello"}'
|
||||||
|
|
||||||
|
# Test with correct API key and subscribers (should succeed with 200)
|
||||||
|
curl -X POST http://localhost:8080/publish \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic":"test","message":"hello"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
Check application logs for detailed information:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs socket-broker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Verify the application is healthy and API key is configured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
socket-broker:
|
||||||
|
build: .
|
||||||
|
container_name: socket-broker
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # WebSocket server
|
||||||
|
- "8080:8080" # HTTP API
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- WS_PORT=3000
|
||||||
|
- API_PORT=8080
|
||||||
|
- API_KEY=${API_KEY:-default-api-key-change-me}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"require('http').get('http://localhost:8080/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- socket-broker-network
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
networks:
|
||||||
|
socket-broker-network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Socket Broker Environment Configuration
|
||||||
|
# Copy this file to .env and update the values
|
||||||
|
|
||||||
|
# WebSocket server port
|
||||||
|
WS_PORT=3000
|
||||||
|
|
||||||
|
# HTTP API server port
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# API Key for authentication (REQUIRED for production)
|
||||||
|
# This key must be provided in HTTP requests to publish messages
|
||||||
|
# Generate a strong, random key for production use
|
||||||
|
API_KEY=your-secure-api-key-here
|
||||||
|
|
||||||
|
# Node environment
|
||||||
|
NODE_ENV=production
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { createServer } = require('http');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
// Create Express app and HTTP server
|
||||||
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
|
// Create Socket.IO server
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: "*", // Allow all origins for development
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store topic subscriptions
|
||||||
|
const topicSubscriptions = new Map(); // topic -> Set of socket IDs
|
||||||
|
const socketTopics = new Map(); // socket ID -> Set of topics
|
||||||
|
|
||||||
|
// Get API key from environment variable
|
||||||
|
const API_KEY = process.env.API_KEY || 'default-api-key-change-me';
|
||||||
|
|
||||||
|
// API Key authentication middleware
|
||||||
|
const authenticateApiKey = (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const apiKey = req.headers['x-api-key'] || req.query.api_key;
|
||||||
|
|
||||||
|
if (!authHeader && !apiKey) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'API key is required. Use Authorization header or x-api-key header or api_key query parameter.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Authorization header (Bearer token format)
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
if (token === API_KEY) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check x-api-key header
|
||||||
|
if (apiKey && apiKey === API_KEY) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Invalid API key'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// WebSocket connection handling (Port 3000)
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log(`Client connected: ${socket.id}`);
|
||||||
|
|
||||||
|
// Handle topic subscription
|
||||||
|
socket.on('subscribe', (topic) => {
|
||||||
|
if (!topic || typeof topic !== 'string') {
|
||||||
|
socket.emit('error', { message: 'Invalid topic' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add socket to topic
|
||||||
|
if (!topicSubscriptions.has(topic)) {
|
||||||
|
topicSubscriptions.set(topic, new Set());
|
||||||
|
}
|
||||||
|
topicSubscriptions.get(topic).add(socket.id);
|
||||||
|
|
||||||
|
// Add topic to socket
|
||||||
|
if (!socketTopics.has(socket.id)) {
|
||||||
|
socketTopics.set(socket.id, new Set());
|
||||||
|
}
|
||||||
|
socketTopics.get(socket.id).add(topic);
|
||||||
|
|
||||||
|
console.log(`Socket ${socket.id} subscribed to topic: ${topic}`);
|
||||||
|
socket.emit('subscribed', { topic, message: `Successfully subscribed to ${topic}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle topic unsubscription
|
||||||
|
socket.on('unsubscribe', (topic) => {
|
||||||
|
if (!topic || typeof topic !== 'string') {
|
||||||
|
socket.emit('error', { message: 'Invalid topic' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove socket from topic
|
||||||
|
if (topicSubscriptions.has(topic)) {
|
||||||
|
topicSubscriptions.get(topic).delete(socket.id);
|
||||||
|
if (topicSubscriptions.get(topic).size === 0) {
|
||||||
|
topicSubscriptions.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove topic from socket
|
||||||
|
if (socketTopics.has(socket.id)) {
|
||||||
|
socketTopics.get(socket.id).delete(topic);
|
||||||
|
if (socketTopics.get(socket.id).size === 0) {
|
||||||
|
socketTopics.delete(socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Socket ${socket.id} unsubscribed from topic: ${topic}`);
|
||||||
|
socket.emit('unsubscribed', { topic, message: `Successfully unsubscribed from ${topic}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle disconnect
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log(`Client disconnected: ${socket.id}`);
|
||||||
|
|
||||||
|
// Clean up subscriptions
|
||||||
|
if (socketTopics.has(socket.id)) {
|
||||||
|
const topics = socketTopics.get(socket.id);
|
||||||
|
topics.forEach(topic => {
|
||||||
|
if (topicSubscriptions.has(topic)) {
|
||||||
|
topicSubscriptions.get(topic).delete(socket.id);
|
||||||
|
if (topicSubscriptions.get(topic).size === 0) {
|
||||||
|
topicSubscriptions.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socketTopics.delete(socket.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTP API endpoints (Port 8080)
|
||||||
|
|
||||||
|
// Public endpoints (no authentication required)
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
connections: io.engine.clientsCount,
|
||||||
|
topics: topicSubscriptions.size,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
apiKeyConfigured: !!API_KEY && API_KEY !== 'default-api-key-change-me'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/topics', (req, res) => {
|
||||||
|
const topics = Array.from(topicSubscriptions.keys()).map(topic => ({
|
||||||
|
topic,
|
||||||
|
subscribers: topicSubscriptions.get(topic).size
|
||||||
|
}));
|
||||||
|
res.json({ topics });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/topics/:topic', (req, res) => {
|
||||||
|
const { topic } = req.params;
|
||||||
|
if (!topicSubscriptions.has(topic)) {
|
||||||
|
return res.status(404).json({ error: 'Topic not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = topicSubscriptions.get(topic).size;
|
||||||
|
res.json({ topic, subscribers });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protected endpoints (require API key authentication)
|
||||||
|
app.post('/publish', authenticateApiKey, (req, res) => {
|
||||||
|
const { topic, message, data } = req.body;
|
||||||
|
|
||||||
|
if (!topic || typeof topic !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Topic is required and must be a string' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message && !data) {
|
||||||
|
return res.status(400).json({ error: 'Either message or data is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if topic has subscribers
|
||||||
|
if (!topicSubscriptions.has(topic)) {
|
||||||
|
// Return 204 No Content when there are no subscribers
|
||||||
|
// The operation succeeded, there just weren't any recipients
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
topic,
|
||||||
|
message: message || null,
|
||||||
|
data: data || null,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit to all subscribers of the topic
|
||||||
|
const subscribers = topicSubscriptions.get(topic);
|
||||||
|
subscribers.forEach(socketId => {
|
||||||
|
io.to(socketId).emit('message', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Message published to topic '${topic}' for ${subscribers.size} subscribers`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Message published to topic '${topic}'`,
|
||||||
|
subscribers: subscribers.size,
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alternative endpoint with topic in URL (also protected)
|
||||||
|
app.post('/publish/:topic', authenticateApiKey, (req, res) => {
|
||||||
|
const { topic } = req.params;
|
||||||
|
const { message, data } = req.body;
|
||||||
|
|
||||||
|
if (!message && !data) {
|
||||||
|
return res.status(400).json({ error: 'Either message or data is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if topic has subscribers
|
||||||
|
if (!topicSubscriptions.has(topic)) {
|
||||||
|
// Return 204 No Content when there are no subscribers
|
||||||
|
// The operation succeeded, there just weren't any recipients
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
topic,
|
||||||
|
message: message || null,
|
||||||
|
data: data || null,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit to all subscribers of the topic
|
||||||
|
const subscribers = topicSubscriptions.get(topic);
|
||||||
|
subscribers.forEach(socketId => {
|
||||||
|
io.to(socketId).emit('message', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Message published to topic '${topic}' for ${subscribers.size} subscribers`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Message published to topic '${topic}'`,
|
||||||
|
subscribers: subscribers.size,
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start servers
|
||||||
|
const WS_PORT = process.env.WS_PORT || 3000;
|
||||||
|
const API_PORT = process.env.API_PORT || 8080;
|
||||||
|
|
||||||
|
// Start WebSocket server
|
||||||
|
io.listen(WS_PORT);
|
||||||
|
console.log(`WebSocket server running on port ${WS_PORT}`);
|
||||||
|
|
||||||
|
// Start HTTP API server
|
||||||
|
app.listen(API_PORT, () => {
|
||||||
|
console.log(`HTTP API server running on port ${API_PORT}`);
|
||||||
|
console.log(`Health check: http://localhost:${API_PORT}/health`);
|
||||||
|
console.log(`Topics: http://localhost:${API_PORT}/topics`);
|
||||||
|
console.log(`Publish: POST http://localhost:${API_PORT}/publish`);
|
||||||
|
console.log(`API Key configured: ${API_KEY !== 'default-api-key-change-me' ? 'Yes' : 'No (using default)'}`);
|
||||||
|
if (API_KEY === 'default-api-key-change-me') {
|
||||||
|
console.log(`⚠️ WARNING: Using default API key. Set API_KEY environment variable for production!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM received, shutting down gracefully');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('Process terminated');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('SIGINT received, shutting down gracefully');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('Process terminated');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+1464
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "socket-broker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A WebSocket broker with HTTP API for topic-based messaging",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"websocket",
|
||||||
|
"broker",
|
||||||
|
"socket.io",
|
||||||
|
"express"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for Socket Broker HTTP API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
API_BASE_URL = "http://localhost:8080"
|
||||||
|
TOPIC_NAME = "test-topic"
|
||||||
|
|
||||||
|
# Get API key from environment variable or use default
|
||||||
|
API_KEY = os.getenv('API_KEY', 'default-api-key-change-me')
|
||||||
|
|
||||||
|
def get_auth_headers():
|
||||||
|
"""Get authentication headers for API requests"""
|
||||||
|
return {
|
||||||
|
'Authorization': f'Bearer {API_KEY}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_health():
|
||||||
|
"""Test the health endpoint"""
|
||||||
|
print("Testing health endpoint...")
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/health")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"✅ Health check passed")
|
||||||
|
print(f" Status: {data['status']}")
|
||||||
|
print(f" Connections: {data['connections']}")
|
||||||
|
print(f" Topics: {data['topics']}")
|
||||||
|
print(f" Timestamp: {data['timestamp']}")
|
||||||
|
print(f" API Key Configured: {data.get('apiKeyConfigured', 'Unknown')}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ Health check failed: {response.status_code}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Health check error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_topics():
|
||||||
|
"""Test the topics endpoint"""
|
||||||
|
print("\nTesting topics endpoint...")
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/topics")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"✅ Topics retrieved successfully")
|
||||||
|
if data['topics']:
|
||||||
|
for topic in data['topics']:
|
||||||
|
print(f" Topic: {topic['topic']}, Subscribers: {topic['subscribers']}")
|
||||||
|
else:
|
||||||
|
print(" No topics found")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ Topics request failed: {response.status_code}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Topics request error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_topic_info(topic):
|
||||||
|
"""Test getting info for a specific topic"""
|
||||||
|
print(f"\nTesting topic info for '{topic}'...")
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/topics/{topic}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"✅ Topic info retrieved successfully")
|
||||||
|
print(f" Topic: {data['topic']}")
|
||||||
|
print(f" Subscribers: {data['subscribers']}")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 404:
|
||||||
|
print(f"ℹ️ Topic '{topic}' not found (no subscribers)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ Topic info request failed: {response.status_code}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Topic info request error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_publish_message(topic, message, data=None):
|
||||||
|
"""Test publishing a message to a topic"""
|
||||||
|
print(f"\nTesting message publish to topic '{topic}'...")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"topic": topic,
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
|
||||||
|
if data:
|
||||||
|
payload["data"] = data
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{API_BASE_URL}/publish", json=payload, headers=get_auth_headers())
|
||||||
|
if response.status_code == 200:
|
||||||
|
response_data = response.json()
|
||||||
|
print(f"✅ Message published successfully")
|
||||||
|
print(f" Subscribers: {response_data['subscribers']}")
|
||||||
|
print(f" Message: {response_data['payload']['message']}")
|
||||||
|
if response_data['payload']['data']:
|
||||||
|
print(f" Data: {json.dumps(response_data['payload']['data'], indent=2)}")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 204:
|
||||||
|
print(f"ℹ️ Message published successfully but no subscribers for topic '{topic}'")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 401:
|
||||||
|
print(f"❌ Authentication failed: {response.json().get('message', 'Unknown error')}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"❌ Message publish failed: {response.status_code}")
|
||||||
|
print(f" Response: {response.text}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Message publish error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_publish_with_url_topic(topic, message, data=None):
|
||||||
|
"""Test publishing using the URL-based endpoint"""
|
||||||
|
print(f"\nTesting message publish to topic '{topic}' (URL endpoint)...")
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
if message:
|
||||||
|
payload["message"] = message
|
||||||
|
if data:
|
||||||
|
payload["data"] = data
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{API_BASE_URL}/publish/{topic}", json=payload, headers=get_auth_headers())
|
||||||
|
if response.status_code == 200:
|
||||||
|
response_data = response.json()
|
||||||
|
print(f"✅ Message published successfully (URL endpoint)")
|
||||||
|
print(f" Subscribers: {response_data['subscribers']}")
|
||||||
|
print(f" Message: {response_data['payload']['message']}")
|
||||||
|
if response_data['payload']['data']:
|
||||||
|
print(f" Data: {json.dumps(response_data['payload']['data'], indent=2)}")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 204:
|
||||||
|
print(f"ℹ️ Message published successfully but no subscribers for topic '{topic}'")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 401:
|
||||||
|
print(f"❌ Authentication failed: {response.json().get('message', 'Unknown error')}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"❌ Message publish failed (URL endpoint): {response.status_code}")
|
||||||
|
print(f" Response: {response.text}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Message publish error (URL endpoint): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_authentication_errors():
|
||||||
|
"""Test authentication error cases"""
|
||||||
|
print(f"\n🧪 Testing authentication error cases...")
|
||||||
|
|
||||||
|
# Test without API key
|
||||||
|
print("Testing request without API key...")
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{API_BASE_URL}/publish", json={
|
||||||
|
"topic": "test-topic",
|
||||||
|
"message": "This should fail"
|
||||||
|
})
|
||||||
|
if response.status_code == 401:
|
||||||
|
print("✅ Correctly rejected request without API key")
|
||||||
|
else:
|
||||||
|
print(f"❌ Expected 401, got {response.status_code}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Request error: {e}")
|
||||||
|
|
||||||
|
# Test with wrong API key
|
||||||
|
print("Testing request with wrong API key...")
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{API_BASE_URL}/publish", json={
|
||||||
|
"topic": "test-topic",
|
||||||
|
"message": "This should fail"
|
||||||
|
}, headers={'Authorization': 'Bearer wrong-key'})
|
||||||
|
if response.status_code == 401:
|
||||||
|
print("✅ Correctly rejected request with wrong API key")
|
||||||
|
else:
|
||||||
|
print(f"❌ Expected 401, got {response.status_code}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Request error: {e}")
|
||||||
|
|
||||||
|
def run_demo():
|
||||||
|
"""Run a complete demo"""
|
||||||
|
print("🚀 Socket Broker HTTP API Test")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"🔑 Using API Key: {API_KEY[:10]}{'...' if len(API_KEY) > 10 else ''}")
|
||||||
|
print(f"🌐 API Base URL: {API_BASE_URL}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Test basic endpoints
|
||||||
|
if not test_health():
|
||||||
|
print("❌ Cannot proceed - health check failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
test_topics()
|
||||||
|
|
||||||
|
# Test topic info
|
||||||
|
test_topic_info(TOPIC_NAME)
|
||||||
|
|
||||||
|
# Test publishing messages
|
||||||
|
print(f"\n📤 Publishing test messages...")
|
||||||
|
|
||||||
|
# Simple message
|
||||||
|
test_publish_message(TOPIC_NAME, "Hello from Python test script!")
|
||||||
|
|
||||||
|
# Message with data
|
||||||
|
test_data = {
|
||||||
|
"source": "python-test",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"user": "test-user",
|
||||||
|
"action": "demo"
|
||||||
|
}
|
||||||
|
test_publish_message(TOPIC_NAME, "Test message with data", test_data)
|
||||||
|
|
||||||
|
# Test URL-based endpoint
|
||||||
|
test_publish_with_url_topic(TOPIC_NAME, "Message via URL endpoint", {"method": "url"})
|
||||||
|
|
||||||
|
# Test error cases
|
||||||
|
print(f"\n🧪 Testing error cases...")
|
||||||
|
|
||||||
|
# Empty topic
|
||||||
|
test_publish_message("", "This should fail")
|
||||||
|
|
||||||
|
# No message or data
|
||||||
|
test_publish_message(TOPIC_NAME, None)
|
||||||
|
|
||||||
|
# Test authentication errors
|
||||||
|
test_authentication_errors()
|
||||||
|
|
||||||
|
print(f"\n✅ Demo completed!")
|
||||||
|
print(f"\n💡 To see real-time messages, open test_client.html in a browser")
|
||||||
|
print(f" and subscribe to topic '{TOPIC_NAME}'")
|
||||||
|
print(f"\n🔐 API Key is required for publishing messages")
|
||||||
|
print(f" Set API_KEY environment variable or use the default key")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
run_demo()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⏹️ Demo interrupted by user")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Demo failed with error: {e}")
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Socket Broker Test Client</title>
|
||||||
|
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.connected {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
.disconnected {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
.connecting {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.controls input,
|
||||||
|
.controls button {
|
||||||
|
margin: 5px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.controls button {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.controls button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
.controls button:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: white;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.message .topic {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
.message .timestamp {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscriptions {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.subscription {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin: 2px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Socket Broker Test Client</h1>
|
||||||
|
|
||||||
|
<div id="status" class="status connecting">Connecting...</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<h3>Topic Management</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="topicInput"
|
||||||
|
placeholder="Enter topic name"
|
||||||
|
value="test-topic"
|
||||||
|
/>
|
||||||
|
<button onclick="subscribe()" id="subscribeBtn">Subscribe</button>
|
||||||
|
<button onclick="unsubscribe()" id="unsubscribeBtn" disabled>
|
||||||
|
Unsubscribe
|
||||||
|
</button>
|
||||||
|
<button onclick="clearMessages()">Clear Messages</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subscriptions">
|
||||||
|
<h3>Active Subscriptions</h3>
|
||||||
|
<div id="subscriptionList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages">
|
||||||
|
<h3>Messages</h3>
|
||||||
|
<div id="messageList">No messages yet...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let socket;
|
||||||
|
let currentTopic = "";
|
||||||
|
let subscriptions = new Set();
|
||||||
|
|
||||||
|
// Initialize socket connection
|
||||||
|
function initSocket() {
|
||||||
|
socket = io("http://localhost:3000");
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
updateStatus("Connected", "connected");
|
||||||
|
document.getElementById("subscribeBtn").disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
updateStatus("Disconnected", "disconnected");
|
||||||
|
document.getElementById("subscribeBtn").disabled = true;
|
||||||
|
document.getElementById("unsubscribeBtn").disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("subscribed", (data) => {
|
||||||
|
console.log("Subscribed to topic:", data.topic);
|
||||||
|
subscriptions.add(data.topic);
|
||||||
|
currentTopic = data.topic;
|
||||||
|
updateSubscriptionList();
|
||||||
|
document.getElementById("unsubscribeBtn").disabled = false;
|
||||||
|
addMessage(
|
||||||
|
"System",
|
||||||
|
`Subscribed to topic: ${data.topic}`,
|
||||||
|
"subscription"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("unsubscribed", (data) => {
|
||||||
|
console.log("Unsubscribed from topic:", data.topic);
|
||||||
|
subscriptions.delete(data.topic);
|
||||||
|
if (currentTopic === data.topic) {
|
||||||
|
currentTopic = "";
|
||||||
|
}
|
||||||
|
updateSubscriptionList();
|
||||||
|
if (subscriptions.size === 0) {
|
||||||
|
document.getElementById("unsubscribeBtn").disabled = true;
|
||||||
|
}
|
||||||
|
addMessage(
|
||||||
|
"System",
|
||||||
|
`Unsubscribed from topic: ${data.topic}`,
|
||||||
|
"subscription"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("message", (data) => {
|
||||||
|
console.log("Received message:", data);
|
||||||
|
addMessage(
|
||||||
|
data.topic,
|
||||||
|
data.message || "No message content",
|
||||||
|
"message",
|
||||||
|
data.data,
|
||||||
|
data.timestamp
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
console.error("Socket error:", error);
|
||||||
|
addMessage("Error", error.message, "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(text, className) {
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = `status ${className}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubscriptionList() {
|
||||||
|
const listEl = document.getElementById("subscriptionList");
|
||||||
|
if (subscriptions.size === 0) {
|
||||||
|
listEl.innerHTML = "<em>No active subscriptions</em>";
|
||||||
|
} else {
|
||||||
|
listEl.innerHTML = Array.from(subscriptions)
|
||||||
|
.map((topic) => `<span class="subscription">${topic}</span>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe() {
|
||||||
|
const topic = document.getElementById("topicInput").value.trim();
|
||||||
|
if (!topic) {
|
||||||
|
alert("Please enter a topic name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptions.has(topic)) {
|
||||||
|
alert("Already subscribed to this topic");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("subscribe", topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribe() {
|
||||||
|
const topic = document.getElementById("topicInput").value.trim();
|
||||||
|
if (!topic) {
|
||||||
|
alert("Please enter a topic name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscriptions.has(topic)) {
|
||||||
|
alert("Not subscribed to this topic");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("unsubscribe", topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(topic, message, type, data = null, timestamp = null) {
|
||||||
|
const messageList = document.getElementById("messageList");
|
||||||
|
const messageEl = document.createElement("div");
|
||||||
|
messageEl.className = `message ${type}`;
|
||||||
|
|
||||||
|
let content = `<div class="topic">${topic}</div>`;
|
||||||
|
content += `<div>${message}</div>`;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
content += `<div><small>Data: ${JSON.stringify(data)}</small></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `<div class="timestamp">${
|
||||||
|
timestamp || new Date().toLocaleTimeString()
|
||||||
|
}</div>`;
|
||||||
|
|
||||||
|
messageEl.innerHTML = content;
|
||||||
|
|
||||||
|
if (messageList.textContent === "No messages yet...") {
|
||||||
|
messageList.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
messageList.appendChild(messageEl);
|
||||||
|
messageList.scrollTop = messageList.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMessages() {
|
||||||
|
document.getElementById("messageList").innerHTML = "No messages yet...";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when page loads
|
||||||
|
window.onload = function () {
|
||||||
|
initSocket();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user