• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mikuso / ocpp-rpc / 30631477563

31 Jul 2026 12:40PM UTC coverage: 97.82% (+2.4%) from 95.455%
30631477563

Pull #100

github

web-flow
Merge 9c81c2ca7 into 9aff04d92
Pull Request #100: ESM conversion and convert TS declarations to JSdocs

412 of 442 branches covered (93.21%)

Branch coverage included in aggregate %.

1050 of 1050 new or added lines in 11 files covered. (100.0%)

25 existing lines in 2 files now uncovered.

2684 of 2723 relevant lines covered (98.57%)

256.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.54
/lib/server.js
1
import { EventEmitter, once } from 'events';
4✔
2
import { WebSocketServer, WebSocket } from 'ws';
4✔
3
const { OPEN, CLOSING, CLOSED } = WebSocket;
4✔
4
import { createServer } from 'http';
4✔
5
import { RPCServerClient } from './server-client.js';
4✔
6
import { abortHandshake, parseSubprotocols } from './ws-util.js';
4✔
7
import standardValidators from './standard-validators.js';
4✔
8
import { getPackageIdent } from './util.js';
4✔
9
import { WebsocketUpgradeError } from './errors.js';
4✔
10

4✔
11
/**
4✔
12
 * Called by the server before accepting each incoming WebSocket connection. Must call either
4✔
13
 * `accept` or `reject` exactly once; subsequent calls to either function are silently ignored.
4✔
14
 * The callback may be `async`.
4✔
15
 *
4✔
16
 * @callback AuthCallback
4✔
17
 * @param {function(*=, string=): void} accept - Accepts the connection and causes the server to
4✔
18
 *   emit a `'client'` event.
4✔
19
 *   - `session` {*}: Optional value attached to the resulting client as `client.session`.
4✔
20
 *     Defaults to `{}` if omitted.
4✔
21
 *   - `protocol` {string}: Optionally override subprotocol selection. When omitted, the first
4✔
22
 *     mutually-supported subprotocol (in server preference order) is used. Passing a protocol
4✔
23
 *     that the client did not advertise rejects the connection with HTTP 400.
4✔
24
 * @param {function(number=, string=): void} reject - Rejects the connection.
4✔
25
 *   - `code` {number}: HTTP status code to send. Defaults to `404`.
4✔
26
 *   - `message` {string}: HTTP response body. Defaults to `'Not found'`.
4✔
27
 *   Calling `reject` also causes the server to emit an `'upgradeAborted'` event.
4✔
28
 * @param {Handshake} handshake - Details of the incoming connection (identity, password, headers,
4✔
29
 *   query string, remote address, etc.). See the `Handshake` typedef in `server-client.js`.
4✔
30
 * @param {AbortSignal} signal - Aborts if the underlying socket is closed before authentication
4✔
31
 *   completes. `signal.reason` is the triggering error and is also available as the `error`
4✔
32
 *   property of the corresponding `'upgradeAborted'` event.
4✔
33
 * @returns {void | Promise<void>}
4✔
34
 */
4✔
35

4✔
36
/**
4✔
37
 * @group Classes
4✔
38
 */
4✔
39
export class RPCServer extends EventEmitter {
4✔
40

4✔
41
    /**
4✔
42
     * Emitted when a client has connected and been accepted. By default, a client is automatically
4✔
43
     * accepted if it connects with a matching subprotocol (as per the `protocols` option). This
4✔
44
     * behaviour can be overridden by setting an auth handler via server.auth().
4✔
45
     * @event
4✔
46
     * @overload
4✔
47
     * @param {'client'} event
4✔
48
     * @param {(client: RPCServerClient) => void} listener
4✔
49
     * @returns {this}
4✔
50
     */
4✔
51
    /**
4✔
52
     * Emitted when the underlying WebSocketServer emits an error.
4✔
53
     * @event
4✔
54
     * @overload
4✔
55
     * @param {'error'} event
4✔
56
     * @param {(error: Error) => void} listener
4✔
57
     * @returns {this}
4✔
58
     */
4✔
59
    /**
4✔
60
     * Emitted when the server has fully closed and all clients have been disconnected.
4✔
61
     * @event
4✔
62
     * @overload
4✔
63
     * @param {'close'} event
4✔
64
     * @param {() => void} listener
4✔
65
     * @returns {this}
4✔
66
     */
4✔
67
    /**
4✔
68
     * Emitted when the server has begun closing. Beyond this point, no more clients will be
4✔
69
     * accepted and the 'client' event will no longer fire.
4✔
70
     * @event
4✔
71
     * @overload
4✔
72
     * @param {'closing'} event
4✔
73
     * @param {() => void} listener
4✔
74
     * @returns {this}
4✔
75
     */
4✔
76
    /**
4✔
77
     * Emitted when a websocket upgrade has been aborted. This could be caused by an authentication
4✔
78
     * rejection, socket error, or websocket handshake error.
4✔
79
     * @event
4✔
80
     * @overload
4✔
81
     * @param {'upgradeAborted'} event
4✔
82
     * @param {(data: {error: Error, socket: import('net').Socket, request: import('http').IncomingMessage, identity: string}) => void} listener
4✔
83
     * @returns {this}
4✔
84
     */
4✔
85
    /**
4✔
86
     * @overload
4✔
87
     * @param {string} event
4✔
88
     * @param {(...args: any[]) => void} listener
4✔
89
     * @returns {this}
4✔
90
     */
4✔
91
    on(event, listener) {
4✔
92
        return super.on(event, listener);
568✔
93
    }
568✔
94
    /**
4✔
95
     * @param {object} [options]
4✔
96
     * @param {string[]} [options.protocols=[]] Array of subprotocols supported by this server. Can be overridden in an auth callback.
4✔
97
     * @param {number} [options.callTimeoutMs=30000] Milliseconds to wait before unanswered outbound calls are rejected automatically.
4✔
98
     * @param {number} [options.pingIntervalMs=30000] Milliseconds between WebSocket pings to connected clients. Used for keep-alive timeouts.
4✔
99
     * @param {boolean} [options.deferPingsOnActivity=false] Should connected clients skip sending keep-alive pings if activity received?
4✔
100
     * @param {boolean} [options.respondWithDetailedErrors=false] Specifies whether to send detailed errors (including stack trace) to remote party upon an error being thrown by a handler.
4✔
101
     * @param {number} [options.callConcurrency=1] The number of concurrent in-flight outbound calls permitted at any one time. Additional calls are queued. There is no concurrency limit imposed on inbound calls.
4✔
102
     * @param {boolean|Array.<string>} [options.strictMode=false] Enable strict validation of calls & responses. Pass an array of subprotocol names to limit strict mode to specific protocols.
4✔
103
     * @param {Array.<Validator>} [options.strictModeValidators=[]] Optional additional validators to be used in conjunction with strictMode.
4✔
104
     * @param {number} [options.maxBadMessages=Infinity] The maximum number of non-conforming RPC messages which can be tolerated before the client is automatically closed.
4✔
105
     * @param {object} [options.wssOptions={}] Additional WebSocketServer options.
4✔
106
     */
4✔
107
    constructor(options) {
4✔
108
        super();
528✔
109
        
528✔
110
        /** @internal */
528✔
111
        this._httpServerAbortControllers = new Set();
528✔
112
        /** @internal */
528✔
113
        this._state = OPEN;
528✔
114
        /** @internal */
528✔
115
        this._clients = new Set();
528✔
116
        /** @internal */
528✔
117
        this._pendingUpgrades = new WeakMap();
528✔
118
        /** @internal */
528✔
119
        this._strictValidators = undefined;
528✔
120
        /** @internal */
528✔
121
        this._authCallback = undefined;
528✔
122

528✔
123
        /** @internal */
528✔
124
        this._options = {
528✔
125
            protocols: [],
528✔
126
            callTimeoutMs: 1000*30,
528✔
127
            pingIntervalMs: 1000*30,
528✔
128
            deferPingsOnActivity: false,
528✔
129
            respondWithDetailedErrors: false,
528✔
130
            callConcurrency: 1,
528✔
131
            maxBadMessages: Infinity,
528✔
132
            strictMode: false,
528✔
133
            strictModeValidators: [],
528✔
134
        };
528✔
135

528✔
136
        this.reconfigure(options || {});
528✔
137

528✔
138
        /** @internal */
528✔
139
        this._wss = new WebSocketServer({
528✔
140
            ...this._options.wssOptions,
528✔
141
            noServer: true,
528✔
142
            handleProtocols: (protocols, request) => {
528✔
143
                const {protocol} = this._pendingUpgrades.get(request);
92✔
144
                return protocol;
92✔
145
            },
528✔
146
        });
528✔
147

528✔
148
        this._wss.on('headers', h => h.push(`Server: ${getPackageIdent()}`));
528✔
149
        this._wss.on('error', err => this.emit('error', err));
528✔
150
        this._wss.on('connection', this._onConnection.bind(this));
528✔
151
    }
528✔
152
    
4✔
153
    /**
4✔
154
     * Use this method to change any of the options that can be passed to the RPCServer's constructor.
4✔
155
     *
4✔
156
     * @param {object} options
4✔
157
     */
4✔
158
    reconfigure(options) {
4✔
159
        const newOpts = Object.assign({}, this._options, options);
528✔
160

528✔
161
        if (newOpts.strictMode && !newOpts.protocols?.length) {
528✔
162
            throw Error(`strictMode requires at least one subprotocol`);
8✔
163
        }
8✔
164

520✔
165
        const strictValidators = [...standardValidators];
520✔
166
        if (newOpts.strictModeValidators) {
520✔
167
            strictValidators.push(...newOpts.strictModeValidators);
520✔
168
        }
520✔
169

520✔
170
        this._strictValidators = strictValidators.reduce((svs, v) => {
520✔
171
            svs.set(v.subprotocol, v);
1,572✔
172
            return svs;
1,572✔
173
        }, new Map());
520✔
174
        
520✔
175
        let strictProtocols = [];
520✔
176
        if (Array.isArray(newOpts.strictMode)) {
528✔
177
            strictProtocols = newOpts.strictMode;
8✔
178
        } else if (newOpts.strictMode) {
528✔
179
            strictProtocols = newOpts.protocols;
32✔
180
        }
32✔
181

520✔
182
        const missingValidator = strictProtocols.find(protocol => !this._strictValidators.has(protocol));
520✔
183
        if (missingValidator) {
528✔
184
            throw Error(`Missing strictMode validator for subprotocol '${missingValidator}'`);
8✔
185
        }
8✔
186

512✔
187
        this._options = newOpts;
512✔
188
    }
528✔
189

4✔
190
    /**
4✔
191
     * Converts an HTTP upgrade request into a WebSocket client to be handled by this RPCServer.
4✔
192
     * This method is bound to the server instance, so it is suitable to pass directly as an
4✔
193
     * http.Server's 'upgrade' event handler.
4✔
194
     */
4✔
195
    get handleUpgrade() {
4✔
196
        /**
508✔
197
         * @param {http.IncomingMessage} request - The HTTP upgrade request
508✔
198
         * @param {net.Socket} socket - Network socket between the server and client
508✔
199
         * @param {Buffer} head - The first packet of the upgraded stream (may be empty)
508✔
200
         * @returns {Promise<void>}
508✔
201
         */
508✔
202
        return async (request, socket, head) => {
508✔
203

572✔
204
            let resolved = false;
572✔
205

572✔
206
            const ac = new AbortController();
572✔
207
            const {signal} = ac;
572✔
208

572✔
209
            const url = new URL('http://localhost' + (request.url || '/'));
572!
210
            const pathParts = url.pathname.split('/');
572✔
211
            const identity = decodeURIComponent(pathParts.pop());
572✔
212

572✔
213
            const abortUpgrade = (error) => {
572✔
214
                resolved = true;
36✔
215

36✔
216
                if (error && error instanceof WebsocketUpgradeError) {
36✔
217
                    abortHandshake(socket, error.code, error.message);
36✔
218
                } else {
36!
219
                    abortHandshake(socket, 500);
×
UNCOV
220
                }
×
221

36✔
222
                if (!signal.aborted) {
36✔
223
                    ac.abort(error);
36✔
224
                    this.emit('upgradeAborted', {
36✔
225
                        error,
36✔
226
                        socket,
36✔
227
                        request,
36✔
228
                        identity,
36✔
229
                    });
36✔
230
                }
36✔
231
            };
572✔
232

572✔
233
            socket.on('error', (err) => {
572✔
234
                abortUpgrade(err);
×
235
            });
572✔
236

572✔
237
            try {
572✔
238
                if (this._state !== OPEN) {
572✔
239
                    throw new WebsocketUpgradeError(500, "Server not open");
4✔
240
                }
4✔
241
                
568✔
242
                if (socket.readyState !== 'open') {
572!
243
                    throw new WebsocketUpgradeError(400, `Client readyState = '${socket.readyState}'`);
×
UNCOV
244
                }
×
245
                
568✔
246
                const headers = request.headers;
568✔
247

568✔
248
                if (headers.upgrade.toLowerCase() !== 'websocket') {
572✔
249
                    throw new WebsocketUpgradeError(400, "Can only upgrade websocket upgrade requests");
4✔
250
                }
4✔
251
                
564✔
252
                const endpoint = pathParts.join('/') || '/';
572✔
253
                const remoteAddress = request.socket.remoteAddress;
572✔
254
                const protocols = ('sec-websocket-protocol' in request.headers)
572✔
255
                    ? parseSubprotocols(request.headers['sec-websocket-protocol'])
572✔
256
                    : new Set();
572✔
257

572✔
258
                let password;
572✔
259
                if (headers.authorization) {
572✔
260
                    try {
32✔
261
                        /**
32✔
262
                         * This is a non-standard basic auth parser because it supports
32✔
263
                         * colons in usernames (which is normally disallowed).
32✔
264
                         * However, this shouldn't cause any confusion as we have a
32✔
265
                         * guarantee from OCPP that the username will always be equal to
32✔
266
                         * the identity.
32✔
267
                         * It also supports binary passwords, which is also a spec violation
32✔
268
                         * but is necessary for allowing truly random binary keys as
32✔
269
                         * recommended by the OCPP security whitepaper.
32✔
270
                         */
32✔
271
                        const b64up = headers.authorization.match(/^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/)[1];
32✔
272
                        const userPassBuffer = Buffer.from(b64up, 'base64');
32✔
273

32✔
274
                        const clientIdentityUserBuffer = Buffer.from(identity + ':');
32✔
275

32✔
276
                        if (clientIdentityUserBuffer.compare(userPassBuffer, 0, clientIdentityUserBuffer.length) === 0) {
32✔
277
                            // first part of buffer matches `${identity}:`
24✔
278
                            password = userPassBuffer.subarray(clientIdentityUserBuffer.length);
24✔
279
                        }
24✔
280
                    } catch (err) {
32✔
281
                        // failing to parse authorization header is no big deal.
4✔
282
                        // just leave password as undefined as if no header was sent.
4✔
283
                    }
4✔
284
                }
32✔
285

564✔
286
                const handshake = {
564✔
287
                    remoteAddress,
564✔
288
                    headers,
564✔
289
                    protocols,
564✔
290
                    endpoint,
564✔
291
                    identity,
564✔
292
                    query: url.searchParams,
564✔
293
                    request,
564✔
294
                    password,
564✔
295
                };
564✔
296

564✔
297
                const accept = (session, protocol) => {
564✔
298
                    if (resolved) return;
548✔
299
                    resolved = true;
540✔
300
                    
540✔
301
                    try {
540✔
302
                        if (socket.readyState !== 'open') {
548!
303
                            throw new WebsocketUpgradeError(400, `Client readyState = '${socket.readyState}'`);
×
UNCOV
304
                        }
×
305

540✔
306
                        if (protocol === undefined) {
548✔
307
                            // pick first subprotocol (preferred by server) that is also supported by the client
532✔
308
                            protocol = (this._options.protocols ?? []).find(p => protocols.has(p));
532!
309
                        } else if (protocol !== false && !protocols.has(protocol)) {
548✔
310
                            throw new WebsocketUpgradeError(400, `Client doesn't support expected subprotocol`);
4✔
311
                        }
4✔
312

536✔
313
                        // cache auth results for connection creation
536✔
314
                        this._pendingUpgrades.set(request, {
536✔
315
                            session: session ?? {},
548✔
316
                            protocol,
548✔
317
                            handshake
548✔
318
                        });
548✔
319

548✔
320
                        this._wss.handleUpgrade(request, socket, head, ws => {
548✔
321
                            this._wss.emit('connection', ws, request);
536✔
322
                        });
548✔
323
                    } catch (err) {
548✔
324
                        abortUpgrade(err);
4✔
325
                    }
4✔
326
                };
564✔
327

564✔
328
                const reject = (code = 404, message = 'Not found') => {
564✔
329
                    if (resolved) return;
1,132✔
330
                    resolved = true;
24✔
331
                    abortUpgrade(new WebsocketUpgradeError(code, message));
24✔
332
                };
564✔
333

564✔
334
                socket.once('end', () => {
564✔
335
                    reject(400, `Client connection closed before upgrade complete`);
540✔
336
                });
564✔
337

564✔
338
                socket.once('close', () => {
564✔
339
                    reject(400, `Client connection closed before upgrade complete`);
564✔
340
                });
564✔
341

564✔
342
                if (this._authCallback) {
572✔
343
                    await this._authCallback(
148✔
344
                        accept,
148✔
345
                        reject,
148✔
346
                        handshake,
148✔
347
                        signal
148✔
348
                    );
148✔
349
                } else {
572✔
350
                    accept();
416✔
351
                }
416✔
352

572✔
353
            } catch (err) {
572✔
354
                abortUpgrade(err);
8✔
355
            }
8✔
356
        };
508✔
357
    }
508✔
358

4✔
359
    /** @internal */
4✔
360
    async _onConnection(websocket, request) {
4✔
361
        try {
536✔
362
            if (this._state !== OPEN) {
536✔
363
                throw Error("Server is no longer open");
4✔
364
            }
4✔
365

532✔
366
            const {handshake, session} = this._pendingUpgrades.get(request);
532✔
367

532✔
368
            const client = new RPCServerClient({
532✔
369
                identity: handshake.identity,
532✔
370
                reconnect: false,
532✔
371
                callTimeoutMs: this._options.callTimeoutMs,
532✔
372
                pingIntervalMs: this._options.pingIntervalMs,
532✔
373
                deferPingsOnActivity: this._options.deferPingsOnActivity,
532✔
374
                respondWithDetailedErrors: this._options.respondWithDetailedErrors,
532✔
375
                callConcurrency: this._options.callConcurrency,
532✔
376
                strictMode: this._options.strictMode,
532✔
377
                strictModeValidators: this._options.strictModeValidators,
532✔
378
                maxBadMessages: this._options.maxBadMessages,
532✔
379
                protocols: this._options.protocols,
532✔
380
            }, {
532✔
381
                ws: websocket,
532✔
382
                session,
532✔
383
                handshake,
532✔
384
            });
532✔
385

532✔
386
            this._clients.add(client);
532✔
387
            client.once('close', () => this._clients.delete(client));
532✔
388
            this.emit('client', client);
532✔
389

532✔
390
        } catch (err) {
536✔
391
            websocket.close(err.statusCode || 1000, err.message);
4✔
392
        }
4✔
393
    }
536✔
394

4✔
395
    /**
4✔
396
     * Registers a callback that is invoked before each incoming client connection is accepted.
4✔
397
     *
4✔
398
     * The callback receives `(accept, reject, handshake, signal)` and must call either `accept()`
4✔
399
     * or `reject()` to resolve the handshake. See the {@link AuthCallback} typedef for the full
4✔
400
     * signature of each argument.
4✔
401
     *
4✔
402
     * Registering an auth callback is optional. When no callback is set, every client that
4✔
403
     * advertises a mutually-supported subprotocol is accepted automatically.
4✔
404
     *
4✔
405
     * @param {AuthCallback} cb - Function called for every incoming connection before it is accepted.
4✔
406
     * @example
4✔
407
     * // Validate HTTP Basic auth credentials, attach session data, and handle early disconnects
4✔
408
     * server.auth(async (accept, reject, handshake, signal) => {
4✔
409
     *     const username = handshake.identity;
4✔
410
     *     const password = handshake.password?.toString('utf8');
4✔
411
     *
4✔
412
     *     const user = await db.findUser(username, password); // async lookup
4✔
413
     *
4✔
414
     *     if (signal.aborted) return; // socket closed while we were waiting
4✔
415
     *
4✔
416
     *     if (user) {
4✔
417
     *         accept({ userId: user.id }); // session data accessible as client.session
4✔
418
     *     } else {
4✔
419
     *         reject(401, 'Unauthorized');
4✔
420
     *     }
4✔
421
     * });
4✔
422
     */
4✔
423
    auth(cb) {
4✔
424
        this._authCallback = cb;
156✔
425
    }
156✔
426

4✔
427
    /**
4✔
428
     * Creates a simple HTTP server which only accepts websocket upgrades and returns a 404
4✔
429
     * response to any other request.
4✔
430
     *
4✔
431
     * @param {number} [port] - The port number to listen on. If not set, the OS will assign an unused port.
4✔
432
     * @param {string} [host] - The host address to bind to. If not set, connections will be accepted on all interfaces.
4✔
433
     * @param {object} [options]
4✔
434
     * @param {AbortSignal} [options.signal] - An AbortSignal used to abort the listen() call.
4✔
435
     * @returns {Promise.<http.Server>} Resolves with the HTTP server instance
4✔
436
     */
4✔
437
    async listen(port, host, options = {}) {
4✔
438
        const ac = new AbortController();
500✔
439
        this._httpServerAbortControllers.add(ac);
500✔
440
        if (options.signal) {
500✔
441
            once(options.signal, 'abort').then(() => {
4✔
442
                ac.abort(options.signal.reason);
4✔
443
            });
4✔
444
        }
4✔
445
        const httpServer = createServer({
500✔
446
            noDelay: true,
500✔
447
        }, (req, res) => {
500✔
448
            res.setHeader('Server', getPackageIdent());
4✔
449
            res.statusCode = 404;
4✔
450
            res.end();
4✔
451
        });
500✔
452
        httpServer.on('upgrade', this.handleUpgrade);
500✔
453
        httpServer.once('close', () => this._httpServerAbortControllers.delete(ac));
500✔
454
        await new Promise((resolve, reject) => {
500✔
455
            httpServer.listen({
500✔
456
                port,
500✔
457
                host,
500✔
458
                signal: ac.signal,
500✔
459
            }, err => err ? reject(err) : resolve());
500!
460
        });
500✔
461
        return httpServer;
500✔
462
    }
500✔
463

4✔
464
    /**
4✔
465
     * This blocks new clients from connecting, calls client.close() on all connected clients,
4✔
466
     * and then finally closes any listening HTTP servers which were created using listen().
4✔
467
     *
4✔
468
     * @param {object} [options]
4✔
469
     * @param {number} [options.code=1001] - The WebSocket close code to pass to all connected clients.
4✔
470
     * @param {string} [options.reason=''] - The reason for closure to pass to all connected clients.
4✔
471
     * @param {boolean} [options.awaitPending=false] - If true, each connected client won't be fully closed until any outstanding in-flight inbound and outbound calls are responded to. Additional calls will be rejected in the meantime.
4✔
472
     * @param {boolean} [options.force=false] - If true, terminates all client WebSocket connections instantly and uncleanly.
4✔
473
     * @returns {Promise<void>} Resolves when the server has completed closing.
4✔
474
     */
4✔
475
    async close({code, reason, awaitPending, force} = {}) {
4✔
476
        if (this._state === OPEN) {
504✔
477
            this._state = CLOSING;
500✔
478
            this.emit('closing');
500✔
479
            code = code ?? 1001;
500✔
480
            await Array.from(this._clients).map(cli => cli.close({code, reason, awaitPending, force}));
500✔
481
            await new Promise((resolve, reject) => {
500✔
482
                this._wss.close(err => err ? reject(err) : resolve());
500!
483
                this._httpServerAbortControllers.forEach(ac => ac.abort("Closing"));
500✔
484
            });
500✔
485
            this._state = CLOSED;
500✔
486
            this.emit('close');
500✔
487
        }
500✔
488
    }
504✔
489
}
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc