ocpp-ws-io 2.1.3 → 2.1.4
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/LICENSE +21 -0
- package/README.md +32 -6
- package/dist/adapters/redis.d.mts +1 -1
- package/dist/adapters/redis.d.ts +1 -1
- package/dist/adapters/redis.js +104 -0
- package/dist/adapters/redis.js.map +1 -1
- package/dist/adapters/redis.mjs +104 -0
- package/dist/adapters/redis.mjs.map +1 -1
- package/dist/browser.d.mts +16 -0
- package/dist/browser.d.ts +16 -0
- package/dist/browser.js.map +1 -1
- package/dist/browser.mjs.map +1 -1
- package/dist/{index-BixJj_yJ.d.mts → index-CagcFzyZ.d.mts} +111 -2
- package/dist/{index-BixJj_yJ.d.ts → index-CagcFzyZ.d.ts} +111 -2
- package/dist/index.d.mts +41 -3
- package/dist/index.d.ts +41 -3
- package/dist/index.js +396 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +395 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -27
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,
|
|
@@ -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
|
+
// C4: Per-stream sequence counter for message ordering
|
|
390
|
+
_sequenceCounters = /* @__PURE__ */ new Map();
|
|
391
|
+
// C3: 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
|
|
@@ -37095,6 +37205,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
37095
37205
|
_badMessageCount = 0;
|
|
37096
37206
|
_lastActivity = 0;
|
|
37097
37207
|
_outboundBuffer = [];
|
|
37208
|
+
_offlineQueue = [];
|
|
37098
37209
|
_middleware;
|
|
37099
37210
|
_validators = [];
|
|
37100
37211
|
_strictProtocols = null;
|
|
@@ -37249,6 +37360,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
37249
37360
|
}
|
|
37250
37361
|
this._attachWebsocket(ws);
|
|
37251
37362
|
this._startPing();
|
|
37363
|
+
this._flushOfflineQueue();
|
|
37252
37364
|
if (this._outboundBuffer.length > 0) {
|
|
37253
37365
|
const buffer = this._outboundBuffer;
|
|
37254
37366
|
this._outboundBuffer = [];
|
|
@@ -37430,8 +37542,32 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
37430
37542
|
options = args[2] ?? {};
|
|
37431
37543
|
}
|
|
37432
37544
|
if (this._state !== OPEN) {
|
|
37545
|
+
if (this._options.offlineQueue && (this._state === CLOSED || this._state === CONNECTING)) {
|
|
37546
|
+
return new Promise((resolve, reject) => {
|
|
37547
|
+
const maxSize = this._options.offlineQueueMaxSize ?? 100;
|
|
37548
|
+
if (this._offlineQueue.length >= maxSize) {
|
|
37549
|
+
this._offlineQueue.shift();
|
|
37550
|
+
this._logger?.warn?.(
|
|
37551
|
+
"Offline queue full \u2014 dropping oldest message",
|
|
37552
|
+
{
|
|
37553
|
+
method,
|
|
37554
|
+
queueSize: this._offlineQueue.length
|
|
37555
|
+
}
|
|
37556
|
+
);
|
|
37557
|
+
}
|
|
37558
|
+
this._offlineQueue.push({ method, params, options, resolve, reject });
|
|
37559
|
+
this._logger?.debug?.("Call queued offline", {
|
|
37560
|
+
method,
|
|
37561
|
+
queueSize: this._offlineQueue.length
|
|
37562
|
+
});
|
|
37563
|
+
});
|
|
37564
|
+
}
|
|
37433
37565
|
throw new Error(`Cannot call: client is in state ${this._state}`);
|
|
37434
37566
|
}
|
|
37567
|
+
const maxRetries = options.retries ?? 0;
|
|
37568
|
+
if (maxRetries > 0) {
|
|
37569
|
+
return this._callWithRetry(method, params, options, maxRetries);
|
|
37570
|
+
}
|
|
37435
37571
|
return this._callQueue.push(() => this._sendCall(method, params, options));
|
|
37436
37572
|
}
|
|
37437
37573
|
async safeCall(...args) {
|
|
@@ -37456,7 +37592,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
37456
37592
|
}
|
|
37457
37593
|
}
|
|
37458
37594
|
async _sendCall(method, params, options) {
|
|
37459
|
-
const msgId = (0, import_cuid2.createId)();
|
|
37595
|
+
const msgId = options.idempotencyKey ?? (0, import_cuid2.createId)();
|
|
37460
37596
|
const timeoutMs = options.timeoutMs ?? this._options.callTimeoutMs;
|
|
37461
37597
|
const ctx = {
|
|
37462
37598
|
type: "outgoing_call",
|
|
@@ -37504,7 +37640,7 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
37504
37640
|
sentAt: Date.now()
|
|
37505
37641
|
});
|
|
37506
37642
|
if (this._ws?.readyState === import_ws.default.OPEN) {
|
|
37507
|
-
this._ws
|
|
37643
|
+
this._safeSend(this._ws, messageStr, (err) => {
|
|
37508
37644
|
if (err) {
|
|
37509
37645
|
clearTimeout(timeoutHandle);
|
|
37510
37646
|
this._pendingCalls.delete(msgId);
|
|
@@ -37851,6 +37987,87 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
37851
37987
|
}
|
|
37852
37988
|
}, delayMs);
|
|
37853
37989
|
}
|
|
37990
|
+
// ─── Internal: Offline Queue ──────────────────────────────────
|
|
37991
|
+
/**
|
|
37992
|
+
* Atomically drains the offline queue and sends each message via _sendCall.
|
|
37993
|
+
* Uses splice(0) to prevent re-entry bugs (double billing) if the connection
|
|
37994
|
+
* drops again mid-flush — the queue is empty before any sends begin.
|
|
37995
|
+
*/
|
|
37996
|
+
_flushOfflineQueue() {
|
|
37997
|
+
if (this._offlineQueue.length === 0) return;
|
|
37998
|
+
const snapshot = this._offlineQueue.splice(0, this._offlineQueue.length);
|
|
37999
|
+
this._logger?.info?.("Flushing offline queue", { count: snapshot.length });
|
|
38000
|
+
for (const entry of snapshot) {
|
|
38001
|
+
this._callQueue.push(() => this._sendCall(entry.method, entry.params, entry.options)).then(entry.resolve).catch(entry.reject);
|
|
38002
|
+
}
|
|
38003
|
+
}
|
|
38004
|
+
// ─── Internal: Call Retry with Full Jitter ───────────────────
|
|
38005
|
+
/**
|
|
38006
|
+
* Retry wrapper using Full Jitter exponential backoff.
|
|
38007
|
+
* delay = random(0, min(retryMaxDelayMs, retryDelayMs * 2^attempt))
|
|
38008
|
+
* Only retries on TimeoutError — all other errors propagate immediately.
|
|
38009
|
+
*/
|
|
38010
|
+
async _callWithRetry(method, params, options, maxRetries) {
|
|
38011
|
+
const baseDelay = options.retryDelayMs ?? 1e3;
|
|
38012
|
+
const maxDelay = options.retryMaxDelayMs ?? 3e4;
|
|
38013
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
38014
|
+
try {
|
|
38015
|
+
return await this._callQueue.push(
|
|
38016
|
+
() => this._sendCall(method, params, options)
|
|
38017
|
+
);
|
|
38018
|
+
} catch (err) {
|
|
38019
|
+
if (attempt === maxRetries || !(err instanceof TimeoutError)) {
|
|
38020
|
+
throw err;
|
|
38021
|
+
}
|
|
38022
|
+
const expDelay = Math.min(maxDelay, baseDelay * 2 ** attempt);
|
|
38023
|
+
const jitteredDelay = Math.random() * expDelay;
|
|
38024
|
+
this._logger?.warn?.("Call retry", {
|
|
38025
|
+
method,
|
|
38026
|
+
attempt: attempt + 1,
|
|
38027
|
+
maxRetries,
|
|
38028
|
+
delayMs: Math.round(jitteredDelay)
|
|
38029
|
+
});
|
|
38030
|
+
await new Promise((r) => setTimeout(r, jitteredDelay));
|
|
38031
|
+
}
|
|
38032
|
+
}
|
|
38033
|
+
throw new Error("Retry exhausted");
|
|
38034
|
+
}
|
|
38035
|
+
// ─── Internal: Backpressure-Aware Send ───────────────────────
|
|
38036
|
+
/** Maximum bytes allowed in the ws send buffer before applying backpressure (512KB) */
|
|
38037
|
+
static _BACKPRESSURE_THRESHOLD = 512 * 1024;
|
|
38038
|
+
/**
|
|
38039
|
+
* Wraps ws.send() with backpressure protection.
|
|
38040
|
+
* If bufferedAmount exceeds the threshold, waits for the buffer to drain
|
|
38041
|
+
* before sending. Prevents OOM on slow 2G/3G charger connections.
|
|
38042
|
+
*/
|
|
38043
|
+
_safeSend(ws, data, cb) {
|
|
38044
|
+
if (!ws || ws.readyState !== import_ws.default.OPEN) {
|
|
38045
|
+
cb?.(new Error("WebSocket is not open"));
|
|
38046
|
+
return;
|
|
38047
|
+
}
|
|
38048
|
+
if (ws.bufferedAmount > _OCPPClient._BACKPRESSURE_THRESHOLD) {
|
|
38049
|
+
this._logger?.warn?.("Backpressure \u2014 pausing send", {
|
|
38050
|
+
bufferedAmount: ws.bufferedAmount,
|
|
38051
|
+
threshold: _OCPPClient._BACKPRESSURE_THRESHOLD
|
|
38052
|
+
});
|
|
38053
|
+
this.emit("backpressure", { bufferedAmount: ws.bufferedAmount });
|
|
38054
|
+
let waited = 0;
|
|
38055
|
+
const drainCheck = setInterval(() => {
|
|
38056
|
+
waited += 50;
|
|
38057
|
+
if (!ws || ws.readyState !== import_ws.default.OPEN) {
|
|
38058
|
+
clearInterval(drainCheck);
|
|
38059
|
+
cb?.(new Error("WebSocket closed during backpressure wait"));
|
|
38060
|
+
return;
|
|
38061
|
+
}
|
|
38062
|
+
if (ws.bufferedAmount <= _OCPPClient._BACKPRESSURE_THRESHOLD || waited >= 1e4) {
|
|
38063
|
+
clearInterval(drainCheck);
|
|
38064
|
+
ws.send(data, cb);
|
|
38065
|
+
}
|
|
38066
|
+
}, 50);
|
|
38067
|
+
} else {
|
|
38068
|
+
ws.send(data, cb);
|
|
38069
|
+
}
|
|
38070
|
+
}
|
|
37854
38071
|
// ─── Internal: Ping/Pong ─────────────────────────────────────
|
|
37855
38072
|
_startPing() {
|
|
37856
38073
|
if (this._options.pingIntervalMs <= 0) return;
|
|
@@ -38000,6 +38217,52 @@ var OCPPClient = class _OCPPClient extends import_node_events.EventEmitter {
|
|
|
38000
38217
|
}
|
|
38001
38218
|
};
|
|
38002
38219
|
|
|
38220
|
+
// src/lru-map.ts
|
|
38221
|
+
var LRUMap = class extends Map {
|
|
38222
|
+
_maxSize;
|
|
38223
|
+
constructor(maxSize) {
|
|
38224
|
+
super();
|
|
38225
|
+
if (maxSize < 1) throw new RangeError("LRUMap maxSize must be >= 1");
|
|
38226
|
+
this._maxSize = maxSize;
|
|
38227
|
+
}
|
|
38228
|
+
/**
|
|
38229
|
+
* Returns the configured maximum capacity of this LRU cache.
|
|
38230
|
+
*/
|
|
38231
|
+
get maxSize() {
|
|
38232
|
+
return this._maxSize;
|
|
38233
|
+
}
|
|
38234
|
+
/**
|
|
38235
|
+
* Sets a key-value pair. If the key already exists, it is promoted to the
|
|
38236
|
+
* most-recently-used position. If inserting a new key would exceed capacity,
|
|
38237
|
+
* the oldest (least-recently-used) entry is evicted.
|
|
38238
|
+
*/
|
|
38239
|
+
set(key, value) {
|
|
38240
|
+
if (this.has(key)) {
|
|
38241
|
+
this.delete(key);
|
|
38242
|
+
}
|
|
38243
|
+
super.set(key, value);
|
|
38244
|
+
if (this.size > this._maxSize) {
|
|
38245
|
+
const oldest = this.keys().next().value;
|
|
38246
|
+
if (oldest !== void 0) {
|
|
38247
|
+
this.delete(oldest);
|
|
38248
|
+
}
|
|
38249
|
+
}
|
|
38250
|
+
return this;
|
|
38251
|
+
}
|
|
38252
|
+
/**
|
|
38253
|
+
* Gets a value by key and promotes it to the most-recently-used position.
|
|
38254
|
+
* Uses `this.has(key)` instead of a value truthiness check to correctly
|
|
38255
|
+
* handle stored values of `undefined`, `null`, `0`, `""`, etc.
|
|
38256
|
+
*/
|
|
38257
|
+
get(key) {
|
|
38258
|
+
if (!this.has(key)) return void 0;
|
|
38259
|
+
const value = super.get(key);
|
|
38260
|
+
this.delete(key);
|
|
38261
|
+
super.set(key, value);
|
|
38262
|
+
return value;
|
|
38263
|
+
}
|
|
38264
|
+
};
|
|
38265
|
+
|
|
38003
38266
|
// src/router.ts
|
|
38004
38267
|
var import_node_events2 = require("events");
|
|
38005
38268
|
async function executeMiddlewareChain(middlewares, ctx) {
|
|
@@ -38356,6 +38619,7 @@ var OCPPServerClient = class extends OCPPClient {
|
|
|
38356
38619
|
this._ws = context.ws;
|
|
38357
38620
|
this._protocol = context.protocol ?? context.ws.protocol;
|
|
38358
38621
|
this._attachServerWebsocket(context.ws);
|
|
38622
|
+
this._startPing();
|
|
38359
38623
|
}
|
|
38360
38624
|
// ─── Rate Limiting State ──────────────────────────────────────────
|
|
38361
38625
|
_rateLimits = {};
|
|
@@ -38523,9 +38787,11 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
38523
38787
|
_httpServerAbortControllers = /* @__PURE__ */ new Set();
|
|
38524
38788
|
_logger = null;
|
|
38525
38789
|
_globalCORS;
|
|
38790
|
+
// Connection-level rate limiting (per-IP token bucket)
|
|
38791
|
+
_connectionBuckets = /* @__PURE__ */ new Map();
|
|
38526
38792
|
// Robustness & Clustering
|
|
38527
38793
|
_nodeId = (0, import_cuid22.createId)();
|
|
38528
|
-
_sessions
|
|
38794
|
+
_sessions;
|
|
38529
38795
|
_gcInterval = null;
|
|
38530
38796
|
_sessionTimeoutMs;
|
|
38531
38797
|
constructor(options = {}) {
|
|
@@ -38550,6 +38816,8 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
38550
38816
|
...options
|
|
38551
38817
|
};
|
|
38552
38818
|
this._sessionTimeoutMs = this._options.sessionTtlMs;
|
|
38819
|
+
const maxSessions = this._options.maxSessions ?? 5e4;
|
|
38820
|
+
this._sessions = new LRUMap(maxSessions);
|
|
38553
38821
|
this._wss = new import_ws2.WebSocketServer({ noServer: true });
|
|
38554
38822
|
this._gcInterval = setInterval(() => {
|
|
38555
38823
|
const now = Date.now();
|
|
@@ -38764,6 +39032,69 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
38764
39032
|
};
|
|
38765
39033
|
httpServer.on("upgrade", upgradeHandler);
|
|
38766
39034
|
this._httpServers.add(httpServer);
|
|
39035
|
+
if (this._options.healthEndpoint) {
|
|
39036
|
+
httpServer.on("request", (req, res) => {
|
|
39037
|
+
const url = req.url ?? "";
|
|
39038
|
+
if (url === "/health") {
|
|
39039
|
+
const s = this.stats();
|
|
39040
|
+
const body = JSON.stringify({
|
|
39041
|
+
status: this._state === "OPEN" ? "ok" : "degraded",
|
|
39042
|
+
state: this._state,
|
|
39043
|
+
connectedClients: s.connectedClients,
|
|
39044
|
+
activeSessions: s.activeSessions,
|
|
39045
|
+
uptimeSeconds: Math.round(s.uptimeSeconds),
|
|
39046
|
+
pid: s.pid
|
|
39047
|
+
});
|
|
39048
|
+
res.writeHead(200, {
|
|
39049
|
+
"Content-Type": "application/json",
|
|
39050
|
+
"Cache-Control": "no-cache"
|
|
39051
|
+
});
|
|
39052
|
+
res.end(body);
|
|
39053
|
+
return;
|
|
39054
|
+
}
|
|
39055
|
+
if (url === "/metrics") {
|
|
39056
|
+
const s = this.stats();
|
|
39057
|
+
const lines = [
|
|
39058
|
+
"# HELP ocpp_connected_clients Number of currently connected OCPP clients",
|
|
39059
|
+
"# TYPE ocpp_connected_clients gauge",
|
|
39060
|
+
`ocpp_connected_clients ${s.connectedClients}`,
|
|
39061
|
+
"",
|
|
39062
|
+
"# HELP ocpp_active_sessions Number of active in-memory sessions",
|
|
39063
|
+
"# TYPE ocpp_active_sessions gauge",
|
|
39064
|
+
`ocpp_active_sessions ${s.activeSessions}`,
|
|
39065
|
+
"",
|
|
39066
|
+
"# HELP ocpp_uptime_seconds Process uptime in seconds",
|
|
39067
|
+
"# TYPE ocpp_uptime_seconds gauge",
|
|
39068
|
+
`ocpp_uptime_seconds ${Math.round(s.uptimeSeconds)}`,
|
|
39069
|
+
"",
|
|
39070
|
+
"# HELP ocpp_memory_rss_bytes Resident set size in bytes",
|
|
39071
|
+
"# TYPE ocpp_memory_rss_bytes gauge",
|
|
39072
|
+
`ocpp_memory_rss_bytes ${s.memoryUsage.rss}`,
|
|
39073
|
+
"",
|
|
39074
|
+
"# HELP ocpp_memory_heap_used_bytes V8 heap used in bytes",
|
|
39075
|
+
"# TYPE ocpp_memory_heap_used_bytes gauge",
|
|
39076
|
+
`ocpp_memory_heap_used_bytes ${s.memoryUsage.heapUsed}`,
|
|
39077
|
+
"",
|
|
39078
|
+
"# HELP ocpp_memory_heap_total_bytes V8 heap total in bytes",
|
|
39079
|
+
"# TYPE ocpp_memory_heap_total_bytes gauge",
|
|
39080
|
+
`ocpp_memory_heap_total_bytes ${s.memoryUsage.heapTotal}`,
|
|
39081
|
+
"",
|
|
39082
|
+
"# HELP ocpp_ws_buffered_bytes Total buffered WebSocket bytes",
|
|
39083
|
+
"# TYPE ocpp_ws_buffered_bytes gauge",
|
|
39084
|
+
`ocpp_ws_buffered_bytes ${s.webSockets?.bufferedAmount ?? 0}`,
|
|
39085
|
+
""
|
|
39086
|
+
];
|
|
39087
|
+
res.writeHead(200, {
|
|
39088
|
+
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
39089
|
+
"Cache-Control": "no-cache"
|
|
39090
|
+
});
|
|
39091
|
+
res.end(lines.join("\n"));
|
|
39092
|
+
return;
|
|
39093
|
+
}
|
|
39094
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
39095
|
+
res.end("Not Found");
|
|
39096
|
+
});
|
|
39097
|
+
}
|
|
38767
39098
|
if (options?.signal) {
|
|
38768
39099
|
const ac = new AbortController();
|
|
38769
39100
|
this._httpServerAbortControllers.add(ac);
|
|
@@ -38827,6 +39158,30 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
38827
39158
|
abortHandshake(socket, 503, "Server is shutting down");
|
|
38828
39159
|
return;
|
|
38829
39160
|
}
|
|
39161
|
+
const connRateLimit = this._options.connectionRateLimit;
|
|
39162
|
+
if (connRateLimit) {
|
|
39163
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
39164
|
+
const now = Date.now();
|
|
39165
|
+
let bucket = this._connectionBuckets.get(ip);
|
|
39166
|
+
if (!bucket) {
|
|
39167
|
+
bucket = { tokens: connRateLimit.limit, lastRefill: now };
|
|
39168
|
+
this._connectionBuckets.set(ip, bucket);
|
|
39169
|
+
} else {
|
|
39170
|
+
const elapsed = now - bucket.lastRefill;
|
|
39171
|
+
const refillRate = connRateLimit.limit / connRateLimit.windowMs;
|
|
39172
|
+
bucket.tokens = Math.min(
|
|
39173
|
+
connRateLimit.limit,
|
|
39174
|
+
bucket.tokens + elapsed * refillRate
|
|
39175
|
+
);
|
|
39176
|
+
bucket.lastRefill = now;
|
|
39177
|
+
}
|
|
39178
|
+
if (bucket.tokens < 1) {
|
|
39179
|
+
this._logger?.warn?.("Connection rate limit exceeded", { ip });
|
|
39180
|
+
abortHandshake(socket, 429, "Too Many Requests");
|
|
39181
|
+
return;
|
|
39182
|
+
}
|
|
39183
|
+
bucket.tokens -= 1;
|
|
39184
|
+
}
|
|
38830
39185
|
if (socket.readyState !== "open") {
|
|
38831
39186
|
this._logger?.debug?.("Socket not open at upgrade start");
|
|
38832
39187
|
if (!socket.destroyed) socket.destroy();
|
|
@@ -39145,6 +39500,20 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
39145
39500
|
protocol: selectedProtocol
|
|
39146
39501
|
});
|
|
39147
39502
|
this._updateSessionActivity(identity, client.session);
|
|
39503
|
+
const existingClient = this._clientsByIdentity.get(identity);
|
|
39504
|
+
if (existingClient && existingClient !== client) {
|
|
39505
|
+
this._logger?.warn?.("Evicting stale connection for identity", {
|
|
39506
|
+
identity,
|
|
39507
|
+
reason: "Duplicate identity replaced by new connection"
|
|
39508
|
+
});
|
|
39509
|
+
existingClient.close({
|
|
39510
|
+
code: 4e3,
|
|
39511
|
+
reason: "Evicted by new connection",
|
|
39512
|
+
force: true
|
|
39513
|
+
}).catch(() => {
|
|
39514
|
+
});
|
|
39515
|
+
this._clients.delete(existingClient);
|
|
39516
|
+
}
|
|
39148
39517
|
this._clients.add(client);
|
|
39149
39518
|
this._clientsByIdentity.set(identity, client);
|
|
39150
39519
|
if (this._adapter?.setPresence) {
|
|
@@ -39200,6 +39569,29 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
39200
39569
|
clearInterval(this._gcInterval);
|
|
39201
39570
|
this._gcInterval = null;
|
|
39202
39571
|
}
|
|
39572
|
+
if (!options.force) {
|
|
39573
|
+
const drainTimeout = 5e3;
|
|
39574
|
+
const drainPromises = Array.from(this._clients).map(async (client) => {
|
|
39575
|
+
const ws = client._ws;
|
|
39576
|
+
if (ws && ws.bufferedAmount > 0) {
|
|
39577
|
+
this._logger?.debug?.("Waiting for client buffer to drain", {
|
|
39578
|
+
identity: client.identity,
|
|
39579
|
+
bufferedAmount: ws.bufferedAmount
|
|
39580
|
+
});
|
|
39581
|
+
await new Promise((resolve) => {
|
|
39582
|
+
let elapsed = 0;
|
|
39583
|
+
const check = setInterval(() => {
|
|
39584
|
+
elapsed += 50;
|
|
39585
|
+
if (!ws || ws.bufferedAmount === 0 || elapsed >= drainTimeout) {
|
|
39586
|
+
clearInterval(check);
|
|
39587
|
+
resolve();
|
|
39588
|
+
}
|
|
39589
|
+
}, 50);
|
|
39590
|
+
});
|
|
39591
|
+
}
|
|
39592
|
+
});
|
|
39593
|
+
await Promise.allSettled(drainPromises);
|
|
39594
|
+
}
|
|
39203
39595
|
const closePromises = Array.from(this._clients).map(
|
|
39204
39596
|
(client) => client.close(options).catch(() => {
|
|
39205
39597
|
})
|
|
@@ -39427,6 +39819,7 @@ var OCPPServer = class extends import_node_events3.EventEmitter {
|
|
|
39427
39819
|
0 && (module.exports = {
|
|
39428
39820
|
ConnectionState,
|
|
39429
39821
|
InMemoryAdapter,
|
|
39822
|
+
LRUMap,
|
|
39430
39823
|
MessageType,
|
|
39431
39824
|
MiddlewareStack,
|
|
39432
39825
|
NOREPLY,
|