OCPP WS IOocpp-ws-io

Middleware

Intercept and modify OCPP messages.

Middleware

The middleware system allows you to intercept, inspect, and modify OCPP messages (calls and results) as they flow through the client and server. It follows an onion-like execution model similar to Koa or Axios interceptors.

Basic Usage

You can add middleware to both OCPPServer and OCPPClient.

client.use(async (ctx, next) => {
  console.log(`Processing ${ctx.method} (${ctx.type})`);

  // ctx is a union on `type`: narrow it before reading phase-specific fields.
  // Observe the payload; a key its schema does not define fails strict mode.
  if (ctx.type === "outgoing_call") {
    console.log("Params:", ctx.params);
  }

  // Proceed to next middleware/handler
  await next();

  // Code here runs after the handler returns (on the way out)
  console.log(`Finished ${ctx.method}`);
});

Built-in Middleware

Logging

The logging middleware is enabled by default but can be customized. It logs all incoming and outgoing messages.

import { createLoggingMiddleware } from "ocpp-ws-io";

// Manually adding (if you disabled default logging); any LoggerLike works
client.use(createLoggingMiddleware(console, "Point1"));

Creating Custom Middleware

Middleware functions receive a context and a next function.

Context Object

The ctx object is a discriminated union on the type property. All six phases of an RPC exchange run the chain — a request and its response in both directions, plus the two error phases. Every phase carries messageId, method and the fields below:

ctx.typeCarriesRuns when
incoming_callparams, protocol?A CALL arrives, before your handler sees it
outgoing_callparams, options, wrap?You send a CALL, before it is written
incoming_resultpayloadA CALLRESULT answers a call you made
outgoing_resultpayload, wrap?Your handler returned, before the CALLRESULT is written
incoming_errorerror (OCPPCallError)A CALLERROR answers a call you made
outgoing_errorerrorCode, errorDescriptionYour handler threw, before the CALLERROR is written

method on the two incoming response phases is correlated from the pending call — the wire message does not carry it. The same is true outbound: a CALLRESULT is [3, messageId, payload] and a CALLERROR is [4, id, code, description, details], so outgoing_result and outgoing_error are the only place to make a decision that depends on which request is being answered.

Middleware transforms; plugin hooks observe and veto. Assigning to ctx.params, ctx.payload, ctx.errorCode or ctx.errorDescription changes the message that is actually sent or handled. Plugin hooks such as onBeforeSend and onBeforeReceive can allow or block a message but cannot rewrite one. Reach for a hook to gate, and middleware to change.

On the server, a request and its response nest — the response chain runs inside the request's, so a middleware written in the wrapping style sees its own exchange close last:

in:incoming_call → in:outgoing_result → out:outgoing_result → out:incoming_call

If a middleware throws while a response is being built, the library fails open: it sends what the handler produced and logs the failure. A broken middleware must never leave a charge point waiting for a CALLRESULT that never arrives.

A middleware that throws on an incoming_call before calling next() rejects the call: the peer is answered with a CALLERROR carrying the error's RPC code and its own message (createRPCError("SecurityError", "Signature does not verify"), for example), or InternalError with that code's standard text for any other error, and the handler does not run. One that throws on an incoming_result or an incoming_error rejects the waiting call() with that error. This holds for the server, the Node client and the browser client.

Strict mode checks an outgoing call after the middleware, as it goes on the wire, so a call your middleware converted is checked as converted, and one it made invalid is not sent. A handler's result is checked before the middleware.

Envelopes: wrap

Some middleware does not change a message but puts it in an envelope, such as a signature. Set ctx.wrap on an outgoing_call or outgoing_result for that: it runs once every middleware has run (and strict mode has checked the plain call), wherever your middleware sits in the chain, and what it returns goes on the wire instead. On a call, the reply is still matched to the plain action.

client.use(async (ctx, next) => {
  if (ctx.type === "outgoing_call") {
    const inner = ctx.wrap; // an envelope set earlier, if any
    ctx.wrap = async (call) => {
      const plain = inner ? await inner(call) : call;
      return { method: `${plain.method}-Signed`, params: await sign(plain) };
    };
  }
  return next();
});

If an outgoing_result wrap throws, the reply is sent without it and the failure is logged, as for any middleware on a response.

