Routing
Express-style message routing for OCPP connections.
Connection Routing
When building a Central System (CSMS) that manages hundreds or thousands of charging stations, maintaining all your message handlers in a single server.on('client') block can quickly become unmanageable.
ocpp-ws-io provides an Express-like router (OCPPRouter) to help you modularize your connection handling, organize middleware, and split authentication logic into distinct segments.
Note on Connection vs. Message Routing: Unlike traditional HTTP routing
(which routes individual requests), the OCPPRouter routes WebSocket
connections based on the URL path the client connects to. Once a connection
is routed, you apply your RPC message handlers (client.handle(...)) to that
connection.
Basic Routing
To capture connections targeting a specific pathname, instantiate an OCPPRouter and mount it to your OCPPServer. Because the server extends the router API, you can use .route() directly:
import { OCPPServer } from "ocpp-ws-io";
const server = new OCPPServer({ protocols: ["ocpp1.6"] });
// 1. Create a route for a specific path using wildcard parameter extraction
const chargerRoute = server.route("/api/v1/chargers/:id");
// 2. Attach handlers for clients matching this route
chargerRoute.on("client", (client) => {
console.log(`Charger connected at endpoint: ${client.handshake.pathname}`);
client.handle("BootNotification", ({ params }) => {
return {
status: "Accepted",
currentTime: new Date().toISOString(),
interval: 300,
};
});
});
await server.listen(3000);Path Matching
The .route() method supports exact string matching, Express-style parameters, and Regular Expressions:
- Exact Match:
server.route("/secure/chargepoint") - Parameters:
server.route("/api/v1/chargers/:stationId") - Wildcards:
server.route("/public/*") - RegEx:
server.route(/^\/legacy\/v[1-2]\/.*$/)
Direct Message Handling (router.handle)
Instead of manually listening for the "client" connection event and then attaching handlers to the client, you can bind message handlers directly to the router.
When a charging station connects to this route, the router will automatically bind these handlers to the underlying OCPPClient.
const v2Router = server.route("/api/v2/chargers/*");
// Directly handle BootNotifications for ANY station connecting to /api/v2
v2Router.handle("ocpp2.0.1", "BootNotification", async (ctx) => {
console.log(`Booting station: ${ctx.client.identity}`);
return {
currentTime: new Date().toISOString(),
interval: 300,
status: "Accepted",
};
});
// Directly handle incoming Heartbeats
v2Router.handle("Heartbeat", () => ({
currentTime: new Date().toISOString(),
}));Router-level Middleware
You can scope connection middleware to specific paths. A middleware placed on a route will only execute for clients connecting to that route's URL.
const adminRouter = server
.use(async (ctx) => {
console.log(`[Admin Access Attempt] Identity: ${ctx.handshake.identity}`);
if (ctx.handshake.identity.startsWith("SYS-")) {
await ctx.next();
} else {
throw new Error("Unauthorized identity for admin endpoint");
}
})
.route("/api/admin/*");
adminRouter.on("client", (client) => {
// Only SYS-* prefixed stations reach here
client.handle("TriggerMessage", () => ({ status: "Accepted" }));
});Path-Specific Authentication
Similar to middleware, you can securely ringfence different authentication mechanisms per route. For instance, you might want to accept Basic Auth for legacy charging stations on /api/v1, but strictly enforce TLS Certificates on /api/v2.
You can do this using .auth(...):
// Allow Basic Auth for legacy route
const legacyRouter = server
.auth(async (ctx) => {
if (ctx.handshake.headers.authorization) {
ctx.accept({ session: { version: "v1" } });
} else {
ctx.reject(401, "Basic Auth Required");
}
})
.route("/api/v1/*");
// Enforce mTLS for modern route
const modernRouter = server
.auth(async (ctx) => {
if (ctx.handshake.isSecure) {
ctx.accept({ session: { version: "v2" } });
} else {
ctx.reject(403, "TLS Certificate Required");
}
})
.route("/api/v2/*");Path-Specific CORS
You can also restrict WebSocket connections based on the Origin header or the client's IP address directly on the router. This allows you to expose public endpoints while tightly locking down administrative or internal endpoints.
// 1. Lock down the Admin router to internal networks
const adminRouter = server
.cors({
allowedIPs: ["10.0.0.0/8", "192.168.1.50"],
})
.route("/api/admin/*");
// 2. Lock down a dashboard WebSocket to a specific web domain
const dashboardRouter = server
.cors({
allowedOrigins: ["https://dashboard.example.com"],
})
.route("/api/dashboard");Modular Routing
For larger applications, defining all your routes on the central server instance can get messy. You can decouple your routes into separate files using the createRouter helper, and then mount them back to the server using server.attachRouters().
import { createRouter } from "ocpp-ws-io";
// Create a standalone router independent of the server
export const adminRouter = createRouter("/api/admin/*");
adminRouter.auth(async (ctx) => {
if (ctx.handshake.headers["authorization"] !== "Bearer my-secret") {
ctx.reject(401, "Unauthorized");
return;
}
ctx.accept({ session: { role: "admin" } });
});
adminRouter.handle("BootNotification", () => ({
status: "Accepted",
interval: 10,
currentTime: new Date().toISOString(),
}));import { OCPPServer } from "ocpp-ws-io";
import { adminRouter } from "./routes/admin.js";
const server = new OCPPServer({ protocols: ["ocpp1.6"] });
// Attach the externally defined router back to the main server!
server.attachRouters(adminRouter);
server.listen(3000);Catch-All & Fallbacks
If a client connects to a URL that does not match any of your defined .route() paths, they will fall back to any handlers registered directly on the root server.
If you prefer to define an explicit catch-all router to forcefully reject unknown endpoints, simply omit the .route() call:
// Specific route
server.route("/api/chargers").on("client", handleCharger);
// Catch-all (matches anything that didn't match above)
const wildcardRouter = server.use(async (ctx) => {
// Reject connection without throwing RPC errors
ctx.reject(404, "Endpoint Not Found");
});Because ocpp-ws-io evaluates routes in the order they are defined, ensure your catch-all routers are placed at the bottom of your initialization code.
Execution Scope (Additive Pipelines)
A key design decision in ocpp-ws-io is its additive execution pipeline. When a connection arrives, the server collects all matching middlewares across your global scopes and route-specific scopes, executing them sequentially in the order they were registered.
Route-level Scoping
If you define router.use() and router.auth() on a specific .route(), they only execute for clients connecting to that exact URL path.
// 1. LEGACY ROUTE (Only runs for /api/v1/*)
const legacyRouter = server.route("/api/v1/*");
// This middleware ONLY runs for /api/v1/* connections
legacyRouter.use(async (ctx) => {
console.log("Legacy connection attempted");
await ctx.next();
});
// This Auth ONLY runs for /api/v1/* connections
legacyRouter.auth(async (ctx) => {
ctx.accept({ session: { version: "legacy" } });
});Global Scoping & Stacking
If you want a middleware to run for every connection to your server regardless of the route (e.g., a global rate limiter), you attach it directly to the server instance without calling .route(). Global middlewares will execute before any route-specific middlewares or auth callbacks.
// 1. GLOBAL: Runs for EVERY connection first
server.use(async (ctx) => {
console.log(
`Global connection attempt from IP: ${ctx.handshake.remoteAddress}`,
);
await ctx.next();
});
// 2. SCOPED: Runs ONLY for /api/v2/* connections AFTER the global middleware
server.route("/api/v2/*").auth(async (ctx) => {
ctx.accept({ session: { version: "v2" } });
});Server & Router Execution Flow
Understanding the precise execution flow of ocpp-ws-io is critical for properly separating your authentication, rate-limiting, and validation logic. The framework operates in a strict, two-phase execution hierarchy: the Connection Phase and the Message Phase.
1. The Connection Phase (HTTP Upgrade)
This happens the moment a charging station attempts to connect via WebSocket, but before the connection is truly accepted. Everything bound to the OCPPRouter runs here.
- Route Matching (
router.route): The incoming URL is evaluated against your defined patterns. - Connection Middleware (
router.use):- Execution Level: Pre-Authentication.
- Purpose: Runs horizontally (similar to Express.js). Use this to inspect HTTP headers, IP addresses (
ctx.handshake), enforce early rate-limiting, or inject data intoctx.state.
- Auth Callback (
router.auth):- Execution Level: Authentication & Acceptance.
- Purpose: Runs last in the HTTP upgrade chain. It looks at everything the middleware prepared, communicates with your database, and definitively calls
accept()orreject(). If accepted, the WebSocket officially opens.
2. The Message Phase (WebSocket Open)
Once the Auth Callback fires accept(), the client is successfully connected. The execution context now moves to the message layer (OCPPClient).
- Message Middleware (
client.use):- Execution Level: Pre-Handler / Interceptor
- Purpose: Runs every time a message is sent or received. It wraps the raw protocol payload. Use this to intercept outgoing calls, format logs, validate custom schemas, or drop bad messages before the business logic sees them.
- Message Handlers (
client.handle):- Execution Level: Business Logic
- Purpose: This is the very end of the line. It receives the parsed OCPP payload (e.g.,
BootNotification), performs your actual business logic, and returns the formal protocol response.