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.mjs CHANGED
@@ -44,6 +44,11 @@ var InMemoryAdapter = class {
44
44
  async removePresence(identity) {
45
45
  this._presence.delete(identity);
46
46
  }
47
+ async setPresenceBatch(entries) {
48
+ for (const { identity, nodeId } of entries) {
49
+ this._presence.set(identity, nodeId);
50
+ }
51
+ }
47
52
  };
48
53
  function defineAdapter(adapter) {
49
54
  return adapter;
@@ -159,6 +164,25 @@ var IoRedisDriver = class {
159
164
  };
160
165
  await Promise.all([close(this.pub), close(this.sub)]);
161
166
  }
167
+ async setPresenceBatch(entries) {
168
+ if (entries.length === 0) return;
169
+ const pipeline = this.pub.pipeline();
170
+ for (const { key, value, ttlSeconds } of entries) {
171
+ pipeline.set(key, value, "EX", ttlSeconds);
172
+ }
173
+ await pipeline.exec();
174
+ }
175
+ async expire(key, ttlSeconds) {
176
+ await this.pub.expire(key, ttlSeconds);
177
+ }
178
+ onError(handler) {
179
+ this.pub.on("error", handler);
180
+ return () => this.pub.removeListener("error", handler);
181
+ }
182
+ onReconnect(handler) {
183
+ this.pub.on("connect", handler);
184
+ return () => this.pub.removeListener("connect", handler);
185
+ }
162
186
  };
