ws-server-handler
v0.0.4
Published
Tiny, zero-dependency TypeScript classes for dealing with clients of a WebSocket server, exposing client lifecycle hooks and type-safe message serialization/deserialization.
Maintainers
Readme
ws-server-handler
Tiny, zero-dependency TypeScript classes for dealing with clients of a WebSocket server, exposing client lifecycle hooks and type-safe message serialization/deserialization.
Simplifies WS server logic, enabling complex flows where clients are routed to into different handler instances based on HTTP/S Upgrade request properties. Once accepted into a handler, clients can be annotated with info (e.g. derived from the request) that is later used as context for message receiving, message sending, and client retrieval.
Install
npm install ws-server-handlerUsage
BoundClientHandler
Wraps a single WS server to automatically accept all incoming connections from a single WS server.
This is the simplest case: one handler to one WS server.
import { BoundClientHandler } from 'ws-server-handler'
import { WebSocketServer } from 'ws'
const handler = BoundClientHandler.wrapping(new WebSocketServer({ port: 8080 }), (ws, request) => ({
userId: getUserId(request)
}), {
onAcceptClient: () => ({
onReceiveMessage: async ({ client }, data) => {
console.log('Received message:', data)
handler.sendMessage(client, { echo: data })
}
})
})Accepting clients
The wrapping() constructor sets up the BoundClientHandler instance to accept clients upon connection events on the WS server. This is the only way that clients can be accepted into the handler, making it "bound" to a single WS server.
ClientHandler
Handles clients accepted manually from any number of WS servers.
For multi-purpose servers where multiple client handlers can share one server, or when you need to manually release clients from the handler without closing them.
import { ClientHandler } from 'ws-server-handler'
const handler = new ClientHandler({
onAcceptClient: () => ({
onReceiveMessage: async ({ client }, data) => {
console.log('Received message:', data)
handler.sendMessage(client, { echo: data })
}
})
})Accepting clients
Connect the handler to a WS server (or multiple):
import { WebSocketServer } from 'ws'
new WebSocketServer({ port: 8080 }).on('connection', (ws, request) => {
const url = new URL(request.url || '', `http://${request.headers.host}`)
handler.acceptClient(ws, {
userId: request.headers['x-user-id'],
})
})External HTTP/S server
If the WS server is initialized with noServer, use it on the separate HTTP/S server:
import { useHttpServerUpgrade, UpgradeRejection } from 'ws-server-handler/use/http'
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
const server = createServer()
const wss = new WebSocketServer({ noServer: true })
// Can setup handlers to accept clients by listening to the 'connection' event on the WS server
// ... (see above sections)
// Handles the HTTP/S server 'upgrade' event
useHttpServerUpgrade(server, (request, socket, head) => {
// Validate the request
if (!request.url?.startsWith('/ws')) {
throw new UpgradeRejection({
statusCode: 404,
body: { error: 'WebSocket endpoint not found' }
})
}
// Perform the upgrade
wss.handleUpgrade(request, socket, head, (ws) => {
// Dispatch 'connection' event to the WS server
wss.emit('connection', ws, request)
})
})The useHttpServerUpgrade utility catches any errors thrown by the callback and rejects the connection. Use FailedUpgradeError to customize the rejection response:
useHttpServerUpgrade(server, (request, socket, head) => {
if (!isAuthenticated(request)) {
throw new UpgradeRejection({
statusCode: 401,
headers: { 'X-Reason': 'Unauthorized' },
body: { error: 'Authentication required' }
})
}
// Perform the upgrade
// ... (see above)
})Route Upgrade requests to multiple servers
You can route between multiple WS servers based on request properties.
import { useHttpServerUpgrade, UpgradeRejection } from 'ws-server-handler/use/http'
import { ClientHandler } from 'ws-server-handler'
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
const chatHandler = new ClientHandler<{ userId: string, room: string }>({ /* ... */ })
const gameHandler = new ClientHandler<{ userId: string, gameId: string }>({ /* ... */ })
const chatWss = new WebSocketServer({ noServer: true })
const gameWss = new WebSocketServer({ noServer: true })
// Connect handlers to their respective WS servers
chatWss.on('connection', (ws, request) => {
const url = new URL(request.url || '', `http://${request.headers.host}`)
chatHandler.acceptClient(ws, {
userId: request.headers['x-user-id'],
room: url.searchParams.get('room'),
})
})
gameWss.on('connection', (ws, request) => {
const url = new URL(request.url || '', `http://${request.headers.host}`)
gameHandler.acceptClient(ws, {
userId: request.headers['x-user-id'],
gameId: url.searchParams.get('gameId'),
})
})
const httpServer = createServer()
// Route based on URL path
useHttpServerUpgrade(httpServer, (request, socket, head) => {
if (request.url?.startsWith('/chat')) {
chatWss.handleUpgrade(request, socket, head, (ws) => {
chatWss.emit('connection', ws, request)
})
} else if (request.url?.startsWith('/game')) {
gameWss.handleUpgrade(request, socket, head, (ws) => {
gameWss.emit('connection', ws, request)
})
} else {
throw new UpgradeRejection({
statusCode: 404,
body: { error: 'WebSocket endpoint not found' }
})
}
})If you want to be able to reject the upgrade during the process of building the client info, you can accept clients into their respective handlers directly within the upgrade handler context.
const handler = new ClientHandler(/* ... */)
const wss = new WebSocketServer({ noServer: true })
useHttpServerUpgrade(httpServer, (request, socket, head) => {
let userInfo
try {
// Fetch from some user service
userInfo = userService.getUserInfo(request.headers['x-user-id'])
} catch (e) {
throw new UpgradeRejection({
statusCode: 403,
body: { message: 'Failed to retrieve user' },
})
}
if (request.url?.startsWith('/ws')) {
wss.handleUpgrade(request, socket, head, (ws) => {
chatWss.emit('connection', ws, request)
handler.acceptClient(ws, {
userInfo,
})
})
} else {
throw new UpgradeRejection({
statusCode: 404,
body: { error: 'WebSocket endpoint not found' }
})
}
})Sending messages
// Broadcast to all clients
handler.getClientContexts()
.forEach(({ client }) => {
handler.sendMessage(client, message)
})
// Broadcast to specific clients
handler.getClientContexts()
.filter(context => context.clientInfo.room === 'lobby')
.forEach(({ client }) => {
handler.sendMessage(client, message)
})
// Send to one specific client
handler.sendMessage(client, message)Querying client info
// List all clients (that have been accepted into the handler) along with their annotated info
handler.getClientContexts()
// Get the annotated info for a specific clientConfiguration
const config: ClientHandlerConfig = {
// How to serialize the sent data (JSON.stringify() by default)
serializeMessage: (context, data) => { ... },
// How to deserialize the received data (JSON.parse() by default)
deserializeMessage: (context, data) => { ... },
// Handle client acceptance, (optionally) returning an object with additional hooks
onAcceptClient: (context) => ({
// Handle incoming messages
onReceiveMessage: async (context, data) => { ... },
// Handle client release
onReleaseClient: async (context) => { ... }
})
}Besides the library-provided hooks, you can setup your own listeners on the client for other events (e.g. close or error). This should be done within the onAcceptClient configuration hook (see note below).
[!Warning] NodeJS runs event listeners synchronously in the order they were registered. When a client emits an event, any listeners that were registered on the client before the handler registered its own listeners will run before the handler has had a chance to update its state in response to the event.
Any client event listener that depends on handler state should be registered only after the client has been accepted into the handler*, or within the onAcceptClient configuration hook which is safely ran after the handler has registered all its own listeners.
*: see above BoundClientHandler section or ClientHandler section on accepting clients into a handler.
