ws-request-manager
v1.0.0
Published
WebSocket middleware for Node.js servers with pluggable auth, designed to work with http-request-manager
Downloads
188
Maintainers
Readme
ws-request-manager
WebSocket middleware for Node.js servers, designed to work with http-request-manager on the frontend.
Provides channels, pub/sub messaging, notifications, heartbeat, delivery tracking, and daily cleanup — all behind a consumer-provided auth function. The message protocol matches exactly what http-request-manager's WebSocketManagerService and WebSocketMessageService send, so the frontend connects seamlessly.
http-request-manager Support
This middleware implements the full WebSocket protocol that http-request-manager expects. Every message type the Angular frontend sends is handled:
Channel Management
| Message type | FE Service Method | What It Does |
|---|---|---|
| subscribe | openChannel(channel) | Adds client to channel, sends channel list |
| unsubscribe | closeChannel(channel) | Removes client from channel |
| createChannel | createChannel(name) | Creates a PUB- channel, broadcasts update |
| deleteChannel | deleteChannel(name) | Removes channel (if empty), notifies subscribers |
| getChannels | getChannels() | Returns list of available PUB- channels |
| getUsers | getUsersInChannel(channel) | Returns users subscribed to a channel |
Messaging
| Message type | FE Service Method | What It Does |
|---|---|---|
| stateManagerMessage | sendStateMessage(channel, data) | Broadcasts state update to all channel subscribers (including sender) |
| message | sendChannelMessage(channel, data) | Sends message to channel subscribers (excluding sender) |
| broadcast | broadcastMessage(data) | Sends to ALL connected clients across all channels |
| channelMessage | sendChannelMessageToChannels(channels, data) | Sends to multiple channels at once |
| userMessage | sendUserMessage(userId, data) | Sends a direct message to a specific user's channel |
Notifications
| Message type | FE Service Method | What It Does |
|---|---|---|
| subscribeNotifications | subscribeNotifications(channel) | Subscribes to a MES- notification channel |
| unsubscribeNotifications | unsubscribeNotifications(channel) | Unsubscribes from a notification channel |
| notification | sendNotification(channel, data) | Sends notification to all subscribers of a MES- channel |
| getNotificationChannels | getNotificationChannels() | Returns available notification channels |
| getTodaysNotificationChannels | getTodaysNotificationChannels() | Returns today's notification channels |
| createNotificationChannel | createNotificationChannel(name) | Creates a new MES- notification channel |
Message Tracking
| Message type | FE Service Method | What It Does |
|---|---|---|
| messageAckBatch | acknowledgeMessages(ids) | Acknowledges batch of delivered messages |
| gapRequest | requestMessageGap(info) | Requests missing messages between IDs |
Server Responses
The server sends these response types back to the frontend:
| Response type | Trigger | Description |
|---|---|---|
| channelsList | On connect, after create/delete channel | Full list of available channels |
| channelCreated | After createChannel | Confirms channel creation |
| channelDeleted | After deleteChannel | Confirms channel deletion |
| success | After subscribe | Confirms subscription with user data |
| info | Re-subscribe to already-joined channel | Info message |
| unsubscribed | After unsubscribe | Confirms unsubscription |
| usersInChannel | After getUsers | List of users in channel(s) |
| notificationSubscribed | After subscribeNotifications | Confirms notification subscription |
| notificationUnsubscribed | After unsubscribeNotifications | Confirms notification unsubscription |
| notificationChannelsList | After getNotificationChannels | List of notification channels |
| todaysNotificationChannelsList | After getTodaysNotificationChannels | Today's notification channels |
| gapResponse | After gapRequest | Response with any found messages |
| error | On invalid operations | Error with description |
HTTP REST Routes
Mounted by register(app, auth):
| Route | Method | Description |
|---|---|---|
| /ws/channels | GET | List active PUB- channels |
| /ws/connections | GET | Channel→user connection table |
| /ws/broadcast | POST | Push messages to channel(s) from backend |
Heartbeat
The server sends WebSocket ping frames every 30 seconds (configurable via WS_HEARTBEAT_MS). Clients that don't respond with pong are terminated.
Installation
npm install ws-request-managerPeer Dependencies
This middleware is designed to run alongside http-request-manager on the backend. The frontend connects using http-request-manager's WebSocketManagerService.
npm install ws-request-manager express ws
npm install http-request-manager # frontend packageQuick Start
import express from 'express';
import { createServer } from 'http';
import { register, registerServer, destroy, noAuth } from 'ws-request-manager';
const app = express();
const server = createServer(app);
// Pass your auth function — noAuth() for dev, your own for production
await register(app, noAuth);
await registerServer(server, noAuth);
server.listen(3000, () => console.log('Server running on :3000'));
// Graceful shutdown
process.on('SIGTERM', () => { destroy(); server.close(); });That's it. The library handles WebSocket upgrades, channels, messages, heartbeat, and cleanup. You provide the auth.
Lifecycle Hooks
| Hook | Purpose |
|------|---------|
| register(app, auth) | Mount HTTP routes (/ws/channels, /ws/broadcast, /ws/connections) |
| registerServer(server, auth) | Attach WebSocket upgrade handler, start heartbeat + cleanup |
| destroy() | Clean up timers (heartbeat, daily cleanup) |
Both register() and registerServer() require an auth: WsAuthFn parameter.
Pluggable Auth
The library does NOT implement any authentication strategy. Instead, it accepts a WsAuthFn — an async function you write that decides whether to allow or reject each WebSocket connection.
WsAuthFn
type WsAuthFn = (socket: WebSocket, request: IncomingMessage) => Promise<WsUser>;- On success: return a
WsUserobject (must havesubproperty) - On failure: throw a
WsAuthErrorwith a close code, or anyError(defaults to code4001)
import { WsAuthFn, WsAuthError } from 'ws-request-manager';
const myAuth: WsAuthFn = async (socket, request) => {
const params = parseQuery(request.url);
if (params.token === 'my-secret') {
return { sub: 'user-123', type: 'service' }; // ✅ allow
}
throw new WsAuthError('Invalid token', 4001); // ❌ reject
};WsUser
interface WsUser {
sub: string; // Required — unique user identifier
[key: string]: any; // Optional — any additional properties
}The returned WsUser is attached to socket.user and available in all message handlers.
WsAuthError
class WsAuthError extends Error {
readonly code: number; // WebSocket close code (default: 4001)
constructor(message: string, code?: number);
}Common close codes:
| Code | Meaning |
|------|---------|
| 4000 | No token provided |
| 4001 | Invalid credentials (default) |
| 4002 | IP blocked (rate limited) |
| 4003 | Token expired |
noAuth
Development convenience — allows all connections:
import { noAuth } from 'ws-request-manager';
await register(app, noAuth);
await registerServer(server, noAuth);
// ⚠️ Logs "WS running without authentication" on each connectionAuth Helpers
The library exports utilities for writing auth functions:
import { parseQuery, parseCookies, getClientIp } from 'ws-request-manager';| Helper | Signature | Description |
|--------|-----------|-------------|
| parseQuery(url) | (url: string \| undefined) => Record<string, string> | Parse URL query parameters |
| parseCookies(header) | (header: string \| undefined) => Record<string, string> | Parse cookie header |
| getClientIp(request) | (request: IncomingMessage) => string | Extract client IP (x-forwarded-for, x-real-ip, remoteAddress) |
Example Auth Functions
API Key Auth
import { WsAuthFn, WsAuthError, parseQuery } from 'ws-request-manager';
function createApiKeyAuth(apiKey: string): WsAuthFn {
return async (_socket, request) => {
const params = parseQuery(request.url);
if (params.token !== apiKey) {
throw new WsAuthError('Invalid API key', 4001);
}
return { sub: 'api-client', type: 'service' };
};
}
// Usage:
await register(app, createApiKeyAuth(process.env.STATIC_API_KEY!));
await registerServer(server, createApiKeyAuth(process.env.STATIC_API_KEY!));JWT Auth
import jwt from 'jsonwebtoken';
import { WsAuthFn, WsAuthError, parseQuery } from 'ws-request-manager';
function createJwtAuth(secret: string): WsAuthFn {
return async (_socket, request) => {
const params = parseQuery(request.url);
const token = params.token;
if (!token) throw new WsAuthError('No token provided', 4000);
return new Promise((resolve, reject) => {
jwt.verify(token, secret, (err, decoded) => {
if (err) reject(new WsAuthError(err.message, 4001));
else resolve(decoded as WsUser);
});
});
};
}Cookie-Based Auth (BFF Session)
import { WsAuthFn, WsAuthError, parseCookies } from 'ws-request-manager';
function createCookieAuth(validateSession: (sid: string) => Promise<WsUser | null>): WsAuthFn {
return async (_socket, request) => {
const cookies = parseCookies(request.headers.cookie);
const sessionId = cookies['bff_0'];
if (!sessionId) throw new WsAuthError('No session cookie', 4000);
const user = await validateSession(sessionId);
if (!user) throw new WsAuthError('Invalid session', 4001);
return user;
};
}Composable Auth Patterns
Auth functions are composable — wrap, chain, and combine them without modifying the library.
Rate Limiting
Rate limiting wraps your auth function. It's NOT separate middleware — the consumer owns the full auth pipeline.
import { WsAuthFn, WsAuthError, getClientIp } from 'ws-request-manager';
function createRateLimitedAuth(
authFn: WsAuthFn,
options: { maxFailures?: number; blockDurationMs?: number } = {}
): WsAuthFn {
const { maxFailures = 10, blockDurationMs = 300_000 } = options;
const failures = new Map<string, { count: number; lastLog: number; blockedAt: number | null }>();
return async (socket, request) => {
const ip = getClientIp(request);
const info = failures.get(ip);
// Check if IP is blocked
if (info?.blockedAt) {
const elapsed = Date.now() - info.blockedAt;
if (elapsed < blockDurationMs) {
const retryAfter = Math.ceil((blockDurationMs - elapsed) / 1000);
throw new WsAuthError(`IP blocked. Retry after ${retryAfter}s`, 4002);
}
failures.delete(ip); // Block expired
}
try {
const user = await authFn(socket, request);
failures.delete(ip); // Success — reset
return user;
} catch (authErr) {
// Track failure
const now = Date.now();
const entry = failures.get(ip) || { count: 0, lastLog: 0, blockedAt: null };
entry.count++;
if (entry.count >= maxFailures && !entry.blockedAt) {
entry.blockedAt = now;
}
failures.set(ip, entry);
throw authErr;
}
};
}Usage:
const auth = createRateLimitedAuth(
createApiKeyAuth(process.env.STATIC_API_KEY!),
{ maxFailures: 5, blockDurationMs: 60_000 }
);
await register(app, auth);
await registerServer(server, auth);Multi-Auth (Fallback)
Try multiple auth methods in order. First success wins.
function createMultiAuth(...authFns: WsAuthFn[]): WsAuthFn {
return async (socket, request) => {
let lastError: Error | null = null;
for (const fn of authFns) {
try {
return await fn(socket, request);
} catch (err) {
lastError = err as Error;
}
}
throw lastError || new WsAuthError('All auth methods failed', 4001);
};
}Usage:
const auth = createMultiAuth(
createApiKeyAuth(process.env.STATIC_API_KEY!),
createJwtAuth(process.env.JWT_SECRET_KEY!)
);Full Production: Rate-Limited Multi-Auth
const auth = createRateLimitedAuth(
createMultiAuth(
createApiKeyAuth(process.env.STATIC_API_KEY!),
createJwtAuth(process.env.JWT_SECRET_KEY!)
),
{ maxFailures: 10, blockDurationMs: 300_000 }
);
await register(app, auth);
await registerServer(server, auth);This tries API key first, then JWT, and blocks IPs after 10 failures for 5 minutes.
Integration with http-request-manager
ws-request-manager is designed to work alongside http-request-manager in a BFF (Backend-For-Frontend) server. The BFF server handles HTTP requests via http-request-manager and WebSocket connections via ws-request-manager.
Setup
// bff-server.ts
import express from 'express';
import { createServer } from 'http';
import { register, registerServer, destroy } from 'ws-request-manager';
import { WsAuthFn, WsAuthError, parseQuery, getClientIp } from 'ws-request-manager';
// 1. Create your auth function
const authFn: WsAuthFn = createRateLimitedAuth(
createMultiAuth(
createApiKeyAuth(process.env.STATIC_API_KEY!),
createJwtAuth(process.env.JWT_SECRET_KEY!)
)
);
// 2. Create Express app and HTTP server
const app = express();
app.use(express.json());
// 3. Mount WS HTTP routes (adds /ws/channels, /ws/broadcast, /ws/connections)
await register(app, authFn);
// 4. Create HTTP server and attach WebSocket upgrade handler
const server = createServer(app);
await registerServer(server, authFn);
// 5. Listen
server.listen(3000, () => {
console.log('BFF server running on :3000');
});
// 6. Graceful shutdown
process.on('SIGTERM', () => { destroy(); server.close(); });How It Works
┌─────────────────────────────────────────────────────────┐
│ BFF Server (Express) │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ http-request-mgr │ │ ws-request-manager │ │
│ │ │ │ │ │
│ │ HTTP routes: │ │ register(app, authFn) ──────►│ │
│ │ /api/* │ │ → mounts /ws/* HTTP routes │ │
│ │ │ │ │ │
│ │ │ │ registerServer(srv, authFn) ─►│ │
│ │ │ │ → handles WS upgrade │ │
│ │ │ │ → calls authFn(socket, req) │ │
│ │ │ │ → sets socket.user on auth │ │
│ │ │ │ → starts heartbeat/cleanup │ │
│ └──────────────────┘ └──────────────────────────────┘ │
│ │
│ Auth function (provided by consumer): │
│ ┌────────────────────────────────────────────────────┐ │
│ │ authFn(socket, request) → Promise<WsUser> │ │
│ │ │ │
│ │ ✅ Return WsUser → connection allowed │ │
│ │ ❌ Throw Error → connection rejected (4001) │ │
│ │ ❌ Throw WsAuthError → rejected (custom code) │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Auth Flow
- Client initiates WebSocket upgrade to
/ws?token=abc123 registerServerintercepts the upgrade request- Library calls
authFn(socket, request)— your auth function - On success:
socket.useris set, connection is established, channel list is sent - On failure: socket is closed with the error code, client receives
AUTH_FAILEDmessage
Frontend Connection (http-request-manager)
The frontend connects via http-request-manager's WebSocket service:
// Angular frontend
import { WSOptions } from 'http-request-manager';
const wsOptions: WSOptions = {
wsServer: 'ws://localhost:3000/ws',
jwtToken: 'my-jwt-token', // Passed as ?token= query parameter
channels: ['PUB-general', 'PUB-notifications']
};
// The token value is opaque to http-request-manager —
// the backend decides what to do with it.The jwtToken is sent as the ?token= query parameter during the WebSocket upgrade. Your WsAuthFn on the backend decides how to validate it — JWT, API key, session cookie, or anything else.
Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| WS_ENABLED | true | Enable/disable WebSocket functionality |
| WS_PATH | /ws | WebSocket upgrade path |
| WS_PORT | 4000 | Dedicated port for WS (falls back to PORT) |
| WS_HEARTBEAT_MS | 30000 | Heartbeat interval in milliseconds |
| WS_LOG_PAYLOADS | false | Log full message payloads (verbose) |
| WS_LOG_PAYLOADS_MAX_CHARS | 8000 | Max chars for payload logging |
| WS_MESSAGES_DB_PATH | ./data/ws-messages.db | Path to messages SQLite database |
| WS_NOTIFICATIONS_DB_PATH | ./data/ws-notifications.db | Path to notifications SQLite database |
| WS_MESSAGE_TTL_DAYS | 30 | Days to retain messages (0 = forever) |
Exports
// Lifecycle
export { register, registerServer, destroy } from './index';
// Auth types
export { WsUser, WsAuthFn, WsAuthError } from './auth/types';
// Auth utilities
export { noAuth } from './auth/noAuth';
export { parseQuery, parseCookies, getClientIp } from './auth/helpers';Node.js 22+ Requirement
This library requires Node.js 22 or later. The sample server.js enforces this at startup:
const REQUIRED_NODE_MAJOR = 22;
if (parseInt(process.version.slice(1).split('.')[0]) < REQUIRED_NODE_MAJOR) {
console.error(`❌ Node.js ${REQUIRED_NODE_MAJOR}+ required`);
process.exit(1);
}Sample Server
A complete standalone sample server is available at server.js in the repository root. It demonstrates:
noAuth— development mode (no authentication)createApiKeyAuth(key)— static API key validationcreateJwtAuth(secret)— JWT token verificationcreateCookieAuth(validate)— BFF session cookie validationcreateRateLimitedAuth(authFn, opts)— IP-based rate limiting wrappercreateMultiAuth(...fns)— try multiple auth methods in ordercreateUpgradeHandler(wss, authFn)— WebSocket upgrade handler with auth
# Run the sample server
node server.js
# Test it
node test/sample-test-client.js --test=noauth
node test/sample-test-client.js --test=apikey --key=my-secret-keySee the Pluggable Auth section above for auth function examples.
Server Protocol Handlers
The server.js sample implements the full ws-request-manager message protocol, matching what the frontend WebSocketManagerService and WebSocketMessageService send. Each message from the FE includes a type field that the server dispatches:
| Message type | FE Service Method | Server Handler |
|---|---|---|
| subscribe | openChannel(channel) | Adds client to channel, sends channel list |
| unsubscribe | closeChannel(channel) | Removes client from channel |
| createChannel | createChannel(name) | Creates channel, adds client, broadcasts update |
| deleteChannel | deleteChannel(name) | Removes channel, notifies subscribers |
| getChannels | getChannels() | Returns available channels |
| getUsers | getUsers(channel) | Returns users in a channel |
| stateManagerMessage | sendStateMessage(channel, data) | Broadcasts state to channel subscribers (including sender) |
| message | sendChannelMessage(channel, data) | Sends message to channel subscribers (excluding sender) |
| broadcast | broadcastMessage(data) | Sends to ALL connected clients |
| channelMessage | sendChannelMessage(channel, data) | Sends to specific channel |
| userMessage | sendUserMessage(userId, data) | Sends to specific user |
| subscribeNotifications | subscribeNotifications(channel) | Subscribes to notification channel |
| unsubscribeNotifications | unsubscribeNotifications(channel) | Unsubscribes from notification channel |
| notification | sendNotification(channel, data) | Sends notification to channel subscribers |
| getNotificationChannels | getNotificationChannels() | Returns available notification channels |
| getTodaysNotificationChannels | getTodaysNotificationChannels() | Returns today's notification channels |
| createNotificationChannel | createNotificationChannel(name) | Creates notification channel |
| messageAckBatch | acknowledgeMessages(ids) | Acknowledges message delivery |
| gapRequest | requestMessageGap(info) | Requests missing messages |
Middleware Test Client
A comprehensive test client at test/ws-middleware-test.js tests all FE service functions against the server:
# Start the server first
node server.js
# Run middleware tests (no-auth mode)
node test/ws-middleware-test.js
# Run with API key auth
node test/ws-middleware-test.js --auth=apikey --key=my-secret-key
# Run with JWT auth
node test/ws-middleware-test.js --auth=jwt --secret=my-jwt-secretThe test client mirrors the exact message types and payloads that the Angular WebSocketManagerService and WebSocketMessageService send, ensuring the server middleware handles the full FE protocol correctly.