import { defineRpcMiddleware } from "ocpp-ws-io/browser"; // Or "ocpp-ws-io" for Server/NodeClient

const validationMiddleware = defineRpcMiddleware(async (ctx, next) => {
  if (ctx.type === "outgoing_call" && !ctx.params) {
    throw new Error("Payload cannot be empty");
  }
  
  // You can wrap the inner execution in a try/catch
  try {
    const result = await next();
    return result;
  } catch (error) {
    console.error(`Handler crashed during ${ctx.method}`, error);
    throw error;
  }
});

client.use(validationMiddleware);

Connection Middleware (Server Setup)

While clients only utilize RPC middleware (parsing payloads), the OCPPServer and OCPPRouter handle raw HTTP upgrades. To gain type-safety when building connection middlewares (e.g. rate-limiting, authentication), use defineMiddleware and the native ctx controls:

import { defineMiddleware } from "ocpp-ws-io";

const rateLimitConnection = defineMiddleware(async (ctx) => {
  // `ctx` includes full `IncomingMessage`, URL parsed `pathname`, and `headers`
  console.log(`Connection attempt from: ${ctx.handshake.remoteAddress}`);

  if (isRateLimited(ctx.handshake.remoteAddress)) {
    // Instantly aborts the WebSocket connection with an HTTP code
    ctx.reject(429, "Too Many Requests");
  } else {
    // Or proceed down the execution chain. You can optionally pass an object
    // to next(), which will automatically be shallow-merged into `ctx.state`,
    // and from there into the connection's session (JSON values; see Sessions
    // in the TypeScript guide).
    await ctx.next({
      isTrusted: true,
      rateLimitRemaining: 99,
    });
  }
});

server.use(rateLimitConnection);

Authentication Helpers

To secure incoming WebSocket connections on the OCPPServer, you attach an auth() hook. ocpp-ws-io provides two utilities to make authentication chains typed and composable.

defineAuth

The defineAuth helper provides immediate IDE type inference for the accept, reject, and handshake parameters, so you don't need to manually type your callback.

import { defineAuth } from "ocpp-ws-io";

const verifyBasicAuth = defineAuth(async (ctx) => {
  const token = ctx.handshake.headers.authorization;
  if (!token) {
    return ctx.reject(401, "Basic auth required");
  }

  // Inject arbitrary session properties that your RPC handlers can access later
  ctx.accept({ session: { identity: ctx.handshake.identity, role: "admin" } });
});

server.auth(verifyBasicAuth);

combineAuth

When building modular endpoints with the OCPPRouter, you might need to combine multiple authentication rules (e.g. check a firewall IP, then check a JWT). combineAuth executes defineAuth callbacks sequentially.

import { combineAuth, defineAuth } from "ocpp-ws-io";

const checkFirewall = defineAuth((ctx) => {
  if (ctx.handshake.remoteAddress === "1.2.3.4") ctx.reject(403, "IP Blocked");
  else ctx.accept({}); // Pass immediately to the next auth handler
});

server.auth(combineAuth(checkFirewall, verifyBasicAuth));

// Alternatively, inline:
server.auth(
  combineAuth(
    // Firewall Check
    async (ctx) => {
      if (isBlocked(ctx.handshake.headers["x-forwarded-for"])) {
        return ctx.reject(403, "IP Blocked");
      }
    },
    // Basic Auth
    async (ctx) => {
      if (!ctx.handshake.headers.authorization) {
        return ctx.reject(401, "Missing Auth");
      }
      ctx.accept({ protocol: "ocpp1.6" });
    },
  ),
);

Callbacks from defineAuth and combineAuth fit any server or route. Written inline in server.auth(...), as above, ctx.accept({ protocol }) takes only that server's protocols.


Execution Flow

Because ocpp-ws-io uses an onion model (await next()), execution happens in two distinct phases:

  1. Downstream (Request Phase): Code before await next() executes in the order middleware was registered (use(a) then use(b)).
  2. Upstream (Response Phase): Code after await next() executes in reverse order (from b back to a).

If a middleware never calls next(), the chain stops there. If it calls next() without await (or return), the rest of the chain still runs and the library still waits for it, so a rejection or error further down is handled as usual and never escapes as an unhandled rejection; only that middleware's own code after next() runs straight away instead of on the way back out.

On this page