ocpp-rpc
Version:
A client & server implementation of the WAMP-like RPC-over-websocket system defined in the OCPP protocols (e.g. OCPP1.6-J and OCPP2.0.1).
1,001 lines (827 loc) • 35.2 kB
JavaScript
const { randomUUID} = require('crypto');
const { EventEmitter, once } = require('events');
const { setTimeout } = require('timers/promises');
const { setTimeout: setTimeoutCb } = require('timers');
const WebSocket = require('ws');
const { ExponentialStrategy } = require('backoff');
const { CONNECTING, OPEN, CLOSING, CLOSED } = WebSocket;
const { NOREPLY } = require('./symbols');
const { TimeoutError, UnexpectedHttpResponse, RPCFrameworkError, RPCGenericError, RPCMessageTypeNotSupportedError } = require('./errors');
const { getErrorPlainObject, createRPCError, getPackageIdent } = require('./util');
const Queue = require('./queue');
const EventBuffer = require('./event-buffer');
const standardValidators = require('./standard-validators');
const {isValidStatusCode} = require('./ws-util');
const MSG_CALL = 2;
const MSG_CALLRESULT = 3;
const MSG_CALLERROR = 4;
class RPCClient extends EventEmitter {
constructor(options) {
super();
this._identity = undefined;
this._wildcardHandler = null;
this._handlers = new Map();
this._state = CLOSED;
this._callQueue = new Queue();
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 = {
// defaults
endpoint: 'ws://localhost',
password: null,
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: {
initialDelay: 1000,
maxDelay: 10*1000,
factor: 2,
randomisationFactor: 0.25,
}
};
this.reconfigure(options || {});
}
get identity() {
return this._identity;
}
get protocol() {
return this._protocol;
}
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 = [...standardValidators];
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 ExponentialStrategy(newOpts.backoff);
if ('pingIntervalMs' in options) {
this._keepAlive();
}
}
/**
* Attempt to connect to the RPCServer.
* @returns {Promise<undefined>} Resolves when connected, rejects on failure
*/
async connect() {
this._protocolOptions = this._options.protocols ?? [];
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) {
// no-op
return;
}
if (this._state === CONNECTING) {
return this._connectPromise;
}
try {
return await this._beginConnect();
} catch (err) {
this._state = CLOSED;
this.emit('close', {code: 1006, reason: "Abnormal Closure"});
throw err;
}
}
/**
* 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) {
// can send while closing so long as websocket doesn't mind
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 ([CLOSED, CLOSING].includes(this._state)) {
// no-op
return this._closePromise;
}
if (this._state === OPEN) {
this._closePromise = (async () => {
if (force || !awaitPending) {
// reject pending calls
this._rejectPendingCalls("Client going away");
}
if (force) {
this._ws.terminate();
} else {
// await pending calls & responses
await this._awaitUntilPendingSettled();
if (!code || !isValidStatusCode(code)) {
code = 1000;
}
this._ws.close(code, reason);
}
let [codeRes, reasonRes] = await 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 = null;
} else {
this._handlers.delete(method);
}
}
removeAllHandlers() {
this._wildcardHandler = null;
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 = {}) {
const timeoutMs = options.callTimeoutMs ?? this._options.callTimeoutMs;
if ([CLOSED, CLOSING].includes(this._state)) {
throw Error(`Cannot make call while socket not open`);
}
const msgId = randomUUID();
const payload = [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) {
once(options.signal, 'abort').then(() => {
pendingCall.abort(options.signal.reason);
});
}
pendingCall.promise = new Promise((resolve, reject) => {
pendingCall.resolve = (...args) => {
cleanup();
resolve(...args);
};
pendingCall.reject = (...args) => {
cleanup();
reject(...args);
};
});
if (timeoutMs && timeoutMs > 0 && timeoutMs < Infinity) {
const timeoutError = new TimeoutError("Call timeout");
pendingCall.timeout = setTimeout(timeoutMs, null, {signal: timeoutAc.signal}).then(() => {
pendingCall.reject(timeoutError);
}).catch(err=>{});
}
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}) {
if (reason instanceof Buffer) {
reason = reason.toString('utf8');
}
// reject any outstanding calls/responses
this._rejectPendingCalls("Client disconnected");
this._keepAliveAbortController?.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 () => {
this._wsAbortController = new AbortController();
const wsOpts = Object.assign({
// defaults
noDelay: true,
signal: this._wsAbortController.signal,
headers: {
'user-agent': getPackageIdent()
},
}, this._options.wsOpts ?? {});
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 WebSocket(
this._connectionUrl,
this._protocolOptions,
wsOpts,
);
const leadMsgBuffer = new EventBuffer(this._ws, 'message');
let upgradeResponse;
try {
await new Promise((resolve, reject) => {
this._ws.once('unexpected-response', (request, response) => {
const err = new UnexpectedHttpResponse(response.statusMessage);
err.code = response.statusCode;
err.request = request;
err.response = response;
reject(err);
});
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 (err) {
this._ws.terminate();
if (upgradeResponse) {
err.upgrade = upgradeResponse;
}
throw err;
}
})();
this._state = CONNECTING;
this.emit('connecting', {protocols: this._protocolOptions});
return this._connectPromise;
}
_deferNextPing() {
if (!this._nextPingTimeout) {
return;
}
this._nextPingTimeout.refresh();
}
async _keepAlive() {
// abort any previously running keepAlive
this._keepAliveAbortController?.abort();
const timerEmitter = new EventEmitter();
const nextPingTimeout = setTimeoutCb(()=>{
timerEmitter.emit('next')
}, this._options.pingIntervalMs);
this._nextPingTimeout = nextPingTimeout;
try {
if (this._state !== OPEN) {
// don't start pinging if connection not open
return;
}
if (!this._options.pingIntervalMs || this._options.pingIntervalMs <= 0 || this._options.pingIntervalMs > 2147483647) {
// don't ping for unusuable intervals
return;
}
// setup new abort controller
this._keepAliveAbortController = new AbortController();
while (true) {
await once(timerEmitter, 'next', {signal: this._keepAliveAbortController.signal}),
this._keepAliveAbortController.signal.throwIfAborted();
if (this._state !== OPEN) {
// keepalive no longer required
break;
}
if (this._pendingPingResponse) {
// we didn't get a response to our last ping
throw Error("Ping timeout");
}
this._lastPingTime = Date.now();
this._pendingPingResponse = true;
this._ws.ping();
nextPingTimeout.refresh();
}
} catch (err) {
// console.log('keepalive failed', err);
if (err.name !== 'AbortError') {
// throws on ws.ping() error
this._ws.terminate();
}
} finally {
clearTimeout(nextPingTimeout);
}
}
async _tryReconnect() {
this._reconnectAttempt++;
if (this._reconnectAttempt > this._options.maxReconnects) {
// give up
this.close({code: 1001, reason: "Giving up"});
} else {
try {
this._state = CONNECTING;
const delay = this._backoffStrategy.next();
await setTimeout(delay, null, {signal: this._wsAbortController.signal});
await this._beginConnect().catch(async (err) => {
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(err.message)) {
throw err;
}
this._tryReconnect();
}).catch(err => {
this.close({code: 1001, reason: err.message});
});
} catch (err) {
// aborted timeout
return;
}
}
}
_onMessage(buffer) {
if (this._options.deferPingsOnActivity) {
this._deferNextPing();
}
const message = buffer.toString('utf8');
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;
try {
let payload;
try {
payload = JSON.parse(message);
} catch (err) {
throw createRPCError("RpcFrameworkError", "Message must be a JSON structure", {});
}
if (!Array.isArray(payload)) {
throw createRPCError("RpcFrameworkError", "Message must be an array", {});
}
const [messageTypePart, msgIdPart, ...more] = payload;
if (typeof messageTypePart !== 'number') {
throw createRPCError("RpcFrameworkError", "Message type must be a number", {});
}
// Extension fallback mechanism
// (see section 4.4 of OCPP2.0.1J)
if (![MSG_CALL, MSG_CALLERROR, MSG_CALLRESULT].includes(messageTypePart)) {
throw createRPCError("MessageTypeNotSupported", "Unrecognised message type", {});
}
messageType = messageTypePart;
if (typeof msgIdPart !== 'string') {
throw createRPCError("RpcFrameworkError", "Message ID must be a string", {});
}
msgId = msgIdPart;
switch (messageType) {
case MSG_CALL:
const [method, params] = more;
if (typeof method !== 'string') {
throw new RPCFrameworkError("Method must be a string");
}
this.emit('call', {outbound: false, payload});
this._onCall(msgId, method, params);
break;
case MSG_CALLRESULT:
const [result] = more;
this.emit('response', {outbound: false, payload});
this._onCallResult(msgId, result);
break;
case MSG_CALLERROR:
const [errorCode, errorDescription, errorDetails] = more;
this.emit('response', {outbound: false, payload});
this._onCallError(msgId, errorCode, errorDescription, errorDetails);
break;
default:
throw new RPCMessageTypeNotSupportedError(`Unexpected message type: ${messageType}`);
}
this._badMessagesCount = 0;
} catch (error) {
const shouldClose = ++this._badMessagesCount > this._options.maxBadMessages;
let response = null;
let errorMessage = '';
if (![MSG_CALLERROR, 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 ? getErrorPlainObject(error) : {});
errorMessage = error.message || error.rpcErrorMessage || "";
response = [
MSG_CALLERROR,
msgId,
error.rpcErrorCode || 'GenericError',
errorMessage,
details ?? {},
];
}
this.emit('badMessage', {buffer, error, response});
if (shouldClose) {
this.close({
code: 1002,
reason: (error instanceof 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 createRPCError("RpcFrameworkError", `Already processing a call with message ID: ${msgId}`, {});
}
let handler = this._handlers.get(method);
if (!handler) {
handler = this._wildcardHandler;
}
if (!handler) {
throw 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 === NOREPLY) {
return; // don't send a reply
}
payload = [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 createRPCError("InternalError");
}
}
} catch (err) {
// catch here to prevent this error from being considered a 'badMessage'.
const details = err.details
|| (this._options.respondWithDetailedErrors ? getErrorPlainObject(err) : {});
let rpcErrorCode = err.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_CALLERROR,
msgId,
rpcErrorCode,
err.message || err.rpcErrorMessage || "",
details ?? {},
];
this.emit('callError', {
outbound: false,
messageId: msgId,
method,
params,
error: err,
});
} 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 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 err = createRPCError(errorCode, errorDescription, errorDetails);
pendingCall.reject(err);
} else {
throw createRPCError("RpcFrameworkError", `Received CALLERROR for unrecognised message ID: ${msgId}`, {
msgId,
errorCode,
errorDescription,
errorDetails
});
}
}
}
RPCClient.OPEN = OPEN;
RPCClient.CONNECTING = CONNECTING;
RPCClient.CLOSING = CLOSING;
RPCClient.CLOSED = CLOSED;
module.exports = RPCClient;