OCPP WS IOocpp-ws-io

Upgrading to 3.0

Every breaking change in ocpp-ws-io 3.0.0, who it affects, and how to keep the 2.x behaviour where that is possible.

3.0.0 brings ocpp-ws-io in line with the OCPP-J specification for 1.6, 2.0.1 and 2.1. Most changes only show up with misbehaving chargers or in tests that assert exact error replies. Read the table, apply the options you need, and run your tests.

Requirements

  • Node.js 20 or later (was 18). Node 20 is past its end of life too, so 22 or 24 is the better choice.
  • Fastify 5.12.5 or later if you use the Fastify plugin. Fastify 4 is end of life and has an unfixed authentication bypass.
  • TypeScript 5.4 or later to type-check against the package's types, which use NoInfer. JavaScript projects are not affected.

Breaking changes

ChangeAffects you ifTo keep the 2.x behaviour
Under security profile 1 or 2, a connection without Basic Auth credentials gets HTTP 401 (OCPP 2.0.1 / 2.1 A00.FR.203/204, A00.FR.302/303)Chargers on profile 1 or 2 connect without credentials, or your auth callback handled that caserequireBasicAuth: false
With strictMode, every protocol it covers needs a validator, built in (OCPP 1.6, 2.0.1, 2.1) or in strictModeValidators; otherwise the server and client constructors (and reconfigure()) throw Missing strictMode validator for subprotocol "…", and a route that turns strict mode on refuses a connection on such a protocol (HTTP 500, logged). Such a protocol used to go unvalidated, silentlyYou list a protocol without schemas (such as "ocpp2.0", or a custom one) under strict modeAdd its validator to strictModeValidators, or limit strict mode to the protocols that have one: strictMode: ["ocpp1.6"]
createValidator (and new Validator) throws a TypeError for a schema whose $id mentions urn: but names no action, such as '"urn:Heartbeat.req"'. ajv refused some of these with URI scheme is malformed.; it registered others, such as 'urn:Heartbeat.req"', under an id no lookup uses, so strict mode let invalid payloads through without a wordA custom schema's $id has quotes, spaces or a wrong case in itWrite it as urn:<Action>.req / urn:<Action>.conf, urn:<Action>Request / urn:<Action>Response, or urn:<Message>
A protocol listed twice in protocols throws at construction (server, client, browser client, route config(), reconfigure()), and is a type error at the repeat. It changed nothing, and ws and browsers already refuse a client's duplicated subprotocolYour list has a repeatRemove it
maxBadMessages defaults to 50 (was unlimited) and counts malformed messages in a row; any valid message resets itA peer sends many malformed frames and must stay connectedmaxBadMessages: Infinity (the server logs a warning at startup)
OCPPClient offers WebSocket compression only when compression is setYour client relied on permessage-deflate being negotiated without setting itcompression: true
With no subprotocol in common, the handshake completes and the connection is closed with code 1002, instead of HTTP 400 (OCPP-J §3.2); also when the charger offers noneChargers or tests expect HTTP 400None: required by the spec
accept({ protocol }) with a version the charger did not offer is refused the same way (RFC 6455)Your auth callback picks a protocolPick from handshake.protocols
Anything thrown in an auth callback, connection middleware, combineAuth or isKnownIdentity gets HTTP 500 with a generic body (was 401 with the error message). The error is logged and emitted as UPGRADE_ERROR instead of AUTH_FAILED, and a numeric code on the error no longer sets the statusYou throw to reject a charger, or alert on AUTH_FAILEDReject with ctx.reject(code, message), which is unchanged; watch UPGRADE_ERROR for outages
Strict mode: a message ID longer than 36 characters is rejected (OCPP-J §4.1.4)strictMode is on and a peer sends longer IDsidValidator: () => true, or your own rule
Strict mode, OCPP 2.0.1 / 2.1: the charging station identity must be an identifierString (a-z A-Z 0-9 * - _ = + | @ .) without :, at most 48 characters, or the charger gets HTTP 400 (OCPP-J §3.1.1)strictMode is on for 2.x and identities break that ruleLength: maxIdentityLength. The character rule has no separate switch; it applies wherever strict mode is on
Reconnect waits are min(backoffMin × 2^(attempt − 1), backoffMax) × (1 + random × 0.25); never below backoffMin, still random at backoffMaxYou depend on exact reconnect timingsNone: tune backoffMin / backoffMax
A frame with an unknown message type or an unreadable message ID is answered under message ID "-1"; an unreadable ID gets RpcFrameworkError on 2.0.1 and 2.1, and GenericError on 1.6, which has no RpcFrameworkErrorPeers or tests match the message ID or code of these errorsNone: required by the spec
OCPP 1.6: a CALL that reuses the ID of a CALL still being handled gets GenericError (1.6 has no RpcFrameworkError)1.6 tests expect RpcFrameworkErrorNone
OCPP 2.0.1 / 2.1: error descriptions are cut to 255 characters (ErrorDescription is string[255])Your handlers send longer error messagesNone
A malformed CALLRESULT or CALLERROR is no longer answered with a CALLERROR (OCPP-J §4.2.3); on 2.1 a malformed CALLRESULT gets a CALLRESULTERRORTests expect a CALLERROR replyNone