163
187
  var NodeRedisDriver = class {
164
188
  constructor(pub, sub, blocking) {
@@ -245,6 +269,25 @@ var NodeRedisDriver = class {
245
269
  async disconnect() {
246
270
  await Promise.all([this.pub.disconnect(), this.sub.disconnect()]);
247
271
  }
272
+ async setPresenceBatch(entries) {
273
+ if (entries.length === 0) return;
274
+ const multi = this.pub.multi();
275
+ for (const { key, value, ttlSeconds } of entries) {
276
+ multi.set(key, value, { EX: ttlSeconds });
277
+ }
278
+ await multi.exec();
279
+ }
280
+ async expire(key, ttlSeconds) {
281
+ await this.pub.expire(key, ttlSeconds);
282
+ }
283
+ onError(handler) {
284
+ this.pub.on("error", handler);
285
+ return () => this.pub.removeListener("error", handler);
286
+ }
287
+ onReconnect(handler) {
288
+ this.pub.on("connect", handler);
289
+ return () => this.pub.removeListener("connect", handler);
290
+ }
248
291
  };
249
292
  function createDriver(pub, sub, blocking) {
250
293
  if (sub.isOpen !== void 0 && typeof sub.subscribe === "function") {
@@ -258,6 +301,8 @@ var RedisAdapter = class {
258
301
  _driver;
259
302
  _prefix;
260
303
  _streamMaxLen;
304
+ _streamTtlSeconds;
305
+ _presenceTtlSeconds;
261
306
  _handlers = /* @__PURE__ */ new Map();
262
307
  _streamOffsets = /* @__PURE__ */ new Map();
263
308
  // streamKey -> lastId
@@ -265,20 +310,48 @@ var RedisAdapter = class {
265
310
  // Active streams to poll
266
311
  _polling = false;
267
312
  _closed = false;
313
+ // Per-stream sequence counter for message ordering
314
+ _sequenceCounters = /* @__PURE__ */ new Map();
315
+ // Rehydration callbacks
316
+ _unsubError;
317
+ _unsubReconnect;
318
+ // Stored presence entries for rehydration on reconnect
319
+ _presenceCache = /* @__PURE__ */ new Map();
268
320
  constructor(options) {
269
321
  this._prefix = options.prefix ?? "ocpp-ws-io:";
270
322
  this._streamMaxLen = options.streamMaxLen ?? 1e3;
323
+ this._streamTtlSeconds = options.streamTtlSeconds ?? 300;
324
+ this._presenceTtlSeconds = options.presenceTtlSeconds ?? 300;
271
325
  this._driver = createDriver(
272
326
  options.pubClient,
273
327
  options.subClient,
274
328
  options.blockingClient
275
329
  );
330
+ if (this._driver.onError) {
331
+ this._unsubError = this._driver.onError((err) => {
332
+ console.error("[RedisAdapter] Redis error:", err.message);
333
+ });
334
+ }
335
+ if (this._driver.onReconnect) {
336
+ this._unsubReconnect = this._driver.onReconnect(() => {
337
+ this._rehydratePresence().catch(() => {
338
+ });
339
+ });
340
+ }
276
341
  }
277
342
  async publish(channel, data) {
278
343
  const prefixedChannel = this._prefix + channel;
344
+ const payload = data;
345
+ if (payload && typeof payload === "object" && channel.startsWith("ocpp:node:")) {
346
+ const seq = (this._sequenceCounters.get(channel) ?? 0) + 1;
347
+ this._sequenceCounters.set(channel, seq);
348
+ payload.__seq = seq;
349
+ }
279
350
  const message = JSON.stringify(data);
280
351
  if (channel.startsWith("ocpp:node:")) {
281
352
  await this._driver.xadd(prefixedChannel, { message }, this._streamMaxLen);
353
+ await this._driver.expire(prefixedChannel, this._streamTtlSeconds).catch(() => {
354
+ });
282
355
  } else {
283
356
  await this._driver.publish(prefixedChannel, message);
284
357
  }
@@ -344,6 +417,10 @@ var RedisAdapter = class {
344
417
  this._closed = true;
345
418
  this._handlers.clear();
346
419
  this._streams.clear();
420
+ this._presenceCache.clear();
421
+ this._sequenceCounters.clear();
422
+ if (this._unsubError) this._unsubError();
423
+ if (this._unsubReconnect) this._unsubReconnect();
347
424
  await this._driver.disconnect();
348
425
  }
349
426
  _handleMessage(channel, message) {
@@ -403,6 +480,7 @@ var RedisAdapter = class {
403
480
  // ─── Presence Registry ─────────────────────────────────────────────
404
481
  async setPresence(identity, nodeId, ttl) {
405
482
  const key = `${this._prefix}presence:${identity}`;
483
+ this._presenceCache.set(identity, { nodeId, ttl });
406
484
  await this._driver.set(key, nodeId, ttl);
407
485
  }
408
486
  async getPresence(identity) {
@@ -440,6 +518,37 @@ var RedisAdapter = class {
440
518
  streamDetails
441
519
  };
442
520
  }
521
+ // ─── C1: Batch Presence Pipeline ────────────────────────────────────
522
+ /**
523
+ * Set multiple presence entries in a single Redis pipeline.
524
+ * Reduces N network round-trips to 1 for bulk presence updates.
525
+ */
526
+ async setPresenceBatch(entries) {
527
+ if (entries.length === 0) return;
528
+ const batchEntries = entries.map(({ identity, nodeId, ttl }) => {
529
+ const key = `${this._prefix}presence:${identity}`;
530
+ const ttlSeconds = ttl ?? this._presenceTtlSeconds;
531
+ this._presenceCache.set(identity, { nodeId, ttl: ttlSeconds });
532
+ return { key, value: nodeId, ttlSeconds };
533
+ });
534
+ await this._driver.setPresenceBatch(batchEntries);
535
+ }
536
+ // ─── C3: Redis Failure Rehydration ──────────────────────────────────
537
+ /**
538
+ * Re-syncs all cached presence entries to Redis after a reconnection.
539
+ * Called automatically when the Redis client reconnects.
540
+ */
541
+ async _rehydratePresence() {
542
+ if (this._presenceCache.size === 0) return;
543
+ const entries = Array.from(this._presenceCache.entries()).map(
544
+ ([identity, { nodeId, ttl }]) => ({
545
+ key: `${this._prefix}presence:${identity}`,
546
+ value: nodeId,
547
+ ttlSeconds: ttl
548
+ })
549
+ );
550
+ await this._driver.setPresenceBatch(entries);
551
+ }
443
552
  };
444
553
 
445
554
  // src/client.ts
@@ -36810,6 +36919,8 @@ var Validator = class {
36810
36919
  /**
36811
36920
  * Validate a payload against a schema identified by its $id.
36812
36921
  * Throws a typed RPCError if validation fails.
36922
+ *
36923
+ * E2: Schema is compiled on first call to this method (lazy).
36813
36924
  */
36814
36925
  validate(schemaId, params) {
36815
36926
  const resolvedId = this._normalizeSchemaId(schemaId);
@@ -36832,16 +36943,26 @@ var Validator = class {
36832
36943
  return !!this._ajv.getSchema(this._normalizeSchemaId(schemaId));
36833
36944
  }
36834
36945
  };
36946
+ var _validatorRegistry = /* @__PURE__ */ new Map();
36835
36947
  function createValidator(subprotocol, schemas) {
36836
- return new Validator(subprotocol, schemas);
36948
+ const existing = _validatorRegistry.get(subprotocol);
36949
+ if (existing) return existing;
36950
+ const validator = new Validator(subprotocol, schemas);
36951
+ _validatorRegistry.set(subprotocol, validator);
36952
+ return validator;
36837
36953
  }
36838
36954
 
36839
36955
  // src/standard-validators.ts
36840
- var standardValidators = [
36841
- createValidator("ocpp1.6", ocpp1_6_default),
36842
- createValidator("ocpp2.0.1", ocpp2_0_1_default),
36843
- createValidator("ocpp2.1", ocpp2_1_default)
36844
- ];
36956
+ var _cached = null;
36957
+ function getStandardValidators() {
36958
+ if (_cached) return _cached;
36959
+ _cached = [
36960
+ createValidator("ocpp1.6", ocpp1_6_default),
36961
+ createValidator("ocpp2.0.1", ocpp2_0_1_default),
36962
+ createValidator("ocpp2.1", ocpp2_1_default)
36963
+ ];
36964
+ return _cached;
36965
+ }
36845
36966
 
36846
36967
  // src/types.ts
36847
36968
  var ConnectionState = {
@@ -37024,6 +37145,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37024
37145
  _badMessageCount = 0;
37025
37146
  _lastActivity = 0;
37026
37147
  _outboundBuffer = [];
37148
+ _offlineQueue = [];
37027
37149
  _middleware;
37028
37150
  _validators = [];
37029
37151
  _strictProtocols = null;
@@ -37033,6 +37155,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37033
37155
  _prettify = false;
37034
37156
  constructor(options) {
37035
37157
  super();
37158
+ this.setMaxListeners(0);
37036
37159
  if (!options.identity) {
37037
37160
  throw new Error("identity is required");
37038
37161
  }
@@ -37178,6 +37301,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37178
37301
  }
37179
37302
  this._attachWebsocket(ws);
37180
37303
  this._startPing();
37304
+ this._flushOfflineQueue();
37181
37305
  if (this._outboundBuffer.length > 0) {
37182
37306
  const buffer = this._outboundBuffer;
37183
37307
  this._outboundBuffer = [];
@@ -37359,8 +37483,32 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37359
37483
  options = args[2] ?? {};
37360
37484
  }
37361
37485
  if (this._state !== OPEN) {
37486
+ if (this._options.offlineQueue && (this._state === CLOSED || this._state === CONNECTING)) {
37487
+ return new Promise((resolve, reject) => {
37488
+ const maxSize = this._options.offlineQueueMaxSize ?? 100;
37489
+ if (this._offlineQueue.length >= maxSize) {
37490
+ this._offlineQueue.shift();
37491
+ this._logger?.warn?.(
37492
+ "Offline queue full \u2014 dropping oldest message",
37493
+ {
37494
+ method,
37495
+ queueSize: this._offlineQueue.length
37496
+ }
37497
+ );
37498
+ }
37499
+ this._offlineQueue.push({ method, params, options, resolve, reject });
37500
+ this._logger?.debug?.("Call queued offline", {
37501
+ method,
37502
+ queueSize: this._offlineQueue.length
37503
+ });
37504
+ });
37505
+ }
37362
37506
  throw new Error(`Cannot call: client is in state ${this._state}`);
37363
37507
  }
37508
+ const maxRetries = options.retries ?? 0;
37509
+ if (maxRetries > 0) {
37510
+ return this._callWithRetry(method, params, options, maxRetries);
37511
+ }
37364
37512
  return this._callQueue.push(() => this._sendCall(method, params, options));
37365
37513
  }
37366
37514
  async safeCall(...args) {
@@ -37385,7 +37533,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37385
37533
  }
37386
37534
  }
37387
37535
  async _sendCall(method, params, options) {
37388
- const msgId = createId();
37536
+ const msgId = options.idempotencyKey ?? createId();
37389
37537
  const timeoutMs = options.timeoutMs ?? this._options.callTimeoutMs;
37390
37538
  const ctx = {
37391
37539
  type: "outgoing_call",
@@ -37433,7 +37581,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37433
37581
  sentAt: Date.now()
37434
37582
  });
37435
37583
  if (this._ws?.readyState === WebSocket.OPEN) {
37436
- this._ws.send(messageStr, (err) => {
37584
+ this._safeSend(this._ws, messageStr, (err) => {
37437
37585
  if (err) {
37438
37586
  clearTimeout(timeoutHandle);
37439
37587
  this._pendingCalls.delete(msgId);
@@ -37509,11 +37657,13 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37509
37657
  this._recordActivity();
37510
37658
  let message;
37511
37659
  try {
37512
- const str = rawData.toString();
37513
- message = JSON.parse(str);
37660
+ message = JSON.parse(rawData);
37514
37661
  if (!Array.isArray(message)) throw new Error("Message is not an array");
37515
37662
  } catch (err) {
37516
- this._onBadMessage(rawData.toString(), err);
37663
+ this._onBadMessage(
37664
+ typeof rawData === "string" ? rawData : rawData.toString(),
37665
+ err
37666
+ );
37517
37667
  return;
37518
37668
  }
37519
37669
  const messageType = message[0];
@@ -37780,6 +37930,91 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37780
37930
  }
37781
37931
  }, delayMs);
37782
37932
  }
37933
+ // ─── Internal: Offline Queue ──────────────────────────────────
37934
+ /**
37935
+ * Atomically drains the offline queue and sends each message via _sendCall.
37936
+ * Uses splice(0) to prevent re-entry bugs (double billing) if the connection
37937
+ * drops again mid-flush — the queue is empty before any sends begin.
37938
+ */
37939
+ _flushOfflineQueue() {
37940
+ if (this._offlineQueue.length === 0) return;
37941
+ const snapshot = this._offlineQueue.splice(0, this._offlineQueue.length);
37942
+ this._logger?.info?.("Flushing offline queue", { count: snapshot.length });
37943
+ for (const entry of snapshot) {
37944
+ this._callQueue.push(() => this._sendCall(entry.method, entry.params, entry.options)).then(entry.resolve).catch(entry.reject);
37945
+ }
37946
+ }
37947
+ // ─── Internal: Call Retry with Full Jitter ───────────────────
37948
+ /**
37949
+ * Retry wrapper using Full Jitter exponential backoff.
37950
+ * delay = random(0, min(retryMaxDelayMs, retryDelayMs * 2^attempt))
37951
+ * Only retries on TimeoutError — all other errors propagate immediately.
37952
+ */
37953
+ async _callWithRetry(method, params, options, maxRetries) {
37954
+ const baseDelay = options.retryDelayMs ?? 1e3;
37955
+ const maxDelay = options.retryMaxDelayMs ?? 3e4;
37956
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
37957
+ try {
37958
+ return await this._callQueue.push(
37959
+ () => this._sendCall(method, params, options)
37960
+ );
37961
+ } catch (err) {
37962
+ if (attempt === maxRetries || !(err instanceof TimeoutError)) {
37963
+ throw err;
37964
+ }
37965
+ const expDelay = Math.min(maxDelay, baseDelay * 2 ** attempt);
37966
+ const jitteredDelay = Math.random() * expDelay;
37967
+ this._logger?.warn?.("Call retry", {
37968
+ method,
37969
+ attempt: attempt + 1,
37970
+ maxRetries,
37971
+ delayMs: Math.round(jitteredDelay)
37972
+ });
37973
+ await new Promise((r) => setTimeout(r, jitteredDelay));
37974
+ }
37975
+ }
37976
+ throw new Error("Retry exhausted");
37977
+ }
37978
+ // ─── Internal: Backpressure-Aware Send ───────────────────────
37979
+ /** Maximum bytes allowed in the ws send buffer before applying backpressure (512KB) */
37980
+ static _BACKPRESSURE_THRESHOLD = 512 * 1024;
37981
+ /**
37982
+ * Wraps ws.send() with backpressure protection.
37983
+ * If bufferedAmount exceeds the threshold, waits for the buffer to drain
37984
+ * before sending. Prevents OOM on slow 2G/3G charger connections.
37985
+ */
37986
+ _safeSend(ws, data, cb) {
37987
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
37988
+ cb?.(new Error("WebSocket is not open"));
37989
+ return;
37990
+ }
37991
+ if (ws.bufferedAmount > _OCPPClient._BACKPRESSURE_THRESHOLD) {
37992
+ this._logger?.warn?.("Backpressure \u2014 pausing send", {
37993
+ identity: this._identity,
37994
+ bufferedAmount: ws.bufferedAmount,
37995
+ threshold: _OCPPClient._BACKPRESSURE_THRESHOLD
37996
+ });
37997
+ this.emit("backpressure", {
37998
+ identity: this._identity,
37999
+ bufferedAmount: ws.bufferedAmount
38000
+ });
38001
+ let waited = 0;
38002
+ const drainCheck = setInterval(() => {
38003
+ waited += 50;
38004
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
38005
+ clearInterval(drainCheck);
38006
+ cb?.(new Error("WebSocket closed during backpressure wait"));
38007
+ return;
38008
+ }
38009
+ if (ws.bufferedAmount <= _OCPPClient._BACKPRESSURE_THRESHOLD || waited >= 1e4) {
38010
+ clearInterval(drainCheck);
38011
+ ws.send(data, cb);
38012
+ }
38013
+ }, 50);
38014
+ } else {
38015
+ ws.send(data, cb);
38016
+ }
38017
+ }
37783
38018
  // ─── Internal: Ping/Pong ─────────────────────────────────────
37784
38019
  _startPing() {
37785
38020
  if (this._options.pingIntervalMs <= 0) return;
@@ -37826,7 +38061,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37826
38061
  if (this._options.strictModeValidators) {
37827
38062
  this._validators = this._options.strictModeValidators;
37828
38063
  } else {
37829
- this._validators = standardValidators;
38064
+ this._validators = getStandardValidators();
37830
38065
  }
37831
38066
  if (Array.isArray(this._options.strictMode)) {
37832
38067
  this._strictProtocols = this._options.strictMode;
@@ -37929,6 +38164,52 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
37929
38164
  }
37930
38165
  };
37931
38166
 
38167
+ // src/lru-map.ts
38168
+ var LRUMap = class extends Map {
38169
+ _maxSize;
38170
+ constructor(maxSize) {
38171
+ super();
38172
+ if (maxSize < 1) throw new RangeError("LRUMap maxSize must be >= 1");
38173
+ this._maxSize = maxSize;
38174
+ }
38175
+ /**
38176
+ * Returns the configured maximum capacity of this LRU cache.
38177
+ */
38178
+ get maxSize() {
38179
+ return this._maxSize;
38180
+ }
38181
+ /**
38182
+ * Sets a key-value pair. If the key already exists, it is promoted to the
38183
+ * most-recently-used position. If inserting a new key would exceed capacity,
38184
+ * the oldest (least-recently-used) entry is evicted.
38185
+ */
38186
+ set(key, value) {
38187
+ if (this.has(key)) {
38188
+ this.delete(key);
38189
+ }
38190
+ super.set(key, value);
38191
+ if (this.size > this._maxSize) {
38192
+ const oldest = this.keys().next().value;
38193
+ if (oldest !== void 0) {
38194
+ this.delete(oldest);
38195
+ }
38196
+ }
38197
+ return this;
38198
+ }
38199
+ /**
38200
+ * Gets a value by key and promotes it to the most-recently-used position.
38201
+ * Uses `this.has(key)` instead of a value truthiness check to correctly
38202
+ * handle stored values of `undefined`, `null`, `0`, `""`, etc.
38203
+ */
38204
+ get(key) {
38205
+ if (!this.has(key)) return void 0;
38206
+ const value = super.get(key);
38207
+ this.delete(key);
38208
+ super.set(key, value);
38209
+ return value;
38210
+ }
38211
+ };
38212
+
37932
38213
  // src/router.ts
37933
38214
  import { EventEmitter as EventEmitter2 } from "events";
37934
38215
  async function executeMiddlewareChain(middlewares, ctx) {
@@ -38287,6 +38568,7 @@ var OCPPServerClient = class extends OCPPClient {
38287
38568
  this._ws = context.ws;
38288
38569
  this._protocol = context.protocol ?? context.ws.protocol;
38289
38570
  this._attachServerWebsocket(context.ws);
38571
+ this._startPing();
38290
38572
  }
38291
38573
  // ─── Rate Limiting State ──────────────────────────────────────────
38292
38574
  _rateLimits = {};
@@ -38335,8 +38617,7 @@ var OCPPServerClient = class extends OCPPClient {
38335
38617
  let pData;
38336
38618
  if (limits.methods) {
38337
38619
  try {
38338
- const str = data.toString();
38339
- pData = JSON.parse(str);
38620
+ pData = JSON.parse(data);
38340
38621
  if (Array.isArray(pData) && pData[0] === 2) {
38341
38622
  method = pData[2];
38342
38623
  }
@@ -38454,13 +38735,16 @@ var OCPPServer = class extends EventEmitter3 {
38454
38735
  _httpServerAbortControllers = /* @__PURE__ */ new Set();
38455
38736
  _logger = null;
38456
38737
  _globalCORS;
38738
+ // Connection-level rate limiting (per-IP token bucket)
38739
+ _connectionBuckets = /* @__PURE__ */ new Map();
38457
38740
  // Robustness & Clustering
38458
38741
  _nodeId = createId2();
38459
- _sessions = /* @__PURE__ */ new Map();
38742
+ _sessions;
38460
38743
  _gcInterval = null;
38461
38744
  _sessionTimeoutMs;
38462
38745
  constructor(options = {}) {
38463
38746
  super();
38747
+ this.setMaxListeners(0);
38464
38748
  if (options.strictMode) {
38465
38749
  if (!options.strictModeValidators && !options.protocols?.length) {
38466
38750
  throw new Error(
@@ -38481,7 +38765,12 @@ var OCPPServer = class extends EventEmitter3 {
38481
38765
  ...options
38482
38766
  };
38483
38767
  this._sessionTimeoutMs = this._options.sessionTtlMs;
38484
- this._wss = new WebSocketServer({ noServer: true });
38768
+ const maxSessions = this._options.maxSessions ?? 5e4;
38769
+ this._sessions = new LRUMap(maxSessions);
38770
+ this._wss = new WebSocketServer({
38771
+ noServer: true,
38772
+ maxPayload: this._options.maxPayloadBytes ?? 65536
38773
+ });
38485
38774
  this._gcInterval = setInterval(() => {
38486
38775
  const now = Date.now();
38487
38776
  for (const [identity, session] of this._sessions.entries()) {
@@ -38695,6 +38984,69 @@ var OCPPServer = class extends EventEmitter3 {
38695
38984
  };
38696
38985
  httpServer.on("upgrade", upgradeHandler);
38697
38986
  this._httpServers.add(httpServer);
38987
+ if (this._options.healthEndpoint) {
38988
+ httpServer.on("request", (req, res) => {
38989
+ const url = req.url ?? "";
38990
+ if (url === "/health") {
38991
+ const s = this.stats();
38992
+ const body = JSON.stringify({
38993
+ status: this._state === "OPEN" ? "ok" : "degraded",
38994
+ state: this._state,
38995
+ connectedClients: s.connectedClients,
38996
+ activeSessions: s.activeSessions,
38997
+ uptimeSeconds: Math.round(s.uptimeSeconds),
38998
+ pid: s.pid
38999
+ });
39000
+ res.writeHead(200, {
39001
+ "Content-Type": "application/json",
39002
+ "Cache-Control": "no-cache"
39003
+ });
39004
+ res.end(body);
39005
+ return;
39006
+ }
39007
+ if (url === "/metrics") {
39008
+ const s = this.stats();
39009
+ const lines = [
39010
+ "# HELP ocpp_connected_clients Number of currently connected OCPP clients",
39011
+ "# TYPE ocpp_connected_clients gauge",
39012
+ `ocpp_connected_clients ${s.connectedClients}`,
39013
+ "",
39014
+ "# HELP ocpp_active_sessions Number of active in-memory sessions",
39015
+ "# TYPE ocpp_active_sessions gauge",
39016
+ `ocpp_active_sessions ${s.activeSessions}`,
39017
+ "",
39018
+ "# HELP ocpp_uptime_seconds Process uptime in seconds",
39019
+ "# TYPE ocpp_uptime_seconds gauge",
39020
+ `ocpp_uptime_seconds ${Math.round(s.uptimeSeconds)}`,
39021
+ "",
39022
+ "# HELP ocpp_memory_rss_bytes Resident set size in bytes",
39023
+ "# TYPE ocpp_memory_rss_bytes gauge",
39024
+ `ocpp_memory_rss_bytes ${s.memoryUsage.rss}`,
39025
+ "",
39026
+ "# HELP ocpp_memory_heap_used_bytes V8 heap used in bytes",
39027
+ "# TYPE ocpp_memory_heap_used_bytes gauge",
39028
+ `ocpp_memory_heap_used_bytes ${s.memoryUsage.heapUsed}`,
39029
+ "",
39030
+ "# HELP ocpp_memory_heap_total_bytes V8 heap total in bytes",
39031
+ "# TYPE ocpp_memory_heap_total_bytes gauge",
39032
+ `ocpp_memory_heap_total_bytes ${s.memoryUsage.heapTotal}`,
39033
+ "",
39034
+ "# HELP ocpp_ws_buffered_bytes Total buffered WebSocket bytes",
39035
+ "# TYPE ocpp_ws_buffered_bytes gauge",
39036
+ `ocpp_ws_buffered_bytes ${s.webSockets?.bufferedAmount ?? 0}`,
39037
+ ""
39038
+ ];
39039
+ res.writeHead(200, {
39040
+ "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
39041
+ "Cache-Control": "no-cache"
39042
+ });
39043
+ res.end(lines.join("\n"));
39044
+ return;
39045
+ }
39046
+ res.writeHead(404, { "Content-Type": "text/plain" });
39047
+ res.end("Not Found");
39048
+ });
39049
+ }
38698
39050
  if (options?.signal) {
38699
39051
  const ac = new AbortController();
38700
39052
  this._httpServerAbortControllers.add(ac);
@@ -38724,6 +39076,51 @@ var OCPPServer = class extends EventEmitter3 {
38724
39076
  }
38725
39077
  return httpServer;
38726
39078
  }
39079
+ /**
39080
+ * Hot-reloads the TLS certificate on all active HTTPS servers without
39081
+ * dropping any existing WebSocket connections.
39082
+ *
39083
+ * **When to use:** Call this whenever your TLS certificate is renewed —
39084
+ * for example, after a Let's Encrypt auto-renewal (every ~90 days).
39085
+ * Without this, you would need to restart the Node.js process to pick up
39086
+ * the new certificate, disconnecting all connected charging stations.
39087
+ *
39088
+ * **How to use:**
39089
+ * ```ts
39090
+ * server.updateTLS({ cert: newCert, key: newKey });
39091
+ * ```
39092
+ *
39093
+ * **Optional:** Only relevant if you are terminating TLS directly in Node.js
39094
+ * (i.e. `SecurityProfile.TLS_BASIC_AUTH` or `TLS_CLIENT_CERT`). If you are
39095
+ * running behind a reverse proxy (Nginx, AWS ALB, etc.) that handles TLS,
39096
+ * you do not need this method — just rotate the cert on the proxy.
39097
+ *
39098
+ * @throws If the server is not using a TLS Security Profile.
39099
+ */
39100
+ updateTLS(tlsOpts) {
39101
+ const profile = this._options.securityProfile ?? 0 /* NONE */;
39102
+ if (profile !== 2 /* TLS_BASIC_AUTH */ && profile !== 3 /* TLS_CLIENT_CERT */) {
39103
+ throw new Error(
39104
+ "updateTLS() requires a TLS Security Profile (TLS_BASIC_AUTH or TLS_CLIENT_CERT)"
39105
+ );
39106
+ }
39107
+ this._options.tls = { ...this._options.tls, ...tlsOpts };
39108
+ const httpsOptions = {};
39109
+ if (tlsOpts.cert) httpsOptions.cert = tlsOpts.cert;
39110
+ if (tlsOpts.key) httpsOptions.key = tlsOpts.key;
39111
+ if (tlsOpts.ca) httpsOptions.ca = tlsOpts.ca;
39112
+ if (tlsOpts.passphrase) httpsOptions.passphrase = tlsOpts.passphrase;
39113
+ let updated = 0;
39114
+ for (const srv of this._httpServers) {
39115
+ if ("setSecureContext" in srv && typeof srv.setSecureContext === "function") {
39116
+ srv.setSecureContext(httpsOptions);
39117
+ updated++;
39118
+ }
39119
+ }
39120
+ this._logger?.info?.(
39121
+ `TLS context hot-reloaded across ${updated} active server(s)`
39122
+ );
39123
+ }
38727
39124
  // ─── Handle Upgrade ──────────────────────────────────────────
38728
39125
  get handleUpgrade() {
38729
39126
  return (req, socket, head) => {
@@ -38758,6 +39155,36 @@ var OCPPServer = class extends EventEmitter3 {
38758
39155
  abortHandshake(socket, 503, "Server is shutting down");
38759
39156
  return;
38760
39157
  }
39158
+ const connRateLimit = this._options.connectionRateLimit;
39159
+ if (connRateLimit) {
39160
+ const ip = req.socket.remoteAddress ?? "unknown";
39161
+ const now = Date.now();
39162
+ let bucket = this._connectionBuckets.get(ip);
39163
+ if (!bucket) {
39164
+ bucket = { tokens: connRateLimit.limit, lastRefill: now };
39165
+ this._connectionBuckets.set(ip, bucket);
39166
+ } else {
39167
+ const elapsed = now - bucket.lastRefill;
39168
+ const refillRate = connRateLimit.limit / connRateLimit.windowMs;
39169
+ bucket.tokens = Math.min(
39170
+ connRateLimit.limit,
39171
+ bucket.tokens + elapsed * refillRate
39172
+ );
39173
+ bucket.lastRefill = now;
39174
+ }
39175
+ if (bucket.tokens < 1) {
39176
+ this._logger?.warn?.("Connection rate limit exceeded", { ip });
39177
+ this.emit("securityEvent", {
39178
+ type: "CONNECTION_RATE_LIMIT",
39179
+ ip,
39180
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39181
+ details: { tokensRemaining: bucket.tokens }
39182
+ });
39183
+ abortHandshake(socket, 429, "Too Many Requests");
39184
+ return;
39185
+ }
39186
+ bucket.tokens -= 1;
39187
+ }
38761
39188
  if (socket.readyState !== "open") {
38762
39189
  this._logger?.debug?.("Socket not open at upgrade start");
38763
39190
  if (!socket.destroyed) socket.destroy();
@@ -39009,6 +39436,13 @@ var OCPPServer = class extends EventEmitter3 {
39009
39436
  if (ac.signal.aborted) {
39010
39437
  const reason = err instanceof Error ? err.message : "Unknown abort";
39011
39438
  this._logger?.warn?.("Handshake aborted", { identity, reason });
39439
+ this.emit("securityEvent", {
39440
+ type: "UPGRADE_ABORTED",
39441
+ identity,
39442
+ ip: req.socket.remoteAddress,
39443
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39444
+ details: { reason }
39445
+ });
39012
39446
  this.emit("upgradeAborted", {
39013
39447
  identity,
39014
39448
  reason,
@@ -39022,6 +39456,13 @@ var OCPPServer = class extends EventEmitter3 {
39022
39456
  const code = typeof errObj?.code === "number" ? errObj.code : 401;
39023
39457
  const message = typeof errObj?.message === "string" ? errObj.message : "Unauthorized";
39024
39458
  this._logger?.warn?.("Auth rejected", { identity, code });
39459
+ this.emit("securityEvent", {
39460
+ type: "AUTH_FAILED",
39461
+ identity,
39462
+ ip: req.socket.remoteAddress,
39463
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39464
+ details: { code, message }
39465
+ });
39025
39466
  abortHandshake(socket, code, message);
39026
39467
  return;
39027
39468
  } finally {
@@ -39045,7 +39486,10 @@ var OCPPServer = class extends EventEmitter3 {
39045
39486
  return;
39046
39487
  }
39047
39488
  if (!this._wss) {
39048
- this._wss = new WebSocketServer({ noServer: true });
39489
+ this._wss = new WebSocketServer({
39490
+ noServer: true,
39491
+ maxPayload: this._options.maxPayloadBytes ?? 65536
39492
+ });
39049
39493
  }
39050
39494
  this._wss.handleUpgrade(req, socket, head, (ws) => {
39051
39495
  const clientOptions = {
@@ -39076,6 +39520,20 @@ var OCPPServer = class extends EventEmitter3 {
39076
39520
  protocol: selectedProtocol
39077
39521
  });
39078
39522
  this._updateSessionActivity(identity, client.session);
39523
+ const existingClient = this._clientsByIdentity.get(identity);
39524
+ if (existingClient && existingClient !== client) {
39525
+ this._logger?.warn?.("Evicting stale connection for identity", {
39526
+ identity,
39527
+ reason: "Duplicate identity replaced by new connection"
39528
+ });
39529
+ existingClient.close({
39530
+ code: 4e3,
39531
+ reason: "Evicted by new connection",
39532
+ force: true
39533
+ }).catch(() => {
39534
+ });
39535
+ this._clients.delete(existingClient);
39536
+ }
39079
39537
  this._clients.add(client);
39080
39538
  this._clientsByIdentity.set(identity, client);
39081
39539
  if (this._adapter?.setPresence) {
@@ -39131,6 +39589,29 @@ var OCPPServer = class extends EventEmitter3 {
39131
39589
  clearInterval(this._gcInterval);
39132
39590
  this._gcInterval = null;
39133
39591
  }
39592
+ if (!options.force) {
39593
+ const drainTimeout = 5e3;
39594
+ const drainPromises = Array.from(this._clients).map(async (client) => {
39595
+ const ws = client._ws;
39596
+ if (ws && ws.bufferedAmount > 0) {
39597
+ this._logger?.debug?.("Waiting for client buffer to drain", {
39598
+ identity: client.identity,
39599
+ bufferedAmount: ws.bufferedAmount
39600
+ });
39601
+ await new Promise((resolve) => {
39602
+ let elapsed = 0;
39603
+ const check = setInterval(() => {
39604
+ elapsed += 50;
39605
+ if (!ws || ws.bufferedAmount === 0 || elapsed >= drainTimeout) {
39606
+ clearInterval(check);
39607
+ resolve();
39608
+ }
39609
+ }, 50);
39610
+ });
39611
+ }
39612
+ });
39613
+ await Promise.allSettled(drainPromises);
39614
+ }
39134
39615
  const closePromises = Array.from(this._clients).map(
39135
39616
  (client) => client.close(options).catch(() => {
39136
39617
  })
@@ -39357,6 +39838,7 @@ var OCPPServer = class extends EventEmitter3 {
39357
39838
  export {
39358
39839
  ConnectionState,
39359
39840
  InMemoryAdapter,
39841
+ LRUMap,
39360
39842
  MessageType,
39361
39843
  MiddlewareStack,
39362
39844
  NOREPLY,
@@ -39394,6 +39876,6 @@ export {
39394
39876
  defineRpcMiddleware,
39395
39877
  getErrorPlainObject,
39396
39878
  getPackageIdent,
39397
- standardValidators
39879
+ getStandardValidators
39398
39880
  };
39399
39881
  //# sourceMappingURL=index.mjs.map