ocpp-ws-io 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  ConnectionState: () => ConnectionState,
34
34
  InMemoryAdapter: () => InMemoryAdapter,
35
+ LRUMap: () => LRUMap,
35
36
  MessageType: () => MessageType,
36
37
  MiddlewareStack: () => MiddlewareStack,
37
38
  NOREPLY: () => NOREPLY,
@@ -69,7 +70,7 @@ __export(src_exports, {
69
70
  defineRpcMiddleware: () => defineRpcMiddleware,
70
71
  getErrorPlainObject: () => getErrorPlainObject,
71
72
  getPackageIdent: () => getPackageIdent,
72
- standardValidators: () => standardValidators
73
+ getStandardValidators: () => getStandardValidators
73
74
  });
74
75
  module.exports = __toCommonJS(src_exports);
75
76
 
@@ -119,6 +120,11 @@ var InMemoryAdapter = class {
119
120
  async removePresence(identity) {
120
121
  this._presence.delete(identity);
121
122
  }
123
+ async setPresenceBatch(entries) {
124
+ for (const { identity, nodeId } of entries) {
125
+ this._presence.set(identity, nodeId);
126
+ }
127
+ }
122
128
  };
123
129
  function defineAdapter(adapter) {
124
130
  return adapter;
@@ -234,6 +240,25 @@ var IoRedisDriver = class {
234
240
  };
235
241
  await Promise.all([close(this.pub), close(this.sub)]);
236
242
  }
243
+ async setPresenceBatch(entries) {
244
+ if (entries.length === 0) return;
245
+ const pipeline = this.pub.pipeline();
246
+ for (const { key, value, ttlSeconds } of entries) {
247
+ pipeline.set(key, value, "EX", ttlSeconds);
248
+ }
249
+ await pipeline.exec();
250
+ }
251
+ async expire(key, ttlSeconds) {
252
+ await this.pub.expire(key, ttlSeconds);
253
+ }
254
+ onError(handler) {
255
+ this.pub.on("error", handler);
256
+ return () => this.pub.removeListener("error", handler);
257
+ }
258
+ onReconnect(handler) {
259
+ this.pub.on("connect", handler);
260
+ return () => this.pub.removeListener("connect", handler);
261
+ }
237
262
  };
238
263
  var NodeRedisDriver = class {
239
264
  constructor(pub, sub, blocking) {
@@ -320,6 +345,25 @@ var NodeRedisDriver = class {
320
345
  async disconnect() {
321
346
  await Promise.all([this.pub.disconnect(), this.sub.disconnect()]);
322
347
  }
348
+ async setPresenceBatch(entries) {
349
+ if (entries.length === 0) return;
350
+ const multi = this.pub.multi();
351
+ for (const { key, value, ttlSeconds } of entries) {
352
+ multi.set(key, value, { EX: ttlSeconds });
353
+ }
354
+ await multi.exec();
355
+ }
356
+ async expire(key, ttlSeconds) {
357
+ await this.pub.expire(key, ttlSeconds);
358
+ }
359
+ onError(handler) {
360
+ this.pub.on("error", handler);
361
+ return () => this.pub.removeListener("error", handler);
362
+ }
363
+ onReconnect(handler) {
364
+ this.pub.on("connect", handler);
365
+ return () => this.pub.removeListener("connect", handler);
366
+ }
323
367
  };
324
368
  function createDriver(pub, sub, blocking) {
325
369
  if (sub.isOpen !== void 0 && typeof sub.subscribe === "function") {
@@ -333,6 +377,8 @@ var RedisAdapter = class {
333
377
  _driver;
334
378
  _prefix;
335
379
  _streamMaxLen;
380
+ _streamTtlSeconds;
381
+ _presenceTtlSeconds;
336
382
  _handlers = /* @__PURE__ */ new Map();
337
383
  _streamOffsets = /* @__PURE__ */ new Map();
338
384
  // streamKey -> lastId
@@ -340,20 +386,48 @@ var RedisAdapter = class {
340
386
  // Active streams to poll
341
387
  _polling = false;
342
388
  _closed = false;
389
+ // Per-stream sequence counter for message ordering
390
+ _sequenceCounters = /* @__PURE__ */ new Map();
391
+ // Rehydration callbacks
392
+ _unsubError;
393
+ _unsubReconnect;
394
+ // Stored presence entries for rehydration on reconnect
395
+ _presenceCache = /* @__PURE__ */ new Map();
343
396
  constructor(options) {
344
397
  this._prefix = options.prefix ?? "ocpp-ws-io:";
345
398
  this._streamMaxLen = options.streamMaxLen ?? 1e3;
399
+ this._streamTtlSeconds = options.streamTtlSeconds ?? 300;
400
+ this._presenceTtlSeconds = options.presenceTtlSeconds ?? 300;
346
401
  this._driver = createDriver(
347
402
  options.pubClient,
348
403
  options.subClient,
349
404
  options.blockingClient
350
405
  );
406
+ if (this._driver.onError) {
407
+ this._unsubError = this._driver.onError((err) => {
408
+ console.error("[RedisAdapter] Redis error:", err.message);
409
+ });
410
+ }
411
+ if (this._driver.onReconnect) {
412
+ this._unsubReconnect = this._driver.onReconnect(() => {
413
+ this._rehydratePresence().catch(() => {
414
+ });
415
+ });
416
+ }
351
417
  }
352
418
  async publish(channel, data) {
353
419
  const prefixedChannel = this._prefix + channel;
420
+ const payload = data;
421
+ if (payload && typeof payload === "object" && channel.startsWith("ocpp:node:")) {
422
+ const seq = (this._sequenceCounters.get(channel) ?? 0) + 1;
423
+ this._sequenceCounters.set(channel, seq);
424
+ payload.__seq = seq;
425
+ }
354
426
  const message = JSON.stringify(data);
355
427
  if (channel.startsWith("ocpp:node:")) {
356
428
  await this._driver.xadd(prefixedChannel, { message }, this._streamMaxLen);
429
+ await this._driver.expire(prefixedChannel, this._streamTtlSeconds).catch(() => {
430
+ });
357
431
  } else {
358
432
  await this._driver.publish(prefixedChannel, message);
359
433
  }
@@ -419,6 +493,10 @@ var RedisAdapter = class {
419
493
  this._closed = true;
420
494
  this._handlers.clear();
421
495
  this._streams.clear();
496
+ this._presenceCache.clear();
497
+ this._sequenceCounters.clear();
498
+ if (this._unsubError) this._unsubError();
499
+ if (this._unsubReconnect) this._unsubReconnect();
422
500
  await this._driver.disconnect();
423
501
  }
424
502
  _handleMessage(channel, message) {
@@ -478,6 +556,7 @@ var RedisAdapter = class {
478
556
  // ─── Presence Registry ─────────────────────────────────────────────
479
557
  async setPresence(identity, nodeId, ttl) {
480
558
  const key = `${this._prefix}presence:${identity}`;
559
+ this._presenceCache.set(identity, { nodeId, ttl });
481
560
  await this._driver.set(key, nodeId, ttl);
482
561
  }
483
562
  async getPresence(identity) {
@@ -515,6 +594,37 @@ var RedisAdapter = class {
515
594
  streamDetails
516
595
  };
517
596
  }
597
+ // ─── C1: Batch Presence Pipeline ────────────────────────────────────
598
+ /**
599
+ * Set multiple presence entries in a single Redis pipeline.
600
+ * Reduces N network round-trips to 1 for bulk presence updates.
601
+ */
602
+ async setPresenceBatch(entries) {
603
+ if (entries.length === 0) return;
604
+ const batchEntries = entries.map(({ identity, nodeId, ttl }) => {
605
+ const key = `${this._prefix}presence:${identity}`;
606
+ const ttlSeconds = ttl ?? this._presenceTtlSeconds;
607
+ this._presenceCache.set(identity, { nodeId, ttl: ttlSeconds });
608
+ return { key, value: nodeId, ttlSeconds };
609
+ });
610
+ await this._driver.setPresenceBatch(batchEntries);
611
+ }
612
+ // ─── C3: Redis Failure Rehydration ──────────────────────────────────
613
+ /**
614
+ * Re-syncs all cached presence entries to Redis after a reconnection.
615
+ * Called automatically when the Redis client reconnects.
616
+ */
617
+ async _rehydratePresence() {
618
+ if (this._presenceCache.size === 0) return;
619
+ const entries = Array.from(this._presenceCache.entries()).map(
620
+ ([identity, { nodeId, ttl }]) => ({
621
+ key: `${this._prefix}presence:${identity}`,
622
+ value: nodeId,
623
+ ttlSeconds: ttl
624
+ })
625
+ );
626
+ await this._driver.setPresenceBatch(entries);
627
+ }
518
628
  };
519
629
 
520
630
  // src/client.ts
@@ -36881,6 +36991,8 @@ var Validator = class {
36881
36991
  /**
36882
36992
  * Validate a payload against a schema identified by its $id.
36883
36993
  * Throws a typed RPCError if validation fails.
36994
+ *
36995
+ * E2: Schema is compiled on first call to this method (lazy).
36884
36996
  */
36885
36997
  validate(schemaId, params) {
36886
36998
  const resolvedId = this._normalizeSchemaId(schemaId);
@@ -36903,16 +37015,26 @@ var Validator = class {
36903
37015
  return !!this._ajv.getSchema(this._normalizeSchemaId(schemaId));
36904
37016
  }
36905
37017
  };
37018
+ var _validatorRegistry = /* @__PURE__ */ new Map();
36906
37019
  function createValidator(subprotocol, schemas) {
36907
- return new Validator(subprotocol, schemas);
37020
+ const existing = _validatorRegistry.get(subprotocol);
37021
+ if (existing) return existing;
37022
+ const validator = new Validator(subprotocol, schemas);
37023
+ _validatorRegistry.set(subprotocol, validator);
37024
+ return validator;
36908
37025
  }
36909
37026
 
36910
37027
  // src/standard-validators.ts
36911
- var standardValidators = [
36912
- createValidator("ocpp1.6", ocpp1_6_default),
36913
- createValidator("ocpp2.0.1", ocpp2_0_1_default),
36914
- createValidator("ocpp2.1", ocpp2_1_default)
36915
- ];
37028
+ var _cached = null;
37029
+ function getStandardValidators() {
37030
+ if (_cached) return _cached;
37031
+ _cached = [
37032
+ createValidator("ocpp1.6", ocpp1_6_default),
37033
+ createValidator("ocpp2.0.1", ocpp2_0_1_default),
37034
+ createValidator("ocpp2.1", ocpp2_1_default)
37035
+ ];
37036
+ return _cached;
37037
+ }
36916
37038
 
36917
37039
  // src/types.ts
36918
37040
  var ConnectionState = {
@@ -37095,6 +37217,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37095
37217
  _badMessageCount = 0;
37096
37218
  _lastActivity = 0;
37097
37219
  _outboundBuffer = [];
37220
+ _offlineQueue = [];
37098
37221
  _middleware;
37099
37222
  _validators = [];
37100
37223
  _strictProtocols = null;
@@ -37104,6 +37227,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37104
37227
  _prettify = false;
37105
37228
  constructor(options) {
37106
37229
  super();
37230
+ this.setMaxListeners(0);
37107
37231
  if (!options.identity) {
37108
37232
  throw new Error("identity is required");
37109
37233
  }
@@ -37249,6 +37373,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37249
37373
  }
37250
37374
  this._attachWebsocket(ws);
37251
37375
  this._startPing();
37376
+ this._flushOfflineQueue();
37252
37377
  if (this._outboundBuffer.length > 0) {
37253
37378
  const buffer = this._outboundBuffer;
37254
37379
  this._outboundBuffer = [];
@@ -37430,8 +37555,32 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37430
37555
  options = args[2] ?? {};
37431
37556
  }
37432
37557
  if (this._state !== OPEN) {
37558
+ if (this._options.offlineQueue && (this._state === CLOSED || this._state === CONNECTING)) {
37559
+ return new Promise((resolve, reject) => {
37560
+ const maxSize = this._options.offlineQueueMaxSize ?? 100;
37561
+ if (this._offlineQueue.length >= maxSize) {
37562
+ this._offlineQueue.shift();
37563
+ this._logger?.warn?.(
37564
+ "Offline queue full \u2014 dropping oldest message",
37565
+ {
37566
+ method,
37567
+ queueSize: this._offlineQueue.length
37568
+ }
37569
+ );
37570
+ }
37571
+ this._offlineQueue.push({ method, params, options, resolve, reject });
37572
+ this._logger?.debug?.("Call queued offline", {
37573
+ method,
37574
+ queueSize: this._offlineQueue.length
37575
+ });
37576
+ });
37577
+ }
37433
37578
  throw new Error(`Cannot call: client is in state ${this._state}`);
37434
37579
  }
37580
+ const maxRetries = options.retries ?? 0;
37581
+ if (maxRetries > 0) {
37582
+ return this._callWithRetry(method, params, options, maxRetries);
37583
+ }
37435
37584
  return this._callQueue.push(() => this._sendCall(method, params, options));
37436
37585
  }
37437
37586
  async safeCall(...args) {
@@ -37456,7 +37605,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37456
37605
  }
37457
37606
  }
37458
37607
  async _sendCall(method, params, options) {
37459
- const msgId = (0, import_cuid2.createId)();
37608
+ const msgId = options.idempotencyKey ?? (0, import_cuid2.createId)();
37460
37609
  const timeoutMs = options.timeoutMs ?? this._options.callTimeoutMs;
37461
37610
  const ctx = {
37462
37611
  type: "outgoing_call",
@@ -37504,7 +37653,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37504
37653
  sentAt: Date.now()
37505
37654
  });
37506
37655
  if (this._ws?.readyState === import_ws.default.OPEN) {
37507
- this._ws.send(messageStr, (err) => {
37656
+ this._safeSend(this._ws, messageStr, (err) => {
37508
37657
  if (err) {
37509
37658
  clearTimeout(timeoutHandle);
37510
37659
  this._pendingCalls.delete(msgId);
@@ -37580,11 +37729,13 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37580
37729
  this._recordActivity();
37581
37730
  let message;
37582
37731
  try {
37583
- const str = rawData.toString();
37584
- message = JSON.parse(str);
37732
+ message = JSON.parse(rawData);
37585
37733
  if (!Array.isArray(message)) throw new Error("Message is not an array");
37586
37734
  } catch (err) {
37587
- this._onBadMessage(rawData.toString(), err);
37735
+ this._onBadMessage(
37736
+ typeof rawData === "string" ? rawData : rawData.toString(),
37737
+ err
37738
+ );
37588
37739
  return;
37589
37740
  }
37590
37741
  const messageType = message[0];
@@ -37851,6 +38002,91 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37851
38002
  }
37852
38003
  }, delayMs);
37853
38004
  }
38005
+ // ─── Internal: Offline Queue ──────────────────────────────────
38006
+ /**
38007
+ * Atomically drains the offline queue and sends each message via _sendCall.
38008
+ * Uses splice(0) to prevent re-entry bugs (double billing) if the connection
38009
+ * drops again mid-flush — the queue is empty before any sends begin.
38010
+ */
38011
+ _flushOfflineQueue() {
38012
+ if (this._offlineQueue.length === 0) return;
38013
+ const snapshot = this._offlineQueue.splice(0, this._offlineQueue.length);
38014
+ this._logger?.info?.("Flushing offline queue", { count: snapshot.length });
38015
+ for (const entry of snapshot) {
38016
+ this._callQueue.push(() => this._sendCall(entry.method, entry.params, entry.options)).then(entry.resolve).catch(entry.reject);
38017
+ }
38018
+ }
38019
+ // ─── Internal: Call Retry with Full Jitter ───────────────────
38020
+ /**
38021
+ * Retry wrapper using Full Jitter exponential backoff.
38022
+ * delay = random(0, min(retryMaxDelayMs, retryDelayMs * 2^attempt))
38023
+ * Only retries on TimeoutError — all other errors propagate immediately.
38024
+ */
38025
+ async _callWithRetry(method, params, options, maxRetries) {
38026
+ const baseDelay = options.retryDelayMs ?? 1e3;
38027
+ const maxDelay = options.retryMaxDelayMs ?? 3e4;
38028
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
38029
+ try {
38030
+ return await this._callQueue.push(
38031
+ () => this._sendCall(method, params, options)
38032
+ );
38033
+ } catch (err) {
38034
+ if (attempt === maxRetries || !(err instanceof TimeoutError)) {
38035
+ throw err;
38036
+ }
38037
+ const expDelay = Math.min(maxDelay, baseDelay * 2 ** attempt);
38038
+ const jitteredDelay = Math.random() * expDelay;
38039
+ this._logger?.warn?.("Call retry", {
38040
+ method,
38041
+ attempt: attempt + 1,
38042
+ maxRetries,
38043
+ delayMs: Math.round(jitteredDelay)
38044
+ });
38045
+ await new Promise((r) => setTimeout(r, jitteredDelay));
38046
+ }
38047
+ }
38048
+ throw new Error("Retry exhausted");
38049
+ }
38050
+ // ─── Internal: Backpressure-Aware Send ───────────────────────
38051
+ /** Maximum bytes allowed in the ws send buffer before applying backpressure (512KB) */
38052
+ static _BACKPRESSURE_THRESHOLD = 512 * 1024;
38053
+ /**
38054
+ * Wraps ws.send() with backpressure protection.
38055
+ * If bufferedAmount exceeds the threshold, waits for the buffer to drain
38056
+ * before sending. Prevents OOM on slow 2G/3G charger connections.
38057
+ */
38058
+ _safeSend(ws, data, cb) {
38059
+ if (!ws || ws.readyState !== import_ws.default.OPEN) {
38060
+ cb?.(new Error("WebSocket is not open"));
38061
+ return;
38062
+ }
38063
+ if (ws.bufferedAmount > _OCPPClient._BACKPRESSURE_THRESHOLD) {
38064
+ this._logger?.warn?.("Backpressure \u2014 pausing send", {
38065
+ identity: this._identity,
38066
+ bufferedAmount: ws.bufferedAmount,
38067
+ threshold: _OCPPClient._BACKPRESSURE_THRESHOLD
38068
+ });
38069
+ this.emit("backpressure", {
38070
+ identity: this._identity,
38071
+ bufferedAmount: ws.bufferedAmount
38072
+ });
38073
+ let waited = 0;
38074
+ const drainCheck = setInterval(() => {
38075
+ waited += 50;
38076
+ if (!ws || ws.readyState !== import_ws.default.OPEN) {
38077
+ clearInterval(drainCheck);
38078
+ cb?.(new Error("WebSocket closed during backpressure wait"));
38079
+ return;
38080
+ }
38081
+ if (ws.bufferedAmount <= _OCPPClient._BACKPRESSURE_THRESHOLD || waited >= 1e4) {
38082
+ clearInterval(drainCheck);
38083
+ ws.send(data, cb);
38084
+ }
38085
+ }, 50);
38086
+ } else {
38087
+ ws.send(data, cb);
38088
+ }
38089
+ }
37854
38090
  // ─── Internal: Ping/Pong ─────────────────────────────────────
37855
38091
  _startPing() {
37856
38092
  if (this._options.pingIntervalMs <= 0) return;
@@ -37897,7 +38133,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
37897
38133
  if (this._options.strictModeValidators) {
37898
38134
  this._validators = this._options.strictModeValidators;
37899
38135
  } else {
37900
- this._validators = standardValidators;
38136
+ this._validators = getStandardValidators();
37901
38137
  }
37902
38138
  if (Array.isArray(this._options.strictMode)) {
37903
38139
  this._strictProtocols = this._options.strictMode;
@@ -38000,6 +38236,52 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
38000
38236
  }
38001
38237
  };
38002
38238
 
38239
+ // src/lru-map.ts
38240
+ var LRUMap = class extends Map {
38241
+ _maxSize;
38242
+ constructor(maxSize) {
38243
+ super();
38244
+ if (maxSize < 1) throw new RangeError("LRUMap maxSize must be >= 1");
38245
+ this._maxSize = maxSize;
38246
+ }
38247
+ /**
38248
+ * Returns the configured maximum capacity of this LRU cache.
38249
+ */
38250
+ get maxSize() {
38251
+ return this._maxSize;
38252
+ }
38253
+ /**
38254
+ * Sets a key-value pair. If the key already exists, it is promoted to the
38255
+ * most-recently-used position. If inserting a new key would exceed capacity,
38256
+ * the oldest (least-recently-used) entry is evicted.
38257
+ */
38258
+ set(key, value) {
38259
+ if (this.has(key)) {
38260
+ this.delete(key);
38261
+ }
38262
+ super.set(key, value);
38263
+ if (this.size > this._maxSize) {
38264
+ const oldest = this.keys().next().value;
38265
+ if (oldest !== void 0) {
38266
+ this.delete(oldest);
38267
+ }
38268
+ }
38269
+ return this;
38270
+ }
38271
+ /**
38272
+ * Gets a value by key and promotes it to the most-recently-used position.
38273
+ * Uses `this.has(key)` instead of a value truthiness check to correctly
38274
+ * handle stored values of `undefined`, `null`, `0`, `""`, etc.
38275
+ */
38276
+ get(key) {
38277
+ if (!this.has(key)) return void 0;
38278
+ const value = super.get(key);
38279
+ this.delete(key);
38280
+ super.set(key, value);
38281
+ return value;
38282
+ }
38283
+ };
38284
+
38003
38285
  // src/router.ts
38004
38286
  var import_node_events2 = require("events");
38005
38287
  async function executeMiddlewareChain(middlewares, ctx) {
@@ -38356,6 +38638,7 @@ var OCPPServerClient = class extends OCPPClient {
38356
38638
  this._ws = context.ws;
38357
38639
  this._protocol = context.protocol ?? context.ws.protocol;
38358
38640
  this._attachServerWebsocket(context.ws);
38641
+ this._startPing();
38359
38642
  }
38360
38643
  // ─── Rate Limiting State ──────────────────────────────────────────
38361
38644
  _rateLimits = {};
@@ -38404,8 +38687,7 @@ var OCPPServerClient = class extends OCPPClient {
38404
38687
  let pData;
38405
38688
  if (limits.methods) {
38406
38689
  try {
38407
- const str = data.toString();
38408
- pData = JSON.parse(str);
38690
+ pData = JSON.parse(data);
38409
38691
  if (Array.isArray(pData) && pData[0] === 2) {
38410
38692
  method = pData[2];
38411
38693
  }
@@ -38523,13 +38805,16 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
38523
38805
  _httpServerAbortControllers = /* @__PURE__ */ new Set();
38524
38806
  _logger = null;
38525
38807
  _globalCORS;
38808
+ // Connection-level rate limiting (per-IP token bucket)
38809
+ _connectionBuckets = /* @__PURE__ */ new Map();
38526
38810
  // Robustness & Clustering
38527
38811
  _nodeId = (0, import_cuid22.createId)();
38528
- _sessions = /* @__PURE__ */ new Map();
38812
+ _sessions;
38529
38813
  _gcInterval = null;
38530
38814
  _sessionTimeoutMs;
38531
38815
  constructor(options = {}) {
38532
38816
  super();
38817
+ this.setMaxListeners(0);
38533
38818
  if (options.strictMode) {
38534
38819
  if (!options.strictModeValidators && !options.protocols?.length) {
38535
38820
  throw new Error(
@@ -38550,7 +38835,12 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
38550
38835
  ...options
38551
38836
  };
38552
38837
  this._sessionTimeoutMs = this._options.sessionTtlMs;
38553
- this._wss = new import_ws2.WebSocketServer({ noServer: true });
38838
+ const maxSessions = this._options.maxSessions ?? 5e4;
38839
+ this._sessions = new LRUMap(maxSessions);
38840
+ this._wss = new import_ws2.WebSocketServer({
38841
+ noServer: true,
38842
+ maxPayload: this._options.maxPayloadBytes ?? 65536
38843
+ });
38554
38844
  this._gcInterval = setInterval(() => {
38555
38845
  const now = Date.now();
38556
38846
  for (const [identity, session] of this._sessions.entries()) {
@@ -38764,6 +39054,69 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
38764
39054
  };
38765
39055
  httpServer.on("upgrade", upgradeHandler);
38766
39056
  this._httpServers.add(httpServer);
39057
+ if (this._options.healthEndpoint) {
39058
+ httpServer.on("request", (req, res) => {
39059
+ const url = req.url ?? "";
39060
+ if (url === "/health") {
39061
+ const s = this.stats();
39062
+ const body = JSON.stringify({
39063
+ status: this._state === "OPEN" ? "ok" : "degraded",
39064
+ state: this._state,
39065
+ connectedClients: s.connectedClients,
39066
+ activeSessions: s.activeSessions,
39067
+ uptimeSeconds: Math.round(s.uptimeSeconds),
39068
+ pid: s.pid
39069
+ });
39070
+ res.writeHead(200, {
39071
+ "Content-Type": "application/json",
39072
+ "Cache-Control": "no-cache"
39073
+ });
39074
+ res.end(body);
39075
+ return;
39076
+ }
39077
+ if (url === "/metrics") {
39078
+ const s = this.stats();
39079
+ const lines = [
39080
+ "# HELP ocpp_connected_clients Number of currently connected OCPP clients",
39081
+ "# TYPE ocpp_connected_clients gauge",
39082
+ `ocpp_connected_clients ${s.connectedClients}`,
39083
+ "",
39084
+ "# HELP ocpp_active_sessions Number of active in-memory sessions",
39085
+ "# TYPE ocpp_active_sessions gauge",
39086
+ `ocpp_active_sessions ${s.activeSessions}`,
39087
+ "",
39088
+ "# HELP ocpp_uptime_seconds Process uptime in seconds",
39089
+ "# TYPE ocpp_uptime_seconds gauge",
39090
+ `ocpp_uptime_seconds ${Math.round(s.uptimeSeconds)}`,
39091
+ "",
39092
+ "# HELP ocpp_memory_rss_bytes Resident set size in bytes",
39093
+ "# TYPE ocpp_memory_rss_bytes gauge",
39094
+ `ocpp_memory_rss_bytes ${s.memoryUsage.rss}`,
39095
+ "",
39096
+ "# HELP ocpp_memory_heap_used_bytes V8 heap used in bytes",
39097
+ "# TYPE ocpp_memory_heap_used_bytes gauge",
39098
+ `ocpp_memory_heap_used_bytes ${s.memoryUsage.heapUsed}`,
39099
+ "",
39100
+ "# HELP ocpp_memory_heap_total_bytes V8 heap total in bytes",
39101
+ "# TYPE ocpp_memory_heap_total_bytes gauge",
39102
+ `ocpp_memory_heap_total_bytes ${s.memoryUsage.heapTotal}`,
39103
+ "",
39104
+ "# HELP ocpp_ws_buffered_bytes Total buffered WebSocket bytes",
39105
+ "# TYPE ocpp_ws_buffered_bytes gauge",
39106
+ `ocpp_ws_buffered_bytes ${s.webSockets?.bufferedAmount ?? 0}`,
39107
+ ""
39108
+ ];
39109
+ res.writeHead(200, {
39110
+ "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
39111
+ "Cache-Control": "no-cache"
39112
+ });
39113
+ res.end(lines.join("\n"));
39114
+ return;
39115
+ }
39116
+ res.writeHead(404, { "Content-Type": "text/plain" });
39117
+ res.end("Not Found");
39118
+ });
39119
+ }
38767
39120
  if (options?.signal) {
38768
39121
  const ac = new AbortController();
38769
39122
  this._httpServerAbortControllers.add(ac);
@@ -38793,6 +39146,51 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
38793
39146
  }
38794
39147
  return httpServer;
38795
39148
  }
39149
+ /**
39150
+ * Hot-reloads the TLS certificate on all active HTTPS servers without
39151
+ * dropping any existing WebSocket connections.
39152
+ *
39153
+ * **When to use:** Call this whenever your TLS certificate is renewed —
39154
+ * for example, after a Let's Encrypt auto-renewal (every ~90 days).
39155
+ * Without this, you would need to restart the Node.js process to pick up
39156
+ * the new certificate, disconnecting all connected charging stations.
39157
+ *
39158
+ * **How to use:**
39159
+ * ```ts
39160
+ * server.updateTLS({ cert: newCert, key: newKey });
39161
+ * ```
39162
+ *
39163
+ * **Optional:** Only relevant if you are terminating TLS directly in Node.js
39164
+ * (i.e. `SecurityProfile.TLS_BASIC_AUTH` or `TLS_CLIENT_CERT`). If you are
39165
+ * running behind a reverse proxy (Nginx, AWS ALB, etc.) that handles TLS,
39166
+ * you do not need this method — just rotate the cert on the proxy.
39167
+ *
39168
+ * @throws If the server is not using a TLS Security Profile.
39169
+ */
39170
+ updateTLS(tlsOpts) {
39171
+ const profile = this._options.securityProfile ?? 0 /* NONE */;
39172
+ if (profile !== 2 /* TLS_BASIC_AUTH */ && profile !== 3 /* TLS_CLIENT_CERT */) {
39173
+ throw new Error(
39174
+ "updateTLS() requires a TLS Security Profile (TLS_BASIC_AUTH or TLS_CLIENT_CERT)"
39175
+ );
39176
+ }
39177
+ this._options.tls = { ...this._options.tls, ...tlsOpts };
39178
+ const httpsOptions = {};
39179
+ if (tlsOpts.cert) httpsOptions.cert = tlsOpts.cert;
39180
+ if (tlsOpts.key) httpsOptions.key = tlsOpts.key;
39181
+ if (tlsOpts.ca) httpsOptions.ca = tlsOpts.ca;
39182
+ if (tlsOpts.passphrase) httpsOptions.passphrase = tlsOpts.passphrase;
39183
+ let updated = 0;
39184
+ for (const srv of this._httpServers) {
39185
+ if ("setSecureContext" in srv && typeof srv.setSecureContext === "function") {
39186
+ srv.setSecureContext(httpsOptions);
39187
+ updated++;
39188
+ }
39189
+ }
39190
+ this._logger?.info?.(
39191
+ `TLS context hot-reloaded across ${updated} active server(s)`
39192
+ );
39193
+ }
38796
39194
  // ─── Handle Upgrade ──────────────────────────────────────────
38797
39195
  get handleUpgrade() {
38798
39196
  return (req, socket, head) => {
@@ -38827,6 +39225,36 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
38827
39225
  abortHandshake(socket, 503, "Server is shutting down");
38828
39226
  return;
38829
39227
  }
39228
+ const connRateLimit = this._options.connectionRateLimit;
39229
+ if (connRateLimit) {
39230
+ const ip = req.socket.remoteAddress ?? "unknown";
39231
+ const now = Date.now();
39232
+ let bucket = this._connectionBuckets.get(ip);
39233
+ if (!bucket) {
39234
+ bucket = { tokens: connRateLimit.limit, lastRefill: now };
39235
+ this._connectionBuckets.set(ip, bucket);
39236
+ } else {
39237
+ const elapsed = now - bucket.lastRefill;
39238
+ const refillRate = connRateLimit.limit / connRateLimit.windowMs;
39239
+ bucket.tokens = Math.min(
39240
+ connRateLimit.limit,
39241
+ bucket.tokens + elapsed * refillRate
39242
+ );
39243
+ bucket.lastRefill = now;
39244
+ }
39245
+ if (bucket.tokens < 1) {
39246
+ this._logger?.warn?.("Connection rate limit exceeded", { ip });
39247
+ this.emit("securityEvent", {
39248
+ type: "CONNECTION_RATE_LIMIT",
39249
+ ip,
39250
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39251
+ details: { tokensRemaining: bucket.tokens }
39252
+ });
39253
+ abortHandshake(socket, 429, "Too Many Requests");
39254
+ return;
39255
+ }
39256
+ bucket.tokens -= 1;
39257
+ }
38830
39258
  if (socket.readyState !== "open") {
38831
39259
  this._logger?.debug?.("Socket not open at upgrade start");
38832
39260
  if (!socket.destroyed) socket.destroy();
@@ -39078,6 +39506,13 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39078
39506
  if (ac.signal.aborted) {
39079
39507
  const reason = err instanceof Error ? err.message : "Unknown abort";
39080
39508
  this._logger?.warn?.("Handshake aborted", { identity, reason });
39509
+ this.emit("securityEvent", {
39510
+ type: "UPGRADE_ABORTED",
39511
+ identity,
39512
+ ip: req.socket.remoteAddress,
39513
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39514
+ details: { reason }
39515
+ });
39081
39516
  this.emit("upgradeAborted", {
39082
39517
  identity,
39083
39518
  reason,
@@ -39091,6 +39526,13 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39091
39526
  const code = typeof errObj?.code === "number" ? errObj.code : 401;
39092
39527
  const message = typeof errObj?.message === "string" ? errObj.message : "Unauthorized";
39093
39528
  this._logger?.warn?.("Auth rejected", { identity, code });
39529
+ this.emit("securityEvent", {
39530
+ type: "AUTH_FAILED",
39531
+ identity,
39532
+ ip: req.socket.remoteAddress,
39533
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39534
+ details: { code, message }
39535
+ });
39094
39536
  abortHandshake(socket, code, message);
39095
39537
  return;
39096
39538
  } finally {
@@ -39114,7 +39556,10 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39114
39556
  return;
39115
39557
  }
39116
39558
  if (!this._wss) {
39117
- this._wss = new import_ws2.WebSocketServer({ noServer: true });
39559
+ this._wss = new import_ws2.WebSocketServer({
39560
+ noServer: true,
39561
+ maxPayload: this._options.maxPayloadBytes ?? 65536
39562
+ });
39118
39563
  }
39119
39564
  this._wss.handleUpgrade(req, socket, head, (ws) => {
39120
39565
  const clientOptions = {
@@ -39145,6 +39590,20 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39145
39590
  protocol: selectedProtocol
39146
39591
  });
39147
39592
  this._updateSessionActivity(identity, client.session);
39593
+ const existingClient = this._clientsByIdentity.get(identity);
39594
+ if (existingClient && existingClient !== client) {
39595
+ this._logger?.warn?.("Evicting stale connection for identity", {
39596
+ identity,
39597
+ reason: "Duplicate identity replaced by new connection"
39598
+ });
39599
+ existingClient.close({
39600
+ code: 4e3,
39601
+ reason: "Evicted by new connection",
39602
+ force: true
39603
+ }).catch(() => {
39604
+ });
39605
+ this._clients.delete(existingClient);
39606
+ }
39148
39607
  this._clients.add(client);
39149
39608
  this._clientsByIdentity.set(identity, client);
39150
39609
  if (this._adapter?.setPresence) {
@@ -39200,6 +39659,29 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39200
39659
  clearInterval(this._gcInterval);
39201
39660
  this._gcInterval = null;
39202
39661
  }
39662
+ if (!options.force) {
39663
+ const drainTimeout = 5e3;
39664
+ const drainPromises = Array.from(this._clients).map(async (client) => {
39665
+ const ws = client._ws;
39666
+ if (ws && ws.bufferedAmount > 0) {
39667
+ this._logger?.debug?.("Waiting for client buffer to drain", {
39668
+ identity: client.identity,
39669
+ bufferedAmount: ws.bufferedAmount
39670
+ });
39671
+ await new Promise((resolve) => {
39672
+ let elapsed = 0;
39673
+ const check = setInterval(() => {
39674
+ elapsed += 50;
39675
+ if (!ws || ws.bufferedAmount === 0 || elapsed >= drainTimeout) {
39676
+ clearInterval(check);
39677
+ resolve();
39678
+ }
39679
+ }, 50);
39680
+ });
39681
+ }
39682
+ });
39683
+ await Promise.allSettled(drainPromises);
39684
+ }
39203
39685
  const closePromises = Array.from(this._clients).map(
39204
39686
  (client) => client.close(options).catch(() => {
39205
39687
  })
@@ -39427,6 +39909,7 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39427
39909
  0 && (module.exports = {
39428
39910
  ConnectionState,
39429
39911
  InMemoryAdapter,
39912
+ LRUMap,
39430
39913
  MessageType,
39431
39914
  MiddlewareStack,
39432
39915
  NOREPLY,
@@ -39464,6 +39947,6 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
39464
39947
  defineRpcMiddleware,
39465
39948
  getErrorPlainObject,
39466
39949
  getPackageIdent,
39467
- standardValidators
39950
+ getStandardValidators
39468
39951
  });
39469
39952
  //# sourceMappingURL=index.js.map