All the options above together:

import { OCPPClient, OCPPServer } from "ocpp-ws-io";

const server = new OCPPServer({
  protocols: ["ocpp1.6", "ocpp2.0.1"],
  requireBasicAuth: false, // profile 1/2: the auth callback handles missing credentials
  maxBadMessages: Infinity, // never close a connection for malformed frames
  idValidator: () => true, // strict mode: accept message IDs of any length
});

const client = new OCPPClient({
  endpoint: "wss://csms.example.com/ocpp",
  identity: "CP001",
  protocols: ["ocpp2.0.1"],
  compression: true, // offer permessage-deflate as 2.x did
});

Type changes

These change only what compiles; nothing differs at runtime.

ChangeAffects you ifWhat to write
A client is typed for the protocols it is configured with: new OCPPClient({ protocols: ["ocpp1.6"] }) is an OCPPClient<"ocpp1.6">, so only 1.6 actions, params and responses type-check, and a version named in a call must be one of its protocolsYour code calls or handles an action, or reads a field, of a version the client is not configured for; or keeps a BrowserOCPPClient in a variable or list typed as plain BrowserOCPPClientWrite the call for a configured version, or add the version to protocols. For the browser client, type the variable for its protocols (BrowserOCPPClient<"ocpp1.6">), or use AnyBrowserOCPPClient for a list that only connects, closes and listens
A server is typed for its protocols the same way: its connections, routes and their handlers, ctx.accept({ protocol }), sendToClient(), broadcast() and inline plugins use only its versions, and a route's config({ protocols }) must be a subset of the server'sYour code handles, calls or sends an action of a version the server does not offer, accepts such a version in auth(), or configures a route for oneAdd the version to the server's protocols, or remove the code: no charger can negotiate that version on this server, so such a handler never runs
Typed methods take only the actions their protocols define (on clients and connections call, safeCall, callImmediate, handle, send, removeHandler; on the server sendToClient, safeSendToClient, broadcast, broadcastBatch, sendBatch and route handle; sendToClient and safeSendToClient of the framework bindings: Express req.ocpp, NestJS OcppService, the Fastify and Hono contexts, which take every declared protocol's actions as a plain OCPPServer does), with their exact params and responses: there is no untyped fallback any more, so a misspelt action, an action of another version, wrong or missing params, or send() on a client without OCPP 2.1 no longer compilesYou call or handle a vendor action, or a name known only at runtime, or send invalid data on purpose (in tests)Declare the vendor action on its version's method map to type it, or wrap the name: client.call(unchecked("VendorPing"), params)
A payload may not carry a key the OCPP schema does not define, at any depth, also from a variable, an async handler or one branch of a returned union. Objects the schema leaves open (2.x customData, DataTransfer's data) take any JSONYou send or return extra keysRemove them, or use unchecked()
A type argument on a typed method is the action now: call<{ status: string }>("BootNotification", ...) no longer compilesYou passed a response typeRemove it; the response type comes from the action. For an action the types do not know: call<MyResult>(unchecked("Vendor"), ...)
A version named with an action the types do not know must be one of the client's protocols: a typo such as call("ocpp16", ...) compiled beforeYou name a version the client is not configured forAdd it to protocols, or correct the name
strictModeValidators, a strictMode list and strictModeMethods name only what the server, route or client is configured with: a validator, protocol or action for anything else compiled and did nothing. createValidator("vendor-proto", schemas) returns a Validator<"vendor-proto">. A configured custom protocol can now be listed in strictModeYou list a protocol or action you do not configure, or build a validator from a name known only at runtimeRemove the entry, or add its protocol to protocols; for an action the types do not know, unchecked("VendorAction"); name the validator's protocol literally
NestJS @OcppMessageEvent(action, protocol?) takes an action that exists, in that protocol when one is given; a misspelt name used to register a handler nothing calledYou name an action the types do not know@OcppMessageEvent(unchecked("VendorAction")), or declare it on its version's method map
Sessions hold JSON values, typed by the keys you declare on OCPPSession: client.session, ctx.accept({ session }), connection middleware's ctx.state and ctx.next(payload) (all of which end up in the session). client.session was Record<string, any>, and accept({ session }) took any objectYou put a Date, function or class instance in a session or ctx.state, or read a session value as a specific type without declaring itDeclare the keys (declare module "ocpp-ws-io" { interface OCPPSession { tenantId: string } }), and store JSON forms; keep other data outside the session
BrowserOCPPClient's events are typed, as OCPPClient's are (BrowserClientEvents): a listener gets each event's arguments typed, and emit() of a declared event takes its arguments. error carries a DOM Event or an Error. An event the client does not declare still takes any argumentsYou annotate a browser listener's parameters with other types, or emit a declared event with other argumentsDrop the annotation, or match the event: client.on("error", (err: Event | Error) => …)
Declarations that took any take their exact types: middleware given to @UseOcppRpcMiddleware() gets a MiddlewareContext; OcppModule.forRootAsync()'s imports and inject take Nest's module and injection types; Hono's binding.close() takes CloseOptions; createLoggingMiddleware() returns MiddlewareFunction<MiddlewareContext>; MiddlewareStack.use()'s TReturn defaults to unknownYour middleware reads a field its context does not have, or you pass these something elseNarrow on ctx.type before reading a phase's fields (if (ctx.type === "incoming_call") ctx.params), or pass the option's real type

What keeps compiling: a typed server, route, connection or Node client still fits a variable or list typed as plain OCPPServer, OCPPRouter, OCPPServerClient or OCPPClient, typed for every version as in 2.x. The framework adapters take any server; callbacks from defineAuth() or combineAuth(), handlers typed with plain RouterHandlerContext, and plugins typed OCPPPlugin fit any server or route.

A server or client created without protocols, from options typed as plain ServerOptions or ClientOptions, or with a list only known at runtime (string[]) keeps the plain type, typed for every version as before. One configured for several versions is typed for all of them; client.forProtocol("ocpp1.6") returns a client or connection typed for the version it negotiated, or undefined. To store connections with their exact type, use ConnectionOf<typeof server>.

Fixes you may notice

These are bug fixes, not breaking changes, but they change what you can observe:

  • Connection middleware without an auth callback no longer changes the negotiated subprotocol.
  • A null payload on a CALL or CALLRESULT is accepted as {} instead of being rejected.
  • strictModeValidators add to the built-in validators, as documented, instead of replacing them: with protocols: ["ocpp1.6", "vendor-proto"] and a vendor validator, 1.6 messages are now validated too.
  • A call whose message ID is already used by a pending call rejects instead of being sent. This needs a repeated idempotencyKey or idGenerator value while calls overlap.
  • The browser client uses a per-call idempotencyKey as the message ID.

The full list, including new options such as isKnownIdentity, duplicateConnection and the TLS settings, is in the CHANGELOG.

On this page