Auction WebSocket Infrastructure¶
Auction realtime traffic now uses a single enterprise WebSocket interface at:
There are no role-specific WebSocket endpoints for subscribers, companies, or admins. Every client connects to /ws; JWT authentication creates a typed socket context, and message-level authorization controls what the connection can do.
Code Layout¶
src/
├── infrastructure/
│ └── websocket/
│ ├── broadcaster.ts
│ ├── connection-manager.ts
│ ├── heartbeat.ts
│ ├── redis-pubsub.ts
│ ├── room-manager.ts
│ ├── socket-registry.ts
│ └── types.ts
│
└── interfaces/
└── websocket/
├── index.ts
├── connection.ts
├── router.ts
├── auth.ts
├── context.ts
├── errors.ts
├── middleware/
├── handlers/
├── schemas/
└── types/
WebSocket code intentionally lives outside src/interfaces/http.
Boot Flow¶
src/interfaces/app.ts registers the WebSocket interface after HTTP routes and webhooks:
src/infrastructure/plugins/00-websocket.ts registers @fastify/websocket and configures:
maxPayloadfromconfig.websocket.connectionMaxPayloadBytes- accepted protocols:
auction-room.v1,websocket.v1, andbearer.<token>
src/interfaces/websocket/index.ts creates one WebSocketRouter, registers all current socket routes, subscribes the local process to ws:global, and exposes:
Configuration¶
The WebSocket config is loaded through the normal app config pipeline.
WEBSOCKET_HEARTBEAT_INTERVAL_MS=20000
WEBSOCKET_CONNECTION_MAX_PAYLOAD_BYTES=65536
WEBSOCKET_RATE_LIMIT_WINDOW_MS=60000
WEBSOCKET_DEFAULT_RATE_LIMIT=120
All values must be positive integers. Event handlers can override the default rate limit per route.
Authentication¶
Authentication happens immediately after the socket upgrades and before any message is processed.
Supported transports:
The token is verified through jwtService.verifyAccessToken. Business handlers never decode JWTs or read auth headers.
The JWT payload is converted into:
interface SocketUser {
readonly id: string;
readonly role: Role;
readonly companyId?: string;
readonly permissions: readonly string[];
}
Role-derived v1 permissions:
SUBSCRIBER: auction:subscribe, auction:bid:place, room:join, room:leave
COMPANY: auction:subscribe, auction:staff, room:join, room:leave, broadcast:receive
SUPERADMIN: auction:subscribe, auction:staff, room:join, room:leave, broadcast:receive
Other roles: room:join, room:leave
For COMPANY, companyId is currently set from userId.
Authentication failure closes the socket with close code 1008 and reason Authentication failed.
Socket Context¶
Every accepted connection receives a strongly typed SocketContext:
interface SocketContext {
readonly socket: WebSocket;
readonly user: SocketUser;
readonly rooms: Set<string>;
readonly requestId: string;
readonly connectionId: string;
readonly ip: string;
readonly connectedAt: Date;
readonly logger: FastifyBaseLogger;
readonly subscriptions: Map<string, () => Promise<void>>;
}
connectionId and requestId are generated with @paralleldrive/cuid2. The context logger is a child logger containing requestId, connectionId, userId, and role.
Message Envelope¶
Incoming messages use:
Envelope rules:
type: required string, 1 to 128 charactersrequestId: optional string, 1 to 128 charactersdata: optional unknown payload, validated by the event route schema
Successful handler responses are wrapped as acknowledgements:
Handlers may return null to suppress the acknowledgement.
Errors use:
{
"type": "error",
"requestId": "client-request-id",
"code": "BAD_REQUEST",
"message": "Safe client-facing message."
}
Router Pipeline¶
WebSocketRouter is registry-based. Each route is registered by event type; there are no large switch statements.
Pipeline order:
- Parse raw message as JSON
- Validate the base envelope
- Find the registered route by
type - Authenticate the context
- Apply per-user and per-IP rate limits
- Validate the event-specific Zod schema
- Authorize required route permissions
- Execute the handler
- Send
ackorerror
Middleware is composable:
type SocketMiddleware = (input: {
readonly context: SocketContext;
readonly message: SocketMessageEnvelope;
readonly route: SocketRoute;
}) => Promise<void>;
Current middleware:
authenticateMiddlewaremakeRateLimitMiddlewarevalidateMiddlewareauthorizeMiddleware
Error Mapping¶
Centralized WebSocket error mapping lives in src/interfaces/websocket/errors.ts.
ZodError, SyntaxError, BadRequestError -> BAD_REQUEST
UnauthorizedError -> UNAUTHORIZED
ForbiddenError -> FORBIDDEN
NotFoundError -> NOT_FOUND
ConflictError -> CONFLICT
WebSocketRateLimitError -> RATE_LIMITED
Unexpected error -> INTERNAL
Unexpected failures are logged with the socket context logger and return a safe client message.
Current Events¶
system.ping¶
Schema:
Response acknowledgement data:
room.join¶
Required permission:
Schema:
Allowed room namespaces:
notificationsadmins, only forSUPERADMINuser:<current-user-id>company:<current-company-id>auction:<cycleId>
Successful acknowledgement data:
room.leave¶
Required permission:
Schema:
Successful acknowledgement data:
auction.subscribe¶
Required permission:
Schema:
enrolledSubscriberId is required by the auction application authorization flow for subscriber users and omitted by staff users. lastSequence is optional; if provided, the server merges replay events into the snapshot payload.
Behavior:
- Authorizes access through
makeAuctionRoomService - Joins local room
auction:<cycleId> - Ensures Redis subscription to
ws:room:auction:<cycleId> - Subscribes to the existing auction realtime channel through
auctionRealtimeService.subscribe(cycleId, listener) - Records subscriber presence through
auctionPresenceService.joinfor subscriber connections - Sends
auction.connected - Sends
auction.snapshot - Sends
presence.snapshot - Returns an acknowledgement containing
auctionId,cycleId, androom
Initial server events:
{
"type": "auction.connected",
"requestId": "subscribe-001",
"data": {
"auctionId": "auction-id",
"cycleId": "cycle-id",
"occurredAt": "2026-08-03T00:00:00.000Z",
"payload": {
"connectionId": "connection-id",
"heartbeatIntervalMs": 20000
}
}
}
{
"type": "auction.snapshot",
"requestId": "subscribe-001",
"data": {
"auctionId": "auction-id",
"cycleId": "cycle-id",
"occurredAt": "2026-08-03T00:00:00.000Z",
"payload": {}
}
}
{
"type": "presence.snapshot",
"requestId": "subscribe-001",
"data": {
"auctionId": "auction-id",
"cycleId": "cycle-id",
"occurredAt": "2026-08-03T00:00:00.000Z",
"payload": {
"onlineSubscribers": []
}
}
}
Successful acknowledgement data:
auction.unsubscribe¶
Required permission:
Schema:
Behavior:
- Finds the connection subscription for
auction:<cycleId> - Leaves the local room
- Removes subscriber presence when applicable
- Calls the auction realtime unsubscribe cleanup
- Returns whether an active subscription was found
Successful acknowledgement data:
auction.bid.place¶
Required permission:
Rate limit:
Schema:
{
"cycleId": "cycle-id",
"amount": "25000",
"idempotencyKey": "bid-request-id",
"enrolledSubscriberId": "enrolled-subscriber-id"
}
amount may be a positive number or a non-empty decimal string. Decimal strings are preferred to avoid JavaScript precision issues.
Behavior:
- Only
SUBSCRIBERusers may execute this event - Authorizes the auction room with
makeAuctionRoomService - Delegates bid placement to the existing auction application use cases
- Uses
idempotencyKeyas the internal auction request id - Emits domain realtime updates through the existing auction realtime service
- Returns the bid command result as acknowledgement data
Staff Command Events¶
The following events execute staff auction commands through the auction room service. They all require the auction:staff permission (COMPANY and SUPERADMIN only) and the cycleId field. Subscribers receive a FORBIDDEN error. Each event accepts an optional requestId (a server-generated id is used when omitted).
auction.status.update¶
Start the live auction.
auction.pause / auction.resume / auction.end¶
Toggle and close an auction.
Each returns the corresponding auction command result (e.g. { "stateVersion": 3 }).
bid.mark¶
Performs a staff bid-moderation action. One of:
{
"cycleId": "cycle-id",
"action": "DISQUALIFY_BIDDER",
"enrolledSubscriberId": "enrolled-id",
"reason": "...",
"reasonCode": "RULE_VIOLATION"
}
{
"cycleId": "cycle-id",
"action": "DISQUALIFY_CANDIDATE_AND_DECLARE_NEXT",
"bidId": "bid-id",
"reason": "...",
"reasonCode": "RULE_VIOLATION"
}
reasonCode is one of KYC_ISSUE | PAYMENT_ISSUE | ELIGIBILITY_ISSUE | RULE_VIOLATION | OTHER and is required except for DELETE_BID.
winner.declare¶
Declare an auction winner, either from an existing bid/candidate or manually.
{
"cycleId": "cycle-id",
"mode": "MANUAL",
"enrolledSubscriberId": "enrolled-id",
"amount": "25000",
"reason": "required for manual"
}
winner.record_lot¶
Record a manual lot winner with at least two runner-up candidates.
{
"cycleId": "cycle-id",
"winnerEnrolledSubscriberId": "enrolled-id",
"candidateEnrolledSubscriberIds": ["enrolled-id-a", "enrolled-id-b"],
"reason": "optional"
}
Server Broadcast Events¶
Auction realtime events are forwarded to socket clients using the event type published by the auction realtime service, for example:
{
"type": "bid.created",
"requestId": "bid-request-id",
"data": {
"auctionId": "auction-id",
"cycleId": "cycle-id",
"stateVersion": 7,
"occurredAt": "2026-08-03T00:00:00.000Z",
"payload": {
"bidId": "bid-id",
"leadingBidAmount": 25000
}
}
}
If an auction realtime event contains targetUserId, subscriber sockets only receive it when targetUserId matches the authenticated socket user.
Infrastructure Services¶
SocketRegistry¶
Stores RegisteredSocket by connectionId.
Operations:
registerremovegetlistsize
ConnectionManager¶
Wraps SocketRegistry and tracks multiple simultaneous connections per user.
Operations:
registerremovegetByConnectionIdgetByUserIdlistmetrics
Metrics:
RoomManager¶
Maintains local room membership for the current Node.js process.
Operations:
join(connectionId, room)leave(connectionId, room)removeConnection(connectionId)getConnectionRooms(connectionId)getRoomMembers(room)
Room membership is intentionally local. Cross-instance delivery is handled by Redis Pub/Sub through Broadcaster.
RedisPubSub¶
Typed Redis Pub/Sub abstraction. It publishes through the app Redis client and uses a duplicated Redis connection for subscriptions.
Message shape:
interface WebSocketPubSubMessage {
readonly target: 'room' | 'global';
readonly room?: string;
readonly message: WebSocketBroadcastMessage;
readonly excludeConnectionId?: string;
}
Standard channels:
auctionRealtimeService remains the compatibility layer for existing auction domain realtime channels.
Broadcaster¶
Owns socket delivery and Redis-backed fanout.
Operations:
sendToSocketsendToConnectionsendToUserbroadcastToRoombroadcastToRoomsbroadcastGlobaldeliverFromPubSubensureRoomSubscriptiongetRoomChannel
Room and global broadcasts publish to Redis by default. Local-only delivery is available for process-scoped sends.
Heartbeat¶
Sends WebSocket protocol pings on the configured interval. Each connection must respond with pong before the next interval or onDead terminates the socket.
The heartbeat cleanup function is called during connection cleanup.
Connection Lifecycle¶
On connection:
- Verify JWT and build
SocketUser - Build
SocketContext - Register the connection
- Start heartbeat
- Attach message, close, and error listeners
On close or error:
- Stop heartbeat
- Run every subscription cleanup in
context.subscriptions - Leave all local rooms
- Remove connection from the connection manager
- Log closure
During application shutdown, disposeDependencies disposes WebSocket Pub/Sub, auction presence, auction realtime services, Redis, queues, database, and the DI container.
Logging¶
The implementation logs:
- Authentication failures
- Successful connections
- Connection errors
- Missed heartbeat termination
- Subscription cleanup failures
- Unexpected message processing failures
- Redis subscriber initialization, parse, and runtime errors
Room joins, room leaves, and broadcasts are represented in the service calls and can be expanded with additional structured log statements if operational visibility needs more granularity.
Rate Limiting¶
Rate limiting is currently in-memory per Node.js process and keyed by both:
Default limit:
auction.bid.place overrides the default with 100 messages per configured window. In horizontally scaled deployments, use Redis-backed rate limiting if the same limit must be enforced globally across all WebSocket servers.
Adding A New Event¶
- Add a Zod schema near the handler module.
- Create a
SocketRoutewithtype,schema, optionalpermissions, optionalrateLimit, and a typed handler. - Register the route from
src/interfaces/websocket/handlers/index.ts. - Keep business rules inside application use cases.
- Throw typed shared errors instead of manually sending error envelopes.
- Add unit or integration tests for validation, authorization, and handler behavior.
Example:
const notificationReadSchema = z.object({
notificationId: z.string().min(1)
});
const route: SocketRoute<typeof notificationReadSchema> = {
type: 'notification.read',
schema: notificationReadSchema,
permissions: ['notification:read'],
handler: async ({ data }) => {
return { notificationId: data.notificationId, read: true };
}
};
Removed Legacy Routes¶
Legacy subscriber, company, admin, and bid-activity WebSocket route files have been removed. HTTP auction routes remain in place; only realtime WebSocket traffic moved to /ws.
Verification¶
Current focused checks: