@openevstack/ocpp-rpc
Version:
⚡ A lightweight, production-ready RPC server built with Express and WebSocket for handling OCPP-based EV charger communication. Part of the OpenEVStack ecosystem.
818 lines (817 loc) • 33.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RpcClient = void 0;
const events_1 = require("events");
const crypto_1 = require("crypto");
const promises_1 = require("timers/promises");
const ws_1 = __importDefault(require("ws"));
const { CONNECTING, OPEN, CLOSING, CLOSED } = ws_1.default;
const standard_validators_1 = __importDefault(require("./utils/standard-validators"));
const rpcError_1 = require("./utils/rpcError");
const event_buffer_1 = __importDefault(require("./utils/event-buffer"));
const backoff_1 = require("backoff");
const ws_utils_1 = require("./utils/ws-utils");
const MSG_1 = require("./utils/MSG");
const symbols_1 = require("./utils/symbols");
const errors_1 = require("./errors");
class RpcClient extends events_1.EventEmitter {
constructor(_) {
super();
this._identity = undefined;
this._wildcardHandler = undefined;
this._handlers = new Map();
this._state = CLOSED; // Ideally use a constant or enum
this._callQueue = []; // Replace with actual Queue class
this._ws = undefined;
this._wsAbortController = undefined;
this._keepAliveAbortController = undefined;
this._pendingPingResponse = false;
this._lastPingTime = 0;
this._closePromise = undefined;
this._protocolOptions = [];
this._protocol = undefined;
this._strictProtocols = [];
this._strictValidators = undefined;
this._pendingCalls = new Map();
this._pendingResponses = new Map();
this._outboundMsgBuffer = [];
this._connectedOnce = false;
this._backoffStrategy = undefined;
this._badMessagesCount = 0;
this._reconnectAttempt = 0;
this._options = {
endpoint: "ws://localhost",
callTimeoutMs: 1000 * 60,
pingIntervalMs: 1000 * 30,
deferPingsOnActivity: false,
wsOpts: {},
headers: {},
protocols: [],
reconnect: true,
maxReconnects: Infinity,
respondWithDetailedErrors: false,
callConcurrency: 1,
maxBadMessages: Infinity,
strictMode: false,
strictModeValidators: [],
backoff: {
initalDelay: 1000,
maxDelay: 10 * 1000,
factor: 2,
randomisationFactor: 0.25,
},
};
}
get identity() {
return this._identity;
}
set identity(id) {
this._identity = id;
}
get state() {
return this._state;
}
reconfigure(options) {
const newOpts = Object.assign(this._options, options);
if (!newOpts.identity) {
throw Error("identity is required");
}
if (newOpts.strictMode && !newOpts.protocols.length) {
throw Error("strictMode requires at least one subprotocol");
}
const strictValidators = [...standard_validators_1.default];
if (newOpts.strictModeValidators) {
strictValidators.push(...newOpts.strictModeValidators);
}
this._strictValidators = strictValidators.reduce((svs, v) => {
svs.set(v.subprotocol, v);
return svs;
}, new Map());
this._strictProtocols = [];
if (Array.isArray(newOpts.strictMode)) {
this._strictProtocols = newOpts.strictMode;
}
else if (newOpts.strictMode) {
this._strictProtocols = newOpts.protocols;
}
const missingValidator = this._strictProtocols.find((protocol) => !this._strictValidators.has(protocol));
if (missingValidator) {
throw Error(`Missing strictMode validator for subprotocol '${missingValidator}'`);
}
this._callQueue.setConcurrency(newOpts.callConcurrency);
this._backoffStrategy = new backoff_1.ExponentialStrategy(newOpts.backoff);
if ("pingIntervalMs" in options) {
this._keepAlive();
}
}
/**
* Attempt to connect to the RPCServer
* @returns {Promise<void>}
*/
async connect() {
var _a;
this._protocolOptions = (_a = this._options.protocols) !== null && _a !== void 0 ? _a : [];
this._protocol = undefined;
this._identity = this._options.identity;
let connUrl = this._options.endpoint +
"/" +
encodeURIComponent(this._options.identity);
if (this._options.query) {
const searchParams = new URLSearchParams(this._options.query);
connUrl += "?" + searchParams.toString();
}
this._connectionUrl = connUrl;
if (this._state === CLOSING) {
throw Error("Cannot connect while closing");
}
if (this._state === OPEN) {
return;
}
if (this._state === CONNECTING) {
return this._connectPromise;
}
try {
return await this._beginConnect();
}
catch (error) {
this._state = CLOSED;
this.emit("close", { code: 1006, reason: "Abnormal Closure" });
throw error;
}
}
/**
* Send a message to the RPCServer. While socket is connecting, the message is queued and send when open.
* @param {Buffer|String} message - String to send via websocket
*/
sendRaw(message) {
if ([OPEN, CLOSING].includes(this._state) && this._ws) {
this._ws.send(message);
this.emit("message", { message, outbound: true });
}
else if (this._state === CONNECTING) {
this._outboundMsgBuffer.push(message);
}
else {
throw Error("Cannot send message in this state");
}
}
/**
* Closes the RPCClient.
* @param {Object} options - Close options
* @param {number} options.code - The websocket CloseEvent code.
* @param {string} options.reason - The websocket CloseEvent reason.
* @param {boolean} options.awaitPending - Wait for in-flight calls & responses to complete before closing.
* @param {boolean} options.force - Terminate websocket immediately without passing code, reason, or waiting.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code CloseEvent codes}
* @returns Promise<Object> - The CloseEvent (code & reason) for closure. May be different from requested code & reason.
*/
async close({ code, reason, awaitPending, force }) {
if ([OPEN, CLOSING].includes(this._state)) {
// no-op
return this._closePromise;
}
if (this._state === OPEN && this._ws) {
this._closePromise = (async () => {
if (force || !awaitPending) {
// reject pending calls
this._rejectPendingCalls("Client going away");
}
if (force && this._ws) {
this._ws.terminate();
}
else if (this._ws) {
// await pending calls & responses
await this._awaitUntilPendingSettled();
if (!code || !(0, ws_utils_1.isValidStatusCode)(code)) {
code = 1000;
}
this._ws.close(code, reason);
}
let [codeRes, reasonRes] = await (0, events_1.once)(this._ws, "close");
if (reasonRes instanceof Buffer) {
reasonRes = reasonRes.toString("utf8");
}
return { code: codeRes, reason: reasonRes };
})();
this._state = CLOSING;
this._connectedOnce = false;
this.emit("closing");
return this._closePromise;
}
else if (this._wsAbortController) {
const result = this._connectedOnce
? { code, reason }
: { code: 1001, reason: "Connection aborted" };
this._wsAbortController.abort();
this._state = CLOSED;
this._connectedOnce = false;
this.emit("close", result);
return result;
}
}
/**
*
* @param {string} [method] - The name of the RPC method to handle.
* @param {Function} handler - A function that can handle incoming calls for this method.
*/
handle(method, handler) {
if (method instanceof Function && !handler) {
this._wildcardHandler = method;
}
else {
this._handlers.set(method, handler);
}
}
/**
*
* @param {string} [method] - The name of the handled method.
*/
removeHandler(method) {
if (method == null) {
this._wildcardHandler = undefined;
}
else {
this._handlers.delete(method);
}
}
removeAllHandlers() {
this._wildcardHandler = undefined;
this._handlers.clear();
}
/**
* Call a method on a remote RPCClient or RPCServerClient.
* @param {string} method - The RPC method to call.
* @param {*} params - A value to be passed as params to the remote handler.
* @param {Object} options - Call options
* @param {number} options.callTimeoutMs - Call timeout (in milliseconds)
* @param {AbortSignal} options.signal - AbortSignal to cancel the call.
* @param {boolean} options.noReply - If set to true, the call will return immediately.
* @returns Promise<*> - Response value from the remote handler.
*/
async call(method, params, options) {
return await this._callQueue.push(this._call.bind(this, method, params, options));
}
async _call(method, params, options) {
var _a;
const timeoutMs = (_a = options.callTimeoutMs) !== null && _a !== void 0 ? _a : this._options.callTimeoutMs;
if ([CLOSED, CLOSING].includes(this._state)) {
throw Error(`Cannot make call while socket not open`);
}
const msgId = (0, crypto_1.randomUUID)();
const payload = [MSG_1.MSG_CALL, msgId, method, params];
if (this._strictProtocols.includes(this._protocol)) {
// perform some strict-mode checks
const validator = this._strictValidators.get(this._protocol);
try {
validator.validate(`urn:${method}.req`, params);
}
catch (error) {
this.emit("strictValidationFailure", {
messageId: msgId,
method,
params,
result: null,
error,
outbound: true,
isCall: true,
});
throw error;
}
}
const pendingCall = { msgId, method, params };
if (!options.noReply) {
const timeoutAc = new AbortController();
const cleanup = () => {
if (pendingCall.timeout) {
timeoutAc.abort();
}
this._pendingCalls.delete(msgId);
};
pendingCall.abort = (reason) => {
const err = Error(reason);
err.name = "AbortError";
pendingCall.reject(err);
};
if (options.signal) {
(0, events_1.once)(options.signal, "abort").then(() => {
pendingCall.abort(options.signal.reason);
});
}
pendingCall.promise = new Promise((resolve, reject) => {
pendingCall.resolve = (value) => {
cleanup();
resolve(value);
};
pendingCall.reject = (reason) => {
cleanup();
reject(reason);
};
});
if (timeoutMs && timeoutMs > 0 && timeoutMs < Infinity) {
const timeoutError = new errors_1.TimeoutError("Call timeout");
pendingCall.timeout = (0, promises_1.setTimeout)(timeoutMs, null, {
signal: timeoutAc.signal,
})
.then(() => {
pendingCall.reject(timeoutError);
})
.catch(() => { });
}
this._pendingCalls.set(msgId, pendingCall);
}
this.emit("call", { outbound: true, payload });
this.sendRaw(JSON.stringify(payload));
if (options.noReply) {
return;
}
try {
const result = await pendingCall.promise;
this.emit("callResult", {
outbound: true,
messageId: msgId,
method,
params,
result,
});
return result;
}
catch (err) {
this.emit("callError", {
outbound: true,
messageId: msgId,
method,
params,
error: err,
});
throw err;
}
}
/**
* Start consuming from a WebSocket
* @param {WebSocket} ws - A WebSocket instance
* @param {EventBuffer} leadMsgBuffer - A buffer which traps all 'message' events
*/
_attachWebsocket(ws, leadMsgBuffer) {
ws.once("close", (code, reason) => this._handleDisconnect({ code, reason }));
ws.on("error", (err) => this.emit("socketError", err));
ws.on("ping", () => {
if (this._options.deferPingsOnActivity) {
this._deferNextPing();
}
});
ws.on("pong", () => {
if (this._options.deferPingsOnActivity) {
this._deferNextPing();
}
this._pendingPingResponse = false;
const rtt = Date.now() - this._lastPingTime;
this.emit("ping", { rtt });
});
this._keepAlive();
process.nextTick(() => {
if (leadMsgBuffer) {
const messages = leadMsgBuffer.condense();
messages.forEach(([msg]) => this._onMessage(msg));
}
ws.on("message", (msg) => this._onMessage(msg));
});
}
_rejectPendingCalls(abortReason) {
const pendingCalls = Array.from(this._pendingCalls.values());
const pendingResponses = Array.from(this._pendingResponses.values());
[...pendingCalls, ...pendingResponses].forEach((c) => c.abort(abortReason));
}
async _awaitUntilPendingSettled() {
const pendingCalls = Array.from(this._pendingCalls.values());
const pendingResponses = Array.from(this._pendingResponses.values());
return await Promise.allSettled([
...pendingResponses.map((c) => c.promise),
...pendingCalls.map((c) => c.promise),
]);
}
_handleDisconnect({ code, reason }) {
var _a;
if (reason instanceof Buffer) {
reason = reason.toString("utf8");
}
// reject any outstanding calls/responses
this._rejectPendingCalls("Client disconnected");
(_a = this._keepAliveAbortController) === null || _a === void 0 ? void 0 : _a.abort();
this.emit("disconnect", { code, reason });
if (this._state === CLOSED) {
// nothing to do here
return;
}
if (this._state !== CLOSING && this._options.reconnect) {
this._tryReconnect();
}
else {
this._state = CLOSED;
this.emit("close", { code, reason });
}
}
_beginConnect() {
this._connectPromise = (async () => {
var _a;
this._wsAbortController = new AbortController();
const wsOpts = Object.assign({
// defaults
noDelay: true,
signal: this._wsAbortController.signal,
headers: {
"user-agent": (0, rpcError_1.getPackageIdent)(),
},
}, (_a = this._options.wsOpts) !== null && _a !== void 0 ? _a : {});
Object.assign(wsOpts.headers, this._options.headers);
if (this._options.password != null) {
const usernameBuffer = Buffer.from(this._identity + ":");
let passwordBuffer = this._options.password;
if (typeof passwordBuffer === "string") {
passwordBuffer = Buffer.from(passwordBuffer, "utf8");
}
const b64 = Buffer.concat([usernameBuffer, passwordBuffer]).toString("base64");
wsOpts.headers.authorization = "Basic " + b64;
}
this._ws = new ws_1.default(this._connectionUrl, this._protocolOptions, wsOpts);
const leadMsgBuffer = new event_buffer_1.default(this._ws, "message");
let upgradeResponse;
try {
await new Promise((resolve, reject) => {
this._ws.once("unexpected-response", (request, response) => {
const error = new errors_1.UnexpectedHttpResponse(response.statusMessage);
error.code = Number(response.statusCode);
error.request = request;
error.response = response;
reject(error);
});
this._ws.once("upgrade", (response) => {
upgradeResponse = response;
});
this._ws.once("error", (err) => reject(err));
this._ws.once("open", () => resolve());
});
// record which protocol was selected
if (this._protocol === undefined) {
this._protocol = this._ws.protocol;
this.emit("protocol", this._protocol);
}
// limit protocol options in case of future reconnect
this._protocolOptions = this._protocol ? [this._protocol] : [];
this._reconnectAttempt = 0;
this._backoffStrategy.reset();
this._state = OPEN;
this._connectedOnce = true;
this._pendingPingResponse = false;
this._attachWebsocket(this._ws, leadMsgBuffer);
// send queued messages
if (this._outboundMsgBuffer.length > 0) {
const buff = this._outboundMsgBuffer;
this._outboundMsgBuffer = [];
buff.forEach((msg) => this.sendRaw(msg));
}
const result = {
response: upgradeResponse,
};
this.emit("open", result);
return result;
}
catch (error) {
this._ws.terminate();
if (upgradeResponse) {
error.upgrade = upgradeResponse;
}
throw error;
}
})();
this._state = CONNECTING;
this.emit("connecting", { protocols: this._protocolOptions });
return this._connectPromise;
}
_deferNextPing() {
if (!this._nextPingTimeout) {
return;
}
this._nextPingTimeout.refresh();
}
async _keepAlive() { }
async _tryReconnect() {
this._reconnectAttempt++;
if (this._reconnectAttempt > Number(this._options.maxReconnects)) {
// give up
this.close({ code: 1001, reason: "Giving up" });
}
else {
try {
this._state = CONNECTING;
const delay = this._backoffStrategy.next();
await (0, promises_1.setTimeout)(delay, null, {
signal: this._wsAbortController.signal,
});
await this._beginConnect()
.catch(async (error) => {
const intolerableErrors = [
"Maximum redirects exceeded",
"Server sent no subprotocol",
"Server sent an invalid subprotocol",
"Server sent a subprotocol but none was requested",
"Invalid Sec-WebSocket-Accept header",
];
if (intolerableErrors.includes(error.message)) {
throw error;
}
this._tryReconnect();
})
.catch((error) => {
this.close({ code: 1001, reason: error.message });
});
}
catch (err) {
// aborted timeout
return;
}
}
}
_onMessage(buffer) {
if (this._options.deferPingsOnActivity) {
this._deferNextPing();
}
const message = buffer.toString();
if (!message.length) {
// ignore empty messages
// for compatibility with some particular charge point vendors (naming no names)
return;
}
this.emit("message", { message, outbound: false });
let msgId = "-1";
let messageType = -1;
try {
let payload;
try {
payload = JSON.parse(message);
}
catch (err) {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", "Message must be a JSON structure", {});
}
if (!Array.isArray(payload)) {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", "Message must be an array", {});
}
const [messageTypePart, msgIdPart, ...more] = payload;
if (typeof messageTypePart !== "number") {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", "Message type must be a number", {});
}
// Extension fallback mechanism
// (see section 4.4 of OCPP2.0.1J)
if (![MSG_1.MSG_CALL, MSG_1.MSG_CALLERROR, MSG_1.MSG_CALLRESULT].includes(messageTypePart)) {
throw (0, rpcError_1.createRPCError)("MessageTypeNotSupported", "Unrecognised message type", {});
}
messageType = messageTypePart;
if (typeof msgIdPart !== "string") {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", "Message ID must be a string", {});
}
msgId = msgIdPart;
switch (messageType) {
case MSG_1.MSG_CALL:
const [method, params] = more;
if (typeof method !== "string") {
throw new errors_1.RPCFrameworkError("Method must be a string");
}
this.emit("call", { outbound: false, payload });
this._onCall(msgId, method, params);
break;
case MSG_1.MSG_CALLRESULT:
const [result] = more;
this.emit("response", { outbound: false, payload });
this._onCallResult(msgId, result);
break;
case MSG_1.MSG_CALLERROR:
const [errorCode, errorDescription, errorDetails] = more;
this.emit("response", { outbound: false, payload });
this._onCallError(msgId, errorCode, errorDescription, errorDetails);
break;
default:
throw new errors_1.RPCMessageTypeNotSupportedError(`Unexpected message type: ${messageType}`);
}
this._badMessagesCount = 0;
}
catch (error) {
const shouldClose = ++this._badMessagesCount > Number(this._options.maxBadMessages);
let response = null;
let errorMessage = "";
if (![MSG_1.MSG_CALLERROR, MSG_1.MSG_CALLRESULT].includes(messageType)) {
// We shouldn't respond to CALLERROR or CALLRESULT, but we may respond
// to any CALL (or other unknown message type) with a CALLERROR
// (see section 4.4 of OCPP2.0.1J - Extension fallback mechanism)
const details = error.details ||
(this._options.respondWithDetailedErrors
? (0, rpcError_1.getErrorPlainObject)(error)
: {});
errorMessage = error.message || error.rpcErrorMessage || "";
response = [
MSG_1.MSG_CALLERROR,
msgId,
error.rpcErrorCode || "GenericError",
errorMessage,
details !== null && details !== void 0 ? details : {},
];
}
this.emit("badMessage", { buffer, error, response });
if (shouldClose) {
this.close({
code: 1002,
reason: error instanceof errors_1.RPCGenericError ? errorMessage : "Protocol error",
});
}
else if (response && this._state === OPEN) {
this.sendRaw(JSON.stringify(response));
}
}
}
async _onCall(msgId, method, params) {
// NOTE: This method must not throw or else it risks sending 2 replies
try {
let payload;
if (this._state !== OPEN) {
throw Error("Call received while client state not OPEN");
}
try {
if (this._pendingResponses.has(msgId)) {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", `Already processing a call with message ID: ${msgId}`, {});
}
let handler = this._handlers.get(method);
if (!handler) {
handler = this._wildcardHandler;
}
if (!handler) {
throw (0, rpcError_1.createRPCError)("NotImplemented", `Unable to handle '${method}' calls`, {});
}
if (this._strictProtocols.includes(this._protocol)) {
// perform some strict-mode checks
const validator = this._strictValidators.get(this._protocol);
try {
validator.validate(`urn:${method}.req`, params);
}
catch (error) {
this.emit("strictValidationFailure", {
messageId: msgId,
method,
params,
result: null,
error,
outbound: false,
isCall: true,
});
throw error;
}
}
const ac = new AbortController();
const callPromise = new Promise(async (resolve, reject) => {
function reply(val) {
if (val instanceof Error) {
reject(val);
}
else {
resolve(val);
}
}
try {
reply(await handler({
messageId: msgId,
method,
params,
signal: ac.signal,
reply,
}));
}
catch (err) {
reply(err);
}
});
const pending = { abort: ac.abort.bind(ac), promise: callPromise };
this._pendingResponses.set(msgId, pending);
const result = await callPromise;
this.emit("callResult", {
outbound: false,
messageId: msgId,
method,
params,
result,
});
if (result === symbols_1.NOREPLY) {
return; // don't send a reply
}
payload = [MSG_1.MSG_CALLRESULT, msgId, result];
if (this._strictProtocols.includes(this._protocol)) {
// perform some strict-mode checks
const validator = this._strictValidators.get(this._protocol);
try {
validator.validate(`urn:${method}.conf`, result);
}
catch (error) {
this.emit("strictValidationFailure", {
messageId: msgId,
method,
params,
result,
error,
outbound: true,
isCall: false,
});
throw (0, rpcError_1.createRPCError)("InternalError");
}
}
}
catch (error) {
// catch here to prevent this error from being considered a 'badMessage'.
const details = error.details ||
(this._options.respondWithDetailedErrors
? (0, rpcError_1.getErrorPlainObject)(error)
: {});
let rpcErrorCode = error.rpcErrorCode || "GenericError";
if (this.protocol === "ocpp1.6") {
// Workaround for some mistakes in the spec in OCPP1.6J
// (clarified in section 5 of OCPP1.6J errata v1.0)
switch (rpcErrorCode) {
case "FormatViolation":
rpcErrorCode = "FormationViolation";
break;
case "OccurenceConstraintViolation":
rpcErrorCode = "OccurrenceConstraintViolation";
break;
}
}
payload = [
MSG_1.MSG_CALLERROR,
msgId,
rpcErrorCode,
error.message || error.rpcErrorMessage || "",
details !== null && details !== void 0 ? details : {},
];
this.emit("callError", {
outbound: false,
messageId: msgId,
method,
params,
error,
});
}
finally {
this._pendingResponses.delete(msgId);
}
this.emit("response", { outbound: true, payload });
this.sendRaw(JSON.stringify(payload));
}
catch (err) {
this.close({ code: 1000, reason: "Unable to send call result" });
}
}
_onCallResult(msgId, result) {
const pendingCall = this._pendingCalls.get(msgId);
if (pendingCall) {
if (this._strictProtocols.includes(this._protocol)) {
// perform some strict-mode checks
const validator = this._strictValidators.get(this._protocol);
try {
validator.validate(`urn:${pendingCall.method}.conf`, result);
}
catch (error) {
this.emit("strictValidationFailure", {
messageId: msgId,
method: pendingCall.method,
params: pendingCall.params,
result,
error,
outbound: false,
isCall: false,
});
return pendingCall.reject(error);
}
}
return pendingCall.resolve(result);
}
else {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", `Received CALLRESULT for unrecognised message ID: ${msgId}`, {
msgId,
result,
});
}
}
_onCallError(msgId, errorCode, errorDescription, errorDetails) {
const pendingCall = this._pendingCalls.get(msgId);
if (pendingCall) {
const error = (0, rpcError_1.createRPCError)(errorCode, errorDescription, errorDetails);
pendingCall.reject(error);
}
else {
throw (0, rpcError_1.createRPCError)("RpcFrameworkError", `Received CALLERROR for unrecognised message ID: ${msgId}`, {
msgId,
errorCode,
errorDescription,
errorDetails,
});
}
}
}
exports.RpcClient = RpcClient;